Uses attachCustomKeyEventHandler so xterm doesn't first consume Ctrl+V
and inject a raw ^V into the PTY. Paste routes through term.paste() so
broadcasting and bracketed paste continue to work.
Each leaf now carries an optional fontSizeOffset, persisted in
workspace.json alongside everything else. Ctrl+= / Ctrl+- / Ctrl+0
adjust the active pane; adding Shift escalates to every pane (the
mirror of the broadcast Shift+Alt convention, with shift alone since
the keys are otherwise unused). Bindings match on e.code so layouts
that don't have "=" / "-" / "0" in the same spot still work.
XtermPane gained a fontSize prop. A secondary effect reacts to changes:
set term.options.fontSize, fit() to recompute cols/rows for the new
cell size, refresh(), then resizePane so bash redraws the prompt at
the right width. No remount, so PTY + scrollback survive zoom changes.
The new tree helpers (resolveFontSize / adjustFontSize /
adjustAllFontSizes) are metadata-only — they don't swap leaf ids, so
nothing respawns. reshapeToPreset also carries the offset across when
splicing existing leaves into a new layout. 12 new vitest cases pin
those invariants plus the clamp and reset-to-default behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lead the "Using it" section with the keyboard shortcuts table, then
break the remaining behaviour into mouse/toolbar and broadcast/idle/
presets subsections. Documents drag-to-swap, the 180px resize minimum,
the home-cwd default, the new idle border + titlebar badge (replacing
the old toasts blurb), and the splice-preserving preset behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Changes since 0.2.1:
- Keyboard shortcuts: Ctrl+K palette + Ctrl+Shift+E/O/W for
split-right / split-down / close, Ctrl+Shift+B per-pane and
Ctrl+Shift+Alt+B global broadcast, Ctrl+Shift+Arrow for spatial
pane navigation
- New panes default to WSL home (~) instead of inherited Windows cwd
- Resize artifacts: rAF-throttle gutter drag, debounce PTY resize,
skip resizePane when cols/rows haven't actually changed (kills the
idle-flap loop that surfaced with many panes)
- Minimum pane size (180px) enforced on both split and gutter drag
- 2px gap around each leaf so per-pane borders read as distinct
rectangles instead of a continuous grid
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With panes packed edge-to-edge, adjacent .leaf elements' 2px borders
touched directly. When all (or many) panes went idle, every pane's
red border combined with its neighbour's to form continuous red lines
across the whole window — looking like a single grid pattern, not
distinct per-pane outlines.
Fix: padding: 2px on .leaf-slot (box-sizing: border-box). Each .leaf
ends up inset 2px on every side, so adjacent panes have a 4px dark
gap between them and their borders read as separate rectangles.
Affects all borders equally (idle red, active blue, broadcasting
orange) and gives the layout a cleaner separation overall.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Most ResizeObserver firings in XtermPane don't change xterm's cell
grid — just shuffle a few pixels around. We were still calling
resizePane() (which SIGWINCHs bash, which redraws its prompt, which
emits data, which resets the idle timer). With many panes, this
created a self-reinforcing flap loop where the idle indicator would
toggle every few seconds.
Now: track the last cols/rows we actually sent to the backend in the
mount effect's closure. If a debounced fit() ends up at the same
grid dimensions, skip the resizePane call entirely. No SIGWINCH, no
prompt redraw, no data event, no idle flap.
Zero functional change for actual resizes; only suppresses redundant
PTY syscalls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spamming the ⇥/⇣ split buttons (or their Ctrl+Shift+E/O shortcuts)
used to subdivide panes indefinitely, leaving toolbar-only slivers
that were unusable.
- tree.ts: MIN_PANE_PX = 180 constant.
- App.tsx: the `split` orchestration callback now computes the active
pane's pixel dimensions from its layout slot + the container rect,
and refuses to split if either child would fall below MIN_PANE_PX.
Surfaces the refusal via a `notify(...)` toast so the user knows
why nothing happened.
- Gutter.tsx: pointermove clamps the new ratio so the smaller child
stays at least MIN_PANE_PX wide/tall. Falls back to the old 0.05
floor only if the parent is so small that two min-sized panes
can't both fit (degraded but functional).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
What the user saw: dragging a gutter filled the affected panes with
many overlapping bash prompts, some corrupted mid-print
(megaproxy@DESKTOP-megaproxy@DESKTOP-SSAQG5 etc).
Root cause: every resizePane() call sends SIGWINCH to the shell, which
makes bash redraw its prompt. The previous fix coalesced the local
xterm fit() into one per rAF, but still fired resizePane on every
rAF — 60+ SIGWINCHes per second during a drag, faster than bash can
finish one prompt redraw before the next interrupts it.
Fix: separate the two concerns. fit() + term.refresh() still run
every rAF (the visual must stay smooth). But resizePane() is
debounced to fire 150 ms after the LAST rAF — i.e. only when you
stop dragging — so bash gets one clean SIGWINCH at the final size
and produces a single tidy prompt redraw.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously: spawn_wsl(cwd=None) passed no --cd to wsl.exe, so each
pane inherited the launcher's cwd — which is typically
C:\Users\<user>, surfacing inside WSL as /mnt/c/Users/<user>. Annoying
because the first thing you do in a new pane is `cd ~`.
Now: if the caller didn't specify a cwd, we explicitly pass `--cd ~`
so the pane lands in the WSL user's home. Existing panes keep their
saved cwd from the workspace.json.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes for stale glyphs / visual artifacts while dragging
a gutter:
- Gutter.tsx: pointermove now writes the new ratio into a ref and
schedules a single requestAnimationFrame flush per frame. Without
this, setTree fires 60+ times per second during a drag and React
+ ResizeObserver + xterm's DOM renderer get out of sync. The
pointerup handler flushes any pending ratio so the final position
always lands.
- XtermPane.tsx: the ResizeObserver callback now also rAF-coalesces
AND calls term.refresh(0, term.rows - 1) after fit.fit(). xterm's
DOM renderer doesn't reliably repaint freed-up rows after a
shrink, so the explicit refresh wipes any stale glyphs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| Ctrl+K | palette |
| Ctrl+Shift+E | split active pane right |
| Ctrl+Shift+O | split active pane down |
| Ctrl+Shift+W | close active pane |
| Ctrl+Shift+B | toggle broadcast on active |
| Ctrl+Shift+Alt+B | toggle broadcast on ALL panes |
| Ctrl+Shift+Arrow | focus neighbour pane in that direction |
The handler attaches at capture phase on window so it wins against
xterm.js. It bails when a non-terminal <input>/<textarea> is focused
so label edits and the palette input keep working normally.
Spatial neighbour-finding lives in tree.ts as findNeighborInDirection
— picks the leaf whose centre is most aligned in the perpendicular
axis, breaking ties by primary-axis distance.
Tooltips on toolbar/titlebar buttons now mention their shortcuts;
README has a key-binding table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Changes since 0.2.0:
- Fix broadcast no-op (useEffect deps captured stale orch ref, so paneIds
got silently unregistered on every click → broadcastFrom found no peers)
- Flat-list layout architecture: render leaves as siblings keyed by id,
position via absolute boxes. PTYs survive any tree reshape.
- Drag a pane's toolbar onto another pane to swap them
- Idle reporting moved out of toast spam into a "N idle" titlebar badge
+ red pane border + red "idle" status text
- Themed terminal scrollbars
- Global 📡 broadcast toggle in the titlebar
- Presets preserve existing panes' shells (only kill what overflows the
preset's slot count, with a confirm dialog)
- React 18 frontend (Svelte version retired to svelte-archive branch)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Old behaviour: every pane fired orch.notify("X is idle") after 5s of
silence, stacking up to N toasts that took ages to dismiss.
New behaviour:
- LeafPane tracks its own isIdle state locally and reports up via
orch.reportLeafIdle(leafId, idle).
- App aggregates into a Set<NodeId> and renders "N idle" in red after
the "N panes" count in the titlebar (hidden when zero).
- The pane itself gets a red border (.leaf.idle) — but active and
broadcasting borders still take precedence, so the focus indicator
isn't masked by idle status.
- The pane's "alive" status text in the toolbar swaps to red "idle"
while it's quiet (reverts to "alive" the moment output arrives).
- Idle clears immediately on the next byte of output (no 1-second lag)
AND when the pane unmounts (cleanup effect).
No more flood of toasts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New interaction: click-and-drag any pane's toolbar onto another pane
to swap their positions in the tree. The shells / scrollback stay
intact (each leaf keeps its data; only the tree slot it occupies
changes).
Implementation:
- tree.ts: `swapLeaves(root, idA, idB)` walks the tree once,
substituting one leaf for the other at each occurrence. The leaf
objects themselves carry their id/distro/cwd/label/broadcast across,
so React preserves the LeafPane instances via the flat-list keying.
- orchestration.tsx: add drag lifecycle to the context —
dragSourceId / dragOverId (reactive) plus beginHeaderDrag,
setHeaderDragOver, endHeaderDrag (stable methods).
- App.tsx: implement those methods. endHeaderDrag(true) swaps if
source and over are different leaves.
- LeafPane.tsx: pointerdown on .pane-toolbar (skipped if the target
is a button/input). 5px movement threshold before drag commits to
prevent accidental swaps when clicking a chip etc. Pointer-capture
the toolbar so we keep getting move events even outside it. Use
document.elementFromPoint to find the leaf under the cursor.
- CSS: source pane fades to 40% opacity during drag; target pane
shows a 3px dashed blue outline; toolbar shows grab/grabbing
cursors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fix for the real preset bug: previously, presetSingle/2H/3H/2V/2×2
appeared to preserve panes (we copied id/distro/cwd/label/broadcast
into the preset's slots), but React's reconciliation tore down every
LeafPane and re-mounted it because the tree structure changed —
killing all PTYs and spawning fresh shells. The "preservation" was
data-only; the React components didn't survive.
Solution: stop rendering the Pane → SplitNode → LeafPane recursion.
Walk the tree to produce a FLAT layout of `{leaf, box}` entries (each
box is top/left/width/height as fractions 0–1). Render all leaves as
siblings of a relative-positioned container, each absolutely
positioned by its box. Key each one by leaf.id — React preserves the
component (and its XtermPane → PTY) across any tree reshape; only the
inline style changes.
Gutters render as separate sibling overlays at the split boundaries,
each with its own pointer handlers. Dragging mutates the split's
ratio via `updateSplitRatio(tree, splitId, r)`; the layout
recomputes; leaf boxes change; nothing remounts.
Now: clicking 2×2 on 4 stacked panes keeps all 4 shells alive and
just rearranges them into the grid. Same for any preset that doesn't
overflow.
Side benefit: removed the recursive Pane.tsx + SplitNode.tsx + their
CSS. The render path is now straightforward, no recursion, easier to
reason about.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously: clicking 1 / 2H / 3H / 2V / 2×2 in the titlebar replaced
the whole tree with brand-new empty leaves, killing every shell — and
the only safeguard was a window.confirm() that's easy to miss-click.
The user lost work whenever they reached for a preset.
New behaviour via `reshapeToPreset`:
- The preset's shape is built fresh (1, 2, 3, or 4 slots), then existing
leaves are spliced into those slots in DFS order. Their id / distro /
cwd / label / broadcast all carry over, so the same PaneId is still
mapped — the PTY keeps running.
- If the preset has MORE slots than existing leaves (e.g. 1 pane → 2×2),
the extra slots stay as fresh empty leaves and new shells spawn there.
No prompt — pure additive change.
- If the preset has FEWER slots than existing leaves (e.g. 8 panes →
2×2), the overflow leaves are returned in `dropped`. We confirm with
the user, and if they accept, kill those PTYs explicitly.
Tradeoff: split ratios reset to 0.5 (the whole point of "apply preset"
is to use its layout). That's an acceptable cost.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bug: clicking 📡 made the visual update (orange border) but typing
in a broadcasting pane only wrote to that pane — peers never received
the keystrokes.
Root cause: the orch context value (useMemo'd over activeLeafId,
distros, and the operation callbacks) is recreated every time
activeLeafId changes (i.e. every click). useEffect cleanups in
LeafPane that had `orch` in their deps fired their cleanup-then-setup
cycle on every click. The unmount-cleanup for paneId registration
ran `orch.registerPaneId(leaf.id, null)`, silently deleting paneIds
from App's paneIdByLeafRef map — so when broadcastFrom later walked
the tree looking up peers, the map returned undefined for every leaf
and the actual writeToPane calls never happened.
Fix: depend on the specific stable method references
(`orch.registerPaneId`, `orch.notify`, etc.) instead of the orch
object itself. The methods are all useCallback'd with stable deps
in App.tsx, so their references don't change across orch object
recreations — effect deps stay stable, no spurious cleanup.
Applied the same fix to all orch-using effects/callbacks in
LeafPane (commitLabel, pickDistro, onPaneClick, onPaneSpawned,
onXtermFocus, onTerminalInput, idle interval, paneId cleanup).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Stack: Svelte 5 → React 18, with a note about the migration history
- Build: pnpm check is now tsc --noEmit, not svelte-check; mention pnpm build
- Architecture: rename component refs to .tsx; describe React Context for
shared orchestration state instead of the old PaneOps drill-down
- Features: mention the new global 📡 titlebar toggle alongside the
per-pane chips
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Modern React 17+ JSX transform doesn't need `import React from "react"`
unless React itself is referenced. The two component files had stale
imports left over from the agent that scaffolded them; tsc -b under
the build config trips on them as TS6133. Drop them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.1.0 was the Svelte version, preserved on branch svelte-archive.
v0.2.0 marks the React rewrite: same features, dramatically more
reliable interactions (no more fighting prop reactivity through the
recursive Pane chain), plus scrollbar theming and the global
broadcast toggle in the titlebar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small QoL additions:
- styles.css: WebKit pseudo-element styling for .xterm-viewport
scrollbars (8px wide, dark thumb #2a2a2a on transparent track,
hover lighten). Matches the rest of the dark theme so the right
edge of each terminal stops looking like default OS chrome.
- tree.ts: setAllBroadcast(root, on) helper that flips every leaf's
broadcast flag to the given value, preserving object identity
where nothing changed.
- App.tsx: titlebar 📡 button showing global broadcast state
("all off" / "all on" / "N/M"). Click toggles every pane between
all-broadcasting and all-off. Orange when any panes are
broadcasting; darker orange when partial.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After hours of fighting Svelte 5's prop-reactivity through the
recursive Pane → SplitNode → LeafPane chain (props captured at
mount, never updated; context+getter pattern crashed; DOM-direct
workarounds created zombie-split click-intercept bugs), we
checkpointed the Svelte version (branch svelte-archive at e9015b2,
tarball at D:\archives\tiletopia-svelte-2026-05-22.tar.gz) and
rewrote the frontend in React.
Kept verbatim:
- All of src-tauri/ (Rust backend, Tauri config, icons)
- scripts/ (make-icon.py, release.sh)
- README.md, CLAUDE.md, memory.md
- src/lib/layout/tree.ts (pure TS — 43 tests still pass)
- src/ipc.ts (Tauri command wrappers)
Rewrote in React:
- src/App.tsx (top-level state via useState, OrchestrationProvider
for descendants via React.Context)
- src/lib/layout/orchestration.tsx (React Context API for shared
state — known-reliable reactivity, no Svelte 5 wall)
- src/lib/layout/Pane.tsx (recursive dispatcher)
- src/lib/layout/SplitNode.tsx (draggable gutter, local ratio state)
- src/lib/layout/LeafPane.tsx (toolbar + XtermPane)
- src/components/XtermPane.tsx (xterm.js wrapper, refs for callbacks)
- src/components/Notifications.tsx, Palette.tsx
Build: Vite + @vitejs/plugin-react. TypeScript strict. Same Tauri 2
config. Verified: pnpm check (clean), pnpm test (43/43 pass).
Not yet verified: pnpm tauri dev — that requires the Windows host.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Agents investigated the close-button-stuck issue and recommended
removing all DOM-direct workarounds, hypothesising they were causing
the bug. Tested empirically: removing the workarounds breaks
EVERY interactive feature (no border, no broadcast, no resize,
no close). So Svelte 5 prop reactivity through the recursive Pane
chain is genuinely broken in this app — workarounds are required.
Restored workaround set:
- App.svelte polling loop: focus detection + active-class + broadcast
class sync via DOM API every 250ms
- SplitNode.svelte drag: rAF-throttled direct .side flex update
- handleClose: bump renderKey to force full Pane remount. This kills
every other pane's PTY (cost) but is the only reliable way to make
the closed pane actually disappear AND avoid the "zombie split"
click-intercept bug from the previous DOM-hide approach.
The underlying Svelte 5 issue remains an open question — the next
thing to try is the context+getter pattern (`setContext('active',
{ get id() { return activeLeafId } })` in App, `getContext('active')`
in LeafPane) which Agent 1's research found is the documented
escape hatch for deep reactive prop chains.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two improvements to the close-pane workaround:
1. When both children of a parent split are now hidden, the .side hide
bubbles up to the parent split's own .side and hides that too. Needed
for deeply-nested splits where closing leaves at the bottom should
propagate the visual collapse upward.
2. When closeLeaf returns null (the user closed the last remaining
leaf), force a full Pane remount via {#key renderKey} bump. The
DOM-hide approach can't simulate mounting a fresh tree node, so this
is the one place where we take the cost of a full unmount + remount.
Only fires when the entire tree resets — not on intermediate closes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After this session's diagnostic confirmed the root cause (Svelte 5
prop reactivity does NOT propagate through Pane → SplitNode → LeafPane
in this app — each LeafPane captures props at mount and never sees
updates), restored the brute-force DOM workarounds that were proven
to work and threw in a throttle for the gutter drag.
What changed vs the broken intermediate state:
- App polling: re-sync .leaf.active, .leaf.broadcasting, .bcast-chip.on
classes from tree+activeLeafId state every 250ms. Bypasses Svelte
reactivity entirely.
- SplitNode drag: rAF-throttle the direct flex update so we stop
spamming SIGWINCH to the PTYs (which was making shells redraw
prompts repeatedly, creating the visual artifacts the user reported).
- Close: keep the targeted PTY-kill + DOM-hide-the-side approach so
panes visually disappear and siblings fill via flex auto-allocation.
This isn't pretty, but it works. The proper fix is to either find /
file the Svelte 5 bug, or migrate the frontend to a framework whose
reactivity we can trust. Both deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dragging the splitter set node.ratio in the Svelte $state tree
correctly (used by save-restore), but the template binding
style=\"flex: {node.ratio}\" on each .side div didn't re-evaluate
when ratio changed — same prop-reactivity wall we hit with the
active border and broadcast color. The gutter would drag invisibly:
internal state moved, panes stayed at their original ratio.
Workaround: SplitNode's onPointerMove now ALSO writes the flex
style directly to the two .side elements via DOM. Svelte still owns
node.ratio for persistence/serialization, but the visual is owned
by the imperative DOM write.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same Svelte 5 reactivity trap as the active-border bug — clicking
the 📡 button toggles leaf.broadcast in the tree (broadcast routing
verifies this works), but class:broadcasting on .leaf and class:on
on .bcast-chip don't reactively update.
Workaround: the existing 250ms polling loop now also walks the tree
and forces .broadcasting / .on classes via element.classList.toggle
on every tick. Verified with PowerShell click automation: clicking
📡 turns the pane border orange; second 📡 click on a different pane
turns its border orange too (both now broadcasting).
(The hover tooltip text is still stale — it's bound via the title
attribute which has the same reactivity issue. Cosmetic only.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Svelte 5's template reactivity on \`class:active={activeLeafId === leaf.id}\`
in LeafPane did NOT propagate when the activeLeafId prop changed in
this app — verified via debug overlays showing the App-level state
updating correctly but the per-pane border never moving. Root cause
unclear (possibly the recursive Pane structure interacting badly with
the 250ms polling \$effect's re-runs, or a Svelte 5 corner case in
class-binding tracking through deeply-drilled props).
Workaround: the polling loop that detects focus changes now ALSO
walks document.querySelectorAll("[data-leaf-id].leaf") on every tick
and directly toggles the .active class via element.classList. If
Svelte re-renders and reverts, the next 250ms tick puts it back.
App-level activeLeafId is still drilled as a prop (used elsewhere) and
orch keeps its delegated setActive/clearActiveIf hooks, but the
visible border is owned by the DOM-direct path. Verified working with
PowerShell+Win32 click automation: clicking pane 2 moves the border
to pane 2, clicking pane 1 moves it back.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After exhausting event-based approaches that all failed in WebView2:
- per-leaf onpointerdown: xterm.js stopPropagation
- document-capture pointerdown: only first event ever delivered
- document-capture mousedown/click: never delivered at all
- document-capture focusin: silently fails
- term.onFocus: no such xterm.js API
The bulletproof fallback: poll document.activeElement every 250ms
and call orch.setActive on its closest [data-leaf-id] ancestor.
No DOM events involved. Verified working with automation: clicking
pane 2 turns its border blue, clicking pane 1 moves the border to
pane 1, etc.
XtermPane gained an onFocus prop (still wired through LeafPane) as a
secondary signal that might fire in some configurations, but the
polling is the actual fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit (96a9180) pulled in 38 debugging screenshots,
the PowerShell automation driver, and the dev.log file alongside
the actual fix. Remove them from the working tree and ignore them
going forward via /-prefixed patterns in .gitignore (so the
legitimate src-tauri/icons/*.png stay tracked).
Files remain in git history; the binary diff is small so this isn't
worth a force-push to scrub.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause: xterm.js attaches its own pointerdown handler inside the
terminal and calls e.stopPropagation(), which prevents the .leaf
div's onpointerdown from firing for any click landing inside the
terminal body. That's why clicking pane bodies never moved the blue
active border — the event simply never reached our handler.
Fix: register a document-level CAPTURE-phase pointerdown listener
in App.svelte. Capture fires before xterm.js's bubble-phase handler
runs (and before it can stop propagation), so we always see the
click. The handler walks up via Element.closest('[data-leaf-id]')
to find which pane was clicked, then calls orch.setActive.
- LeafPane.svelte: add data-leaf-id={leaf.id} attribute so the
document handler can identify the clicked pane.
- App.svelte: $effect attaches document.addEventListener('pointerdown',
..., true) and cleans up on teardown.
- Keep the per-leaf onpointerdown as a redundant backup for clicks
on toolbar buttons (which sit outside the xterm subtree). Cheap
+ idempotent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptoms in v0.1.0 install: 📡 broadcast button didn't change color
on toggle, × close button didn't remove the pane, blue active
border stuck on the first pane. All three were UI-not-rerendering-
on-state-change manifestations of the same prop-reactivity quirk
that drilling activeLeafId tried (and apparently failed) to fix.
Refactor to the Svelte 5 canonical pattern for shared reactive
state:
- New src/lib/layout/orchestration.svelte.ts with an Orchestration
class. Reactive fields (activeLeafId, notifications, distros) are
class-field $state declarations; methods mutate them directly.
Provided via context (provideOrchestration / useOrchestration);
no prop drilling.
- App.svelte: provideOrchestration(treeOps). Tree mutations remain
closures over the App-level tree $state; the class delegates to
them. Pane only takes `node` now.
- Pane.svelte / SplitNode.svelte: stop drilling ops + activeLeafId.
Pure pass-through of node.
- LeafPane.svelte: useOrchestration(); `active = $derived(
orch.activeLeafId === leaf.id)` reads the class field directly so
Svelte 5 tracks it per-property.
- Notifications.svelte: receives notifications + onDismiss from App
(which gets them from orch).
- Deleted src/lib/layout/ops.ts (TreeOps moved into orchestration).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Repo is now public; the previous README leaked author-only context
(local ~/claude/projects path, scripts/release.sh + tea workflow,
icon-regen steps) that's irrelevant to anyone landing fresh.
Now: Install / Using it / Stack / Build from source / Tests /
Architecture / License-note. The release + icon-regen flows live in
memory.md for the maintainer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cargo auto-rewrote the tiletopia entry from 0.0.1 to 0.1.0 during
the M5 release build; manually updating Cargo.toml in M5 didn't
touch the lockfile. Committing so the release tag points at a
clean tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- scripts/make-icon.py: generates a 1024x1024 source.png — dark
rounded square + 2x2 tile grid with one active-blue tile and one
broadcast-orange tile (matches the in-app accent colors).
Regenerated all desktop icon sizes via 'pnpm tauri icon';
pruned iOS/Android/UWP outputs.
- Version bump 0.0.1 -> 0.1.0 across package.json, Cargo.toml,
tauri.conf.json. First real release.
- scripts/release.sh: takes vX.Y.Z, sanity-checks (clean tree,
on main, in sync, tag matches package.json, installer exists,
tag not already present), tags + pushes, uploads NSIS .exe to
Forgejo via tea releases create --asset.
- README rewritten: Install section pointing at Forgejo releases,
Using-it cheatsheet for all M2-M4 features (splits, broadcast,
palette, etc.), Develop/Test/Release triplet for the WSL<->Windows
workflow, icon regen instructions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drill activeLeafId as a separate prop through Pane -> SplitNode ->
LeafPane instead of bundling it into the \$derived ops object.
Passing activeLeafId via ops caused subsequent focus changes to
not propagate to children (LeafPane's active = \$derived(...) wasn't
re-evaluating when ops's identity changed). Drilling sidesteps any
prop-as-derived-object reactivity quirks.
- Ctrl+K listener now uses capture phase so it wins over xterm.js's
keydown handler inside the focused terminal.
- Bump active/broadcasting borders to 2px and brighter colors so the
visual change is unmissable.
- Add a 🔔 test-toast button in the titlebar to verify the
notification pipeline independently of idle detection.
- Sprinkle console.log diagnostics through the active/broadcast/
idle/notify flows so we can pinpoint any remaining issues from
devtools next time something looks off.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend:
- save_workspace / load_workspace Tauri commands writing to
%APPDATA%\com.megaproxy.tiletopia\workspace.json with atomic
tmp+rename. Path from app.path().app_config_dir() (no dirs crate).
Layout helpers:
- tree.ts: changeDistro (with id swap to force XtermPane remount via
{#key}), changeLabel, presetSingle / TwoColumns / ThreeColumns /
TwoRows / TwoByTwo.
- New ops.ts with PaneOps interface bundling split / close /
setDistro / setLabel / distros, drilled through Pane chain
instead of individual callbacks.
UI:
- LeafPane: in-toolbar editable label (click to rename, Enter
saves, Esc cancels) and distro chip popover. Picking a different
distro respawns the pane.
- App.svelte: migrated from localStorage to APPDATA via the new
Tauri commands, debounced 500ms. One-time localStorage migration
on boot. Split inherits parent's distro+cwd. Titlebar preset
buttons with confirm when replacing >1 pane.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace post-test placeholder with the actual verified behaviors:
split-right/-down, multiple alive panes, gutter drag, close-collapse,
localStorage restore.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- src/lib/layout/tree.ts: pure helpers + types (newLeaf, splitLeaf,
closeLeaf, replaceById, serialize/deserialize with shape-checking).
- SplitNode.svelte: flex container with pointer-captured gutter drag.
- LeafPane.svelte: per-pane toolbar (split-right ⇥, split-down ⇣,
close ×) over the existing XtermPane.
- Pane.svelte: recursive dispatcher between SplitNode and LeafPane,
keyed on leaf.id so swaps unmount XtermPane cleanly (kills PTY).
- App.svelte: tree-as-state with split/close handlers, auto-save to
localStorage on every \$effect tick. Titlebar shows clickable distro
buttons setting the default for new panes; existing panes keep theirs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Forgot to land the gitignore line in the previous commit; the
git rm --cached removed the tracked files but new builds would
re-add them without this.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These JSON schemas are regenerated on every cargo build from
src-tauri/capabilities/. Same convention as claude-usage-widget.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tauri 2 + Svelte 5 + xterm.js + portable-pty. Single full-window
WSL terminal pane with clickable distro picker. M1 verified manually
on Windows: window opens, xterm.js renders, claude TUI works,
resize reflows cleanly.
Graduated from ~/claude/ideas/wsl-mux/ per the approved plan at
~/.claude/plans/imperative-coalescing-feigenbaum.md. See memory.md
for decisions, open TODOs, and the M2-M5 roadmap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>