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..2449149 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. 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 -### 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 @@ -304,7 +306,7 @@ The direction is carried via `FreshlySpawnedContext` — a `Map` where `` is the ontology noun (`PersistedPane`, `PersistedDoor`). +- A handle type is `State` (`ActivityState`, not `SessionUiState`). diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index f46b520..4bfedc0 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -58,7 +58,7 @@ Progress is stored in localStorage so the user can leave and return. Show progre Implemented in `website/src/lib/tutorial-detection.ts` (`TutorialDetector` class). Two event sources: 1. **DockviewApi events** — `onDidAddPanel`, `onDidLayoutChange`, `onDidActivePanelChange`. Subscribed in `TutorialDetector.attach(api)`. -2. **PondEvent callbacks** — `modeChange`, `zoomChange`, `detachChange`, `split`. Routed via `Pond`'s `onEvent` prop (added in `lib/src/components/Pond.tsx`). +2. **PondEvent callbacks** — `modeChange`, `zoomChange`, `minimizeChange`, `split`. Routed via `Pond`'s `onEvent` prop (added in `lib/src/components/Pond.tsx`). ### Phase 1: See Everything at Once @@ -90,7 +90,7 @@ Detection: Watches `PondEvent.zoomChange` — requires both a `zoomed: true` the > > *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 b121ada..6210818 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) | @@ -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/.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..e3ff624 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 } 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,22 +75,9 @@ let dialogKeyboardActive = false; // --- Types --- -export interface DetachedItem { - id: string; - title: string; - neighborId: string | null; // panel that was adjacent before detach - direction: DetachDirection; // 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 { - return { - ...item, - restoreLayout: item.restoreLayout as SerializedDockview | null, - }; -} +export type DooredItem = Omit & { + layoutAtMinimize: 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: 'detachChange'; count: number } + | { 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 --- @@ -372,9 +361,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 +382,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 +418,12 @@ function TodoAlertDialog({ [t] TODO
- -
@@ -495,7 +484,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 +497,7 @@ export interface PondActions { } export const PondActionsContext = createContext({ onKill: () => {}, - onDetach: () => {}, + onMinimize: () => {}, onAlertButton: () => 'noop', onToggleTodo: () => {}, onSplitH: () => {}, @@ -616,10 +605,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 +624,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 +702,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 +729,10 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { dataAlertButtonFor={api.id} > - {sessionState.status === 'ALERT_DISABLED' ? ( + {activity.status === 'ALERT_DISABLED' ? ( ) : ( - + )} @@ -761,7 +750,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 +819,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]" > @@ -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; }) { @@ -1002,7 +991,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(); @@ -1152,7 +1141,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 +1237,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 +1276,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 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 [detached, setDetached] = useState(() => (initialDetached ?? []).map(toDetachedItem)); + 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) @@ -1334,8 +1323,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 +1339,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,16 +1351,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) => ({ - id: item.id, - title: item.title, - neighborId: item.neighborId, - direction: item.direction, - remainingPanelIds: item.remainingPanelIds, - restoreLayout: item.restoreLayout, - detachedLayoutSignature: item.detachedLayoutSignature, - })); - return saveSession(getPlatform(), api.toJSON(), panes, detachedItems); + return saveSession(getPlatform(), api.toJSON(), panes, doorsRef.current); }, []); const persistSessionNow = useCallback((): Promise => { @@ -1448,47 +1428,47 @@ 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); 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 } = findRestoreNeighbor(id, api, panelElements); + 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 nextDetached = [...detachedRef.current, { + const layoutAtMinimizeSignature = getLayoutStructureSignature(api.toJSON()); + const nextDoors = [...doorsRef.current, { id, title, neighborId, direction, - remainingPanelIds, - restoreLayout, - detachedLayoutSignature, + remainingPaneIds, + layoutAtMinimize, + layoutAtMinimizeSignature, }]; - detachedRef.current = nextDetached; - setDetached(nextDetached); + doorsRef.current = nextDoors; + setDoors(nextDoors); - // 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 +1479,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,15 +1497,15 @@ export function Pond({ // Restore existing PTY sessions if available const restored = initialPaneIdsRef.current; const layout = restoredLayoutRef.current; - const restoredDetached = initialDetachedRef.current; + const restoredDoors = initialDoorsRef.current; initialPaneIdsRef.current = undefined; // consume once restoredLayoutRef.current = undefined; - initialDetachedRef.current = []; - detachedRef.current = restoredDetached; - setDetached(restoredDetached); + initialDoorsRef.current = []; + 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()]; @@ -1617,7 +1597,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,9 +1610,9 @@ 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 + // 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. @@ -1648,8 +1628,8 @@ 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 - // must not override that — the door retains focus per the detach UX. + // clears selection). On minimize the new door is selected and we + // must not override that — the door retains focus per the minimize UX. if (selectedIdRef.current === null) { selectPanel(id); } @@ -1870,7 +1850,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 +1916,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 +1933,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 +1958,7 @@ export function Pond({ if (dialogKeyboardActive) return; e.preventDefault(); e.stopPropagation(); - dismissOrToggleAlert(sid, getSessionState(sid).status); + dismissOrToggleAlert(sid, getActivity(sid).status); return; } @@ -1998,7 +1978,7 @@ export function Pond({ const dir = e.key; const currentType = selectedTypeRef.current; - const currentDetached = detachedRef.current; + const currentDoors = doorsRef.current; // Navigation from a door if (currentType === 'door') { @@ -2010,11 +1990,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 +2012,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; } @@ -2043,12 +2023,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; @@ -2057,27 +2037,27 @@ 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 detached. If new - // panels were auto-spawned (e.g. last pane detached → auto-create), the - // restoreLayout 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() + // 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 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 = - !!item.restoreLayout && - currentLayoutSignature === item.detachedLayoutSignature && - idsMatch(currentPanelIds, restorePanelIds); + const canReattachExactLayout = + !!item.layoutAtMinimize && + currentLayoutSignature === item.layoutAtMinimizeSignature && + idsMatch(currentPaneIds, reattachPaneIds); - if (canRestoreExactLayout) { + if (canReattachExactLayout) { const currentTitles = new Map( api.panels.map(panel => [panel.id, panel.title ?? panel.id] as const), ); // 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 +2068,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 @@ -2117,9 +2097,9 @@ export function Pond({ } } - const nextDetached = detachedRef.current.filter(p => p.id !== item.id); - detachedRef.current = nextDetached; - setDetached(nextDetached); + const nextDoors = doorsRef.current.filter(p => p.id !== item.id); + doorsRef.current = nextDoors; + setDoors(nextDoors); selectPanel(item.id); if (enterPassthrough) { enterTerminalMode(item.id); @@ -2129,7 +2109,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 +2193,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 +2230,7 @@ export function Pond({ onCancelRename: () => { setRenamingPaneId(null); }, - }), [addSplitPanel, detachPanel, enterTerminalMode, exitTerminalMode]); + }), [addSplitPanel, minimizePane, enterTerminalMode, exitTerminalMode]); const pondActionsRef = useRef(pondActions); pondActionsRef.current = pondActions; @@ -2282,7 +2262,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/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/reconnect.test.ts b/lib/src/lib/reconnect.test.ts index d871c2d..f3de693 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,31 +66,31 @@ function createPlatform(ptys: PtyInfo[], savedState: PersistedSession | null): P }; } -describe('reconnectFromInit', () => { +describe('resumeOrRestore', () => { beforeEach(() => { 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 }, @@ -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 }, @@ -106,10 +106,10 @@ describe('reconnectFromInit', () => { expect(result).toEqual({ paneIds: ['pane-a', 'pane-b'], - detached, + doors, layout, }); - expect(terminalRegistryMocks.reconnectTerminal).toHaveBeenCalledWith('pane-c', 'pane-c-replay', { + expect(terminalRegistryMocks.resumeTerminal).toHaveBeenCalledWith('pane-c', 'pane-c-replay', { alive: true, exitCode: undefined, }); @@ -117,7 +117,7 @@ describe('reconnectFromInit', () => { 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 }, @@ -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 }, @@ -133,14 +133,56 @@ describe('reconnectFromInit', () => { expect(result).toEqual({ paneIds: ['pane-a', 'pane-b', 'extra-pane'], - detached: [], + doors: [], }); }); + 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 = { - version: 1, + version: 2, layout, panes: [ { id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }, @@ -149,14 +191,14 @@ describe('reconnectFromInit', () => { ], }; - const result = await reconnectFromInit(createPlatform([ + const result = await resumeOrRestore(createPlatform([ { id: 'pane-a', alive: true }, { id: 'pane-b', alive: true }, ], saved)); 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 0a999e4..4459542 100644 --- a/lib/src/lib/reconnect.ts +++ b/lib/src/lib/reconnect.ts @@ -1,36 +1,36 @@ 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 { 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?: PersistedDetachedItem[]; + 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 reconnectFromInit(platform: PlatformAdapter): Promise { - // First, try to reconnect to live PTYs - const liveResult = await reconnectLivePtys(platform); - if (liveResult.paneIds.length > 0) return liveResult; +export async function resumeOrRestore(platform: PlatformAdapter): Promise { + // First, try to resume over live PTYs + const liveResult = await resumeLiveSessions(platform); + if (liveResult) return liveResult; - // No live PTYs — try saved session restore + // No live PTYs — try cold restore const restored = await restoreSession(platform); if (restored) return restored; return { paneIds: [] }; } -function reconnectLivePtys(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,28 +59,28 @@ function reconnectLivePtys(platform: PlatformAdapter): Promise platform.offPtyReplay(handleReplay); if (!ptyList || ptyList.length === 0) { - resolve({ paneIds: [] }); + resolve(null); return; } 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, }); 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 reconnectLivePtys(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..a22ebfb --- /dev/null +++ b/lib/src/lib/session-migration.test.ts @@ -0,0 +1,108 @@ +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(); + 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-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 a5dbb7c..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 { PersistedDetachedItem, 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: PersistedDetachedItem[]; + 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 d825f28..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 { PersistedDetachedItem, 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: PersistedDetachedItem[] = [], + 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 9df7757..1def595 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,19 +16,130 @@ export interface PersistedPane { alert?: PersistedAlertState | null; } -export interface PersistedDetachedItem { +export interface PersistedDoor { id: string; title: string; neighborId: string | null; - direction: DetachDirection; + 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; + direction: DoorDirection; remainingPanelIds: string[]; restoreLayout: unknown; detachedLayoutSignature: string; } -export interface PersistedSession { +export interface PersistedSessionV1 { version: 1; panes: PersistedPane[]; - detached?: PersistedDetachedItem[]; - layout: unknown; // SerializedDockview — kept as `unknown` to avoid dockview dep in types + detached?: PersistedDoorV1[]; + 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, + 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, + })), + }; +} + +export function readPersistedSession(raw: unknown): PersistedSession | null { + if (!isRecord(raw)) return null; + if (isPersistedSessionV2(raw)) return raw; + if (isPersistedSessionV1(raw)) return migrateSessionV1toV2(raw); + return null; } 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..a591a78 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, @@ -185,18 +185,18 @@ function expireAttention(id?: string): void { clearSessionAttention(id); } -function detachSession(id: string): void { - detachTerminal(id); +function minimizeSession(id: string): void { + 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,53 +421,53 @@ 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', () => { + 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(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, }); }); - 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); - 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..dc30fad 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) --- @@ -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(); @@ -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); @@ -564,12 +564,12 @@ function setupTerminalEntry(id: string): TerminalEntry { attentionDismissedRing: false, }; - // Apply any primed alert state (from platform reconnect) - const primed = primedSessionStates.get(id); + // Apply any primed alert state (from platform resume) + 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); @@ -611,10 +611,10 @@ 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 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); @@ -686,10 +686,10 @@ export function attachTerminal(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 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,19 +717,19 @@ export function destroyTerminal(id: string): void { entry.terminal.dispose(); registry.delete(id); removeMouseSelectionState(id); - notifySessionStateListeners(); + notifyActivityListeners(); } /** - * 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 * 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. */ @@ -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(); @@ -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..ad828a7 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..cade52a 100644 --- a/lib/src/stories/Baseboard.stories.tsx +++ b/lib/src/stories/Baseboard.stories.tsx @@ -1,16 +1,16 @@ 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, direction: 'right', - remainingPanelIds: [], - restoreLayout: null, - detachedLayoutSignature: '', + remainingPaneIds: [], + layoutAtMinimize: null, + layoutAtMinimizeSignature: '', }); function withState(byId: Record>) { @@ -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..edae0ea 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); } }); } @@ -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', 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..c9015ae 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/extension.ts b/vscode-ext/src/extension.ts index 17d1d81..51b6dae 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 { mergeAlertStates, refreshSavedSessionStateFromPtys } 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/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/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, diff --git a/website/src/lib/tutorial-detection.ts b/website/src/lib/tutorial-detection.ts index 4581997..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; @@ -117,13 +117,13 @@ export class TutorialDetector { } break; - case 'detachChange': + 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;