Right-click any pane's title bar → "Move to new window" pops it into a
fresh tiletopia window with its PTY intact. Same Tauri process; the
PtyManager is shared, so the existing PaneId stays valid and Tauri 2's
process-wide event routing keeps pane://{id}/data flowing into the new
window's XtermPane.
Mechanism (Rust-side, plan-agent's main correction over my draft):
- pty.rs: PtyManager.transferring is a per-pane refcount; kill_pane
becomes a no-op while it's >0. Source window's React unmount calls
kill_pane → silently dropped while in flight; target window's
claim_pane decrements after it has subscribed.
- window_state.rs: per-window workspaces snapshot map +
debounced-by-tokio aggregate save. Each window pushes its tabs via
push_window_workspaces; backend writes the merged
{ version: 2, workspaces: [...] } envelope. Non-main windows have
their entries dropped on CloseRequested so closing a detached window
discards its tabs (Chrome-style).
- commands: mark_pane_transferring, claim_pane, get_pane_ring (base64
scrollback ring snapshot), create_pane_window, take_pending_window_init,
push_window_workspaces.
Frontend:
- XtermPane gets `existingPaneId?: PaneId`: skip spawn, replay ring
snapshot via term.write before attaching the live data listener,
resize PTY to this window's grid, claim_pane. Scrollback replay was
the plan agent's other ship-in-v1 call — without it a transferred
Claude session looks blank until next prompt repaint.
- LeafPane: onContextMenu opens a fixed-positioned "Move to new
window" popover. Esc / outside-click dismiss.
- orchestration adds moveToNewWindow + getInitialPaneIdFor; App owns a
one-shot transferredPaneIdsRef cleared in registerPaneId.
- App mount branches on getCurrentWebviewWindow().label: main loads
workspace.json as before; non-main calls take_pending_window_init
and builds a singleton workspace around the adopted leaf.
- MCP mirror + onMcpRequest only run in main (paneIdByLeafRef is per-
window; Claude sees the main window's current tab as the single
workspace surface).
pnpm check (tsc -b) clean. 79/79 vitest pass. Rust side authored in
WSL; cargo build needs verification on Windows host before this is
runnable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each tab is an independent tile tree; PTYs in non-active tabs keep
running (render-all-panes with visibility:hidden on inactive layers
so xterm.js's fit() still sees valid dimensions and the existing
per-pane resize dedupe absorbs no-op SIGWINCHes).
workspace.json shape goes from a bare TreeNode to
`{ version: 2, workspaces: [{ id, name, tree }] }` with a legacy v1
auto-wrap migration (the old single tree becomes one tab named
"Default").
App.tsx wraps the old single-tree state in workspace-aware state
but keeps `tree` / `setTree` / `activeLeafId` / `setActiveLeafId` as
identity-stable derived wrappers (reading currentWorkspaceId from a
ref), so the bulk of App.tsx stays unchanged.
XtermPane's initial term.focus() now checks `visibility !== "hidden"`
on the container so a pane mounting inside a hidden tab on app boot
doesn't yank focus away from the active tab. The focus poller is
scoped to the active workspace layer for the same reason.
Shortcuts: Ctrl+T new tab, Ctrl+Shift+T close current (window.confirm
when there are live panes), Ctrl+PageDown/PageUp navigate, Ctrl+1..9
switch to tab N. README + help overlay auto-generated from
shortcuts.ts.
79/79 vitest pass (7 new envelope-migration cases). tsc -b clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
buildConfirmInfo's spawn_pane case used `a.spec!.hostId` inside a
hosts.find() callback, which compiles under tsc --noEmit (what
pnpm check runs) but fails under tsc -b (what pnpm build runs):
the non-null assertion drops the kind==="ssh" narrowing the parent
ternary had established, so .hostId can't be resolved against the
WSL/PowerShell variants of the union.
Fix: bind a.spec to a local const inside the narrowed if-block so
the closure carries the SSH variant through.
Caught by pnpm tauri build during v0.3.0 release prep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final v2 PR. All 11 planned write tools live. add_host/delete_host let
Claude mutate the saved-hosts list; both gated by a new allowAddHost
switch (default off) — symmetric with the allowOpenSsh gate from PR-3.5.
add_host's extraArgs are sanitised against CVE-2023-51385-class
local-RCE primitives: ProxyCommand, LocalCommand, KnownHostsCommand,
PermitLocalCommand=yes are refused server-side. Recognises both -o KEY=VAL
and -oKEY=VAL, case-insensitive on the key. The manual host manager UI
stays unrestricted (user has full agency over their own hosts).
Also fixes a pre-existing compile bug: mcp_policy.rs's policy_with test
helper was missing the ssh_safeguards field added in PR-3.5, silently
breaking the entire policy test module since then. Re-enabling those
tests is the prereq for the hard-deny rework that follows in the next
commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small things bundled from PR-3 verification:
1. Split SSH out of mcp.spawn_pane schema. New McpSpawnSpec enum
(Wsl | Powershell only) used for SpawnPaneArgs, so Claude's
spawn_pane tool description and JSON schema show only the local
shells. SSH must go through connect_host. The internal
pty::SpawnSpec is unchanged — the frontend's manual spawn path
via XtermPane still supports all three variants. Previously
spawn_pane(kind=ssh) was a half-broken path that required `host`
as a separate mandatory field even when hostId was given;
serde-rejected the natural "spawn to a saved host" call shape.
2. Refresh the MCP server's `with_instructions` text and the
module-level header comment. Both still claimed "read-only v1"
long after the v2 write surface landed, which was making Claude
refuse to attempt tools on first contact ("the server has
flagged itself as read-only..."). The instructions now describe
the actual tool set, the SSH-via-connect_host convention, and
the policy/safeguards gates so Claude doesn't have to infer.
3. Add a "Connect" button to the SSH hosts manager. Previously
the dialog only had Edit — users (rightly) expected clicking a
saved host to spawn an SSH pane to it. New onConnect callback
does the splitLeaf + smart-orient dance and closes the manager.
Buttons wrapped in a flex container so the row's
space-between layout doesn't strand the new button mid-row.
Three of the highest-power v2 tools, plus a defense-in-depth pass
on SSH-specific risk.
write_pane sends keystrokes (or any bytes) to a pane's PTY. The
policy engine matches against the text content directly so rules
like write_pane(npm test*) match by what would run, and the
compiled-in hard-deny catches rm -rf /, fork bombs, etc. regardless
of policy. Per-pane token-bucket rate limiter (30 calls / 10s,
3/sec refill) prevents a runaway loop from spamming the user with
confirm modals or burning audit-log capacity. The frontend handler
truncates the text in modal/audit summaries to ~60 chars + escapes
control characters so secrets pasted into write_pane don't echo
verbatim into the UI.
spawn_pane mirrors the existing SpawnSpec enum (WSL distro,
PowerShell, SSH) as the tool schema. New splitLeafWith helper
inserts a caller-built LeafNode (with a pre-generated id) so the
handler can await waitForPaneRegistration on that exact leaf before
replying with the resulting {leafId, paneId}. 15s spawn timeout
covers cold-start WSL distros; 30s for connect_host covers SSH
handshake + auth. Outer dispatch timeout bumped 30s → 60s. SSH
spawns without a saved hostId are refused — LeafNode only persists
sshHostId, no inline params, so use connect_host.
connect_host is a thin wrapper that looks up a saved SSH host by
id and routes through the same spawn machinery.
McpConfirm.tsx gains an optional ssh context — when the call
targets or spawns an SSH pane, a red warning banner renders
explaining that pattern matching is best-effort on the bytes we
send (remote shell expands aliases/subshells before executing).
buildConfirmSummary became buildConfirmInfo and returns the SSH
context alongside the summary string.
PR-3.5 — SSH safeguards. Two new switches in the Policy tab,
both off by default, both gated by mcp_policy::SshSafeguards:
allowOpenSsh: when off, connect_host and spawn_pane(kind=ssh)
refuse server-side with a clear "ssh-disabled" message pointing
at the Policy tab. User must open SSH manually via the titlebar
🔑 picker and toggle 🤖 on to grant Claude access.
autoAllowSpawnedSsh: when off, an SSH pane Claude spawns starts
with mcpAllow=false. User must explicitly toggle 🤖 before
Claude can read scrollback or send keystrokes. The second switch
is disabled in the UI when the first is off.
The safe-by-default design means a fresh install gives Claude no
ability to autonomously touch SSH — full safety with one click per
level to enable when consciously wanted. Both switches read fresh
per call so policy edits take effect without a server restart.
ErrorBoundary.tsx — last-resort guard against React render
exceptions. Wraps the App root + each MCP panel tab independently
so a bug in one tab doesn't blank the entire app. Shows a small
red error card with the exception message and a "Try again"
button. Caught a serde rename_all bug during PR-3.5 testing where
PolicyTab read policy.sshSafeguards but Rust serialized
ssh_safeguards (snake_case); without the boundary the whole window
went black.
newId() now exported from tree.ts for the splitLeafWith path.
McpPolicy struct gained #[serde(rename_all = "camelCase")] so
sshSafeguards survives the IPC round-trip cleanly; older policy
files without the field still load (serde defaults to safe).
Four more tree-shape tools routed through the existing dispatcher
+ confirm modal + audit log. All take leaf_id args (single or pair)
that must be MCP-allowed via the per-pane chip; apply_preset takes
a typed PresetName enum (single, two_columns, three_columns,
two_rows, two_by_two) plus an allow_drops boolean.
apply_preset's data-loss case is handled non-interactively: if the
preset has fewer slots than the current pane count and allow_drops
is not set, the frontend handler throws with a descriptive message
listing the leaf labels that would be killed, so Claude can decide
whether to retry with allow_drops=true rather than the user being
ambushed by a destructive confirm modal.
promote_pane errors with "no perpendicular split above it" when the
parent shares orientation with the grandparent (same condition the
Ctrl+Shift+P shortcut uses to toast a no-op).
Extracted a require_visible_leaf helper on TileService since 4+ of
the v2 tools now do the same mirror-presence + cloned-metadata
check. Same args_repr convention as set_label so policy rules like
"close_pane" (bare tool name) work uniformly.
App.tsx now listens on "mcp://request" and resolves each call:
needsConfirm=true queues a confirm modal (Accept/Reject, or
"Always allow <tool>" which appends the bare tool name to the
policy's allow bucket on the fly); needsConfirm=false runs straight
through. Replies via mcp_action_reply with externally-tagged
Result. The only wired-up tool for now is set_label, which delegates
to the existing ops.setLabel path.
McpConfirm.tsx (new) — themed amber-bordered modal sibling to the
existing overlays. Enter = accept, Esc = reject. Shows tool, the
policy reason that triggered the prompt, a human-readable summary
("Rename pane X → Y"), and an expandable raw-args section.
Audit log: subscription lifted from AuditTab up to App.tsx so events
fired while the panel is closed (or on Config/Policy tab) still land
in the ring. AuditTab becomes presentational; McpPanel forwards
entries + clearAudit + computes the unread badge from a baseline
seen-count.
StrictMode race fix: both new App-level listeners (mcp://audit and
mcp://request) use the cancelled-flag pattern so a late-resolving
listen() Promise after a strict-mode pretend-unmount tears itself
down instead of leaking a second subscription. Previously this
manifested as duplicate audit rows and a need-to-click-twice on
modal buttons.
Port (default 47821) and bearer token now persist to mcp.json with
OS-picked fallback if the port is taken; new Regenerate button in the
panel rotates the token and restarts the running server. rmcp's
DNS-rebinding host allowlist is disabled so WSL gateway IPs can
connect (bearer-auth handles the gatekeeping); the auth middleware
only enforces on /mcp paths so OAuth-discovery clients don't see a
Bearer challenge on /.well-known/* probes.
Claude Code's HTTP-MCP client currently tries OAuth and ignores
static `headers` auth (anthropics/claude-code#17152, #46879), so the
panel + README config snippet now uses `npx mcp-remote` as a stdio
shim that proxies the HTTP endpoint with the bearer baked in.
- Collapse the inline distro buttons + PowerShell + 🔑 SSH hosts into
a single 'Ubuntu ▾' dropdown (WSL distros + PowerShell sections),
with 🔑 as a separate icon-only button.
- Collapse the 5 preset buttons into a 'layout ▾' dropdown.
- Add a '+' button next to the shell picker that spawns a new pane of
the picked shell by splitting the active pane (smart orientation:
splits right if wide, down if tall). Per-pane ⇥/⇣ arrows still
inherit from parent — only '+' uses the titlebar selection.
- Drop the 🔔 test-toast button.
- Drop overflow:hidden from titlebar + pane toolbar so dropdowns
aren't clipped; height lock + nowrap still prevent the reflow bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
| 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>
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>
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>