From 80ca24c8fc7e606ee358c9440642ce36426c95bd Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 23 Apr 2026 14:15:05 -0700 Subject: [PATCH 1/8] Phase A: add ontology spec, align layout.md and vscode.md. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce docs/specs/ontology.md as the canonical vocabulary for Session states across six orthogonal layers (Process, Registry, View, Link, Activity, Snapshot). Define state names, transition verbs, and the Liskov contract for Registry APIs. Rewrite layout.md and vscode.md to use ontology vocabulary: resume vs restore (was overloaded "reconnect"), mount/unmount (was DOM-level "attach/detach"), dispose (was "destroyTerminal"), ActivityState (was "SessionUiState"), layoutAtMinimize (was "restoreLayout"), doors (was "detached"), exact/neighbor reattach (was "restore"). Code identifiers unchanged — this commit is spec prose only. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 1 + docs/specs/layout.md | 46 +++++++------ docs/specs/ontology.md | 147 +++++++++++++++++++++++++++++++++++++++++ docs/specs/vscode.md | 32 ++++----- 4 files changed, 188 insertions(+), 38 deletions(-) create mode 100644 docs/specs/ontology.md diff --git a/AGENTS.md b/AGENTS.md index 373c7f4..4451596 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ pnpm build # build lib, vscode extension, and website The primary job of a spec is to be an accurate reference for the current state of the code. Read the relevant spec before modifying a feature it covers — the spec describes invariants, edge cases, and design decisions that are not obvious from the code alone. +- **`docs/specs/ontology.md`** — Canonical vocabulary for Session states, layers (Process / Registry / View / Link / Activity / Snapshot), transition verbs, and the Liskov contract on Registry APIs. Read this first. Other specs defer to it when naming a state or a verb. - **`docs/specs/layout.md`** — Tiling layout, pane/door containers, dockview configuration, modes (passthrough/command), keyboard shortcuts, selection overlay, spatial navigation, minimize/reattach, inline rename, session lifecycle, session persistence, and theming. Read this when touching: `Pond.tsx`, `Baseboard.tsx`, `Door.tsx`, `TerminalPane.tsx`, `spatial-nav.ts`, `layout-snapshot.ts`, `terminal-registry.ts`, `session-save.ts`, `session-restore.ts`, `reconnect.ts`, `index.css`, `theme.css`, or any keyboard/navigation/mode behavior. - **`docs/specs/alert.md`** — Activity monitoring state machine, alert trigger/clearing rules, attention model, TODO lifecycle (soft/hard), bell button visual states and interaction, door alert indicators, and hardening (a11y, motion, i18n, overflow). Read this when touching: `activity-monitor.ts`, `alert-manager.ts`, the alert bell or TODO pill in `Pond.tsx` (TerminalPaneHeader), alert indicators in `Door.tsx`, or the `a`/`t` keyboard shortcuts. Layout.md defers to this spec for all alert/TODO behavior. - **`docs/specs/vscode.md`** — VS Code extension architecture: hosting modes (WebviewView + WebviewPanel), PTY lifecycle and buffering, message protocol between webview and extension host, session persistence flow, reconnection protocol, theme integration, CSP, build pipeline, and invariants (save-before-kill ordering, PTY ownership, alert state merging). Read this when touching: `extension.ts`, `webview-view-provider.ts`, `message-router.ts`, `message-types.ts`, `pty-manager.ts`, `pty-host.js`, `session-state.ts`, `webview-html.ts`, `vscode-adapter.ts`, or `pty-core.js`. diff --git a/docs/specs/layout.md b/docs/specs/layout.md index a45792f..0338ed8 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -1,10 +1,12 @@ # Layout Spec +> See `docs/specs/ontology.md` for canonical state names, layer definitions, and transition verbs. This spec uses the ontology's vocabulary throughout. + ## Conceptual model -A **Session** is a single PTY instance — a running shell process with its scrollback, environment, and working directory. Sessions are managed by the terminal registry and persist independently of how they are displayed. Each session also carries UI state: an alert status (from the activity monitor) and an optional TODO flag. +A **Session** is a single PTY instance — a running shell process with its scrollback, environment, and working directory. Sessions are managed by the terminal registry and persist independently of how they are displayed. Each session also carries Activity state (alert status from the activity monitor, optional TODO flag). -A Session can be in one of two containers: +A Session's **View** state places it in one of two containers: - **Pane** — a visible container in the content area. The session's terminal output is rendered via xterm.js. The pane has a header with controls and acts as the drag handle for layout rearrangement. - **Door** — a minimized container in the baseboard. The session is still alive (PTY running, output buffered) but not visible. The door shows the session's title plus alert and TODO indicators, and looks like a mouse hole cut into the baseboard. @@ -211,24 +213,24 @@ Swaps session **content** between two panes — the layout shape is unchanged. U ## Minimize and reattach ### Minimize (`m` key or minimize header button) -1. Capture restore context before removing: +1. Capture reattach context before removing: - `neighborId` and `direction`: spatial position relative to nearest neighbor - - `remainingPanelIds`: sorted IDs of panes that stay - - `restoreLayout`: full layout snapshot - - `detachedLayoutSignature`: structural fingerprint (ignores sizes) + - `remainingPaneIds`: sorted IDs of panes that stay + - `layoutAtMinimize`: full layout snapshot + - `layoutAtMinimizeSignature`: structural fingerprint (ignores sizes) 2. Remove pane from dockview (`api.removePanel`) -3. Add to `detached` state → door appears in baseboard -4. Session stays alive in registry (not destroyed) +3. Add to `doors` state → door appears in baseboard +4. Session stays in registry (not disposed) 5. Selection moves to the new door (stays in command mode) ### Reattach (click door, Enter/d on door) Three strategies based on layout state: -**Exact restore** (layout structure signature matches AND same panes exist): +**Exact reattach** (layout structure signature matches AND same panes exist): - Deserialize the saved layout snapshot with `reuseExistingPanels: true` - Preserves exact split ratios from before minimize -**Neighbor restore** (neighbor still exists AND pane set matches `remainingPanelIds`): +**Neighbor reattach** (neighbor still exists AND pane set matches `remainingPaneIds`): - `addPanel` with `position: { referencePanel: neighborId, direction }` - Restores original position relative to neighbor @@ -249,28 +251,28 @@ The name `` is replaced by an `` with: ## Session lifecycle and terminal registry -Pane IDs are session IDs. `TerminalPane` calls `getOrCreateTerminal(id)` on mount and `detachTerminal(id)` on unmount. The session (xterm.js instance, PTY, DOM element) persists in the registry across mount/unmount cycles. +Pane IDs are session IDs. `TerminalPane` calls `getOrCreateTerminal(id)` on React mount and `unmountElement(id)` on React unmount. The session (xterm.js instance, PTY, DOM element) persists in the registry across mount/unmount cycles — the DOM element is detached from its container but the Registry entry stays `Mounted`. - **Create**: `getOrCreateTerminal` spawns xterm.js + FitAddon + PTY, returns existing if already created -- **Reconnect**: `reconnectTerminal` creates xterm entry and writes replay data without spawning a new PTY (used after webview recreation when platform preserves live PTYs) -- **Restore**: `restoreTerminal` creates xterm entry and spawns new PTY with saved cwd and scrollback (used on app restart from saved session) -- **Attach/detach**: moves the persistent DOM element in/out of a container — no session state loss -- **Destroy**: `destroyTerminal` kills PTY, disposes xterm, removes from registry. Only called on explicit kill (`x`). -- **Swap**: `swapTerminals` swaps two registry entries and reattaches DOM elements to each other's containers +- **Resume**: `resumeTerminal` creates xterm entry and writes replay data without spawning a new PTY. Used when the webview is recreated while the host retains Live PTYs (Link: Severed → Resuming → Live). +- **Restore**: `restoreTerminal` creates xterm entry and spawns a new PTY with saved cwd and scrollback. Used on cold start from a saved Snapshot (Link: Cold → Live). +- **mount / unmount (DOM)**: `mountElement` reparents the persistent DOM element into a container; `unmountElement` removes it. The Registry entry survives. +- **Dispose**: `disposeSession` kills the PTY, disposes xterm, removes the registry entry. Only called on explicit kill (`x`). +- **Swap**: `swapTerminals` swaps two registry entries and reattaches DOM elements to each other's containers. ### Session persistence Layout, scrollback, cwd, minimized items, and alert state are saved to persistent storage via a debounced save (500ms). Saves are triggered by layout changes, panel add/remove, and a 30s periodic interval. Saves are flushed immediately on PTY exit, `pagehide`, and extension shutdown requests. On startup, recovery is priority-based: -1. **Live PTYs** (webview hidden/shown): request PTY list + replay data from platform, `reconnectTerminal()` for each (500ms timeout). If the saved session covers every live PTY, restore the saved dockview layout when its visible panel set matches and restore saved minimized items as doors. -2. **Saved session** (app restart): restore layout from serialized dockview state, `restoreTerminal()` for each pane with saved cwd + scrollback, and spawn each PTY with the current default shell selection +1. **Resume** (webview hidden/shown, live PTYs): request PTY list + replay data from platform, `resumeTerminal()` for each (500ms timeout). If the saved session covers every live PTY, restore the saved dockview layout when its visible panel set matches and reattach saved minimized items as doors. +2. **Restore** (app restart, cold start): restore layout from serialized dockview state, `restoreTerminal()` for each pane with saved cwd + scrollback, and spawn each PTY with the current default shell selection 3. **Fallback/manual pane creation**: when no saved layout can be safely applied, add multiple panes as splits from the previous pane rather than tabs 4. **Empty state**: create a single new pane -### Session UI state +### Activity state -Each session carries `SessionUiState` with `status: SessionStatus` and `todo: TodoState`. These are synced to React via `useSyncExternalStore`. State that arrives from the platform before a terminal entry is created (reconnect scenario) is held as "primed state" and applied when the terminal is finally created. +Each session carries `ActivityState` with `status: SessionStatus` and `todo: TodoState`. These are synced to React via `useSyncExternalStore`. State that arrives from the platform before a registry entry exists (resume scenario) is held as "primed state" and applied when the registry entry is created. ## Theme @@ -337,8 +339,8 @@ The deferred spawn also only calls `selectPanel` if selection is null. The kill | `lib/src/components/Pond.tsx` | Main layout orchestrator: modes, keyboard, selection overlay, minimize/reattach. Also defines `TerminalPanel`, `TerminalPaneHeader`, `KillConfirmOverlay` | | `lib/src/components/Baseboard.tsx` | Always-visible bottom strip with door components, overflow arrows, and shortcut hints | | `lib/src/components/Door.tsx` | Individual door element — mouse-hole styled button with alert/TODO indicators | -| `lib/src/components/TerminalPane.tsx` | Thin xterm.js mount point — attaches/detaches persistent session elements | -| `lib/src/lib/terminal-registry.ts` | Session lifecycle: create, reconnect, restore, attach, detach, destroy, swap, focus, refit. Session UI state store | +| `lib/src/components/TerminalPane.tsx` | Thin xterm.js mount point — mounts/unmounts persistent session elements | +| `lib/src/lib/terminal-registry.ts` | Session lifecycle: create, resume, restore, mount, unmount, dispose, swap, focus, refit. Activity state store | | `lib/src/lib/spatial-nav.ts` | Spatial navigation (`findPanelInDirection`) and restore-neighbor detection (`findRestoreNeighbor`) | | `lib/src/lib/layout-snapshot.ts` | Layout cloning (`cloneLayout`) and structural signature (`getLayoutStructureSignature`) for restore comparison | | `lib/src/lib/activity-monitor.ts` | Per-session activity state machine: output timing → alert escalation | diff --git a/docs/specs/ontology.md b/docs/specs/ontology.md new file mode 100644 index 0000000..d06dcae --- /dev/null +++ b/docs/specs/ontology.md @@ -0,0 +1,147 @@ +# Ontology + +This spec is the canonical vocabulary for states, entities, and transitions in mouseterm. Every other spec defers to this one when naming a state or a verb. When writing code or prose, pick names from here first. + +## The core idea + +A **Session** is the durable unit. A Session's state lives on six orthogonal axes — change one without touching the others. A caller holding a `SessionId` can reason about each axis independently. + +The **Liskov contract**: a Session is substitutable across most operations regardless of which states it currently occupies. `kill` and `rename` work universally. State-gated operations (`write`, `focus`) document their preconditions in ontology terms rather than failing silently. + +## Layers + +| Layer | Tracks | Owner | +|---|---|---| +| **Process** | PTY life on the host | `vscode-ext/src/pty-manager.ts` | +| **Registry** | xterm.js Terminal + persistent DOM element + cached Activity state | `lib/src/lib/terminal-registry.ts` | +| **View** | Where and how the session renders | `lib/src/components/Pond.tsx` | +| **Link** | Webview ↔ host relationship | `lib/src/lib/reconnect.ts` | +| **Activity** | Alert / attention state machine | `lib/src/lib/alert-manager.ts` | +| **Snapshot** | Persisted-to-disk projection | `lib/src/lib/session-save.ts` / `session-restore.ts` | + +A **Session** is the tuple of its `SessionId` plus one state per layer. `SessionId` is immutable for the life of the Session and stable across restarts. + +## States per layer + +### Process + +| State | Meaning | +|---|---| +| `Live` | PTY process running, receiving and emitting data | +| `Exited` | Process ended; exit buffer retained so the user can inspect the output | +| `Tombstoned` | User-killed; host refuses to resurrect even if a late `exit` event arrives | +| `Absent` | No host record at all | + +### Registry + +| State | Meaning | +|---|---| +| `Unregistered` | No entry in `terminal-registry` | +| `Mounted` | Entry present, persistent DOM element is in the document tree | +| `Orphaned` | Entry present, element detached from DOM — transient state during reparent or minimize | +| `Disposed` | Entry removed, xterm disposed | + +### View + +| State | Meaning | +|---|---| +| `Paned` | Rendered as a pane in the content area (dockview group) | +| `Zoomed` | Subset of `Paned` — the selected pane is maximized | +| `Doored` | Rendered as a door on the baseboard | +| `Hidden` | In neither — the webview itself is closed, or the session is mid-transition | + +### Link + +| State | Meaning | +|---|---| +| `Cold` | First load of the webview; no handshake yet | +| `Live` | Handshake complete; events flowing from host to webview | +| `Resuming` | Webview just reopened; replay drain in progress | +| `Severed` | Webview closed while host retains the processes | + +### Activity + +Keep the existing state machine (see `docs/specs/alert.md` for transition rules): + +`ALERT_DISABLED` · `NOTHING_TO_SHOW` · `MIGHT_BE_BUSY` · `BUSY` · `MIGHT_NEED_ATTENTION` · `ALERT_RINGING` + +### Snapshot + +| State | Meaning | +|---|---| +| `Clean` | In-memory state matches disk | +| `Dirty` | Changes pending | +| `Flushing` | Debounced write in flight | + +## Transitions + +### User verbs + +A user verb is an intentional action that produces a single observable change. + +| Verb | Effect | +|---|---| +| `spawn` | Create a new Session (Process: Absent → Live) | +| `kill` | Request termination (Process: Live → Tombstoned, Registry: Mounted → Disposed, View: any → Hidden) | +| `minimize` | Pane → Door (View: Paned → Doored) | +| `reattach` | Door → Pane (View: Doored → Paned) | +| `rename` | Update title; layer-agnostic | +| `zoom` / `unzoom` | Paned ↔ Zoomed | +| `swap` | Exchange Registry entries across two View slots without touching Processes | + +### System verbs + +A system verb is a lifecycle transition driven by the runtime. + +| Verb | Effect | +|---|---| +| `register` / `dispose` | Create / destroy a Registry entry | +| `mount` / `unmount` | Attach / detach the persistent DOM element from a container (low-level op; the Registry entry survives `unmount`) | +| `exit` | Host observes process death (Process: Live → Exited) | +| `resume` | Webview reopens over live PTYs (Link: Severed → Resuming → Live; Registry rebuilt from replay data; Process stays Live) | +| `restore` | Cold start from Snapshot (Link: Cold → Live; Process: Absent → Live with saved cwd; Registry rebuilt from saved scrollback) | +| `tombstone` | Host marks a Session non-recoverable | + +## Liskov contract + +Every Registry API declares its layer preconditions. Calls against a gated state fail with a typed error rather than silently no-op. + +| Category | Valid when | Examples | +|---|---|---| +| **Universal** | any state combination | `kill`, `rename`, state queries | +| **View-gated** | `View ≠ Hidden` | `focus` | +| **Process-gated** | `Process = Live` | `write`, `resize` | +| **Registry-gated** | `Registry = Mounted` | `refit` | + +A caller holding a `SessionId` can issue universal operations without branching. Gated operations are explicit: the caller checks the relevant layer first or catches the typed error. + +## Invariants + +- I1: `SessionId` is immutable for the life of a Session and stable across `resume` / `restore`. +- I2: Process state is independent of Registry, View, and Link. A `Live` process may be `Doored` or `Hidden`; an `Exited` process may still be `Paned`. +- I3: Activity state survives `minimize` / `reattach`. `ALERT_RINGING` fires only on a *fresh* transition, never on `mount` or `reattach`. +- I4: `Registry: Orphaned` is transient. Steady states are `Mounted` or `Disposed`. +- I5: `kill` is universally valid. It always terminates at (Process: Tombstoned, Registry: Disposed, View: Hidden). +- I6: `rename` is universally valid including when `Process = Exited` and `View = Doored`. + +## Retired / overloaded terms + +Use ontology names instead of these. The left column retains a meaning only where noted. + +| Term | Status | +|---|---| +| **detach** | Retired. Previous meanings: DOM-level op → **unmount**; user-level Pane→Door → **minimize**. | +| **reconnect** | Retired. Live-PTY case → **resume**; cold start → **restore**. | +| **restore** | Keeps its meaning for cold-start rehydrate. Do not use it for Door→Pane (that is **reattach**) or for alert-manager seeding (that is **seed**). | +| **attach** | Retired at the DOM layer (was `attachTerminal`) → **mount**. User-level "reattach" (Door→Pane) keeps the `re-` prefix. | +| **session** | Keeps its meaning as the durable identity. Do not use it for the Activity projection (that is `ActivityState`, not `SessionUiState`). | +| **terminal** | Keeps its meaning for the `xterm.Terminal` instance. Prose meaning "the whole thing" is **Session**. | +| **panel / pane** | Prefer **pane**. Use "panel" only when quoting dockview's own API (`api.panels`, `addPanel`). | + +## Naming conventions + +- Layer names and state names are `PascalCase` nouns (`Paned`, `Tombstoned`). +- Verbs are `camelCase` in code and lowercase in prose (`minimize`, not `Minimize`). +- Event kind strings match the verb: `'minimizeChange'`, not `'detachChange'`. +- A persisted type is `Persisted` where `` is the ontology noun (`PersistedPane`, `PersistedDoor`). +- A handle type is `State` (`ActivityState`, not `SessionUiState`). diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index b121ada..0176e91 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -2,7 +2,7 @@ ## What's built -MouseTerm has two hosting modes: a `WebviewView` in the bottom panel (alongside Terminal, Problems, Output) and `WebviewPanel` editor tabs (via `mouseterm.open`, supports multiple instances). Both restore across "Developer: Reload Window". PTY lifecycle is fully decoupled from the webview — PTYs live in the extension host via `pty-manager.ts`, survive panel visibility toggling, and replay buffered output on reconnect. Session persistence works across restarts: pane layout, CWD, scrollback, alert state (enabled/disabled + todo), and resume commands are saved and restored on cold start. The view uses `workspaceState` for persistence; editor panels use VS Code's per-panel `vscode.setState()` so multiple panels don't clobber each other. Alert state is merged into every periodic save (not just deactivate) so it survives even if VS Code kills the extension host before deactivate completes. A `WebviewPanelSerializer` handles editor tab restoration; `onWebviewPanel:mouseterm` activation event ensures the extension activates early enough. Theme integration uses a two-layer CSS variable system mapping `--vscode-*` tokens to semantic `--mt-*` variables, covering all 16 ANSI colors, surfaces, typography, and borders. CSP is strict with nonce-gated scripts. +MouseTerm has two hosting modes: a `WebviewView` in the bottom panel (alongside Terminal, Problems, Output) and `WebviewPanel` editor tabs (via `mouseterm.open`, supports multiple instances). Both restore across "Developer: Reload Window". PTY lifecycle is fully decoupled from the webview — PTYs live in the extension host via `pty-manager.ts`, survive panel visibility toggling, and replay buffered output on **resume**. Session persistence works across cold **restore**: pane layout, CWD, scrollback, alert state (enabled/disabled + todo), and resume commands are saved and restored on cold start. The view uses `workspaceState` for persistence; editor panels use VS Code's per-panel `vscode.setState()` so multiple panels don't clobber each other. Alert state is merged into every periodic save (not just deactivate) so it survives even if VS Code kills the extension host before deactivate completes. A `WebviewPanelSerializer` handles editor tab restoration; `onWebviewPanel:mouseterm` activation event ensures the extension activates early enough. Theme integration uses a two-layer CSS variable system mapping `--vscode-*` tokens to semantic `--mt-*` variables, covering all 16 ANSI colors, surfaces, typography, and borders. CSP is strict with nonce-gated scripts. **Architecture:** @@ -34,7 +34,7 @@ Frontend Library (lib/src/) │ └── Door.tsx — individual minimized-pane door └── lib/ ├── terminal-registry.ts — global xterm.js registry, theme observer, alert wiring - ├── reconnect.ts — live reconnect + cold-start restore + ├── reconnect.ts — resume (live-PTY) + restore (cold-start) entry point ├── alert-manager.ts — alert state machine (portable, no DOM deps) ├── activity-monitor.ts — silence/output pattern detection for alert ├── session-save.ts — periodic save (debounced 500ms + 30s interval) @@ -54,11 +54,11 @@ Frontend Library (lib/src/) - **Save before kill.** Deactivate must save session state *before* killing PTYs. CWD and scrollback queries need live processes. See ordering in `extension.ts:deactivate()`. - **Alert state is global.** A single `AlertManager` instance in `message-router.ts` is shared across all routers and survives router disposal. PTY data feeds into it at module level, regardless of webview visibility. -- **PTY ownership.** Each router tracks its PTYs in `ownedPtyIds`. A module-level `globalOwnedPtyIds` set prevents a reconnecting router from stealing PTYs owned by another webview. +- **PTY ownership.** Each router tracks its PTYs in `ownedPtyIds`. A module-level `globalOwnedPtyIds` set prevents a resuming router from stealing PTYs owned by another webview. - **Shell login args are shell-specific.** The shared `pty-core.js` launches POSIX shells with `-l` only for shells that accept it. `csh`/`tcsh` must be spawned without `-l` so both the standalone app and VS Code extension can open a usable terminal for users whose login shell is C shell-derived. - **mergeAlertStates on every save path.** Both the frontend periodic save (`onSaveState` callback) and the backend deactivate refresh (`refreshSavedSessionStateFromPtys`) must merge current alert states. Missing this causes alert state to revert on restore. - **Scrollback trailing newline.** Restored scrollback must end with `\n` to avoid zsh printing a `%` artifact at the top of the terminal. -- **retainContextWhenHidden.** Set on both `WebviewPanel` (editor tabs) and `WebviewView` (bottom panel) so that xterm.js DOM, scrollback, and PTY subscriptions survive panel hide/show without going through the reconnect dance. +- **retainContextWhenHidden.** Set on both `WebviewPanel` (editor tabs) and `WebviewView` (bottom panel) so that xterm.js DOM, scrollback, and PTY subscriptions survive panel hide/show without going through a resume. - **Two save sources.** Session state is saved from two places: the frontend (debounced 500ms + 30s interval via `mouseterm:saveState`) and the backend (deactivate flushes webviews then refreshes from live PTYs). Both paths must produce consistent state. ### Extension manifest (current) @@ -91,14 +91,14 @@ Frontend Library (lib/src/) ### PTY lifecycle (decoupled from webview) -PTYs are managed by the extension host, not by the webview. The webview is a view layer that connects and disconnects from PTYs. +PTYs are managed by the extension host, not by the webview. The webview is a view layer that **resumes** over live PTYs (host-preserved) or **restores** from a Snapshot (cold start). See `ontology.md` for the Process / Link states. ``` Extension Host (always running while extension is active) ├── pty-manager.ts (forks pty-host.js child process) -│ ├── pty-1 (shell session, alive) -│ ├── pty-2 (shell session, alive) -│ └── pty-3 (shell session, exited) +│ ├── pty-1 (Process: Live) +│ ├── pty-2 (Process: Live) +│ └── pty-3 (Process: Exited) │ ├── WebviewView "MouseTerm" (bottom panel) │ └── message-router: owns pty-1, pty-2 @@ -110,17 +110,17 @@ Extension Host (always running while extension is active) This means: - Hiding the MouseTerm panel doesn't kill its PTYs. - VS Code toggling the panel visibility doesn't destroy sessions. -- When the view becomes visible again, the webview reconnects to still-owned PTYs and reapplies the saved visible-pane layout when the saved session covers the live PTY set and the layout's visible panels match. +- When the view becomes visible again, the webview **resumes** over still-owned PTYs and reapplies the saved visible-pane layout when the saved session covers the live PTY set and the layout's visible panels match. - Each message router tracks which PTYs it owns; PTYs cannot be stolen by another router. -- Explicitly killed PTYs are tombstoned in the extension host so a late child-process `exit` event cannot recreate their buffer and make them reconnectable. +- Explicitly killed PTYs are **tombstoned** in the extension host (`Process: Tombstoned`) so a late child-process `exit` event cannot recreate their buffer and make them resumable. - Multiple VS Code windows each get their own extension host process, and therefore their own pty-host child process. #### PTY buffering `pty-manager.ts` maintains two buffer types per PTY: -- **replayChunks**: cleared on first consume, used for hot reconnect (webview hidden then shown) -- **scrollbackChunks**: never cleared, used for re-reconnects and session save +- **replayChunks**: cleared on first consume, used for resume (webview hidden then shown) +- **scrollbackChunks**: never cleared, used for repeat resumes and session save Both are capped at 1M chars per PTY. When the cap is reached, oldest chunks are trimmed. @@ -133,10 +133,10 @@ Both are capped at 1M chars per PTY. When the cap is reached, oldest chunks are - { type: 'pty:list', ptys: [{ id, alive, exitCode }] } // all owned PTYs - { type: 'pty:replay', id, data } // buffered output per PTY 4. Webview restores terminals from replay data, resumes live stream -5. If the saved session covers those live PTYs, the frontend uses the saved dockview layout when its visible panels match and restores saved minimized doors; minimized PTYs reconnect into the registry but remain doors instead of visible panes +5. If the saved session covers those live PTYs, the frontend uses the saved dockview layout when its visible panels match and reattaches saved minimized doors; minimized PTYs are registered but remain doors instead of visible panes ``` -For cold-start restore (no live PTYs), the webview falls back to saved session state: spawns new PTYs in saved CWDs using the currently selected MouseTerm shell, injects saved scrollback (with trailing newline to avoid zsh `%` artifact), and restores dockview layout. The reconnect module (`reconnect.ts`) uses a 500ms timeout when waiting for the PTY list. +For cold restore (no live PTYs), the webview falls back to saved session state: spawns new PTYs in saved CWDs using the currently selected MouseTerm shell, injects saved scrollback (with trailing newline to avoid zsh `%` artifact), and restores dockview layout. The entry module (`reconnect.ts`) uses a 500ms timeout when waiting for the PTY list. ### Message protocol @@ -153,7 +153,7 @@ All types defined in `message-types.ts`. Webview-side handling in `vscode-adapte | `pty:getCwd` | Query PTY working directory (request-response via requestId) | | `pty:getScrollback` | Query PTY scrollback buffer (request-response via requestId) | | `pty:getShells` | Query available shells (request-response via requestId) | -| `mouseterm:init` | Trigger reconnection: get PTY list + replay data | +| `mouseterm:init` | Trigger resume: get PTY list + replay data | | `mouseterm:saveState` | Frontend persisting session state | | `mouseterm:flushSessionSaveDone` | Ack for deactivate-triggered flush (matched by requestId) | | `alert:toggle` | Toggle alert enabled/disabled for a PTY | @@ -174,7 +174,7 @@ All types defined in `message-types.ts`. Webview-side handling in `vscode-adapte |---------|---------| | `pty:data` | PTY output (routed only to owning router) | | `pty:exit` | PTY process exited (with exitCode) | -| `pty:list` | List of all reconnectable PTYs (response to `mouseterm:init`) | +| `pty:list` | List of all resumable PTYs (response to `mouseterm:init`) | | `pty:replay` | Buffered output since spawn (response to `mouseterm:init`) | | `pty:cwd` | CWD query response (matched by requestId) | | `pty:scrollback` | Scrollback query response (matched by requestId) | From 60c9cd8b57502daee5786a75275c2b617847adb6 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 23 Apr 2026 14:22:29 -0700 Subject: [PATCH 2/8] Phase B: internal rename to ontology vocabulary. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit terminal-registry.ts exports renamed to match the ontology: SessionUiState → ActivityState DEFAULT_SESSION_UI_STATE → DEFAULT_ACTIVITY_STATE subscribeToSessionStateChanges → subscribeToActivity getSessionStateSnapshot → getActivitySnapshot getSessionState → getActivity primeSessionState → primeActivity clearPrimedSessionState → clearPrimedActivity attachTerminal → mountElement detachTerminal → unmountElement reconnectTerminal → resumeTerminal destroyTerminal → disposeSession destroyAllTerminals → disposeAllSessions focusTerminal → focusSession refitTerminal → refitSession Module-level: reconnectFromInit → resumeOrRestore reconnectLivePtys → resumeLiveSessions alert-manager.restore() → seed() Shared types: DetachedItem → DooredItem PersistedDetachedItem → PersistedDoor DetachDirection → DoorDirection toDetachedItem → toDooredItem Pond.tsx identifiers: detachPanel → minimizePane onDetach prop → onMinimize 'detachChange' event → 'minimizeChange' [detached, setDetached] state → [doors, setDoors] detachedRef, detachedItems, initialDetached, restoredDetached → doorsRef, doorItems, initialDoors, restoredDoors findRestoreNeighbor → findReattachNeighbor (in spatial-nav.ts) Persisted schema field names (PersistedDoor.restoreLayout, detachedLayoutSignature, PersistedSession.detached) are unchanged — they are kept on disk format until Phase D's migration. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/.storybook/preview.ts | 26 +-- lib/src/App.tsx | 8 +- lib/src/components/Baseboard.tsx | 24 +-- lib/src/components/Pond.tsx | 164 ++++++++--------- lib/src/components/TerminalPane.tsx | 20 +-- lib/src/lib/alert-manager.ts | 2 +- lib/src/lib/reconnect.test.ts | 16 +- lib/src/lib/reconnect.ts | 14 +- lib/src/lib/session-restore.ts | 4 +- lib/src/lib/session-save.ts | 4 +- lib/src/lib/session-types.ts | 8 +- lib/src/lib/spatial-nav.ts | 10 +- lib/src/lib/terminal-registry.alert.test.ts | 166 +++++++++--------- lib/src/lib/terminal-registry.ts | 94 +++++----- lib/src/main.tsx | 6 +- lib/src/stories/Baseboard.stories.tsx | 6 +- lib/src/stories/MouseHeaderIcon.stories.tsx | 2 +- lib/src/stories/Pond.stories.tsx | 8 +- .../stories/TerminalPaneHeader.stories.tsx | 2 +- standalone/src/main.tsx | 6 +- vscode-ext/src/message-router.ts | 2 +- website/src/lib/tutorial-detection.ts | 2 +- 22 files changed, 297 insertions(+), 297 deletions(-) diff --git a/lib/.storybook/preview.ts b/lib/.storybook/preview.ts index e6f5d25..4f1c4a0 100644 --- a/lib/.storybook/preview.ts +++ b/lib/.storybook/preview.ts @@ -5,11 +5,11 @@ import '../src/theme.css'; import '../src/index.css'; import { initPlatform, type FakePtyAdapter, type FakeScenario } from '../src/lib/platform'; import { - clearPrimedSessionState, - destroyAllTerminals, - getSessionStateSnapshot, - primeSessionState, - type SessionUiState, + clearPrimedActivity, + disposeAllSessions, + getActivitySnapshot, + primeActivity, + type ActivityState, } from '../src/lib/terminal-registry'; import { VSCODE_THEMES } from './themes'; import { cfg } from '../src/cfg'; @@ -69,8 +69,8 @@ const preview: Preview = { const scenario = (context.parameters?.fakePty as { scenario?: FakeScenario })?.scenario; const primedSessionState = context.parameters?.primedSessionState as | { - byId?: Record>; - byIndex?: Partial[]; + byId?: Record>; + byIndex?: Partial[]; } | undefined; const platform = fakePlatform as FakePtyAdapter; @@ -82,17 +82,17 @@ const preview: Preview = { let raf2 = 0; const applyPrimedState = () => { - clearPrimedSessionState(); + clearPrimedActivity(); for (const [id, state] of Object.entries(primedSessionState?.byId ?? {})) { - primeSessionState(id, state); + primeActivity(id, state); } - const sessionIds = [...getSessionStateSnapshot().keys()]; + const sessionIds = [...getActivitySnapshot().keys()]; primedSessionState?.byIndex?.forEach((state, index) => { const id = sessionIds[index]; if (id) { - primeSessionState(id, state); + primeActivity(id, state); } }); }; @@ -104,9 +104,9 @@ const preview: Preview = { return () => { window.cancelAnimationFrame(raf1); window.cancelAnimationFrame(raf2); - clearPrimedSessionState(); + clearPrimedActivity(); platform.clearDefaultScenario(); - destroyAllTerminals(); + disposeAllSessions(); }; }, [platform, primedSessionState]); diff --git a/lib/src/App.tsx b/lib/src/App.tsx index 4228dcf..8e68668 100644 --- a/lib/src/App.tsx +++ b/lib/src/App.tsx @@ -1,6 +1,6 @@ import { Component, type ReactNode } from "react"; import { Pond } from "./components/Pond"; -import type { PersistedDetachedItem } from "./lib/session-types"; +import type { PersistedDoor } from "./lib/session-types"; class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { state: { error: Error | null } = { error: null }; @@ -24,17 +24,17 @@ class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | export default function App({ initialPaneIds, restoredLayout, - initialDetached, + initialDoors, baseboardNotice, }: { initialPaneIds?: string[]; restoredLayout?: unknown; - initialDetached?: PersistedDetachedItem[]; + initialDoors?: PersistedDoor[]; baseboardNotice?: ReactNode; }) { return ( - + ); } diff --git a/lib/src/components/Baseboard.tsx b/lib/src/components/Baseboard.tsx index 18f5bfc..77dc7d9 100644 --- a/lib/src/components/Baseboard.tsx +++ b/lib/src/components/Baseboard.tsx @@ -1,20 +1,20 @@ import { useRef, useState, useMemo, useLayoutEffect, useContext, useSyncExternalStore, type ReactNode } from 'react'; import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'; import { Door } from './Door'; -import { DoorElementsContext, WindowFocusedContext, type DetachedItem } from './Pond'; -import { DEFAULT_SESSION_UI_STATE, getSessionStateSnapshot, subscribeToSessionStateChanges } from '../lib/terminal-registry'; +import { DoorElementsContext, WindowFocusedContext, type DooredItem } from './Pond'; +import { DEFAULT_ACTIVITY_STATE, getActivitySnapshot, subscribeToActivity } from '../lib/terminal-registry'; export interface BaseboardProps { - items: DetachedItem[]; + items: DooredItem[]; activeId: string | null; - onReattach: (item: DetachedItem) => void; + onReattach: (item: DooredItem) => void; notice?: ReactNode; } export function Baseboard({ items, activeId, onReattach, notice }: BaseboardProps) { const { elements: doorElements, bumpVersion } = useContext(DoorElementsContext); const windowFocused = useContext(WindowFocusedContext); - const sessionStates = useSyncExternalStore(subscribeToSessionStateChanges, getSessionStateSnapshot); + const activityStates = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const [startIndex, setStartIndex] = useState(0); @@ -52,7 +52,7 @@ export function Baseboard({ items, activeId, onReattach, notice }: BaseboardProp if (arrowMeasureEl.current) { layoutMetrics.current.arrowWidth = arrowMeasureEl.current.offsetWidth; } - }, [items, sessionStates]); + }, [items, activityStates]); // Reset startIndex when the set of door items changes (not just count) const itemKey = useMemo(() => items.map(i => i.id).join('\0'), [items]); @@ -137,13 +137,13 @@ export function Baseboard({ items, activeId, onReattach, notice }: BaseboardProp {/* Hidden measurement pass — doors + overflow arrow */}
{items.map(item => { - const sessionState = sessionStates.get(item.id) ?? DEFAULT_SESSION_UI_STATE; + const activity = activityStates.get(item.id) ?? DEFAULT_ACTIVITY_STATE; return ( ); @@ -170,7 +170,7 @@ export function Baseboard({ items, activeId, onReattach, notice }: BaseboardProp )} {items.slice(startIndex, endIndex).map(item => { - const sessionState = sessionStates.get(item.id) ?? DEFAULT_SESSION_UI_STATE; + const activity = activityStates.get(item.id) ?? DEFAULT_ACTIVITY_STATE; return ( onReattach(item)} /> ); diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 601f60d..057e8ca 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -32,18 +32,18 @@ import { type AlertButtonActionResult, clearSessionAttention, clearSessionTodo, - DEFAULT_SESSION_UI_STATE, + DEFAULT_ACTIVITY_STATE, disableSessionAlert, dismissOrToggleAlert, - focusTerminal, - getSessionState, - getSessionStateSnapshot, + focusSession, + getActivity, + getActivitySnapshot, markSessionAttention, markSessionTodo, - subscribeToSessionStateChanges, + subscribeToActivity, toggleSessionAlert, toggleSessionTodo, - destroyTerminal, + disposeSession, swapTerminals, setPendingShellOpts, getDefaultShellOpts, @@ -52,11 +52,11 @@ import { isHardTodo, TODO_OFF, } from '../lib/terminal-registry'; -import { resolvePanelElement, findPanelInDirection, findRestoreNeighbor, type DetachDirection } from '../lib/spatial-nav'; +import { resolvePanelElement, findPanelInDirection, findReattachNeighbor, type DoorDirection } from '../lib/spatial-nav'; import { cloneLayout, getLayoutStructureSignature } from '../lib/layout-snapshot'; import { getPlatform } from '../lib/platform'; import { saveSession } from '../lib/session-save'; -import type { PersistedDetachedItem } from '../lib/session-types'; +import type { PersistedDoor } from '../lib/session-types'; import { cfg } from '../cfg'; import { bellIconClass } from './bell-icon-class'; import { useTodoPillContent } from './TodoPillBody'; @@ -75,17 +75,17 @@ let dialogKeyboardActive = false; // --- Types --- -export interface DetachedItem { +export interface DooredItem { id: string; title: string; neighborId: string | null; // panel that was adjacent before detach - direction: DetachDirection; // where we were relative to that neighbor + direction: DoorDirection; // where we were relative to that neighbor remainingPanelIds: string[]; // sorted panel IDs after detach (for layout-changed check) restoreLayout: SerializedDockview | null; detachedLayoutSignature: string; } -function toDetachedItem(item: PersistedDetachedItem): DetachedItem { +function toDooredItem(item: PersistedDoor): DooredItem { return { ...item, restoreLayout: item.restoreLayout as SerializedDockview | null, @@ -103,7 +103,7 @@ export type PondMode = 'command' | 'passthrough'; export type PondEvent = | { type: 'modeChange'; mode: PondMode } | { type: 'zoomChange'; zoomed: boolean } - | { type: 'detachChange'; count: number } + | { type: 'minimizeChange'; count: number } | { type: 'split'; direction: 'horizontal' | 'vertical'; source: 'keyboard' | 'mouse' } | { type: 'selectionChange'; id: string | null; kind: 'pane' | 'door' }; @@ -372,9 +372,9 @@ function TodoAlertDialog({ sessionId: string; onClose: () => void; }) { - const sessionStates = useSyncExternalStore(subscribeToSessionStateChanges, getSessionStateSnapshot); - const sessionState = sessionStates.get(sessionId) ?? DEFAULT_SESSION_UI_STATE; - const alertEnabled = sessionState.status !== 'ALERT_DISABLED'; + const activityStates = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); + const activity = activityStates.get(sessionId) ?? DEFAULT_ACTIVITY_STATE; + const alertEnabled = activity.status !== 'ALERT_DISABLED'; const dialogRef = useRef(null); usePopoverFocusTrap(dialogRef, onClose, `[data-alert-button-for="${sessionId}"]`); @@ -393,7 +393,7 @@ function TodoAlertDialog({ if (e.key === 'a') { e.preventDefault(); e.stopImmediatePropagation(); - dismissOrToggleAlert(sessionId, getSessionState(sessionId).status); + dismissOrToggleAlert(sessionId, getActivity(sessionId).status); } if (e.key === 't') { e.preventDefault(); @@ -429,12 +429,12 @@ function TodoAlertDialog({ [t] TODO
- -
@@ -495,7 +495,7 @@ export const DoorElementsContext = createContext({ export interface PondActions { onKill: (id: string) => void; - onDetach: (id: string) => void; + onMinimize: (id: string) => void; onAlertButton: (id: string, displayedStatus: SessionStatus) => AlertButtonActionResult; onToggleTodo: (id: string) => void; onSplitH: (id: string | null, source?: 'keyboard' | 'mouse') => void; @@ -508,7 +508,7 @@ export interface PondActions { } export const PondActionsContext = createContext({ onKill: () => {}, - onDetach: () => {}, + onMinimize: () => {}, onAlertButton: () => 'noop', onToggleTodo: () => {}, onSplitH: () => {}, @@ -616,10 +616,10 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const renamingId = useContext(RenamingIdContext); const zoomed = useContext(ZoomedContext); const windowFocused = useContext(WindowFocusedContext); - const sessionStates = useSyncExternalStore(subscribeToSessionStateChanges, getSessionStateSnapshot); + const activityStates = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); const mouseStates = useSyncExternalStore(subscribeToMouseSelection, getMouseSelectionSnapshot); const actions = useContext(PondActionsContext); - const sessionState = sessionStates.get(api.id) ?? DEFAULT_SESSION_UI_STATE; + const activity = activityStates.get(api.id) ?? DEFAULT_ACTIVITY_STATE; const mouseState = mouseStates.get(api.id) ?? DEFAULT_MOUSE_SELECTION_STATE; const showMouseIcon = mouseState.mouseReporting !== 'none'; const inOverride = mouseState.override !== 'off'; @@ -635,19 +635,19 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const suppressAlertClickRef = useRef(false); const [tier, setTier] = useState('full'); const [dialogPosition, setDialogPosition] = useState<{ x: number; y: number } | null>(null); - const todoPill = useTodoPillContent(sessionState.todo); + const todoPill = useTodoPillContent(activity.todo); const showTodoPill = todoPill.visible && tier !== 'minimal'; - const alertButtonAriaLabel = sessionState.status === 'ALERT_RINGING' + const alertButtonAriaLabel = activity.status === 'ALERT_RINGING' ? 'Alert ringing' - : sessionState.status === 'ALERT_DISABLED' + : activity.status === 'ALERT_DISABLED' ? 'Enable alert' : 'Disable alert'; - const alertButtonTooltip = sessionState.status === 'ALERT_RINGING' + const alertButtonTooltip = activity.status === 'ALERT_RINGING' ? 'Alert ringing' - : sessionState.status === 'ALERT_DISABLED' + : activity.status === 'ALERT_DISABLED' ? 'Enable [a]lert' : 'Disable [a]lert'; - const alertButtonTooltipDetail = sessionState.status === 'ALERT_RINGING' + const alertButtonTooltipDetail = activity.status === 'ALERT_RINGING' ? 'Click to dismiss and show options' : 'Right-click for options'; @@ -713,7 +713,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { { if (suppressAlertClickRef.current) { suppressAlertClickRef.current = false; return; } - triggerAlertButtonAction(sessionState.status, e.currentTarget); + triggerAlertButtonAction(activity.status, e.currentTarget); }} onContextMenu={(e) => { e.preventDefault(); setDialogPosition({ x: e.clientX, y: e.clientY }); }} ariaLabel={alertButtonAriaLabel} @@ -740,10 +740,10 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { dataAlertButtonFor={api.id} > - {sessionState.status === 'ALERT_DISABLED' ? ( + {activity.status === 'ALERT_DISABLED' ? ( ) : ( - + )} @@ -761,7 +761,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { data-session-todo-for={api.id} className={[ 'shrink-0 rounded px-1.5 py-px text-[9px] font-semibold tracking-[0.08em] text-muted transition-colors hover:bg-foreground/10', - isSoftTodo(sessionState.todo) ? 'border border-dashed border-muted' : 'border border-muted', + isSoftTodo(activity.todo) ? 'border border-dashed border-muted' : 'border border-muted', ].join(' ')} aria-label="TODO settings" onMouseDown={(e) => e.stopPropagation()} @@ -830,11 +830,11 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { >{zoomed ? : }
)} - {/* Detach / Kill controls — always visible */} + {/* Minimize / Kill controls — always visible */}
{ e.stopPropagation(); actions.onDetach(api.id); }} + onClick={(e) => { e.stopPropagation(); actions.onMinimize(api.id); }} ariaLabel="Minimize" tooltip="Minimize [m] or [d]" > @@ -1152,7 +1152,7 @@ function orchestrateKill( const bareRemove = () => { killInProgressRef.current = true; - destroyTerminal(killedId); + disposeSession(killedId); api.removePanel(panel); killInProgressRef.current = false; if (api.panels.length > 0) selectPanel(api.panels[0].id); @@ -1248,14 +1248,14 @@ function orchestrateKill( export function Pond({ initialPaneIds, restoredLayout, - initialDetached, + initialDoors, onApiReady, onEvent, baseboardNotice, }: { initialPaneIds?: string[]; restoredLayout?: unknown; - initialDetached?: PersistedDetachedItem[]; + initialDoors?: PersistedDoor[]; onApiReady?: (api: DockviewApi) => void; onEvent?: (event: PondEvent) => void; baseboardNotice?: React.ReactNode; @@ -1287,7 +1287,7 @@ export function Pond({ // Consumed once in handleReady to restore existing sessions const initialPaneIdsRef = useRef(initialPaneIds); const restoredLayoutRef = useRef(restoredLayout); - const initialDetachedRef = useRef((initialDetached ?? []).map(toDetachedItem)); + const initialDetachedRef = useRef((initialDoors ?? []).map(toDooredItem)); // Mutable maps shared via context — consumers must call bumpVersion() after // any mutation so that dependent effects/components re-run. @@ -1315,7 +1315,7 @@ export function Pond({ const [confirmKill, setConfirmKill] = useState(null); useEffect(() => { if (!confirmKill) { clearTimeout(shakeTimerRef.current!); } }, [confirmKill]); const [renamingPaneId, setRenamingPaneId] = useState(null); - const [detached, setDetached] = useState(() => (initialDetached ?? []).map(toDetachedItem)); + const [doors, setDoors] = useState(() => (initialDoors ?? []).map(toDooredItem)); const [zoomed, setZoomed] = useState(false); // Refs for mode-switch gesture (Left Cmd → Right Cmd, or Left Shift → Right Shift, within 500ms) @@ -1334,8 +1334,8 @@ export function Pond({ selectedIdRef.current = selectedId; const selectedTypeRef = useRef(selectedType); selectedTypeRef.current = selectedType; - const detachedRef = useRef(detached); - detachedRef.current = detached; + const doorsRef = useRef(doors); + doorsRef.current = doors; const confirmKillRef = useRef(confirmKill); confirmKillRef.current = confirmKill; const renamingRef = useRef(renamingPaneId); @@ -1350,7 +1350,7 @@ export function Pond({ useEffect(() => { onEventRef.current?.({ type: 'modeChange', mode }); }, [mode]); useEffect(() => { onEventRef.current?.({ type: 'zoomChange', zoomed }); }, [zoomed]); - useEffect(() => { onEventRef.current?.({ type: 'detachChange', count: detached.length }); }, [detached]); + useEffect(() => { onEventRef.current?.({ type: 'minimizeChange', count: doors.length }); }, [doors]); useEffect(() => { onEventRef.current?.({ type: 'selectionChange', id: selectedId, kind: selectedType }); }, [selectedId, selectedType]); // --- Helpers --- @@ -1362,7 +1362,7 @@ export function Pond({ if (!api) return Promise.resolve(); const panes = api.panels.map((p) => ({ id: p.id, title: p.title ?? '' })); - const detachedItems: PersistedDetachedItem[] = detachedRef.current.map((item) => ({ + const doorItems: PersistedDoor[] = doorsRef.current.map((item) => ({ id: item.id, title: item.title, neighborId: item.neighborId, @@ -1371,7 +1371,7 @@ export function Pond({ restoreLayout: item.restoreLayout, detachedLayoutSignature: item.detachedLayoutSignature, })); - return saveSession(getPlatform(), api.toJSON(), panes, detachedItems); + return saveSession(getPlatform(), api.toJSON(), panes, doorItems); }, []); const persistSessionNow = useCallback((): Promise => { @@ -1448,15 +1448,15 @@ export function Pond({ markSessionAttention(id); // Defer focus so it happens after mousedown/click event finishes, // preventing dockview from stealing focus back from xterm - requestAnimationFrame(() => focusTerminal(id, true)); + requestAnimationFrame(() => focusSession(id, true)); const panel = apiRef.current?.getPanel(id); if (panel) panel.api.setActive(); }, []); const enterTerminalModeRef = useRef(enterTerminalMode); enterTerminalModeRef.current = enterTerminalMode; - /** Detach a panel: capture neighbor context, remove from dockview, add to detached state */ - const detachPanel = useCallback((id: string) => { + /** Minimize a pane: capture neighbor context, remove from dockview, add to doors state */ + const minimizePane = useCallback((id: string) => { const api = apiRef.current; if (!api) return; const panel = api.getPanel(id); @@ -1466,7 +1466,7 @@ export function Pond({ // Capture the nearest adjacent pane and our actual relative position // so immediate restore can reconstruct the original split precisely. - const { neighborId, direction } = findRestoreNeighbor(id, api, panelElements); + const { neighborId, direction } = findReattachNeighbor(id, api, panelElements); const remainingPanelIds = api.panels .filter(p => p.id !== id) @@ -1476,7 +1476,7 @@ export function Pond({ api.removePanel(panel); clearSessionAttention(id); const detachedLayoutSignature = getLayoutStructureSignature(api.toJSON()); - const nextDetached = [...detachedRef.current, { + const nextDetached = [...doorsRef.current, { id, title, neighborId, @@ -1485,10 +1485,10 @@ export function Pond({ restoreLayout, detachedLayoutSignature, }]; - detachedRef.current = nextDetached; - setDetached(nextDetached); + doorsRef.current = nextDetached; + setDoors(nextDetached); - // Keep the detached terminal selected as a door so the user can track where it went. + // Keep the minimized session selected as a door so the user can track where it went. modeRef.current = 'command'; setMode('command'); selectDoor(id); @@ -1499,7 +1499,7 @@ export function Pond({ modeRef.current = 'command'; setMode('command'); const id = selectedIdRef.current; - if (id) focusTerminal(id, false); + if (id) focusSession(id, false); }, []); useEffect(() => { @@ -1517,12 +1517,12 @@ export function Pond({ // Restore existing PTY sessions if available const restored = initialPaneIdsRef.current; const layout = restoredLayoutRef.current; - const restoredDetached = initialDetachedRef.current; + const restoredDoors = initialDetachedRef.current; initialPaneIdsRef.current = undefined; // consume once restoredLayoutRef.current = undefined; initialDetachedRef.current = []; - detachedRef.current = restoredDetached; - setDetached(restoredDetached); + doorsRef.current = restoredDoors; + setDoors(restoredDoors); // Apply the currently-selected shell to a freshly-added panel. Panels // that are reconnecting to an existing PTY already have a running shell, @@ -1617,7 +1617,7 @@ export function Pond({ if (panel) { // Dockview auto-activates a panel on addPanel. Don't let that steal // selection away from a currently-selected door (happens when the last - // pane is detached: selectDoor runs, then the delayed auto-spawn's + // pane is minimized: selectDoor runs, then the delayed auto-spawn's // addPanel would otherwise flip selectedId to the new pane's id while // selectedType is still 'door', desyncing the door's highlight). if (selectedTypeRef.current === 'door') return; @@ -1630,7 +1630,7 @@ export function Pond({ }); // Always keep one pane visible: when the last visible pane is removed (killed - // or detached), spawn a fresh one — regardless of whether doors exist. + // or minimized), spawn a fresh one — regardless of whether doors exist. // // Delay the spawn by the kill/detach animation duration so the two animations // don't overlap — the outgoing pane crushes/fades first, then the new pane @@ -1648,7 +1648,7 @@ export function Pond({ freshlySpawnedRef.current.set(id, 'top-left'); e.api.addPanel({ id, component: 'terminal', tabComponent: 'terminal', title: '' }); // Only steal focus if nothing is selected (i.e., the kill path, which - // clears selection). On detach the just-detached door is selected and we + // clears selection). On minimize the new door is selected and we // must not override that — the door retains focus per the detach UX. if (selectedIdRef.current === null) { selectPanel(id); @@ -1870,7 +1870,7 @@ export function Pond({ e.preventDefault(); e.stopPropagation(); if (selectedTypeRef.current === 'door') { - const item = detachedRef.current.find(d => d.id === sid); + const item = doorsRef.current.find(d => d.id === sid); if (item) handleReattachRef.current(item); } else { enterTerminalMode(sid); @@ -1936,7 +1936,7 @@ export function Pond({ e.preventDefault(); e.stopPropagation(); if (selectedTypeRef.current === 'door') { - const item = detachedRef.current.find(d => d.id === sid); + const item = doorsRef.current.find(d => d.id === sid); if (item) handleReattachRef.current(item, { enterPassthrough: false, confirmKill: true }); return; } @@ -1953,15 +1953,15 @@ export function Pond({ return; } - // Detach (pane) / Reattach (door) — "m" or "d" toggles detach state + // Minimize (pane) / Reattach (door) — "m" or "d" toggles View state if ((e.key === 'm' || e.key === 'd') && sid) { e.preventDefault(); e.stopPropagation(); if (selectedTypeRef.current === 'door') { - const item = detachedRef.current.find(d => d.id === sid); + const item = doorsRef.current.find(d => d.id === sid); if (item) handleReattachRef.current(item, { enterPassthrough: false }); } else { - detachPanel(sid); + minimizePane(sid); } return; } @@ -1978,7 +1978,7 @@ export function Pond({ if (dialogKeyboardActive) return; e.preventDefault(); e.stopPropagation(); - dismissOrToggleAlert(sid, getSessionState(sid).status); + dismissOrToggleAlert(sid, getActivity(sid).status); return; } @@ -1998,7 +1998,7 @@ export function Pond({ const dir = e.key; const currentType = selectedTypeRef.current; - const currentDetached = detachedRef.current; + const currentDetached = doorsRef.current; // Navigation from a door if (currentType === 'door') { @@ -2043,12 +2043,12 @@ export function Pond({ // capture: true so we intercept before xterm.js gets the event window.addEventListener('keydown', handler, true); return () => window.removeEventListener('keydown', handler, true); - }, [selectPanel, selectDoor, enterTerminalMode, exitTerminalMode, detachPanel]); + }, [selectPanel, selectDoor, enterTerminalMode, exitTerminalMode, minimizePane]); // --- Reattach --- const handleReattach = useCallback(( - item: DetachedItem, + item: DooredItem, options?: { enterPassthrough?: boolean; confirmKill?: boolean }, ) => { const api = apiRef.current; @@ -2058,8 +2058,8 @@ export function Pond({ const currentLayoutSignature = getLayoutStructureSignature(api.toJSON()); // Exact restore is only safe when the layout structure matches AND the - // current panels are the same ones that existed when we detached. If new - // panels were auto-spawned (e.g. last pane detached → auto-create), the + // current panels are the same ones that existed when we minimized. If new + // panels were auto-spawned (e.g. last pane minimized → auto-create), the // restoreLayout would destroy them. const currentPanelIds = api.panels.map(p => p.id).sort(); const restorePanelIds = item.restoreLayout @@ -2117,9 +2117,9 @@ export function Pond({ } } - const nextDetached = detachedRef.current.filter(p => p.id !== item.id); - detachedRef.current = nextDetached; - setDetached(nextDetached); + const nextDetached = doorsRef.current.filter(p => p.id !== item.id); + doorsRef.current = nextDetached; + setDoors(nextDetached); selectPanel(item.id); if (enterPassthrough) { enterTerminalMode(item.id); @@ -2129,7 +2129,7 @@ export function Pond({ requestAnimationFrame(() => { // Guard against panel removal between scheduling and execution if (!apiRef.current?.getPanel(item.id)) return; - focusTerminal(item.id, false); + focusSession(item.id, false); if (confirmKillAfterRestore) { setConfirmKill({ id: item.id, char: randomKillChar() }); } @@ -2213,8 +2213,8 @@ export function Pond({ onToggleTodo: (id: string) => { toggleSessionTodo(id); }, - onDetach: (id: string) => { - detachPanel(id); + onMinimize: (id: string) => { + minimizePane(id); }, onSplitH: (id: string | null, source: 'keyboard' | 'mouse' = 'mouse') => { addSplitPanel(id, 'right', 'horizontal', source); @@ -2250,7 +2250,7 @@ export function Pond({ onCancelRename: () => { setRenamingPaneId(null); }, - }), [addSplitPanel, detachPanel, enterTerminalMode, exitTerminalMode]); + }), [addSplitPanel, minimizePane, enterTerminalMode, exitTerminalMode]); const pondActionsRef = useRef(pondActions); pondActionsRef.current = pondActions; @@ -2282,7 +2282,7 @@ export function Pond({
{/* Baseboard — always visible */} - + {/* Kill confirmation overlay — centered over the pane being killed */} {confirmKill && ( diff --git a/lib/src/components/TerminalPane.tsx b/lib/src/components/TerminalPane.tsx index 8dd4364..a94b443 100644 --- a/lib/src/components/TerminalPane.tsx +++ b/lib/src/components/TerminalPane.tsx @@ -2,10 +2,10 @@ import { useEffect, useRef } from 'react'; import '@xterm/xterm/css/xterm.css'; import { getOrCreateTerminal, - attachTerminal, - detachTerminal, - refitTerminal, - focusTerminal, + mountElement, + unmountElement, + refitSession, + focusSession, } from '../lib/terminal-registry'; import { SelectionOverlay } from './SelectionOverlay'; import { SelectionPopup } from './SelectionPopup'; @@ -18,7 +18,7 @@ interface TerminalPaneProps { /** * Thin mount point for a terminal. The actual xterm.js instance lives in the * terminal registry and persists across React mount/unmount cycles (reparenting, - * detach/reattach, row moves). This component just attaches/detaches the + * minimize/reattach, row moves). This component just mounts/unmounts the * terminal's persistent DOM element to its container. */ export function TerminalPane({ id, isFocused = true }: TerminalPaneProps) { @@ -32,21 +32,21 @@ export function TerminalPane({ id, isFocused = true }: TerminalPaneProps) { getOrCreateTerminal(id); // Attach the terminal's persistent element to this container - attachTerminal(id, container); + mountElement(id, container); // Resize observer — refit terminal when container changes size - const observer = new ResizeObserver(() => refitTerminal(id)); + const observer = new ResizeObserver(() => refitSession(id)); observer.observe(container); return () => { observer.disconnect(); - // Detach (but don't destroy) — terminal stays alive in the registry - detachTerminal(id); + // Unmount DOM element — registry entry and Session survive + unmountElement(id); }; }, [id]); useEffect(() => { - focusTerminal(id, isFocused); + focusSession(id, isFocused); }, [id, isFocused]); return ( diff --git a/lib/src/lib/alert-manager.ts b/lib/src/lib/alert-manager.ts index c9f2c72..e1292b9 100644 --- a/lib/src/lib/alert-manager.ts +++ b/lib/src/lib/alert-manager.ts @@ -349,7 +349,7 @@ export class AlertManager { * creates a fresh ActivityMonitor (it will start in NOTHING_TO_SHOW until * PTY data arrives). */ - restore(id: string, state: { status: string; todo: TodoState }): void { + seed(id: string, state: { status: string; todo: TodoState }): void { const entry = this.getOrCreateEntry(id); entry.todo = migrateTodoState(state.todo); // If the alert was enabled (anything other than ALERT_DISABLED), create a monitor diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts index d871c2d..9675dfb 100644 --- a/lib/src/lib/reconnect.test.ts +++ b/lib/src/lib/reconnect.test.ts @@ -3,16 +3,16 @@ import type { PlatformAdapter, PtyInfo } from './platform/types'; import type { PersistedSession } from './session-types'; const terminalRegistryMocks = vi.hoisted(() => ({ - reconnectTerminal: vi.fn(), + resumeTerminal: vi.fn(), restoreTerminal: vi.fn(), })); vi.mock('./terminal-registry', () => ({ - reconnectTerminal: terminalRegistryMocks.reconnectTerminal, + resumeTerminal: terminalRegistryMocks.resumeTerminal, restoreTerminal: terminalRegistryMocks.restoreTerminal, })); -import { reconnectFromInit } from './reconnect'; +import { resumeOrRestore } from './reconnect'; function createPlatform(ptys: PtyInfo[], savedState: PersistedSession | null): PlatformAdapter { const listHandlers = new Set<(detail: { ptys: PtyInfo[] }) => void>(); @@ -66,7 +66,7 @@ function createPlatform(ptys: PtyInfo[], savedState: PersistedSession | null): P }; } -describe('reconnectFromInit', () => { +describe('resumeOrRestore', () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -98,7 +98,7 @@ describe('reconnectFromInit', () => { ], }; - const result = await reconnectFromInit(createPlatform([ + const result = await resumeOrRestore(createPlatform([ { id: 'pane-a', alive: true }, { id: 'pane-b', alive: true }, { id: 'pane-c', alive: true }, @@ -109,7 +109,7 @@ describe('reconnectFromInit', () => { detached, layout, }); - expect(terminalRegistryMocks.reconnectTerminal).toHaveBeenCalledWith('pane-c', 'pane-c-replay', { + expect(terminalRegistryMocks.resumeTerminal).toHaveBeenCalledWith('pane-c', 'pane-c-replay', { alive: true, exitCode: undefined, }); @@ -125,7 +125,7 @@ describe('reconnectFromInit', () => { ], }; - const result = await reconnectFromInit(createPlatform([ + const result = await resumeOrRestore(createPlatform([ { id: 'pane-a', alive: true }, { id: 'pane-b', alive: true }, { id: 'extra-pane', alive: true }, @@ -149,7 +149,7 @@ describe('reconnectFromInit', () => { ], }; - const result = await reconnectFromInit(createPlatform([ + const result = await resumeOrRestore(createPlatform([ { id: 'pane-a', alive: true }, { id: 'pane-b', alive: true }, ], saved)); diff --git a/lib/src/lib/reconnect.ts b/lib/src/lib/reconnect.ts index 0a999e4..b38d9aa 100644 --- a/lib/src/lib/reconnect.ts +++ b/lib/src/lib/reconnect.ts @@ -1,12 +1,12 @@ import type { PlatformAdapter, PtyInfo } from './platform/types'; -import { reconnectTerminal } from './terminal-registry'; -import type { PersistedDetachedItem, PersistedSession } from './session-types'; +import { resumeTerminal } from './terminal-registry'; +import type { PersistedDoor, PersistedSession } from './session-types'; import { restoreSession } from './session-restore'; export interface ReconnectResult { paneIds: string[]; layout?: unknown; // dockview SerializedDockview, only present on cold-start restore - detached?: PersistedDetachedItem[]; + detached?: PersistedDoor[]; } /** @@ -17,9 +17,9 @@ export interface ReconnectResult { * 2. Saved session (app restarted) → restore with saved scrollback + cwd * 3. Neither → return empty (Pond creates a fresh terminal) */ -export async function reconnectFromInit(platform: PlatformAdapter): Promise { +export async function resumeOrRestore(platform: PlatformAdapter): Promise { // First, try to reconnect to live PTYs - const liveResult = await reconnectLivePtys(platform); + const liveResult = await resumeLiveSessions(platform); if (liveResult.paneIds.length > 0) return liveResult; // No live PTYs — try saved session restore @@ -29,7 +29,7 @@ export async function reconnectFromInit(platform: PlatformAdapter): Promise { +function resumeLiveSessions(platform: PlatformAdapter): Promise { return new Promise((resolve) => { const replayBuffer = new Map(); let ptyList: PtyInfo[] | null = null; @@ -65,7 +65,7 @@ function reconnectLivePtys(platform: PlatformAdapter): Promise const ids: string[] = []; for (const pty of ptyList) { - reconnectTerminal(pty.id, replayBuffer.get(pty.id) ?? null, { + resumeTerminal(pty.id, replayBuffer.get(pty.id) ?? null, { alive: pty.alive, exitCode: pty.exitCode, }); diff --git a/lib/src/lib/session-restore.ts b/lib/src/lib/session-restore.ts index a5dbb7c..7eeca4e 100644 --- a/lib/src/lib/session-restore.ts +++ b/lib/src/lib/session-restore.ts @@ -1,11 +1,11 @@ import type { PlatformAdapter } from './platform/types'; -import type { PersistedDetachedItem, PersistedSession } from './session-types'; +import type { PersistedDoor, PersistedSession } from './session-types'; import { getDefaultShellOpts, restoreTerminal } from './terminal-registry'; export interface RestoredSession { paneIds: string[]; layout: unknown; - detached: PersistedDetachedItem[]; + detached: PersistedDoor[]; } export function restoreSession(platform: PlatformAdapter): RestoredSession | null { diff --git a/lib/src/lib/session-save.ts b/lib/src/lib/session-save.ts index d825f28..517ebe9 100644 --- a/lib/src/lib/session-save.ts +++ b/lib/src/lib/session-save.ts @@ -1,5 +1,5 @@ import type { PlatformAdapter } from './platform/types'; -import type { PersistedDetachedItem, PersistedPane, PersistedSession } from './session-types'; +import type { PersistedDoor, PersistedPane, PersistedSession } from './session-types'; import { detectResumeCommand } from './resume-patterns'; import { getLivePersistedAlertState, resolveTerminalSessionId } from './terminal-registry'; @@ -15,7 +15,7 @@ export async function saveSession( platform: PlatformAdapter, layout: unknown, panes: Array<{ id: string; title: string }>, - detached: PersistedDetachedItem[] = [], + detached: PersistedDoor[] = [], ): Promise { const previousPanes = getPreviousPaneMap(platform); const allPanes = new Map(); diff --git a/lib/src/lib/session-types.ts b/lib/src/lib/session-types.ts index 9df7757..a268ae7 100644 --- a/lib/src/lib/session-types.ts +++ b/lib/src/lib/session-types.ts @@ -1,4 +1,4 @@ -import type { DetachDirection } from './spatial-nav'; +import type { DoorDirection } from './spatial-nav'; import type { SessionStatus } from './activity-monitor'; import type { TodoState } from './alert-manager'; @@ -16,11 +16,11 @@ export interface PersistedPane { alert?: PersistedAlertState | null; } -export interface PersistedDetachedItem { +export interface PersistedDoor { id: string; title: string; neighborId: string | null; - direction: DetachDirection; + direction: DoorDirection; remainingPanelIds: string[]; restoreLayout: unknown; detachedLayoutSignature: string; @@ -29,6 +29,6 @@ export interface PersistedDetachedItem { export interface PersistedSession { version: 1; panes: PersistedPane[]; - detached?: PersistedDetachedItem[]; + detached?: PersistedDoor[]; layout: unknown; // SerializedDockview — kept as `unknown` to avoid dockview dep in types } diff --git a/lib/src/lib/spatial-nav.ts b/lib/src/lib/spatial-nav.ts index fcd2b12..b419e57 100644 --- a/lib/src/lib/spatial-nav.ts +++ b/lib/src/lib/spatial-nav.ts @@ -1,6 +1,6 @@ import { type DockviewApi } from 'dockview-react'; -export type DetachDirection = 'left' | 'right' | 'above' | 'below'; +export type DoorDirection = 'left' | 'right' | 'above' | 'below'; interface SpatialCandidate { id: string; dist: number; overlaps: boolean } @@ -15,17 +15,17 @@ export function resolvePanelElement(element: HTMLElement | null | undefined): HT * if the current panel is to the right of the neighbor, direction='right' means * "place me to the right of this reference panel." */ -export function findRestoreNeighbor( +export function findReattachNeighbor( currentId: string, api: DockviewApi, panelElements: Map, -): { neighborId: string | null; direction: DetachDirection } { +): { neighborId: string | null; direction: DoorDirection } { const currentEl = resolvePanelElement(panelElements.get(currentId)); if (!currentEl) return { neighborId: null, direction: 'right' }; const c = currentEl.getBoundingClientRect(); const EDGE_TOLERANCE = 12; - let best: { neighborId: string | null; direction: DetachDirection; score: number } = { + let best: { neighborId: string | null; direction: DoorDirection; score: number } = { neighborId: null, direction: 'right', score: Number.POSITIVE_INFINITY, @@ -39,7 +39,7 @@ export function findRestoreNeighbor( const verticalOverlap = Math.min(c.bottom, r.bottom) - Math.max(c.top, r.top); const horizontalOverlap = Math.min(c.right, r.right) - Math.max(c.left, r.left); - const candidates: Array<{ direction: DetachDirection; gap: number; overlap: number }> = []; + const candidates: Array<{ direction: DoorDirection; gap: number; overlap: number }> = []; if (verticalOverlap > 0) { if (Math.abs(c.left - r.right) <= EDGE_TOLERANCE) { diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 879213f..8e8b9f1 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -90,19 +90,19 @@ import { cfg } from '../cfg'; const STRIKE_RECOVERY_MS = cfg.todoBucket.recoverySecondsPerLetter * 1_000; import { - DEFAULT_SESSION_UI_STATE, - attachTerminal, + DEFAULT_ACTIVITY_STATE, + mountElement, clearSessionAttention, clearSessionTodo, - destroyAllTerminals, - destroyTerminal, - detachTerminal, + disposeAllSessions, + disposeSession, + unmountElement, disableSessionAlert, dismissOrToggleAlert, dismissSessionAlert, - focusTerminal, + focusSession, getOrCreateTerminal, - getSessionState, + getActivity, initAlertStateReceiver, markSessionAttention, markSessionTodo, @@ -186,17 +186,17 @@ function expireAttention(id?: string): void { } function detachSession(id: string): void { - detachTerminal(id); + unmountElement(id); clearSessionAttention(id); } function reattachDoorViaEnter(id: string): void { - attachTerminal(id, createContainer() as unknown as HTMLElement); + mountElement(id, createContainer() as unknown as HTMLElement); markSessionAttention(id); } function reattachDoorViaD(id: string): void { - attachTerminal(id, createContainer() as unknown as HTMLElement); + mountElement(id, createContainer() as unknown as HTMLElement); } // Timing helpers based on cfg.alert values: @@ -207,16 +207,16 @@ function driveToBusy(id: string): void { advance(1_600); emitOutput(id, 'working...'); emitOutput(id, 'more work'); - expect(getSessionState(id).status).toBe('BUSY'); + expect(getActivity(id).status).toBe('BUSY'); } function driveToRingingNeedsAttention(id: string): void { driveToBusy(id); expireAttention(id); advance(2_000); - expect(getSessionState(id).status).toBe('MIGHT_NEED_ATTENTION'); + expect(getActivity(id).status).toBe('MIGHT_NEED_ATTENTION'); advance(3_000); - expect(getSessionState(id).status).toBe('ALERT_RINGING'); + expect(getActivity(id).status).toBe('ALERT_RINGING'); } describe('terminal-registry alert behavior', () => { @@ -245,7 +245,7 @@ describe('terminal-registry alert behavior', () => { }); afterEach(() => { - destroyAllTerminals(); + disposeAllSessions(); fakePlatform.reset(); vi.unstubAllGlobals(); vi.useRealTimers(); @@ -264,7 +264,7 @@ describe('terminal-registry alert behavior', () => { advance(12_000); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'NOTHING_TO_SHOW', todo: TODO_OFF, @@ -285,17 +285,17 @@ describe('terminal-registry alert behavior', () => { attendSession(id); advance(1_800); - expect(getSessionState(id)).toMatchObject({ + expect(getActivity(id)).toMatchObject({ status: 'BUSY', }); expireAttention(id); advance(2_000); - expect(getSessionState(id).status).toBe('MIGHT_NEED_ATTENTION'); + expect(getActivity(id).status).toBe('MIGHT_NEED_ATTENTION'); advance(3_000); - expect(getSessionState(id)).toMatchObject({ + expect(getActivity(id)).toMatchObject({ status: 'ALERT_RINGING', todo: TODO_OFF, @@ -309,11 +309,11 @@ describe('terminal-registry alert behavior', () => { driveToBusy(id); advance(2_000); - expect(getSessionState(id).status).toBe('MIGHT_NEED_ATTENTION'); + expect(getActivity(id).status).toBe('MIGHT_NEED_ATTENTION'); emitOutput(id, 'still running'); - expect(getSessionState(id)).toMatchObject({ + expect(getActivity(id)).toMatchObject({ status: 'BUSY', }); @@ -329,7 +329,7 @@ describe('terminal-registry alert behavior', () => { advance(2_000); advance(3_000); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'NOTHING_TO_SHOW', todo: TODO_OFF, @@ -344,7 +344,7 @@ describe('terminal-registry alert behavior', () => { driveToRingingNeedsAttention(id); attendSession(id); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'NOTHING_TO_SHOW', todo: TODO_SOFT_FULL, }); @@ -358,7 +358,7 @@ describe('terminal-registry alert behavior', () => { driveToRingingNeedsAttention(id); dismissSessionAlert(id); - expect(getSessionState(id)).toMatchObject({ + expect(getActivity(id)).toMatchObject({ status: 'NOTHING_TO_SHOW', todo: TODO_SOFT_FULL, }); @@ -369,7 +369,7 @@ describe('terminal-registry alert behavior', () => { advance(2_000); advance(3_000); - expect(getSessionState(id)).toMatchObject({ + expect(getActivity(id)).toMatchObject({ status: 'ALERT_RINGING', todo: TODO_SOFT_FULL, }); @@ -383,7 +383,7 @@ describe('terminal-registry alert behavior', () => { driveToRingingNeedsAttention(id); markSessionTodo(id); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'NOTHING_TO_SHOW', todo: TODO_HARD, }); @@ -397,7 +397,7 @@ describe('terminal-registry alert behavior', () => { driveToRingingNeedsAttention(id); disableSessionAlert(id); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'ALERT_DISABLED', todo: TODO_OFF, }); @@ -407,7 +407,7 @@ describe('terminal-registry alert behavior', () => { emitOutput(id, 'more work'); advance(12_000); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'ALERT_DISABLED', todo: TODO_OFF, }); @@ -421,18 +421,18 @@ describe('terminal-registry alert behavior', () => { driveToRingingNeedsAttention(id); // Shell prompt output should NOT silently dismiss the alert emitOutput(id, 'shell prompt'); - expect(getSessionState(id).status).toBe('ALERT_RINGING'); + expect(getActivity(id).status).toBe('ALERT_RINGING'); // User attends (focuses the pane) — this resets the monitor via attend() attendSession(id); - expect(getSessionState(id).status).toBe('NOTHING_TO_SHOW'); + expect(getActivity(id).status).toBe('NOTHING_TO_SHOW'); // Now new output starts a fresh cycle emitOutput(id, 'next task'); advance(1_600); emitOutput(id, 'still going'); emitOutput(id, 'more work'); - expect(getSessionState(id).status).toBe('BUSY'); + expect(getActivity(id).status).toBe('BUSY'); }); it('Story 10: detach preserves state, click restore clears ring', () => { @@ -444,14 +444,14 @@ describe('terminal-registry alert behavior', () => { detachSession(id); driveToRingingNeedsAttention(id); - expect(getSessionState(id)).toMatchObject({ + expect(getActivity(id)).toMatchObject({ status: 'ALERT_RINGING', }); reattachDoorViaEnter(id); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'NOTHING_TO_SHOW', todo: TODO_SOFT_FULL, }); @@ -467,7 +467,7 @@ describe('terminal-registry alert behavior', () => { driveToRingingNeedsAttention(id); reattachDoorViaD(id); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'ALERT_RINGING', todo: TODO_OFF, @@ -483,7 +483,7 @@ describe('terminal-registry alert behavior', () => { emitOutput(id, 'redraw noise'); advance(12_000); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'NOTHING_TO_SHOW', todo: TODO_OFF, @@ -504,11 +504,11 @@ describe('terminal-registry alert behavior', () => { dismissSessionAlert(alpha); attendSession(beta); - expect(getSessionState(alpha)).toEqual({ + expect(getActivity(alpha)).toEqual({ status: 'NOTHING_TO_SHOW', todo: TODO_SOFT_FULL, }); - expect(getSessionState(beta)).toEqual({ + expect(getActivity(beta)).toEqual({ status: 'NOTHING_TO_SHOW', todo: TODO_SOFT_FULL, }); @@ -521,13 +521,13 @@ describe('terminal-registry alert behavior', () => { driveToRingingNeedsAttention(id); toggleSessionTodo(id); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'NOTHING_TO_SHOW', todo: TODO_HARD, }); - destroyTerminal(id); - expect(getSessionState(id)).toEqual(DEFAULT_SESSION_UI_STATE); + disposeSession(id); + expect(getActivity(id)).toEqual(DEFAULT_ACTIVITY_STATE); createSession(id); toggleSessionAlert(id); @@ -536,7 +536,7 @@ describe('terminal-registry alert behavior', () => { advance(2_000); advance(3_000); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'ALERT_RINGING', todo: TODO_OFF, @@ -552,9 +552,9 @@ describe('terminal-registry alert behavior', () => { entry.terminal.emitInput('x'); // Typing while ringing: attend creates a fresh soft TODO, then the keypress strikes one letter - expect(getSessionState(id).status).toBe('NOTHING_TO_SHOW'); - expect(isSoftTodo(getSessionState(id).todo)).toBe(true); - expect(getSessionState(id).todo).toBeCloseTo(0.75); + expect(getActivity(id).status).toBe('NOTHING_TO_SHOW'); + expect(isSoftTodo(getActivity(id).todo)).toBe(true); + expect(getActivity(id).todo).toBeCloseTo(0.75); }); it('no monitor is created until alert is enabled', () => { @@ -567,7 +567,7 @@ describe('terminal-registry alert behavior', () => { emitOutput(id, 'more work'); advance(12_000); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'ALERT_DISABLED', todo: TODO_OFF, @@ -585,14 +585,14 @@ describe('terminal-registry alert behavior', () => { toggleSessionAlert(id); // Status starts at NOTHING_TO_SHOW, not retroactively computed - expect(getSessionState(id).status).toBe('NOTHING_TO_SHOW'); + expect(getActivity(id).status).toBe('NOTHING_TO_SHOW'); // New output after enabling drives state normally emitOutput(id, 'prompt> '); advance(1_600); emitOutput(id, 'working...'); emitOutput(id, 'more work'); - expect(getSessionState(id).status).toBe('BUSY'); + expect(getActivity(id).status).toBe('BUSY'); }); it('phantom dismiss creates soft TODO, typing 4 chars clears it', () => { @@ -603,17 +603,17 @@ describe('terminal-registry alert behavior', () => { driveToRingingNeedsAttention(id); attendSession(id); - expect(getSessionState(id).todo).toBe(TODO_SOFT_FULL); + expect(getActivity(id).todo).toBe(TODO_SOFT_FULL); // 3 keypresses strike 3 letters but don't clear for (let i = 0; i < 3; i++) { entry.terminal.emitInput('a'); } - expect(isSoftTodo(getSessionState(id).todo)).toBe(true); + expect(isSoftTodo(getActivity(id).todo)).toBe(true); // 4th keypress clears it entry.terminal.emitInput('a'); - expect(getSessionState(id).todo).toBe(TODO_OFF); + expect(getActivity(id).todo).toBe(TODO_OFF); }); it('soft TODO recovers after idle and requires fresh keypresses', () => { @@ -624,25 +624,25 @@ describe('terminal-registry alert behavior', () => { driveToRingingNeedsAttention(id); attendSession(id); - expect(getSessionState(id).todo).toBe(TODO_SOFT_FULL); + expect(getActivity(id).todo).toBe(TODO_SOFT_FULL); // 2 keypresses strike 2 letters entry.terminal.emitInput('a'); entry.terminal.emitInput('a'); - expect(getSessionState(id).todo).toBeCloseTo(0.5); + expect(getActivity(id).todo).toBeCloseTo(0.5); // 2 recovery intervals restore both letters vi.advanceTimersByTime(2 * STRIKE_RECOVERY_MS); - expect(getSessionState(id).todo).toBe(TODO_SOFT_FULL); + expect(getActivity(id).todo).toBe(TODO_SOFT_FULL); // Need 4 fresh keypresses to clear again for (let i = 0; i < 3; i++) { entry.terminal.emitInput('a'); } - expect(isSoftTodo(getSessionState(id).todo)).toBe(true); + expect(isSoftTodo(getActivity(id).todo)).toBe(true); entry.terminal.emitInput('a'); - expect(getSessionState(id).todo).toBe(TODO_OFF); + expect(getActivity(id).todo).toBe(TODO_OFF); }); it('focus-report control sequences do not clear a soft TODO', () => { @@ -653,11 +653,11 @@ describe('terminal-registry alert behavior', () => { driveToRingingNeedsAttention(id); attendSession(id); - expect(getSessionState(id).todo).toBe(TODO_SOFT_FULL); + expect(getActivity(id).todo).toBe(TODO_SOFT_FULL); entry.terminal.emitInput('\x1b[I'); - expect(getSessionState(id).todo).toBe(TODO_SOFT_FULL); + expect(getActivity(id).todo).toBe(TODO_SOFT_FULL); }); it('typing does not clear a hard TODO', () => { @@ -668,11 +668,11 @@ describe('terminal-registry alert behavior', () => { driveToRingingNeedsAttention(id); toggleSessionTodo(id); // ringing → hard TODO + attend - expect(getSessionState(id).todo).toBe(TODO_HARD); + expect(getActivity(id).todo).toBe(TODO_HARD); entry.terminal.emitInput('ls'); - expect(getSessionState(id).todo).toBe(TODO_HARD); + expect(getActivity(id).todo).toBe(TODO_HARD); }); it('toggleSessionTodo promotes soft to hard', () => { @@ -683,24 +683,24 @@ describe('terminal-registry alert behavior', () => { driveToRingingNeedsAttention(id); attendSession(id); - expect(getSessionState(id).todo).toBe(TODO_SOFT_FULL); + expect(getActivity(id).todo).toBe(TODO_SOFT_FULL); toggleSessionTodo(id); - expect(getSessionState(id).todo).toBe(TODO_HARD); + expect(getActivity(id).todo).toBe(TODO_HARD); }); it('toggleSessionTodo cycles: false → hard → false', () => { const id = 'toggle-cycle'; createSession(id); - expect(getSessionState(id).todo).toBe(TODO_OFF); + expect(getActivity(id).todo).toBe(TODO_OFF); toggleSessionTodo(id); - expect(getSessionState(id).todo).toBe(TODO_HARD); + expect(getActivity(id).todo).toBe(TODO_HARD); toggleSessionTodo(id); - expect(getSessionState(id).todo).toBe(TODO_OFF); + expect(getActivity(id).todo).toBe(TODO_OFF); }); it('dismiss does not downgrade hard TODO to soft', () => { @@ -713,12 +713,12 @@ describe('terminal-registry alert behavior', () => { expireAttention(id); advance(2_000); advance(3_000); - expect(getSessionState(id).status).toBe('ALERT_RINGING'); + expect(getActivity(id).status).toBe('ALERT_RINGING'); dismissSessionAlert(id); // Hard TODO should survive — soft TODO only set when todo === false - expect(getSessionState(id).todo).toBe(TODO_HARD); + expect(getActivity(id).todo).toBe(TODO_HARD); }); it('new output while ringing without attention does not create a soft TODO', () => { @@ -730,7 +730,7 @@ describe('terminal-registry alert behavior', () => { // New output without attention — alert latches, no soft TODO created emitOutput(id, 'next task'); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'ALERT_RINGING', todo: TODO_OFF, }); @@ -744,7 +744,7 @@ describe('terminal-registry alert behavior', () => { driveToRingingNeedsAttention(id); disableSessionAlert(id); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'ALERT_DISABLED', todo: TODO_OFF, }); @@ -756,7 +756,7 @@ describe('terminal-registry alert behavior', () => { dismissOrToggleAlert(id, 'ALERT_DISABLED'); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'NOTHING_TO_SHOW', todo: TODO_OFF, }); @@ -770,7 +770,7 @@ describe('terminal-registry alert behavior', () => { dismissOrToggleAlert(id, 'BUSY'); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'ALERT_DISABLED', todo: TODO_OFF, }); @@ -784,7 +784,7 @@ describe('terminal-registry alert behavior', () => { dismissOrToggleAlert(id, 'ALERT_RINGING'); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'NOTHING_TO_SHOW', todo: TODO_SOFT_FULL, }); @@ -798,14 +798,14 @@ describe('terminal-registry alert behavior', () => { driveToRingingNeedsAttention(id); markSessionAttention(id); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'NOTHING_TO_SHOW', todo: TODO_SOFT_FULL, }); dismissOrToggleAlert(id, 'ALERT_RINGING'); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'NOTHING_TO_SHOW', todo: TODO_SOFT_FULL, }); @@ -819,13 +819,13 @@ describe('terminal-registry alert behavior', () => { driveToRingingNeedsAttention(id); markSessionAttention(id); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'NOTHING_TO_SHOW', todo: TODO_SOFT_FULL, }); expect(dismissOrToggleAlert(id, 'NOTHING_TO_SHOW')).toBe('dismissed'); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'NOTHING_TO_SHOW', todo: TODO_SOFT_FULL, }); @@ -837,9 +837,9 @@ describe('terminal-registry alert behavior', () => { toggleSessionAlert(id); driveToRingingNeedsAttention(id); - focusTerminal(id, true); + focusSession(id, true); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'ALERT_RINGING', todo: TODO_OFF, }); @@ -856,7 +856,7 @@ describe('terminal-registry alert behavior', () => { advance(1_600); emitOutput(id, 'working...'); - expect(getSessionState(id)).toEqual({ + expect(getActivity(id)).toEqual({ status: 'NOTHING_TO_SHOW', todo: TODO_OFF, }); @@ -877,11 +877,11 @@ describe('terminal-registry alert behavior', () => { emitOutput(alpha, 'working...'); emitOutput(alpha, 'more work'); - expect(getSessionState(alpha)).toEqual({ + expect(getActivity(alpha)).toEqual({ status: 'ALERT_DISABLED', todo: TODO_OFF, }); - expect(getSessionState(beta)).toEqual({ + expect(getActivity(beta)).toEqual({ status: 'BUSY', todo: TODO_OFF, }); @@ -896,22 +896,22 @@ describe('terminal-registry alert behavior', () => { markSessionTodo(alpha); swapTerminals(alpha, beta); - expect(getSessionState(alpha)).toEqual({ + expect(getActivity(alpha)).toEqual({ status: 'ALERT_DISABLED', todo: TODO_OFF, }); - expect(getSessionState(beta)).toEqual({ + expect(getActivity(beta)).toEqual({ status: 'ALERT_DISABLED', todo: TODO_HARD, }); clearSessionTodo(beta); - expect(getSessionState(alpha)).toEqual({ + expect(getActivity(alpha)).toEqual({ status: 'ALERT_DISABLED', todo: TODO_OFF, }); - expect(getSessionState(beta)).toEqual({ + expect(getActivity(beta)).toEqual({ status: 'ALERT_DISABLED', todo: TODO_OFF, }); diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 33feda9..03875c4 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -25,12 +25,12 @@ import { extractSelectionText } from './selection-text'; export type { SessionStatus } from './activity-monitor'; export { TODO_OFF, TODO_SOFT_FULL, TODO_HARD, isSoftTodo, isHardTodo, hasTodo, type TodoState, type AlertButtonActionResult } from './alert-manager'; -export interface SessionUiState { +export interface ActivityState { status: SessionStatus; todo: TodoState; } -export const DEFAULT_SESSION_UI_STATE: SessionUiState = { +export const DEFAULT_ACTIVITY_STATE: ActivityState = { status: 'ALERT_DISABLED', todo: TODO_OFF, }; @@ -54,7 +54,7 @@ interface TerminalEntry { const registry = new Map(); const pendingShellOpts = new Map(); -const primedSessionStates = new Map>(); +const primedActivityStates = new Map>(); // Re-export from shell-defaults to preserve the public API surface. // The actual state lives in shell-defaults.ts to avoid a circular dependency @@ -83,26 +83,26 @@ function startThemeObserver(): void { // --- Session state subscription API (for useSyncExternalStore) --- -const sessionStateListeners = new Set<() => void>(); -let cachedSnapshot: Map | null = null; +const activityListeners = new Set<() => void>(); +let cachedSnapshot: Map | null = null; -function notifySessionStateListeners(): void { +function notifyActivityListeners(): void { cachedSnapshot = null; - sessionStateListeners.forEach((listener) => listener()); + activityListeners.forEach((listener) => listener()); } -export function subscribeToSessionStateChanges(listener: () => void): () => void { - sessionStateListeners.add(listener); - return () => sessionStateListeners.delete(listener); +export function subscribeToActivity(listener: () => void): () => void { + activityListeners.add(listener); + return () => activityListeners.delete(listener); } -export function getSessionStateSnapshot(): Map { +export function getActivitySnapshot(): Map { if (cachedSnapshot) return cachedSnapshot; - const snapshot = new Map(); - const ids = new Set([...registry.keys(), ...primedSessionStates.keys()]); + const snapshot = new Map(); + const ids = new Set([...registry.keys(), ...primedActivityStates.keys()]); for (const id of ids) { - const state = readSessionState(id); + const state = readActivity(id); if (state) { snapshot.set(id, state); } @@ -111,11 +111,11 @@ export function getSessionStateSnapshot(): Map { return snapshot; } -export function getSessionState(id: string): SessionUiState { - return readSessionState(id) ?? DEFAULT_SESSION_UI_STATE; +export function getActivity(id: string): ActivityState { + return readActivity(id) ?? DEFAULT_ACTIVITY_STATE; } -function readLiveSessionState(id: string): SessionUiState | null { +function readLiveActivity(id: string): ActivityState | null { const entry = registry.get(id); if (!entry) return null; @@ -125,13 +125,13 @@ function readLiveSessionState(id: string): SessionUiState | null { }; } -function readSessionState(id: string): SessionUiState | null { - const primedState = primedSessionStates.get(id); - const liveState = readLiveSessionState(id); +function readActivity(id: string): ActivityState | null { + const primedState = primedActivityStates.get(id); + const liveState = readLiveActivity(id); if (!liveState && !primedState) return null; return { - ...(liveState ?? DEFAULT_SESSION_UI_STATE), + ...(liveState ?? DEFAULT_ACTIVITY_STATE), ...primedState, }; } @@ -150,7 +150,7 @@ export function resolveTerminalSessionId(id: string): string { } export function getLivePersistedAlertState(id: string): PersistedAlertState | null { - const state = readLiveSessionState(id); + const state = readLiveActivity(id); if (!state) return null; return { status: state.status, @@ -158,21 +158,21 @@ export function getLivePersistedAlertState(id: string): PersistedAlertState | nu }; } -export function primeSessionState(id: string, state: Partial): void { - primedSessionStates.set(id, state); - notifySessionStateListeners(); +export function primeActivity(id: string, state: Partial): void { + primedActivityStates.set(id, state); + notifyActivityListeners(); } -export function clearPrimedSessionState(id?: string): void { +export function clearPrimedActivity(id?: string): void { if (id === undefined) { - if (primedSessionStates.size === 0) return; - primedSessionStates.clear(); - notifySessionStateListeners(); + if (primedActivityStates.size === 0) return; + primedActivityStates.clear(); + notifyActivityListeners(); return; } - if (!primedSessionStates.delete(id)) return; - notifySessionStateListeners(); + if (!primedActivityStates.delete(id)) return; + notifyActivityListeners(); } // --- Alert state receiver (from platform's AlertManager) --- @@ -197,11 +197,11 @@ export function initAlertStateReceiver(): void { entry.todo = detail.todo; entry.attentionDismissedRing = detail.attentionDismissedRing; // Clear any primed state now that we have live data - primedSessionStates.delete(detail.id); - notifySessionStateListeners(); + primedActivityStates.delete(detail.id); + notifyActivityListeners(); } else { // Terminal entry not created yet — prime the state so it's ready when it is - primeSessionState(detail.id, { status: detail.status, todo: detail.todo }); + primeActivity(detail.id, { status: detail.status, todo: detail.todo }); } }; platform.onAlertState(currentAlertHandler); @@ -565,11 +565,11 @@ function setupTerminalEntry(id: string): TerminalEntry { }; // Apply any primed alert state (from platform reconnect) - const primed = primedSessionStates.get(id); + const primed = primedActivityStates.get(id); if (primed) { if (primed.status !== undefined) entry.alertStatus = primed.status; if (primed.todo !== undefined) entry.todo = primed.todo; - primedSessionStates.delete(id); + primedActivityStates.delete(id); } registry.set(id, entry); @@ -614,7 +614,7 @@ export function getOrCreateTerminal(id: string): TerminalEntry { * Reconnect to an existing PTY after the webview is recreated. * Creates the xterm instance and writes replay data, but does NOT spawn a new PTY. */ -export function reconnectTerminal( +export function resumeTerminal( id: string, replayData: string | null, exitInfo?: { alive: boolean; exitCode?: number }, @@ -677,7 +677,7 @@ export function restoreTerminal( * Attach a terminal's persistent element to a container div. * Call this when the TerminalPane component mounts or reparents. */ -export function attachTerminal(id: string, container: HTMLElement): void { +export function mountElement(id: string, container: HTMLElement): void { const entry = registry.get(id); if (!entry) return; container.appendChild(entry.element); @@ -689,7 +689,7 @@ export function attachTerminal(id: string, container: HTMLElement): void { * Detach a terminal's element from its current container. * The terminal stays alive — just not in the DOM. */ -export function detachTerminal(id: string): void { +export function unmountElement(id: string): void { const entry = registry.get(id); if (!entry) return; entry.element.remove(); @@ -698,16 +698,16 @@ export function detachTerminal(id: string): void { /** * Destroy all terminals. Used for cleanup between Storybook stories. */ -export function destroyAllTerminals(): void { +export function disposeAllSessions(): void { for (const id of [...registry.keys()]) { - destroyTerminal(id); + disposeSession(id); } } /** * Permanently destroy a terminal: kill PTY, dispose xterm, remove from registry. */ -export function destroyTerminal(id: string): void { +export function disposeSession(id: string): void { const entry = registry.get(id); if (!entry) return; getPlatform().alertRemove(entry.ptyId); @@ -717,7 +717,7 @@ export function destroyTerminal(id: string): void { entry.terminal.dispose(); registry.delete(id); removeMouseSelectionState(id); - notifySessionStateListeners(); + notifyActivityListeners(); } /** @@ -729,7 +729,7 @@ export function destroyTerminal(id: string): void { * created for idB (and vice versa). The PTY data/exit handlers inside each * entry still filter by their original spawn ID, so PTY output continues to * route correctly — the PTY doesn't know or care about registry keys. - * However, destroyTerminal(idA) after a swap will kill the PTY that was + * However, disposeSession(idA) after a swap will kill the PTY that was * originally spawned as idB. This is correct because the user sees that * terminal in slot A and expects "kill A" to kill it. */ @@ -760,13 +760,13 @@ export function swapTerminals(idA: string, idB: string): void { requestAnimationFrame(() => entryA.fit.fit()); } - notifySessionStateListeners(); + notifyActivityListeners(); } /** * Refit the terminal to its container. Call after container resize. */ -export function refitTerminal(id: string): void { +export function refitSession(id: string): void { const entry = registry.get(id); if (!entry) return; entry.fit.fit(); @@ -847,7 +847,7 @@ export function getTerminalOverlayDims(id: string): TerminalOverlayDims | null { /** * Focus or blur the terminal. */ -export function focusTerminal(id: string, focused: boolean): void { +export function focusSession(id: string, focused: boolean): void { const entry = registry.get(id); if (!entry) return; diff --git a/lib/src/main.tsx b/lib/src/main.tsx index 8dc0799..77bc9ee 100644 --- a/lib/src/main.tsx +++ b/lib/src/main.tsx @@ -1,7 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { initPlatform } from "./lib/platform"; -import { reconnectFromInit } from "./lib/reconnect"; +import { resumeOrRestore } from "./lib/reconnect"; import { initAlertStateReceiver } from "./lib/terminal-registry"; import App from "./App"; import "./index.css"; @@ -13,10 +13,10 @@ initAlertStateReceiver(); // Request PTY list before rendering so Pond can restore existing sessions. // On non-VSCode platforms (or first launch), this resolves immediately with no IDs. -reconnectFromInit(platform).then((result) => { +resumeOrRestore(platform).then((result) => { createRoot(document.getElementById("root")!).render( - + , ); }); diff --git a/lib/src/stories/Baseboard.stories.tsx b/lib/src/stories/Baseboard.stories.tsx index 4cc6aaf..5e7c762 100644 --- a/lib/src/stories/Baseboard.stories.tsx +++ b/lib/src/stories/Baseboard.stories.tsx @@ -1,9 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Baseboard } from '../components/Baseboard'; -import type { DetachedItem } from '../components/Pond'; +import type { DooredItem } from '../components/Pond'; import { TODO_OFF, TODO_HARD } from '../lib/terminal-registry'; -const makeItem = (id: string, title: string): DetachedItem => ({ +const makeItem = (id: string, title: string): DooredItem => ({ id, title, neighborId: null, @@ -21,7 +21,7 @@ function withState(byId: Record>) { }; } -function BaseboardStory({ items, activeId = null }: { items: DetachedItem[]; activeId?: string | null }) { +function BaseboardStory({ items, activeId = null }: { items: DooredItem[]; activeId?: string | null }) { return (
{}, - onDetach: () => {}, + onMinimize: () => {}, onAlertButton: () => 'noop', onToggleTodo: () => {}, onSplitH: () => {}, diff --git a/lib/src/stories/Pond.stories.tsx b/lib/src/stories/Pond.stories.tsx index 3d514e3..b0a8b8c 100644 --- a/lib/src/stories/Pond.stories.tsx +++ b/lib/src/stories/Pond.stories.tsx @@ -7,7 +7,7 @@ import { SCENARIO_ANSI_COLORS, SCENARIO_LONG_RUNNING, } from '../lib/platform'; -import { getSessionStateSnapshot, primeSessionState, type SessionUiState, TODO_OFF, TODO_HARD } from '../lib/terminal-registry'; +import { getActivitySnapshot, primeActivity, type ActivityState, TODO_OFF, TODO_HARD } from '../lib/terminal-registry'; const meta: Meta = { title: 'App/Pond', @@ -21,12 +21,12 @@ function wait(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } -function primeByIndex(states: Partial[]) { - const ids = [...getSessionStateSnapshot().keys()]; +function primeByIndex(states: Partial[]) { + const ids = [...getActivitySnapshot().keys()]; states.forEach((state, index) => { const id = ids[index]; if (id) { - primeSessionState(id, state); + primeActivity(id, state); } }); } diff --git a/lib/src/stories/TerminalPaneHeader.stories.tsx b/lib/src/stories/TerminalPaneHeader.stories.tsx index 62ca769..6141641 100644 --- a/lib/src/stories/TerminalPaneHeader.stories.tsx +++ b/lib/src/stories/TerminalPaneHeader.stories.tsx @@ -14,7 +14,7 @@ const SESSION_ID = 'tab-story'; const noopActions: PondActions = { onKill: () => {}, - onDetach: () => {}, + onMinimize: () => {}, onAlertButton: () => 'noop', onToggleTodo: () => {}, onSplitH: () => {}, diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index eb8a0c9..fda1dee 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -2,7 +2,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { invoke } from "@tauri-apps/api/core"; import { setPlatform } from "mouseterm-lib/lib/platform"; -import { reconnectFromInit } from "mouseterm-lib/lib/reconnect"; +import { resumeOrRestore } from "mouseterm-lib/lib/reconnect"; import { applyTheme, getActiveThemeId, @@ -40,7 +40,7 @@ async function bootstrap() { const { initAlertStateReceiver } = await import("mouseterm-lib/lib/terminal-registry"); initAlertStateReceiver(); restoreStandaloneTheme(); - const result = await reconnectFromInit(platform); + const result = await resumeOrRestore(platform); startUpdateCheck(); @@ -54,7 +54,7 @@ async function bootstrap() { } /> , diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 3df645d..b4a3795 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -262,7 +262,7 @@ export function attachRouter( claim(pane.id); } if (pane.alert) { - alertManager.restore(pane.id, pane.alert); + alertManager.seed(pane.id, pane.alert); } } } diff --git a/website/src/lib/tutorial-detection.ts b/website/src/lib/tutorial-detection.ts index 4581997..f181374 100644 --- a/website/src/lib/tutorial-detection.ts +++ b/website/src/lib/tutorial-detection.ts @@ -117,7 +117,7 @@ export class TutorialDetector { } break; - case 'detachChange': + case 'minimizeChange': if (event.count > 0) { this.hasDetached = true; } else if (this.hasDetached && this.shell.isStepComplete(2)) { From db6a12c7f3ba2b623186e255a56c4abc7e2e7aae Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 23 Apr 2026 14:23:59 -0700 Subject: [PATCH 3/8] Phase C: story names, test descriptions, code comments. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storybook: WithDetached story → WithDoors DetachedRingingSession → MinimizedRingingSession detachSelectedPane helper → minimizeSelectedPane Tests: detachSession helper → minimizeSession "detach preserves state" → "minimize preserves state" Pond.tsx local variables and comments: initialDetachedRef → initialDoorsRef nextDetached → nextDoors currentDetached → currentDoors Comments aligned to use "minimize" / "door" vocabulary. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/Pond.tsx | 42 ++++++++++----------- lib/src/lib/terminal-registry.alert.test.ts | 10 ++--- lib/src/stories/Pond.stories.tsx | 12 +++--- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 057e8ca..a51434f 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -78,9 +78,9 @@ let dialogKeyboardActive = false; export interface DooredItem { id: string; title: string; - neighborId: string | null; // panel that was adjacent before detach + neighborId: string | null; // pane that was adjacent before minimize direction: DoorDirection; // where we were relative to that neighbor - remainingPanelIds: string[]; // sorted panel IDs after detach (for layout-changed check) + remainingPanelIds: string[]; // sorted pane IDs after minimize (for layout-changed check) restoreLayout: SerializedDockview | null; detachedLayoutSignature: string; } @@ -1002,7 +1002,7 @@ function SelectionOverlay({ apiRef, selectedId, selectedType, mode, overlayElRef ? doorElements.get(selectedId) : resolvePanelElement(panelElements.get(selectedId)); // Keep stale rect while the element is temporarily missing (e.g. during - // detach → door transition) so the overlay stays mounted and can animate. + // minimize → door transition) so the overlay stays mounted and can animate. if (!targetEl) return; const targetRect = targetEl.getBoundingClientRect(); @@ -1287,7 +1287,7 @@ export function Pond({ // Consumed once in handleReady to restore existing sessions const initialPaneIdsRef = useRef(initialPaneIds); const restoredLayoutRef = useRef(restoredLayout); - const initialDetachedRef = useRef((initialDoors ?? []).map(toDooredItem)); + const initialDoorsRef = useRef((initialDoors ?? []).map(toDooredItem)); // Mutable maps shared via context — consumers must call bumpVersion() after // any mutation so that dependent effects/components re-run. @@ -1476,7 +1476,7 @@ export function Pond({ api.removePanel(panel); clearSessionAttention(id); const detachedLayoutSignature = getLayoutStructureSignature(api.toJSON()); - const nextDetached = [...doorsRef.current, { + const nextDoors = [...doorsRef.current, { id, title, neighborId, @@ -1485,8 +1485,8 @@ export function Pond({ restoreLayout, detachedLayoutSignature, }]; - doorsRef.current = nextDetached; - setDoors(nextDetached); + doorsRef.current = nextDoors; + setDoors(nextDoors); // Keep the minimized session selected as a door so the user can track where it went. modeRef.current = 'command'; @@ -1517,10 +1517,10 @@ export function Pond({ // Restore existing PTY sessions if available const restored = initialPaneIdsRef.current; const layout = restoredLayoutRef.current; - const restoredDoors = initialDetachedRef.current; + const restoredDoors = initialDoorsRef.current; initialPaneIdsRef.current = undefined; // consume once restoredLayoutRef.current = undefined; - initialDetachedRef.current = []; + initialDoorsRef.current = []; doorsRef.current = restoredDoors; setDoors(restoredDoors); @@ -1632,7 +1632,7 @@ export function Pond({ // Always keep one pane visible: when the last visible pane is removed (killed // or minimized), spawn a fresh one — regardless of whether doors exist. // - // Delay the spawn by the kill/detach animation duration so the two animations + // Delay the spawn by the kill/minimize animation duration so the two animations // don't overlap — the outgoing pane crushes/fades first, then the new pane // reveals from the top-left. If anything restores a pane in the meantime // (e.g. door reattach), the delayed spawn becomes a no-op. @@ -1649,7 +1649,7 @@ export function Pond({ e.api.addPanel({ id, component: 'terminal', tabComponent: 'terminal', title: '' }); // Only steal focus if nothing is selected (i.e., the kill path, which // clears selection). On minimize the new door is selected and we - // must not override that — the door retains focus per the detach UX. + // must not override that — the door retains focus per the minimize UX. if (selectedIdRef.current === null) { selectPanel(id); } @@ -1998,7 +1998,7 @@ export function Pond({ const dir = e.key; const currentType = selectedTypeRef.current; - const currentDetached = doorsRef.current; + const currentDoors = doorsRef.current; // Navigation from a door if (currentType === 'door') { @@ -2010,11 +2010,11 @@ export function Pond({ return; } // Left/Right between doors - const doorIdx = currentDetached.findIndex(d => d.id === sid); + const doorIdx = currentDoors.findIndex(d => d.id === sid); if (dir === 'ArrowLeft' && doorIdx > 0) { - selectDoor(currentDetached[doorIdx - 1].id); - } else if (dir === 'ArrowRight' && doorIdx < currentDetached.length - 1) { - selectDoor(currentDetached[doorIdx + 1].id); + selectDoor(currentDoors[doorIdx - 1].id); + } else if (dir === 'ArrowRight' && doorIdx < currentDoors.length - 1) { + selectDoor(currentDoors[doorIdx + 1].id); } return; } @@ -2032,9 +2032,9 @@ export function Pond({ if (targetId) { navHistory.current = { direction: dir, fromId: sid }; selectPanel(targetId); - } else if (dir === 'ArrowDown' && currentDetached.length > 0) { + } else if (dir === 'ArrowDown' && currentDoors.length > 0) { // No pane below — move to first door in baseboard - selectDoor(currentDetached[0].id); + selectDoor(currentDoors[0].id); } return; } @@ -2117,9 +2117,9 @@ export function Pond({ } } - const nextDetached = doorsRef.current.filter(p => p.id !== item.id); - doorsRef.current = nextDetached; - setDoors(nextDetached); + const nextDoors = doorsRef.current.filter(p => p.id !== item.id); + doorsRef.current = nextDoors; + setDoors(nextDoors); selectPanel(item.id); if (enterPassthrough) { enterTerminalMode(item.id); diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 8e8b9f1..a591a78 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -185,7 +185,7 @@ function expireAttention(id?: string): void { clearSessionAttention(id); } -function detachSession(id: string): void { +function minimizeSession(id: string): void { unmountElement(id); clearSessionAttention(id); } @@ -435,13 +435,13 @@ describe('terminal-registry alert behavior', () => { expect(getActivity(id).status).toBe('BUSY'); }); - it('Story 10: detach preserves state, click restore clears ring', () => { + it('Story 10: minimize preserves state, click reattach clears ring', () => { const id = 'story-10'; createSession(id); toggleSessionAlert(id); attendSession(id); - detachSession(id); + minimizeSession(id); driveToRingingNeedsAttention(id); expect(getActivity(id)).toMatchObject({ @@ -457,13 +457,13 @@ describe('terminal-registry alert behavior', () => { }); }); - it('Story 11: detach preserves state, d restore does not clear ring', () => { + it('Story 11: minimize preserves state, d reattach does not clear ring', () => { const id = 'story-11'; createSession(id); toggleSessionAlert(id); attendSession(id); - detachSession(id); + minimizeSession(id); driveToRingingNeedsAttention(id); reattachDoorViaD(id); diff --git a/lib/src/stories/Pond.stories.tsx b/lib/src/stories/Pond.stories.tsx index b0a8b8c..edae0ea 100644 --- a/lib/src/stories/Pond.stories.tsx +++ b/lib/src/stories/Pond.stories.tsx @@ -41,7 +41,7 @@ async function splitPanes() { await wait(100); } -async function detachSelectedPane() { +async function minimizeSelectedPane() { await wait(200); window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', bubbles: true })); await wait(150); @@ -83,11 +83,11 @@ export const MultiPaneLight: Story = { play: splitPanes, }; -export const WithDetached: Story = { +export const WithDoors: Story = { parameters: { fakePty: { scenario: flattenScenario(SCENARIO_LS_OUTPUT) } }, play: async () => { await splitPanes(); - await detachSelectedPane(); + await minimizeSelectedPane(); }, }; @@ -134,7 +134,7 @@ export const AlertRingingPane: Story = { export const AlertRingingDoor: Story = { parameters: { fakePty: { scenario: flattenScenario(SCENARIO_SHELL_PROMPT) } }, play: async () => { - await detachSelectedPane(); + await minimizeSelectedPane(); primeByIndex([ { status: 'ALERT_RINGING', @@ -177,10 +177,10 @@ export const TodoAfterDismiss: Story = { }, }; -export const DetachedRingingSession: Story = { +export const MinimizedRingingSession: Story = { parameters: { fakePty: { scenario: flattenScenario(SCENARIO_SHELL_PROMPT) } }, play: async () => { - await detachSelectedPane(); + await minimizeSelectedPane(); primeByIndex([ { status: 'ALERT_RINGING', From be971861ddb6ab4de522e0963f26bbfaa9a6d1d3 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 23 Apr 2026 14:28:03 -0700 Subject: [PATCH 4/8] =?UTF-8?q?Phase=20D:=20migrate=20persisted=20schema?= =?UTF-8?q?=20v1=20=E2=86=92=20v2.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename fields in the on-disk PersistedSession shape to match the ontology: PersistedSession: version 1 → 2 detached → doors PersistedDoor (was PersistedDetachedItem): remainingPanelIds → remainingPaneIds restoreLayout → layoutAtMinimize detachedLayoutSignature → layoutAtMinimizeSignature Migration path: readPersistedSession(raw) normalizes v1 and v2 blobs into the current v2 shape. Both session-save (write) and session-restore and reconnect (read) route through it. Writes are always v2; reads accept v1 and translate. v1 support can be removed after two releases. Added session-migration.test.ts covering: - v1 → v2 field translation - missing optional detached field - readPersistedSession accepting v1, v2, and rejecting malformed blobs In-memory DooredItem interface and downstream consumers (Pond.tsx, reconnect.ts, session-save.ts, session-restore.ts, stories, tests) use the v2 field names throughout. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/Pond.tsx | 40 +++++----- lib/src/lib/reconnect.test.ts | 24 +++--- lib/src/lib/reconnect.ts | 34 ++++----- lib/src/lib/session-migration.test.ts | 104 ++++++++++++++++++++++++++ lib/src/lib/session-restore.test.ts | 2 +- lib/src/lib/session-restore.ts | 16 ++-- lib/src/lib/session-save.test.ts | 10 +-- lib/src/lib/session-save.ts | 12 +-- lib/src/lib/session-types.ts | 59 ++++++++++++++- lib/src/main.tsx | 2 +- lib/src/stories/Baseboard.stories.tsx | 6 +- standalone/src/main.tsx | 2 +- 12 files changed, 234 insertions(+), 77 deletions(-) create mode 100644 lib/src/lib/session-migration.test.ts diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index a51434f..bf2089a 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -80,15 +80,15 @@ export interface DooredItem { title: string; neighborId: string | null; // pane that was adjacent before minimize direction: DoorDirection; // where we were relative to that neighbor - remainingPanelIds: string[]; // sorted pane IDs after minimize (for layout-changed check) - restoreLayout: SerializedDockview | null; - detachedLayoutSignature: string; + remainingPaneIds: string[]; // sorted pane IDs after minimize (for layout-changed check) + layoutAtMinimize: SerializedDockview | null; + layoutAtMinimizeSignature: string; } function toDooredItem(item: PersistedDoor): DooredItem { return { ...item, - restoreLayout: item.restoreLayout as SerializedDockview | null, + layoutAtMinimize: item.layoutAtMinimize as SerializedDockview | null, }; } @@ -1367,9 +1367,9 @@ export function Pond({ title: item.title, neighborId: item.neighborId, direction: item.direction, - remainingPanelIds: item.remainingPanelIds, - restoreLayout: item.restoreLayout, - detachedLayoutSignature: item.detachedLayoutSignature, + remainingPaneIds: item.remainingPaneIds, + layoutAtMinimize: item.layoutAtMinimize, + layoutAtMinimizeSignature: item.layoutAtMinimizeSignature, })); return saveSession(getPlatform(), api.toJSON(), panes, doorItems); }, []); @@ -1462,28 +1462,28 @@ export function Pond({ const panel = api.getPanel(id); if (!panel) return; const title = panel.title ?? id; - const restoreLayout = cloneLayout(api.toJSON()); + const layoutAtMinimize = cloneLayout(api.toJSON()); // Capture the nearest adjacent pane and our actual relative position // so immediate restore can reconstruct the original split precisely. const { neighborId, direction } = findReattachNeighbor(id, api, panelElements); - const remainingPanelIds = api.panels + const remainingPaneIds = api.panels .filter(p => p.id !== id) .map(p => p.id) .sort(); api.removePanel(panel); clearSessionAttention(id); - const detachedLayoutSignature = getLayoutStructureSignature(api.toJSON()); + const layoutAtMinimizeSignature = getLayoutStructureSignature(api.toJSON()); const nextDoors = [...doorsRef.current, { id, title, neighborId, direction, - remainingPanelIds, - restoreLayout, - detachedLayoutSignature, + remainingPaneIds, + layoutAtMinimize, + layoutAtMinimizeSignature, }]; doorsRef.current = nextDoors; setDoors(nextDoors); @@ -2060,14 +2060,14 @@ export function Pond({ // Exact restore is only safe when the layout structure matches AND the // current panels are the same ones that existed when we minimized. If new // panels were auto-spawned (e.g. last pane minimized → auto-create), the - // restoreLayout would destroy them. + // layoutAtMinimize would destroy them. const currentPanelIds = api.panels.map(p => p.id).sort(); - const restorePanelIds = item.restoreLayout - ? Object.keys(item.restoreLayout.panels).filter(id => id !== item.id).sort() + const restorePanelIds = item.layoutAtMinimize + ? Object.keys(item.layoutAtMinimize.panels).filter(id => id !== item.id).sort() : []; const canRestoreExactLayout = - !!item.restoreLayout && - currentLayoutSignature === item.detachedLayoutSignature && + !!item.layoutAtMinimize && + currentLayoutSignature === item.layoutAtMinimizeSignature && idsMatch(currentPanelIds, restorePanelIds); if (canRestoreExactLayout) { @@ -2077,7 +2077,7 @@ export function Pond({ // reuseExistingPanels: keep existing panel component instances mounted // rather than destroying and recreating them during deserialization. - api.fromJSON(cloneLayout(item.restoreLayout!), { reuseExistingPanels: true }); + api.fromJSON(cloneLayout(item.layoutAtMinimize!), { reuseExistingPanels: true }); for (const [panelId, title] of currentTitles) { if (panelId === item.id) continue; @@ -2088,7 +2088,7 @@ export function Pond({ const layoutUnchanged = item.neighborId && api.getPanel(item.neighborId) && - idsMatch(currentIds, item.remainingPanelIds); + idsMatch(currentIds, item.remainingPaneIds); if (layoutUnchanged) { // Restore to original position next to the same neighbor diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts index 9675dfb..3848fbe 100644 --- a/lib/src/lib/reconnect.test.ts +++ b/lib/src/lib/reconnect.test.ts @@ -71,26 +71,26 @@ describe('resumeOrRestore', () => { vi.clearAllMocks(); }); - it('restores saved visible layout and detached doors for matching live PTYs', async () => { + it('restores saved visible layout and minimized doors for matching live PTYs', async () => { const layout = { panels: { 'pane-a': {}, 'pane-b': {}, }, }; - const detached = [{ + const doors = [{ id: 'pane-c', title: 'Pane C', neighborId: 'pane-b', direction: 'right' as const, - remainingPanelIds: ['pane-a', 'pane-b'], - restoreLayout: layout, - detachedLayoutSignature: 'sig', + remainingPaneIds: ['pane-a', 'pane-b'], + layoutAtMinimize: layout, + layoutAtMinimizeSignature: 'sig', }]; const saved: PersistedSession = { - version: 1, + version: 2, layout, - detached, + doors, panes: [ { id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }, { id: 'pane-b', title: 'Pane B', cwd: null, scrollback: null, resumeCommand: null }, @@ -106,7 +106,7 @@ describe('resumeOrRestore', () => { expect(result).toEqual({ paneIds: ['pane-a', 'pane-b'], - detached, + doors, layout, }); expect(terminalRegistryMocks.resumeTerminal).toHaveBeenCalledWith('pane-c', 'pane-c-replay', { @@ -117,7 +117,7 @@ describe('resumeOrRestore', () => { it('does not reuse a saved layout when live PTYs do not match saved panes', async () => { const saved: PersistedSession = { - version: 1, + version: 2, layout: { panels: { 'pane-a': {}, 'pane-b': {} } }, panes: [ { id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }, @@ -133,14 +133,14 @@ describe('resumeOrRestore', () => { expect(result).toEqual({ paneIds: ['pane-a', 'pane-b', 'extra-pane'], - detached: [], + doors: [], }); }); it('ignores stale saved panes when the saved layout still matches live visible panes', async () => { const layout = { panels: { 'pane-a': {}, 'pane-b': {} } }; const saved: PersistedSession = { - version: 1, + version: 2, layout, panes: [ { id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }, @@ -156,7 +156,7 @@ describe('resumeOrRestore', () => { expect(result).toEqual({ paneIds: ['pane-a', 'pane-b'], - detached: [], + doors: [], layout, }); }); diff --git a/lib/src/lib/reconnect.ts b/lib/src/lib/reconnect.ts index b38d9aa..f7e5904 100644 --- a/lib/src/lib/reconnect.ts +++ b/lib/src/lib/reconnect.ts @@ -1,28 +1,28 @@ import type { PlatformAdapter, PtyInfo } from './platform/types'; import { resumeTerminal } from './terminal-registry'; -import type { PersistedDoor, PersistedSession } from './session-types'; +import { readPersistedSession, type PersistedDoor } from './session-types'; import { restoreSession } from './session-restore'; export interface ReconnectResult { paneIds: string[]; layout?: unknown; // dockview SerializedDockview, only present on cold-start restore - detached?: PersistedDoor[]; + doors?: PersistedDoor[]; } /** - * Attempt to reconnect to live PTYs, or restore from saved session. + * Resume over live PTYs, or cold-restore from saved session. * * Priority: - * 1. Live PTYs (webview was hidden/shown) → reconnect with replay data + * 1. Live PTYs (webview was hidden/shown) → resume with replay data * 2. Saved session (app restarted) → restore with saved scrollback + cwd * 3. Neither → return empty (Pond creates a fresh terminal) */ export async function resumeOrRestore(platform: PlatformAdapter): Promise { - // First, try to reconnect to live PTYs + // First, try to resume over live PTYs const liveResult = await resumeLiveSessions(platform); if (liveResult.paneIds.length > 0) return liveResult; - // No live PTYs — try saved session restore + // No live PTYs — try cold restore const restored = await restoreSession(platform); if (restored) return restored; @@ -71,16 +71,16 @@ function resumeLiveSessions(platform: PlatformAdapter): Promise }); ids.push(pty.id); } - // Pull saved visible/detached state so reconnect (e.g. after panel + // Pull saved visible/doors state so a resume (e.g. after panel // close/reopen) restores splits and doors instead of stacking every live // PTY into one tab group. - const savedPlan = getSavedLiveReconnectPlan(platform.getState(), ids); + const savedPlan = getSavedResumePlan(platform.getState(), ids); if (savedPlan) { resolve(savedPlan); return; } - resolve({ paneIds: ids, detached: [] }); + resolve({ paneIds: ids, doors: [] }); } platform.onPtyList(handleList); @@ -89,21 +89,21 @@ function resumeLiveSessions(platform: PlatformAdapter): Promise }); } -function getSavedLiveReconnectPlan(savedState: unknown, liveIds: string[]): ReconnectResult | null { - const saved = savedState as PersistedSession | null; - if (!saved || saved.version !== 1 || !Array.isArray(saved.panes)) return null; +function getSavedResumePlan(savedState: unknown, liveIds: string[]): ReconnectResult | null { + const saved = readPersistedSession(savedState); + if (!saved || !Array.isArray(saved.panes)) return null; - // Reuse persisted visible/detached state only when every live PTY is covered + // Reuse persisted visible/doors state only when every live PTY is covered // by the saved session. Extra saved panes can be stale, but extra live panes // have no reliable saved layout position. const liveSet = new Set(liveIds); const savedSet = new Set(saved.panes.map((p) => p.id)); if (!liveIds.every((id) => savedSet.has(id))) return null; - const detached = (saved.detached ?? []).filter((item) => liveSet.has(item.id)); - const detachedIds = new Set(detached.map((item) => item.id)); + const doors = (saved.doors ?? []).filter((item) => liveSet.has(item.id)); + const doorIds = new Set(doors.map((item) => item.id)); const paneIds = saved.panes - .filter((pane) => liveSet.has(pane.id) && !detachedIds.has(pane.id)) + .filter((pane) => liveSet.has(pane.id) && !doorIds.has(pane.id)) .map((pane) => pane.id); const layoutPanelIds = getLayoutPanelIds(saved.layout); const layoutMatchesVisiblePanes = @@ -113,7 +113,7 @@ function getSavedLiveReconnectPlan(savedState: unknown, liveIds: string[]): Reco return { paneIds, - detached, + doors, layout: layoutMatchesVisiblePanes ? saved.layout : undefined, }; } diff --git a/lib/src/lib/session-migration.test.ts b/lib/src/lib/session-migration.test.ts new file mode 100644 index 0000000..f21c2b3 --- /dev/null +++ b/lib/src/lib/session-migration.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { migrateSessionV1toV2, readPersistedSession, type PersistedSessionV1 } from './session-types'; + +describe('session migration v1 → v2', () => { + it('migrates a v1 blob with doors to v2, renaming fields', () => { + const v1: PersistedSessionV1 = { + version: 1, + layout: { panels: { 'pane-a': {} } }, + panes: [ + { id: 'pane-a', title: 'Pane A', cwd: '/home/ned', scrollback: '$ ls\n', resumeCommand: null, alert: null }, + { id: 'pane-b', title: 'Pane B', cwd: null, scrollback: null, resumeCommand: null }, + ], + detached: [ + { + id: 'pane-b', + title: 'Pane B', + neighborId: 'pane-a', + direction: 'right', + remainingPanelIds: ['pane-a'], + restoreLayout: { panels: { 'pane-a': {}, 'pane-b': {} } }, + detachedLayoutSignature: 'sig-abc', + }, + ], + }; + + const v2 = migrateSessionV1toV2(v1); + + expect(v2).toEqual({ + version: 2, + layout: { panels: { 'pane-a': {} } }, + panes: v1.panes, + doors: [ + { + id: 'pane-b', + title: 'Pane B', + neighborId: 'pane-a', + direction: 'right', + remainingPaneIds: ['pane-a'], + layoutAtMinimize: { panels: { 'pane-a': {}, 'pane-b': {} } }, + layoutAtMinimizeSignature: 'sig-abc', + }, + ], + }); + }); + + it('migrates a v1 blob with no detached field to v2 with empty doors', () => { + const v1: PersistedSessionV1 = { + version: 1, + layout: null, + panes: [{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }], + }; + + const v2 = migrateSessionV1toV2(v1); + + expect(v2.version).toBe(2); + expect(v2.doors).toEqual([]); + }); +}); + +describe('readPersistedSession', () => { + it('returns a v2 blob unchanged', () => { + const v2 = { + version: 2 as const, + layout: null, + panes: [{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }], + doors: [], + }; + expect(readPersistedSession(v2)).toBe(v2); + }); + + it('migrates a v1 blob on read', () => { + const v1 = { + version: 1 as const, + layout: null, + panes: [{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }], + detached: [ + { + id: 'pane-b', + title: 'Pane B', + neighborId: null, + direction: 'right' as const, + remainingPanelIds: [], + restoreLayout: null, + detachedLayoutSignature: '', + }, + ], + }; + const result = readPersistedSession(v1); + expect(result?.version).toBe(2); + expect(result?.doors?.[0]).toMatchObject({ + id: 'pane-b', + remainingPaneIds: [], + layoutAtMinimize: null, + layoutAtMinimizeSignature: '', + }); + }); + + it('returns null for malformed or missing blobs', () => { + expect(readPersistedSession(null)).toBeNull(); + expect(readPersistedSession(undefined)).toBeNull(); + expect(readPersistedSession({ version: 99 })).toBeNull(); + expect(readPersistedSession('not an object')).toBeNull(); + }); +}); diff --git a/lib/src/lib/session-restore.test.ts b/lib/src/lib/session-restore.test.ts index 8ffba51..d8808b8 100644 --- a/lib/src/lib/session-restore.test.ts +++ b/lib/src/lib/session-restore.test.ts @@ -69,7 +69,7 @@ describe('restoreSession', () => { args: ['-NoLogo'], }); const saved: PersistedSession = { - version: 1, + version: 2, layout: { panels: { 'pane-a': {} } }, panes: [ { id: 'pane-a', title: 'Pane A', cwd: 'C:\\repo', scrollback: 'hello', resumeCommand: null }, diff --git a/lib/src/lib/session-restore.ts b/lib/src/lib/session-restore.ts index 7eeca4e..abd3947 100644 --- a/lib/src/lib/session-restore.ts +++ b/lib/src/lib/session-restore.ts @@ -1,18 +1,18 @@ import type { PlatformAdapter } from './platform/types'; -import type { PersistedDoor, PersistedSession } from './session-types'; +import { readPersistedSession, type PersistedDoor } from './session-types'; import { getDefaultShellOpts, restoreTerminal } from './terminal-registry'; export interface RestoredSession { paneIds: string[]; layout: unknown; - detached: PersistedDoor[]; + doors: PersistedDoor[]; } export function restoreSession(platform: PlatformAdapter): RestoredSession | null { - const saved = platform.getState() as PersistedSession | null; - if (!saved || saved.version !== 1 || !saved.panes || saved.panes.length === 0) return null; - const detached = saved.detached ?? []; - const detachedIds = new Set(detached.map((item) => item.id)); + const saved = readPersistedSession(platform.getState()); + if (!saved || !saved.panes || saved.panes.length === 0) return null; + const doors = saved.doors ?? []; + const doorIds = new Set(doors.map((item) => item.id)); const shellOpts = getDefaultShellOpts(); for (const pane of saved.panes) { @@ -26,8 +26,8 @@ export function restoreSession(platform: PlatformAdapter): RestoredSession | nul } return { - paneIds: saved.panes.filter((pane) => !detachedIds.has(pane.id)).map((p) => p.id), + paneIds: saved.panes.filter((pane) => !doorIds.has(pane.id)).map((p) => p.id), layout: saved.layout, - detached, + doors, }; } diff --git a/lib/src/lib/session-save.test.ts b/lib/src/lib/session-save.test.ts index 3c36675..4006332 100644 --- a/lib/src/lib/session-save.test.ts +++ b/lib/src/lib/session-save.test.ts @@ -72,7 +72,7 @@ describe('saveSession', () => { it('persists the live alert state even when the previous snapshot was empty', async () => { const platform = createPlatform({ - version: 1, + version: 2, layout: null, panes: [{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null, alert: null }], }); @@ -82,9 +82,9 @@ describe('saveSession', () => { await saveSession(platform, { root: true }, [{ id: 'pane-a', title: 'Pane A' }]); expect(platform.saveState).toHaveBeenCalledWith({ - version: 1, + version: 2, layout: { root: true }, - detached: [], + doors: [], panes: [ expect.objectContaining({ id: 'pane-a', @@ -103,9 +103,9 @@ describe('saveSession', () => { expect(platform.getScrollback).toHaveBeenCalledWith('pane-b'); expect(platform.getCwd).toHaveBeenCalledWith('pane-b'); expect(platform.saveState).toHaveBeenCalledWith({ - version: 1, + version: 2, layout: { root: true }, - detached: [], + doors: [], panes: [ expect.objectContaining({ id: 'pane-a', diff --git a/lib/src/lib/session-save.ts b/lib/src/lib/session-save.ts index 517ebe9..d7d8f51 100644 --- a/lib/src/lib/session-save.ts +++ b/lib/src/lib/session-save.ts @@ -1,11 +1,11 @@ import type { PlatformAdapter } from './platform/types'; -import type { PersistedDoor, PersistedPane, PersistedSession } from './session-types'; +import { readPersistedSession, type PersistedDoor, type PersistedPane, type PersistedSession } from './session-types'; import { detectResumeCommand } from './resume-patterns'; import { getLivePersistedAlertState, resolveTerminalSessionId } from './terminal-registry'; function getPreviousPaneMap(platform: PlatformAdapter): Map { - const saved = platform.getState() as PersistedSession | null; - if (!saved || saved.version !== 1 || !Array.isArray(saved.panes)) { + const saved = readPersistedSession(platform.getState()); + if (!saved || !Array.isArray(saved.panes)) { return new Map(); } return new Map(saved.panes.map((pane) => [pane.id, pane])); @@ -15,14 +15,14 @@ export async function saveSession( platform: PlatformAdapter, layout: unknown, panes: Array<{ id: string; title: string }>, - detached: PersistedDoor[] = [], + doors: PersistedDoor[] = [], ): Promise { const previousPanes = getPreviousPaneMap(platform); const allPanes = new Map(); for (const pane of panes) { allPanes.set(pane.id, pane); } - for (const item of detached) { + for (const item of doors) { allPanes.set(item.id, { id: item.id, title: item.title }); } @@ -46,6 +46,6 @@ export async function saveSession( }; }), ); - const session: PersistedSession = { version: 1, panes: persisted, detached, layout }; + const session: PersistedSession = { version: 2, panes: persisted, doors, layout }; platform.saveState(session); } diff --git a/lib/src/lib/session-types.ts b/lib/src/lib/session-types.ts index a268ae7..14e8817 100644 --- a/lib/src/lib/session-types.ts +++ b/lib/src/lib/session-types.ts @@ -17,6 +17,25 @@ export interface PersistedPane { } export interface PersistedDoor { + id: string; + title: string; + neighborId: string | null; + direction: DoorDirection; + remainingPaneIds: string[]; + layoutAtMinimize: unknown; + layoutAtMinimizeSignature: string; +} + +export interface PersistedSession { + version: 2; + panes: PersistedPane[]; + doors?: PersistedDoor[]; + layout: unknown; // SerializedDockview — kept as `unknown` to avoid dockview dep in types +} + +// --- Legacy v1 shapes (read-only, for migration) --- + +export interface PersistedDoorV1 { id: string; title: string; neighborId: string | null; @@ -26,9 +45,43 @@ export interface PersistedDoor { detachedLayoutSignature: string; } -export interface PersistedSession { +export interface PersistedSessionV1 { version: 1; panes: PersistedPane[]; - detached?: PersistedDoor[]; - layout: unknown; // SerializedDockview — kept as `unknown` to avoid dockview dep in types + detached?: PersistedDoorV1[]; + layout: unknown; +} + +/** + * Migrate a v1 session blob to v2. Renames `detached` → `doors` and per-door + * fields: `remainingPanelIds` → `remainingPaneIds`, `restoreLayout` → + * `layoutAtMinimize`, `detachedLayoutSignature` → `layoutAtMinimizeSignature`. + */ +export function migrateSessionV1toV2(v1: PersistedSessionV1): PersistedSession { + return { + version: 2, + panes: v1.panes, + layout: v1.layout, + doors: (v1.detached ?? []).map((door) => ({ + id: door.id, + title: door.title, + neighborId: door.neighborId, + direction: door.direction, + remainingPaneIds: door.remainingPanelIds, + layoutAtMinimize: door.restoreLayout, + layoutAtMinimizeSignature: door.detachedLayoutSignature, + })), + }; +} + +/** + * Read a persisted blob of unknown version and normalize to the current + * `PersistedSession` shape. Returns null if the blob is missing or malformed. + */ +export function readPersistedSession(raw: unknown): PersistedSession | null { + if (!raw || typeof raw !== 'object') return null; + const blob = raw as { version?: number }; + if (blob.version === 2) return raw as PersistedSession; + if (blob.version === 1) return migrateSessionV1toV2(raw as PersistedSessionV1); + return null; } diff --git a/lib/src/main.tsx b/lib/src/main.tsx index 77bc9ee..ad828a7 100644 --- a/lib/src/main.tsx +++ b/lib/src/main.tsx @@ -16,7 +16,7 @@ initAlertStateReceiver(); resumeOrRestore(platform).then((result) => { createRoot(document.getElementById("root")!).render( - + , ); }); diff --git a/lib/src/stories/Baseboard.stories.tsx b/lib/src/stories/Baseboard.stories.tsx index 5e7c762..cade52a 100644 --- a/lib/src/stories/Baseboard.stories.tsx +++ b/lib/src/stories/Baseboard.stories.tsx @@ -8,9 +8,9 @@ const makeItem = (id: string, title: string): DooredItem => ({ title, neighborId: null, direction: 'right', - remainingPanelIds: [], - restoreLayout: null, - detachedLayoutSignature: '', + remainingPaneIds: [], + layoutAtMinimize: null, + layoutAtMinimizeSignature: '', }); function withState(byId: Record>) { diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index fda1dee..c9015ae 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -54,7 +54,7 @@ async function bootstrap() { } /> , From dc353ffd29ab405965ebb2974890d153be56bb67 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Thu, 23 Apr 2026 15:20:16 -0700 Subject: [PATCH 5/8] Phase E: simplify review follow-ups. - Collapse DooredItem into a PersistedDoor-derived type alias, remove toDooredItem adapter and the manual reverse-map in doSave. - Extract PondSelectionKind = 'pane' | 'door' and replace inline unions. - Route vscode-ext session-state reads through readPersistedSession so the extension host accepts v1 blobs and normalizes to v2. Fixes a version-check bug (isPersistedSession was still asserting v1). - Finish retired-term cleanup: rename leftover panelIds locals and reconnect/detach comments in Pond.tsx, terminal-registry.ts, and platform/types.ts to the ontology vocabulary. - Drop migration JSDoc that recited the field-rename table. Co-Authored-By: Claude Opus 4.7 --- lib/src/components/Pond.tsx | 64 +++++++++++--------------------- lib/src/lib/platform/types.ts | 2 +- lib/src/lib/session-types.ts | 9 ----- lib/src/lib/terminal-registry.ts | 8 ++-- vscode-ext/src/extension.ts | 5 ++- vscode-ext/src/session-state.ts | 19 ++++------ 6 files changed, 37 insertions(+), 70 deletions(-) diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index bf2089a..e3ff624 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -52,7 +52,7 @@ import { isHardTodo, TODO_OFF, } from '../lib/terminal-registry'; -import { resolvePanelElement, findPanelInDirection, findReattachNeighbor, type DoorDirection } from '../lib/spatial-nav'; +import { resolvePanelElement, findPanelInDirection, findReattachNeighbor } from '../lib/spatial-nav'; import { cloneLayout, getLayoutStructureSignature } from '../lib/layout-snapshot'; import { getPlatform } from '../lib/platform'; import { saveSession } from '../lib/session-save'; @@ -75,22 +75,9 @@ let dialogKeyboardActive = false; // --- Types --- -export interface DooredItem { - id: string; - title: string; - neighborId: string | null; // pane that was adjacent before minimize - direction: DoorDirection; // where we were relative to that neighbor - remainingPaneIds: string[]; // sorted pane IDs after minimize (for layout-changed check) +export type DooredItem = Omit & { layoutAtMinimize: SerializedDockview | null; - layoutAtMinimizeSignature: string; -} - -function toDooredItem(item: PersistedDoor): DooredItem { - return { - ...item, - layoutAtMinimize: item.layoutAtMinimize as SerializedDockview | null, - }; -} +}; interface ConfirmKill { id: string; @@ -100,12 +87,14 @@ interface ConfirmKill { export type PondMode = 'command' | 'passthrough'; +export type PondSelectionKind = 'pane' | 'door'; + export type PondEvent = | { type: 'modeChange'; mode: PondMode } | { type: 'zoomChange'; zoomed: boolean } | { type: 'minimizeChange'; count: number } | { type: 'split'; direction: 'horizontal' | 'vertical'; source: 'keyboard' | 'mouse' } - | { type: 'selectionChange'; id: string | null; kind: 'pane' | 'door' }; + | { type: 'selectionChange'; id: string | null; kind: PondSelectionKind }; // --- Variants --- @@ -980,7 +969,7 @@ export function MarchingAntsRect({ width, height, isDoor, color, paused }: { function SelectionOverlay({ apiRef, selectedId, selectedType, mode, overlayElRef }: { apiRef: React.RefObject; selectedId: string | null; - selectedType: 'pane' | 'door'; + selectedType: PondSelectionKind; mode: PondMode; overlayElRef?: React.RefObject; }) { @@ -1287,7 +1276,7 @@ export function Pond({ // Consumed once in handleReady to restore existing sessions const initialPaneIdsRef = useRef(initialPaneIds); const restoredLayoutRef = useRef(restoredLayout); - const initialDoorsRef = useRef((initialDoors ?? []).map(toDooredItem)); + const initialDoorsRef = useRef((initialDoors ?? []) as DooredItem[]); // Mutable maps shared via context — consumers must call bumpVersion() after // any mutation so that dependent effects/components re-run. @@ -1307,7 +1296,7 @@ export function Pond({ // We own these — dockview is just for spatial layout and DnD const [mode, setMode] = useState('command'); const [selectedId, setSelectedId] = useState(null); - const [selectedType, setSelectedType] = useState<'pane' | 'door'>('pane'); + const [selectedType, setSelectedType] = useState('pane'); const windowFocused = useWindowFocused(); @@ -1315,7 +1304,7 @@ export function Pond({ const [confirmKill, setConfirmKill] = useState(null); useEffect(() => { if (!confirmKill) { clearTimeout(shakeTimerRef.current!); } }, [confirmKill]); const [renamingPaneId, setRenamingPaneId] = useState(null); - const [doors, setDoors] = useState(() => (initialDoors ?? []).map(toDooredItem)); + const [doors, setDoors] = useState(() => (initialDoors ?? []) as DooredItem[]); const [zoomed, setZoomed] = useState(false); // Refs for mode-switch gesture (Left Cmd → Right Cmd, or Left Shift → Right Shift, within 500ms) @@ -1362,16 +1351,7 @@ export function Pond({ if (!api) return Promise.resolve(); const panes = api.panels.map((p) => ({ id: p.id, title: p.title ?? '' })); - const doorItems: PersistedDoor[] = doorsRef.current.map((item) => ({ - id: item.id, - title: item.title, - neighborId: item.neighborId, - direction: item.direction, - remainingPaneIds: item.remainingPaneIds, - layoutAtMinimize: item.layoutAtMinimize, - layoutAtMinimizeSignature: item.layoutAtMinimizeSignature, - })); - return saveSession(getPlatform(), api.toJSON(), panes, doorItems); + return saveSession(getPlatform(), api.toJSON(), panes, doorsRef.current); }, []); const persistSessionNow = useCallback((): Promise => { @@ -1524,8 +1504,8 @@ export function Pond({ doorsRef.current = restoredDoors; setDoors(restoredDoors); - // Apply the currently-selected shell to a freshly-added panel. Panels - // that are reconnecting to an existing PTY already have a running shell, + // Apply the currently-selected shell to a freshly-added pane. Panes + // that are resuming over an existing PTY already have a running shell, // so their pendingShellOpts are never consumed — only first-time spawns // use this. const addTerminalPanel = (id: string) => { @@ -1557,7 +1537,7 @@ export function Pond({ setSelectedId(restored[0]); } } else { - // Reconnect or fresh start: create panels from IDs + // Resume/restore or fresh start: create panels from IDs const paneIds = restored && restored.length > 0 ? restored : [generatePaneId()]; @@ -2057,20 +2037,20 @@ export function Pond({ const confirmKillAfterRestore = options?.confirmKill ?? false; const currentLayoutSignature = getLayoutStructureSignature(api.toJSON()); - // Exact restore is only safe when the layout structure matches AND the - // current panels are the same ones that existed when we minimized. If new - // panels were auto-spawned (e.g. last pane minimized → auto-create), the + // Exact reattach is only safe when the layout structure matches AND the + // current panes are the same ones that existed when we minimized. If new + // panes were auto-spawned (e.g. last pane minimized → auto-create), the // layoutAtMinimize would destroy them. - const currentPanelIds = api.panels.map(p => p.id).sort(); - const restorePanelIds = item.layoutAtMinimize + const currentPaneIds = api.panels.map(p => p.id).sort(); + const reattachPaneIds = item.layoutAtMinimize ? Object.keys(item.layoutAtMinimize.panels).filter(id => id !== item.id).sort() : []; - const canRestoreExactLayout = + const canReattachExactLayout = !!item.layoutAtMinimize && currentLayoutSignature === item.layoutAtMinimizeSignature && - idsMatch(currentPanelIds, restorePanelIds); + idsMatch(currentPaneIds, reattachPaneIds); - if (canRestoreExactLayout) { + if (canReattachExactLayout) { const currentTitles = new Map( api.panels.map(panel => [panel.id, panel.title ?? panel.id] as const), ); diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 65a2965..95fa0e5 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -38,7 +38,7 @@ export interface PlatformAdapter { onPtyExit(handler: (detail: { id: string; exitCode: number }) => void): void; offPtyExit(handler: (detail: { id: string; exitCode: number }) => void): void; - // Reconnection + // Resume (live-PTY replay after webview hide/show) requestInit(): void; onPtyList(handler: (detail: { ptys: PtyInfo[] }) => void): void; offPtyList(handler: (detail: { ptys: PtyInfo[] }) => void): void; diff --git a/lib/src/lib/session-types.ts b/lib/src/lib/session-types.ts index 14e8817..bef8d58 100644 --- a/lib/src/lib/session-types.ts +++ b/lib/src/lib/session-types.ts @@ -52,11 +52,6 @@ export interface PersistedSessionV1 { layout: unknown; } -/** - * Migrate a v1 session blob to v2. Renames `detached` → `doors` and per-door - * fields: `remainingPanelIds` → `remainingPaneIds`, `restoreLayout` → - * `layoutAtMinimize`, `detachedLayoutSignature` → `layoutAtMinimizeSignature`. - */ export function migrateSessionV1toV2(v1: PersistedSessionV1): PersistedSession { return { version: 2, @@ -74,10 +69,6 @@ export function migrateSessionV1toV2(v1: PersistedSessionV1): PersistedSession { }; } -/** - * Read a persisted blob of unknown version and normalize to the current - * `PersistedSession` shape. Returns null if the blob is missing or malformed. - */ export function readPersistedSession(raw: unknown): PersistedSession | null { if (!raw || typeof raw !== 'object') return null; const blob = raw as { version?: number }; diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 03875c4..75b566e 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -181,7 +181,7 @@ let currentAlertHandler: ((detail: AlertStateDetail) => void) | null = null; /** * Wire up the platform's alert state events to the local session state store. - * Call once during startup, before reconnect. Safe to call again after platform reset. + * Call once during startup, before resume/restore. Safe to call again after platform reset. */ export function initAlertStateReceiver(): void { const platform = getPlatform(); @@ -564,7 +564,7 @@ function setupTerminalEntry(id: string): TerminalEntry { attentionDismissedRing: false, }; - // Apply any primed alert state (from platform reconnect) + // Apply any primed alert state (from platform resume) const primed = primedActivityStates.get(id); if (primed) { if (primed.status !== undefined) entry.alertStatus = primed.status; @@ -721,8 +721,8 @@ export function disposeSession(id: string): void { } /** - * Swap two terminals' registry entries. Their DOM elements are detached, - * entries swapped, and elements reattached to each other's containers. + * Swap two terminals' registry entries. Their DOM elements are unmounted, + * entries swapped, and elements remounted into each other's containers. * The layout stays the same — only the terminal content swaps. * * Note: after swapping, registry key idA holds the entry that was originally diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 17d1d81..2661ed6 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -5,7 +5,8 @@ import { MouseTermViewProvider } from './webview-view-provider'; import { attachRouter, flushAllSessions, getAlertStates } from './message-router'; import { getWebviewHtml } from './webview-html'; import { log } from './log'; -import { getSavedSessionState, isPersistedSession, mergeAlertStates, refreshSavedSessionStateFromPtys, saveSessionState } from './session-state'; +import { getSavedSessionState, mergeAlertStates, refreshSavedSessionStateFromPtys, saveSessionState } from './session-state'; +import { readPersistedSession } from '../../lib/src/lib/session-types'; import { resolveSelectedShell, setSelectedShellPath, getSelectedShellPath } from './shell-selection'; let extensionContext: vscode.ExtensionContext | null = null; @@ -46,7 +47,7 @@ function setupPanel( const router = attachRouter(panel.webview, { reconnect: !!savedState, killOnDispose: true, - savedSession: isPersistedSession(initialState) ? initialState : null, + savedSession: readPersistedSession(initialState), getSelectedShell, // Panels persist via vscode.setState() (per-panel, managed by VS Code). // Don't write to workspaceState — that's for the WebviewView only. diff --git a/vscode-ext/src/session-state.ts b/vscode-ext/src/session-state.ts index 7a47f53..90b71af 100644 --- a/vscode-ext/src/session-state.ts +++ b/vscode-ext/src/session-state.ts @@ -1,20 +1,14 @@ import * as vscode from 'vscode'; import * as ptyManager from './pty-manager'; import type { AlertState } from '../../lib/src/lib/alert-manager'; -import type { PersistedAlertState, PersistedPane, PersistedSession } from '../../lib/src/lib/session-types'; +import { readPersistedSession, type PersistedAlertState, type PersistedPane, type PersistedSession } from '../../lib/src/lib/session-types'; import { log } from './log'; const SESSION_STATE_KEY = 'mouseterm.session'; -export function isPersistedSession(value: unknown): value is PersistedSession { - if (!value || typeof value !== 'object') return false; - const maybeSession = value as Partial; - return maybeSession.version === 1 && Array.isArray(maybeSession.panes); -} - export function getSavedSessionState(context: vscode.ExtensionContext): PersistedSession | null { - const saved = context.workspaceState.get(SESSION_STATE_KEY); - return isPersistedSession(saved) ? saved : null; + const saved = readPersistedSession(context.workspaceState.get(SESSION_STATE_KEY)); + return saved && Array.isArray(saved.panes) ? saved : null; } export function saveSessionState(context: vscode.ExtensionContext, state: unknown): Thenable { @@ -27,10 +21,11 @@ export function saveSessionState(context: vscode.ExtensionContext, state: unknow * rather than relying on deactivate (which may not complete). */ export function mergeAlertStates(state: unknown, alertStates: Map): unknown { - if (!isPersistedSession(state)) return state; + const parsed = readPersistedSession(state); + if (!parsed || !Array.isArray(parsed.panes)) return state; return { - ...state, - panes: state.panes.map((pane) => { + ...parsed, + panes: parsed.panes.map((pane) => { const alert = alertStates.get(pane.id); return { ...pane, From 38ed1438258ad8ddfc51880d04ffe277010dc65d Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Thu, 23 Apr 2026 17:06:13 -0700 Subject: [PATCH 6/8] Phase F: clean up ontology vocabulary leftovers. Sweep stragglers missed by Phases A-E so the specs and code fully match the retired-terms table in ontology.md. - docs/specs/layout.md: destroyTerminal -> disposeSession; findRestoreNeighbor -> findReattachNeighbor; PersistedDetachedItem -> PersistedDoor. - docs/specs/vscode.md: update session schema block to v2 shape (doors?: PersistedDoor[], with renamed subfields). - docs/specs/tutorial.md: detachChange -> minimizeChange. - website/src/lib/tutorial-detection.ts: hasDetached -> hasMinimized. - lib/src/lib/terminal-registry.ts: JSDoc/comment verbs align with function names (Resume, Unmount). - vscode-ext/src/extension.ts: drop unused getSavedSessionState and saveSessionState imports (both still used in webview-view-provider). Co-Authored-By: Claude Opus 4.7 --- docs/specs/layout.md | 6 +++--- docs/specs/tutorial.md | 6 +++--- docs/specs/vscode.md | 14 ++++++++++++-- lib/src/lib/terminal-registry.ts | 6 +++--- vscode-ext/src/extension.ts | 2 +- website/src/lib/tutorial-detection.ts | 10 +++++----- 6 files changed, 27 insertions(+), 17 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 0338ed8..63626ca 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -306,7 +306,7 @@ The direction is carried via `FreshlySpawnedContext` — a `Map > *Click the minimize button in the tab header. Click the door in the baseboard to reattach.* -Detection: Watches `PondEvent.detachChange` — requires `count > 0` (detach) then `count === 0` (reattach back to zero). +Detection: Watches `PondEvent.minimizeChange` — requires `count > 0` (minimize) then `count === 0` (reattach back to zero). ### Phase 3: Keyboard Power @@ -142,7 +142,7 @@ The picker restores the persisted active theme on mount. The playground header i - All progress keyed as `mouseterm-tutorial-step-N` in localStorage (values: `'true'`). - `FakePtyAdapter` extensions: `setInputHandler(id, fn)` routes `writePty` calls to a custom handler; `sendOutput(id, data)` writes to a terminal's output stream. -- `Pond` extensions: `initialPaneIds` prop seeds the first pane(s); `onApiReady` callback prop exposes `DockviewApi`; `onEvent` callback prop fires `PondEvent` for mode/zoom/detach/selection/split changes (types: `modeChange`, `zoomChange`, `detachChange`, `split`, `selectionChange`). +- `Pond` extensions: `initialPaneIds` prop seeds the first pane(s); `onApiReady` callback prop exposes `DockviewApi`; `onEvent` callback prop fires `PondEvent` for mode/zoom/minimize/selection/split changes (types: `modeChange`, `zoomChange`, `minimizeChange`, `split`, `selectionChange`). - `SCENARIO_TUTORIAL_MOTD` scenario added to `lib/src/lib/platform/fake-scenarios.ts`. ## Mouse and Clipboard Feature Coverage diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 0176e91..6210818 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -194,9 +194,9 @@ activationEvents: ["onWebviewPanel:mouseterm"] ```typescript interface PersistedSession { - version: 1; + version: 2; panes: PersistedPane[]; - detached?: PersistedDetachedItem[]; + doors?: PersistedDoor[]; layout: unknown; // SerializedDockview } @@ -208,6 +208,16 @@ interface PersistedPane { resumeCommand: string | null; alert?: PersistedAlertState | null; } + +interface PersistedDoor { + id: string; + title: string; + neighborId: string | null; + direction: DoorDirection; + remainingPaneIds: string[]; + layoutAtMinimize: unknown; + layoutAtMinimizeSignature: string; +} ``` **Persistence flow:** diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 75b566e..dc30fad 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -611,7 +611,7 @@ export function getOrCreateTerminal(id: string): TerminalEntry { } /** - * Reconnect to an existing PTY after the webview is recreated. + * Resume an existing PTY after the webview is recreated. * Creates the xterm instance and writes replay data, but does NOT spawn a new PTY. */ export function resumeTerminal( @@ -686,7 +686,7 @@ export function mountElement(id: string, container: HTMLElement): void { } /** - * Detach a terminal's element from its current container. + * Unmount a terminal's element from its current container. * The terminal stays alive — just not in the DOM. */ export function unmountElement(id: string): void { @@ -742,7 +742,7 @@ export function swapTerminals(idA: string, idB: string): void { const containerA = entryA.element.parentElement; const containerB = entryB.element.parentElement; - // Detach both + // Unmount both entryA.element.remove(); entryB.element.remove(); diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 2661ed6..51b6dae 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -5,7 +5,7 @@ import { MouseTermViewProvider } from './webview-view-provider'; import { attachRouter, flushAllSessions, getAlertStates } from './message-router'; import { getWebviewHtml } from './webview-html'; import { log } from './log'; -import { getSavedSessionState, mergeAlertStates, refreshSavedSessionStateFromPtys, saveSessionState } from './session-state'; +import { mergeAlertStates, refreshSavedSessionStateFromPtys } from './session-state'; import { readPersistedSession } from '../../lib/src/lib/session-types'; import { resolveSelectedShell, setSelectedShellPath, getSelectedShellPath } from './shell-selection'; diff --git a/website/src/lib/tutorial-detection.ts b/website/src/lib/tutorial-detection.ts index f181374..4f579f4 100644 --- a/website/src/lib/tutorial-detection.ts +++ b/website/src/lib/tutorial-detection.ts @@ -39,7 +39,7 @@ export class TutorialDetector { private initialPanelCount = 0; private currentMode: PondMode = 'command'; private hasZoomed = false; - private hasDetached = false; + private hasMinimized = false; private focusedPanelIds = new Set(); private pendingResizeBaselineReset = false; private resizeBaseline: ResizeSnapshot | null = null; @@ -119,11 +119,11 @@ export class TutorialDetector { case 'minimizeChange': if (event.count > 0) { - this.hasDetached = true; - } else if (this.hasDetached && this.shell.isStepComplete(2)) { - // Reattached (count back to 0 after detach) — Step 4 complete + this.hasMinimized = true; + } else if (this.hasMinimized && this.shell.isStepComplete(2)) { + // Reattached (count back to 0 after minimize) — Step 4 complete this.shell.markStepComplete(3); - this.hasDetached = false; + this.hasMinimized = false; } break; From cf4f5e1bfc2081d8544c79f4a02a7d7c763d82cd Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Thu, 23 Apr 2026 17:11:39 -0700 Subject: [PATCH 7/8] Validate persisted session blobs before migration --- lib/src/lib/session-migration.test.ts | 4 ++ lib/src/lib/session-types.ts | 75 +++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/lib/src/lib/session-migration.test.ts b/lib/src/lib/session-migration.test.ts index f21c2b3..a22ebfb 100644 --- a/lib/src/lib/session-migration.test.ts +++ b/lib/src/lib/session-migration.test.ts @@ -100,5 +100,9 @@ describe('readPersistedSession', () => { expect(readPersistedSession(undefined)).toBeNull(); expect(readPersistedSession({ version: 99 })).toBeNull(); expect(readPersistedSession('not an object')).toBeNull(); + expect(readPersistedSession({ version: 2, layout: null, panes: 'nope' })).toBeNull(); + expect(readPersistedSession({ version: 2, layout: null, panes: [], doors: {} })).toBeNull(); + expect(readPersistedSession({ version: 1, layout: null, panes: [] as const, detached: {} })).toBeNull(); + expect(readPersistedSession({ version: 1, layout: null, panes: {} })).toBeNull(); }); }); diff --git a/lib/src/lib/session-types.ts b/lib/src/lib/session-types.ts index bef8d58..1def595 100644 --- a/lib/src/lib/session-types.ts +++ b/lib/src/lib/session-types.ts @@ -52,6 +52,74 @@ export interface PersistedSessionV1 { layout: unknown; } +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function isPersistedAlertState(value: unknown): value is PersistedAlertState { + if (value === null) return true; + if (!isRecord(value)) return false; + return typeof value.status === 'string' && (typeof value.todo === 'number' || typeof value.todo === 'boolean'); +} + +function isPersistedPane(value: unknown): value is PersistedPane { + if (!isRecord(value)) return false; + return ( + typeof value.id === 'string' && + typeof value.title === 'string' && + (typeof value.cwd === 'string' || value.cwd === null) && + (typeof value.scrollback === 'string' || value.scrollback === null) && + (typeof value.resumeCommand === 'string' || value.resumeCommand === null) && + (value.alert === undefined || isPersistedAlertState(value.alert)) + ); +} + +function isPersistedDoorV1(value: unknown): value is PersistedDoorV1 { + if (!isRecord(value)) return false; + return ( + typeof value.id === 'string' && + typeof value.title === 'string' && + (typeof value.neighborId === 'string' || value.neighborId === null) && + typeof value.direction === 'string' && + Array.isArray(value.remainingPanelIds) && + value.remainingPanelIds.every((id) => typeof id === 'string') && + typeof value.detachedLayoutSignature === 'string' + ); +} + +function isPersistedDoor(value: unknown): value is PersistedDoor { + if (!isRecord(value)) return false; + return ( + typeof value.id === 'string' && + typeof value.title === 'string' && + (typeof value.neighborId === 'string' || value.neighborId === null) && + typeof value.direction === 'string' && + Array.isArray(value.remainingPaneIds) && + value.remainingPaneIds.every((id) => typeof id === 'string') && + typeof value.layoutAtMinimizeSignature === 'string' + ); +} + +function isPersistedSessionV1(value: unknown): value is PersistedSessionV1 { + if (!isRecord(value) || value.version !== 1) return false; + return ( + Array.isArray(value.panes) && + value.panes.every(isPersistedPane) && + (value.detached === undefined || (Array.isArray(value.detached) && value.detached.every(isPersistedDoorV1))) && + 'layout' in value + ); +} + +function isPersistedSessionV2(value: unknown): value is PersistedSession { + if (!isRecord(value) || value.version !== 2) return false; + return ( + Array.isArray(value.panes) && + value.panes.every(isPersistedPane) && + (value.doors === undefined || (Array.isArray(value.doors) && value.doors.every(isPersistedDoor))) && + 'layout' in value + ); +} + export function migrateSessionV1toV2(v1: PersistedSessionV1): PersistedSession { return { version: 2, @@ -70,9 +138,8 @@ export function migrateSessionV1toV2(v1: PersistedSessionV1): PersistedSession { } export function readPersistedSession(raw: unknown): PersistedSession | null { - if (!raw || typeof raw !== 'object') return null; - const blob = raw as { version?: number }; - if (blob.version === 2) return raw as PersistedSession; - if (blob.version === 1) return migrateSessionV1toV2(raw as PersistedSessionV1); + if (!isRecord(raw)) return null; + if (isPersistedSessionV2(raw)) return raw; + if (isPersistedSessionV1(raw)) return migrateSessionV1toV2(raw); return null; } From 23a25c2940d6332759b95790ee9a0b5499653650 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Thu, 23 Apr 2026 17:45:20 -0700 Subject: [PATCH 8/8] Fix minimized live reconnect fallback --- docs/specs/layout.md | 2 +- lib/src/lib/reconnect.test.ts | 42 +++++++++++++++++++++++++++++++++++ lib/src/lib/reconnect.ts | 8 +++---- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 63626ca..2449149 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -265,7 +265,7 @@ Pane IDs are session IDs. `TerminalPane` calls `getOrCreateTerminal(id)` on Reac Layout, scrollback, cwd, minimized items, and alert state are saved to persistent storage via a debounced save (500ms). Saves are triggered by layout changes, panel add/remove, and a 30s periodic interval. Saves are flushed immediately on PTY exit, `pagehide`, and extension shutdown requests. On startup, recovery is priority-based: -1. **Resume** (webview hidden/shown, live PTYs): request PTY list + replay data from platform, `resumeTerminal()` for each (500ms timeout). If the saved session covers every live PTY, restore the saved dockview layout when its visible panel set matches and reattach saved minimized items as doors. +1. **Resume** (webview hidden/shown, live PTYs): request PTY list + replay data from platform, `resumeTerminal()` for each (500ms timeout). If the saved session covers every live PTY, restore the saved dockview layout when its visible panel set matches and reattach saved minimized items as doors. This still counts as a live resume when every live session is minimized, so recovery must not fall through to cold restore just because the visible `paneIds` list is empty. 2. **Restore** (app restart, cold start): restore layout from serialized dockview state, `restoreTerminal()` for each pane with saved cwd + scrollback, and spawn each PTY with the current default shell selection 3. **Fallback/manual pane creation**: when no saved layout can be safely applied, add multiple panes as splits from the previous pane rather than tabs 4. **Empty state**: create a single new pane diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts index 3848fbe..f3de693 100644 --- a/lib/src/lib/reconnect.test.ts +++ b/lib/src/lib/reconnect.test.ts @@ -137,6 +137,48 @@ describe('resumeOrRestore', () => { }); }); + it('returns the live resume plan when every live session is minimized', async () => { + const doors = [{ + id: 'pane-a', + title: 'Pane A', + neighborId: 'pane-b', + direction: 'right' as const, + remainingPaneIds: ['pane-b'], + layoutAtMinimize: { panels: { 'pane-b': {} } }, + layoutAtMinimizeSignature: 'sig-a', + }, { + id: 'pane-b', + title: 'Pane B', + neighborId: 'pane-a', + direction: 'left' as const, + remainingPaneIds: ['pane-a'], + layoutAtMinimize: { panels: { 'pane-a': {} } }, + layoutAtMinimizeSignature: 'sig-b', + }]; + const saved: PersistedSession = { + version: 2, + layout: { panels: {} }, + doors, + panes: [ + { id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }, + { id: 'pane-b', title: 'Pane B', cwd: null, scrollback: null, resumeCommand: null }, + { id: 'stale-pane', title: 'Stale Pane', cwd: null, scrollback: null, resumeCommand: null }, + ], + }; + + const result = await resumeOrRestore(createPlatform([ + { id: 'pane-a', alive: true }, + { id: 'pane-b', alive: true }, + ], saved)); + + expect(result).toEqual({ + paneIds: [], + doors, + layout: { panels: {} }, + }); + expect(terminalRegistryMocks.restoreTerminal).not.toHaveBeenCalled(); + }); + it('ignores stale saved panes when the saved layout still matches live visible panes', async () => { const layout = { panels: { 'pane-a': {}, 'pane-b': {} } }; const saved: PersistedSession = { diff --git a/lib/src/lib/reconnect.ts b/lib/src/lib/reconnect.ts index f7e5904..4459542 100644 --- a/lib/src/lib/reconnect.ts +++ b/lib/src/lib/reconnect.ts @@ -20,7 +20,7 @@ export interface ReconnectResult { export async function resumeOrRestore(platform: PlatformAdapter): Promise { // First, try to resume over live PTYs const liveResult = await resumeLiveSessions(platform); - if (liveResult.paneIds.length > 0) return liveResult; + if (liveResult) return liveResult; // No live PTYs — try cold restore const restored = await restoreSession(platform); @@ -29,8 +29,8 @@ export async function resumeOrRestore(platform: PlatformAdapter): Promise { - return new Promise((resolve) => { +function resumeLiveSessions(platform: PlatformAdapter): Promise { + return new Promise((resolve) => { const replayBuffer = new Map(); let ptyList: PtyInfo[] | null = null; @@ -59,7 +59,7 @@ function resumeLiveSessions(platform: PlatformAdapter): Promise platform.offPtyReplay(handleReplay); if (!ptyList || ptyList.length === 0) { - resolve({ paneIds: [] }); + resolve(null); return; }