Session log: tabs + multi-window pane transfer (3 phases)

Documents architecture (Rust-side transferring refcount; backend-aggregated
save; scrollback ring replay), the load-bearing Tauri facts (process-wide
event routing, shared PtyManager), and the verification steps still needed
on the Windows host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-28 19:01:26 +01:00
parent 6faf7e5e19
commit 597f9ac9b7

View file

@ -34,7 +34,7 @@ Durable memory for this project. Read at session start, update before session en
- [ ] **Configurable idle threshold.** Hardcoded 5000ms in `LeafPane.svelte`. Should move into a settings panel; M5 territory.
- [x] ~~**Logic tests for `tree.ts`.**~~ Vitest, 43 cases, runs via `pnpm test`. Done 2026-05-22.
- [ ] **Component-level tests** (vitest + jsdom + @testing-library/svelte) — would have caught the M4 active-border reactivity bug. Useful when the Svelte component surface stops being trivial; defer until/unless something else goes sideways.
- [ ] **Multi-workspace tabs.** Several independent layouts the user can switch between. Saved as `workspaces.json` with `{ current: id, list: [{ id, name, tree }] }`. Not on the M0M5 critical path; either bolt on after M5 ship or fold into a "tabs" minor milestone.
- [x] ~~**Multi-workspace tabs.**~~ Done 2026-05-28. Implementation lives under "Tabs + multi-window pane transfer" session log. Envelope shape ended up as `{ version: 2, workspaces: [{ id, name, tree }] }` (no separate `current` field — per-window in React state only).
- [x] ~~**M5 — Ship infrastructure.**~~ Custom icon, version bumped to 0.1.0, `scripts/release.sh` for one-shot tag+upload, README install section. Done 2026-05-22. **Next step (user action):** run `pnpm tauri build` on Windows then `scripts/release.sh v0.1.0` from WSL to cut the actual release.
- [ ] **Native Windows shells (cmd / pwsh)?** `portable-pty` supports them for free; keep the option open. Decide whether to expose in UI at M3.
- [ ] **Persistent scrollback across app restarts.** Would need an out-of-process mux daemon. Big scope creep; explicitly deferred past v1.
@ -52,6 +52,58 @@ Durable memory for this project. Read at session start, update before session en
## Session log
### 2026-05-28 — Tabs + multi-window pane transfer (3 phases, pushed)
Two big features the user asked for in one session. Three commits on `main`: `1a035ad` (Phase 1 tabs), `8ad5178` (Phase 2 transfer), `6faf7e5` (Phase 3 drag-out). **Rust side authored in WSL — cargo build still needs verification on Windows host before this is runnable.**
**Phase 1 — tabbed workspaces.** Tab strip above the existing pane area; each tab owns an independent tile tree.
- **Persistence shape:** workspace.json migrated from bare `TreeNode` to `{ version: 2, workspaces: [{ id, name, tree }] }`. Legacy v1 is auto-detected in `deserializeWorkspaces` and wrapped as `[{ name: "Default", tree: <legacy> }]`. Per-leaf `migrateLegacyLeaves` (PowerShell sentinel etc.) still applies per-tree.
- **PTYs survive tab switches via render-all-panes.** Every workspace's panes mount at once; inactive workspace layers use `visibility: hidden; pointer-events: none; z-index: 0` while keeping `position: absolute; inset: 0`. `visibility: hidden` (vs `display: none`) preserves the container's bounding rect so xterm.js's fit() reads valid dims; the existing per-pane resize dedupe in XtermPane (`lastSentCols/Rows` check) absorbs no-op SIGWINCHes.
- **`tree` / `setTree` kept as identity-stable derived wrappers** that read `currentWorkspaceIdRef.current`. Means the bulk of App.tsx didn't change despite the state model shift. Same trick for `activeLeafId` / `setActiveLeafId` — backed by `activeLeafByWorkspace: Map<WorkspaceId, NodeId | null>` so each tab remembers its own focus.
- **Hidden-tab focus guard (plan-agent catch).** XtermPane's mount-time `term.focus()` would yank focus into hidden tabs on app boot. Guarded with `getComputedStyle(container).visibility !== "hidden"`. CSS visibility is inherited, so the computed value on the container reflects the workspace-layer's setting. Focus poller in App.tsx:223 also scoped to the active workspace layer via `data-workspace-id` ancestor check.
- **Shortcuts:** Ctrl+T new tab, Ctrl+Shift+T close (window.confirm when there are live panes), Ctrl+PageDown/PageUp navigate, Ctrl+1..9 switch. shortcuts.ts is SoT; README + Help auto-regenerate via `pnpm gen:readme`.
- **Tab close confirm is inline popover** anchored to the X button (per plan-agent: not modal-queue style — close is user-initiated, not a stream of unsolicited prompts like MCP).
**Phase 2 — multi-window pane transfer.** Right-click pane toolbar → "Move to new window" pops the pane into a fresh tiletopia window with its PTY intact. New window is a full peer with its own tab strip.
- **The load-bearing facts** (verified by reading pty.rs / lib.rs / ipc.ts):
1. `PaneId = u64`, never reused, sequence-assigned. Stable across windows.
2. `pane://{id}/data` events go through `AppHandle::emit` — Tauri 2 event system is **process-wide**, so any window that `listen()`s on the same id gets the same stream.
3. `PtyManager` lives in `Arc<>` managed state; one process, one manager, every window shares it.
- **Transfer-suppression: Rust-side refcount, NOT a JS module Set.** `PtyManager.transferring: Mutex<HashMap<PaneId, u32>>`. `kill_pane` becomes a no-op while refcount > 0. Source window's unmount calls `kill_pane` → silently dropped; target window's `claim_pane` decrements after subscribing. The JS-side "in-flight set" the plan-agent vetoed would have raced cross-window React event loops.
- **Scrollback replay shipped in v1** (plan-agent's other ship-in-v1 call). `get_pane_ring(id) -> base64` returns the existing PaneRing snapshot (256 KiB ≈ 3000 lines @ 80 cols). New window's XtermPane writes the ring to xterm.js BEFORE attaching the live `onPaneData` listener. Without this, a transferred Claude session looks blank until the next prompt repaint.
- **Cross-window save coordination via backend aggregator** (plan-agent's third correction). Each window debouncing its own write to workspace.json would race. New `window_state.rs`: `WindowsState { per_window: Mutex<HashMap<String, Vec<Value>>>, save_task: Mutex<Option<JoinHandle>> }`. Frontends call `push_window_workspaces(label, json)`; backend stores per-window, debounces save with a 500ms tokio sleep, atomic-writes the merged `{ version: 2, workspaces: [<all from all windows>] }`. **Workspaces stored as `serde_json::Value`** — backend stays agnostic of tree shape across future LeafNode changes.
- **Non-main window close drops its entry** via `Tauri::WindowEvent::CloseRequested` in lib.rs `on_window_event`. Matches Chrome-style "closing a detached window discards its tabs". Main window's entry persists across the app lifetime so on next launch all of main's tabs reopen.
- **MCP scoped to main window only.** Both the mirror push and `onMcpRequest` subscription gated on `IS_MAIN_WINDOW = getCurrentWebviewWindow().label === "main"`. `paneIdByLeafRef` is per-window, so a request targeting a leaf in another window would fail to resolve anyway. Documented as "MCP sees main's current tab" — future extension could expose `list_windows()` / `switch_window()` MCP tools.
**Phase 3 — drag-out gesture.** Extended the existing pointer-drag for header swap: release more than 60px past any viewport edge → drag-out via the same `moveToNewWindow` path. The 60px margin avoids triggering on accidental release over the OS titlebar (~30px). No backend changes — just a second entry point into Phase 2's mechanism.
**Architecture artefacts worth remembering:**
- **`getCurrentWebviewWindow().label`** is sync-available at module-load time (not async!) — captured into module-level `CURRENT_WINDOW_LABEL` and `IS_MAIN_WINDOW` constants. Cleaner than `useEffect`-awaiting it.
- **`transferredPaneIdsRef: Map<NodeId, PaneId>`** is a one-shot side channel populated BEFORE `setWorkspaces` during mount, consumed in `registerPaneId`. LeafPane reads it via `orch.getInitialPaneIdFor(leaf.id)` and passes `existingPaneId` to XtermPane to skip spawn. Cleaner than threading the id through LeafNode (which is persisted state).
- **`WindowEvent::CloseRequested` closure captures `Arc<WindowsState>` and `Arc<PendingInits>` by move.** `windows_state_for_event.forget(label)` is the cleanup path; `pending_inits_for_event.by_label.lock().remove(&label)` removes any unconsumed init payload (the consumed-then-window-died case).
**Phase 2 verification needed** (user, on Windows host):
1. `cd D:\dev\tiletopia && cargo check` — the Rust changes have to compile. Watch for: tauri 2 `WebviewWindowBuilder::new` signature, `on_window_event` handler closure types, my `Arc<Self>` method receiver style on WindowsState.
2. `pnpm tauri dev` — smoke test:
- Existing workspace loads as one tab named "Default" ✓ migrate
- Ctrl+T spawns new tab with default-shell pane
- Switch tabs while a `sleep 60` is running in another tab — countdown continues
- Right-click any pane → "Move to new window" → new window appears with the pane, PTY content visible (ring replay)
- Resize new window → `tput cols` in the moved pane shows new dims
- Close new window → reopen the app → those tabs should NOT come back (the close-discards-tabs Chrome behavior)
- With MCP running, `list_panes` from Claude only sees main's current tab
**Known follow-ups specific to this session** (none ship-blocking; all v0.4.0+ territory):
- **Per-tab MCP visibility.** Today Claude only sees main's current tab; switching tabs in main changes Claude's view mid-conversation. Could expose `list_workspaces()` + `switch_workspace(id)` MCP tools. Defer until requested.
- **Window position persistence across restart.** User chose "tabs persist, not windows" in the design Q&A so this is by design, but if a power user ever wants restored window geometry, the `WindowsState` map already has the structure to track it; just add inner_size/outer_position to the per-window entry.
- **Drag-out across monitors with mismatched DPI.** Tauri 2's `outerPosition()` is physical px while `clientX/Y` is CSS px. My implementation only uses clientX/Y (no async query at drag start), so multi-monitor drag works as long as the user releases far enough from the source window's edge. New window appears at the OS default position; user manually drags it to the target monitor. Acceptable v1.
- **Drag a pane INTO an existing other window.** Only NEW-window drag in v1. Adding "drag to existing window" needs cross-window pointer-event coordination (Tauri 2 doesn't expose this). Defer.
- **CLAUDE.md still says Svelte 5** (called out in 5+ session logs now). Bump it next time someone touches the file.
### 2026-05-26 — **v0.3.0 shipped to Forgejo releases**
Cut after a marathon session that took MCP from read-only v1 → full write surface + policy engine + audit + safeguards + .mcpb bundle. Tag `v0.3.0`, both `tiletopia_0.3.0_x64-setup.exe` and `tiletopia.mcpb` attached.