From 597f9ac9b702cf3f5d3a74ea80b32fd1f57050ed Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 19:01:26 +0100 Subject: [PATCH] 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) --- memory.md | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/memory.md b/memory.md index f22704f..892abc2 100644 --- a/memory.md +++ b/memory.md @@ -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 M0–M5 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: }]`. 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` 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>`. `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>>, save_task: Mutex> }`. 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: [] }`. **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`** 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` and `Arc` 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` 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.