diff --git a/docs/specs/alert.md b/docs/specs/alert.md index b82a9e81..0c2060e0 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -177,7 +177,7 @@ Clearing behavior: > See `docs/specs/glossary.md` for the Workspace / Window containers. > -> **Not yet implemented (stage 2b).** The union projection and its surfacing are specified ahead of the code. The implemented piece (stage 2a) is that a browser Surface's user-set `todo` round-trips to the activity store live, so its door shows the TODO pill — the same input the union will read once it exists. +> **Partially implemented.** The union *projection* is implemented as a pure function — `computeWorkspaceUnion(surfaceIds, activitySnapshot)` in `lib/src/lib/workspace-union.ts` (stage 2b). Its **surfacing** is not built yet: the standalone strip indicators (stage 3) and the VS Code per-webview union + native-chrome reflection (`docs/specs/vscode.md`). A browser Surface's user-set `todo` already round-trips to the activity store live (stage 2a), so it is counted by the projection and shows on the surface's door today. A Workspace projects a **union status** over the attention state of the Surfaces it contains (terminal Sessions and browser Surfaces alike — see `docs/specs/glossary.md`): diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md index dade9f87..edf1c214 100644 --- a/docs/specs/glossary.md +++ b/docs/specs/glossary.md @@ -80,8 +80,9 @@ The union is **display-only**: it is derived from member Activity, never enters This vocabulary is the target model; it lands in stages, so parts of it are specified ahead of the code. As of this spec: -- **Implemented** — **Pane** and **Surface** as first-class concepts, the terminal/browser surface kinds and their per-axis participation, `surfaceType` in the persisted snapshot, and the restore/resume handling that keeps browser Surfaces (no stray PTY, saved layout preserved; `docs/specs/transport.md`). A browser Surface's user-set `todo` already round-trips to the activity store live. -- **Not yet implemented** — the **Workspace** and **Window** containers themselves: `PersistedWorkspace` / `PersistedWindow`, the container verbs (`switchWorkspace` / `createWorkspace` / `closeWorkspace` / `renameWorkspace`), the union projection, and the standalone workspace strip. The app today runs exactly one implicit Workspace per Window. The container layer lands behind a feature flag (**stage 2b**, dormant in-app), the switching UI follows (**stage 3**), and real multi-Workspace support comes last (**stage 4**). Container/union/strip passages below and in the area specs are forward-looking until then. +- **Implemented, live (stage 2a)** — **Pane** and **Surface** as first-class concepts, the terminal/browser surface kinds and their per-axis participation, `surfaceType` in the persisted snapshot, and the restore/resume handling that keeps browser Surfaces (no stray PTY, saved layout preserved; `docs/specs/transport.md`). A browser Surface's user-set `todo` already round-trips to the activity store live. +- **Implemented but dormant behind the `dormouse.flags.workspaces` flag (stage 2b)** — the **Workspace** and **Window** containers: `PersistedWorkspace` / `PersistedWindow` and the migration of a pre-workspace snapshot to a single "Workspace 1" (`readPersistedWindow`); the standalone persisting a `PersistedWindow` (the active Workspace's session wrapped, other Workspaces preserved — `window-persistence.ts`, wired into the standalone adapters); the in-memory workspace model and its container verbs (`createWorkspace` / `closeWorkspace` / `renameWorkspace` / `setActiveWorkspace` in `workspace-store.ts`); and the union projection (`computeWorkspaceUnion`). The flag is **off by default**, so the app persists a bare `PersistedSession` and runs exactly one implicit Workspace — behavior is byte-identical to stage 2a. +- **Not yet implemented (stages 3–4)** — the standalone workspace **strip** and the **surfacing** of the union (strip indicators; VS Code per-webview union + native-chrome reflection); the actual Wall **mount/unmount on `switchWorkspace`** (today the model switches the active id but does not yet re-render the Wall); and lifting the cap so a user can create more than one Workspace. The switching UI is stage 3, real multi-Workspace activation is stage 4. Strip/surfacing passages in the area specs are forward-looking until then. ## Layers diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 585c7e48..b4d850b3 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -149,7 +149,7 @@ Extreme case: a single door with a very long title, with more doors on both side > See `docs/specs/glossary.md` for the Workspace / Window containers and `docs/specs/alert.md` for the union status. VS Code's per-webview mapping is in `docs/specs/vscode.md`. > -> **Not yet implemented (stages 2b/3).** This section is specified ahead of the code. The app today runs one implicit Workspace; the container, switching, and strip land behind a feature flag (the glossary's Implementation status has the staging). Stage 2a — `surfaceType` persistence and the browser-surface restore/resume fixes — is implemented. +> **Partially implemented.** The container and model exist behind the `dormouse.flags.workspaces` flag (stage 2b, dormant): the standalone persists a `PersistedWindow` and the workspace model holds the list + active id (`docs/specs/transport.md`, glossary Implementation status). Still **not built**: the **strip UI** below (stage 3) and the actual Wall **mount/unmount when switching** (stage 4) — today `setActiveWorkspace` changes the active id in the model but does not re-render the Wall. The app runs one implicit Workspace with the flag off (the default). A **Workspace** is one Wall's worth of Surfaces (terminal Sessions and browser surfaces) plus its layout, with a user-facing name. The standalone Window hosts several Workspaces but mounts only one — the **active** Workspace — at a time. Each Workspace owns its own Content (dockview layout) and Baseboard (doors). diff --git a/docs/specs/transport.md b/docs/specs/transport.md index bbe376aa..bc8988e1 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -108,13 +108,15 @@ The OSC parsing/stripping rules that produce `pty:data` and `terminal:semanticEv ## Persisted session types -Source of truth: `lib/src/lib/session-types.ts` defines the persisted-session interfaces (`PersistedSession` v3, `PersistedPane` — now carrying `surfaceType` — `PersistedAlertState`, `PersistedDoor`) and their v1→v2→v3 migrations. +Source of truth: `lib/src/lib/session-types.ts` defines the persisted-session interfaces (`PersistedSession` v3, `PersistedPane` — now carrying `surfaceType` — `PersistedAlertState`, `PersistedDoor`, and the stage-2b `PersistedWorkspace` / `PersistedWindow`) and their migrations. **Surface kinds in the snapshot (stage 2a, implemented).** Each `PersistedPane` records a `surfaceType` (`docs/specs/glossary.md`): `'terminal'` (the default, omitted from the row to keep terminal snapshots byte-identical) or `'browser'`. This is the discriminator that routes restore/resume. `restoreSession` skips terminal restoration for a browser pane, so it no longer mints a stray PTY + xterm for each browser pane id (`session-restore.ts`); the resume plan keeps browser panes (and minimized browser doors) even though they have no live PTY, so the saved `layout`'s panel set still matches and is not discarded (`reconnect.ts`). A browser pane rebuilds from the dockview `layout` blob (visible) or `PersistedDoor.params` (minimized); its render params (`renderMode`, `url`, agent-browser `session`) live there, not in `PersistedPane` — `surfaceType` alone is enough to route restore. A pane lacking `surfaceType` reads as `'terminal'`, so older snapshots migrate transparently. -**Workspace/Window containers (stage 2b, not yet implemented).** The container layer below is specified ahead of the code (`docs/specs/glossary.md` → Implementation status). A **Workspace** will persist as a `PersistedWorkspace`: a `WorkspaceId`, a user-facing `name`, and the Workspace's `PersistedSession` (its panes, doors, and dockview layout). The standalone Window will persist as a `PersistedWindow`: the ordered list of `PersistedWorkspace` plus the active `WorkspaceId`. VS Code will not use `PersistedWindow`; each webview persists exactly one `PersistedSession` — its single Workspace — through the same per-surface state API as today (`workspaceState` for the view, `vscode.setState()` per editor panel; see `docs/specs/vscode.md`). +**Workspace/Window containers (stage 2b — implemented, dormant behind the `dormouse.flags.workspaces` flag).** A **Workspace** persists as a `PersistedWorkspace`: a `WorkspaceId`, a user-facing `name`, and the Workspace's `PersistedSession` (its panes, doors, and dockview layout). The standalone Window persists as a `PersistedWindow`: the ordered list of `PersistedWorkspace` plus the active `WorkspaceId`. Source of truth: `PersistedWorkspace` / `PersistedWindow` / `readPersistedWindow` / `replaceActiveSession` in `session-types.ts`. VS Code does **not** use `PersistedWindow`; each webview persists exactly one `PersistedSession` — its single Workspace — through the same per-surface state API as today (`workspaceState` for the view, `vscode.setState()` per editor panel; see `docs/specs/vscode.md`). -Versioning and migration (stage 2b): introducing the Window container advances the standalone top-level snapshot version. A pre-workspace `PersistedSession` (v3) will migrate to a single `PersistedWorkspace` named `Workspace 1`, marked active, inside a `PersistedWindow`. The standalone reader applies this migration; a host that hands back a bare `PersistedSession` (VS Code, or legacy standalone storage) is read as one Workspace. Migrations stay additive — older shapes keep flowing v1→v2→v3→(window) without losing panes, doors, alert state, or surface kind. +The wrapping lives at the **standalone adapter boundary**, not in the shared save/restore code: `lib/src/lib/window-persistence.ts` (`activeSessionFromStored` / `storedValueForSession`) translates between the host's stored top-level blob and the bare `PersistedSession` that `reconnect.ts` / `session-save.ts` operate on, and `tauri-adapter.ts` / `browser-sidecar-adapter.ts` route `getState` / `saveState` through it. With the flag **off** (the default) these are identity passthroughs — the stored blob stays a bare `PersistedSession` and behavior is byte-identical to stage 2a. With the flag **on**, load returns the active Workspace's session and save merges it back into the active slot, preserving the other Workspaces. + +Versioning and migration (stage 2b): the standalone top-level snapshot is a `PersistedWindow` (its own `version: 1`) wrapping v3 sessions. A pre-workspace bare `PersistedSession` (any version) migrates on read to a single `PersistedWorkspace` named `Workspace 1`, marked active, inside a `PersistedWindow` (`readPersistedWindow`); unreadable inner sessions are dropped and a dangling `activeWorkspaceId` is repaired to the first Workspace. A host that hands back a bare `PersistedSession` (VS Code, or legacy/flag-off standalone storage) is read as one Workspace. Migrations stay additive — older shapes keep flowing v1→v2→v3→(window) without losing panes, doors, alert state, or surface kind. Every saved-session entry point must pass through `readPersistedSession()`. That reader accepts both the canonical parsed object and a JSON-stringified session blob before validating/migrating; this covers host state APIs that may hand back the inner serialized JSON string. diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 4ada5729..37241d8d 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -107,7 +107,7 @@ PTY lifecycle, buffering, the reconnection sequence, and the full message protoc > See `docs/specs/glossary.md` for the Workspace / Window containers and `docs/specs/alert.md` for the union status. > -> **Not yet implemented (stage 2b).** The webview↔Workspace mapping is the conceptual frame; VS Code already partitions PTYs per webview, but Dormouse does not yet compute a per-Workspace union or reflect it onto native chrome (see the "Not yet implemented" bullet below). Stage 2a (`surfaceType` persistence, browser-surface restore/resume) is implemented and adapter-agnostic. +> **Not yet implemented (VS Code side).** The webview↔Workspace mapping is the conceptual frame; VS Code already partitions PTYs per webview. The union *projection* exists as a shared primitive (`computeWorkspaceUnion`, stage 2b), but VS Code does not yet feed it per webview or reflect the result onto native chrome (see the "Not yet implemented" bullet below). The stage-2b Window persistence container is standalone-only and does not touch VS Code, which keeps one bare `PersistedSession` per webview. Stage 2a (`surfaceType` persistence, browser-surface restore/resume) is implemented and adapter-agnostic. In VS Code, **one webview is one Workspace**. The bottom-panel `WebviewView` ("Dormouse") is the default Workspace; each `dormouse.open` editor-tab `WebviewPanel` is an independent Workspace. Unlike standalone, several Workspaces are visible at once, and VS Code — not Dormouse — owns their tabs, creation, and closing: opening a Dormouse editor tab creates a Workspace and closing the tab closes it, so Dormouse adds no create/rename/close affordances here. A webview owns the terminal Sessions whose PTYs its router tracks (`ownedPtyIds`, `docs/specs/transport.md`) plus any browser surfaces rendered in it; together those are the Workspace's Surfaces. diff --git a/lib/src/lib/feature-flags.test.ts b/lib/src/lib/feature-flags.test.ts new file mode 100644 index 00000000..4704f47d --- /dev/null +++ b/lib/src/lib/feature-flags.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isWorkspacesEnabled, setWorkspacesEnabled, WORKSPACES_FLAG_KEY } from './feature-flags'; + +function stubLocalStorage(): Map { + const store = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), + setItem: (k: string, v: string) => store.set(k, v), + removeItem: (k: string) => store.delete(k), + }); + return store; +} + +describe('feature-flags: workspaces', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('is off by default (dormant)', () => { + stubLocalStorage(); + expect(isWorkspacesEnabled()).toBe(false); + }); + + it('round-trips via localStorage', () => { + const store = stubLocalStorage(); + setWorkspacesEnabled(true); + expect(store.get(WORKSPACES_FLAG_KEY)).toBe('true'); + expect(isWorkspacesEnabled()).toBe(true); + setWorkspacesEnabled(false); + expect(store.has(WORKSPACES_FLAG_KEY)).toBe(false); + expect(isWorkspacesEnabled()).toBe(false); + }); + + describe('without localStorage', () => { + beforeEach(() => vi.stubGlobal('localStorage', undefined)); + it('treats the flag as disabled and never throws', () => { + expect(isWorkspacesEnabled()).toBe(false); + expect(() => setWorkspacesEnabled(true)).not.toThrow(); + }); + }); +}); diff --git a/lib/src/lib/feature-flags.ts b/lib/src/lib/feature-flags.ts new file mode 100644 index 00000000..ca6279ce --- /dev/null +++ b/lib/src/lib/feature-flags.ts @@ -0,0 +1,37 @@ +/** + * Runtime feature flags, toggled via `localStorage` so they work uniformly + * across standalone, the VS Code webview, the website, Storybook, and tests. + * + * The **workspaces** flag gates the Workspace/Window container (stage 2b) and + * everything built on it — the switching UI (stage 3) and real multi-Workspace + * support (stage 4). It is **off by default**: with the flag off, the app + * persists and restores a single bare `PersistedSession` exactly as before, so + * the container code is dormant. See `docs/specs/glossary.md` → Implementation + * status. + */ + +export const WORKSPACES_FLAG_KEY = 'dormouse.flags.workspaces'; + +function readBoolFlag(key: string): boolean { + try { + return globalThis.localStorage?.getItem(key) === 'true'; + } catch { + // No localStorage (some host/test contexts): treat as disabled. + return false; + } +} + +/** Whether the Workspace/Window container is enabled. Off by default (dormant). */ +export function isWorkspacesEnabled(): boolean { + return readBoolFlag(WORKSPACES_FLAG_KEY); +} + +/** Toggle the workspaces flag (used by dev tooling / the stage-3 Storybook UI). */ +export function setWorkspacesEnabled(enabled: boolean): void { + try { + if (enabled) globalThis.localStorage?.setItem(WORKSPACES_FLAG_KEY, 'true'); + else globalThis.localStorage?.removeItem(WORKSPACES_FLAG_KEY); + } catch { + // No localStorage: nothing to persist. + } +} diff --git a/lib/src/lib/session-types.ts b/lib/src/lib/session-types.ts index 2e6b7be7..69a57807 100644 --- a/lib/src/lib/session-types.ts +++ b/lib/src/lib/session-types.ts @@ -71,6 +71,34 @@ export interface PersistedSession { layout: unknown; // SerializedDockview — kept as `unknown` to avoid dockview dep in types } +export type WorkspaceId = string; + +/** + * A named Workspace (one Wall's worth of Surfaces + its layout) as persisted + * inside a `PersistedWindow` (`docs/specs/glossary.md`). Stage 2b. The inner + * `session` keeps its own v3 versioning; the Window wraps it. + */ +export interface PersistedWorkspace { + id: WorkspaceId; + name: string; + session: PersistedSession; +} + +/** + * The standalone Window's persisted snapshot: an ordered list of Workspaces and + * which one is active. VS Code does NOT use this — each webview persists exactly + * one bare `PersistedSession` (`docs/specs/vscode.md`). Stage 2b. + */ +export interface PersistedWindow { + version: 1; + workspaces: PersistedWorkspace[]; + activeWorkspaceId: WorkspaceId; +} + +/** Default id/name for the single Workspace a pre-workspace snapshot migrates to. */ +export const DEFAULT_WORKSPACE_ID: WorkspaceId = 'workspace-1'; +export const DEFAULT_WORKSPACE_NAME = 'Workspace 1'; + type PersistedPaneInput = Omit & { untouched?: boolean }; interface PersistedSessionV3Input { @@ -284,3 +312,75 @@ function parseJsonString(raw: unknown): unknown { return raw; } } + +// --- Window container (stage 2b) --- + +// Structural gate only: a v1 Window with a workspaces array and an active id. +// Each Workspace element is validated (and dropped if bad) per-item in +// readPersistedWindow, so malformed elements don't reject the whole Window. +function isPersistedWindowShape(value: unknown): boolean { + return ( + isRecord(value) && + value.version === 1 && + Array.isArray(value.workspaces) && + typeof value.activeWorkspaceId === 'string' + ); +} + +/** Wrap a single `PersistedSession` as a one-Workspace `PersistedWindow`. */ +export function wrapSessionInWindow( + session: PersistedSession, + id: WorkspaceId = DEFAULT_WORKSPACE_ID, + name: string = DEFAULT_WORKSPACE_NAME, +): PersistedWindow { + return { version: 1, workspaces: [{ id, name, session }], activeWorkspaceId: id }; +} + +/** + * Read a persisted Window snapshot. Accepts a canonical `PersistedWindow`, a + * JSON-stringified one, or a bare `PersistedSession` (any version) — the + * pre-workspace shape — which migrates to a single Workspace named `Workspace 1` + * (`docs/specs/transport.md`). Returns null when nothing usable is present. + * + * Each inner session is normalized/migrated through `readPersistedSession`. If + * `activeWorkspaceId` does not match any Workspace, the first Workspace is made + * active so the snapshot stays usable. + */ +export function readPersistedWindow(raw: unknown): PersistedWindow | null { + const value = parseJsonString(raw); + if (!isRecord(value)) return null; + + if (isPersistedWindowShape(value)) { + const workspaces = (value.workspaces as unknown[]) + .map((ws): PersistedWorkspace | null => { + if (!isRecord(ws) || typeof ws.id !== 'string' || typeof ws.name !== 'string') return null; + const session = readPersistedSession(ws.session); + return session ? { id: ws.id, name: ws.name, session } : null; + }) + .filter((ws): ws is PersistedWorkspace => ws !== null); + if (workspaces.length === 0) return null; + const activeWorkspaceId = workspaces.some((ws) => ws.id === value.activeWorkspaceId) + ? (value.activeWorkspaceId as WorkspaceId) + : workspaces[0].id; + return { version: 1, workspaces, activeWorkspaceId }; + } + + // Pre-workspace bare PersistedSession → single-Workspace window. + const session = readPersistedSession(value); + return session ? wrapSessionInWindow(session) : null; +} + +/** The active Workspace's session, or the first Workspace's as a fallback. */ +export function activeWorkspaceSession(window: PersistedWindow): PersistedSession { + const active = window.workspaces.find((ws) => ws.id === window.activeWorkspaceId); + return (active ?? window.workspaces[0]).session; +} + +/** Return a copy of the Window with the active Workspace's session replaced, + * preserving every other Workspace. */ +export function replaceActiveSession(window: PersistedWindow, session: PersistedSession): PersistedWindow { + return { + ...window, + workspaces: window.workspaces.map((ws) => (ws.id === window.activeWorkspaceId ? { ...ws, session } : ws)), + }; +} diff --git a/lib/src/lib/session-window.test.ts b/lib/src/lib/session-window.test.ts new file mode 100644 index 00000000..66772914 --- /dev/null +++ b/lib/src/lib/session-window.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { + activeWorkspaceSession, + DEFAULT_WORKSPACE_ID, + DEFAULT_WORKSPACE_NAME, + readPersistedWindow, + wrapSessionInWindow, + type PersistedSession, + type PersistedWindow, +} from './session-types'; + +const sessionA: PersistedSession = { + version: 3, + layout: { panels: { 'pane-a': {} } }, + panes: [{ id: 'pane-a', title: 'A', cwd: null, scrollback: null, resumeCommand: null, untouched: false }], +}; + +const sessionB: PersistedSession = { + version: 3, + layout: { panels: { 'pane-b': {} } }, + panes: [{ id: 'pane-b', title: 'B', cwd: null, scrollback: null, resumeCommand: null, untouched: false }], +}; + +describe('readPersistedWindow', () => { + it('migrates a pre-workspace bare PersistedSession to a single Workspace named "Workspace 1"', () => { + const win = readPersistedWindow(sessionA); + expect(win).toEqual({ + version: 1, + activeWorkspaceId: DEFAULT_WORKSPACE_ID, + workspaces: [{ id: DEFAULT_WORKSPACE_ID, name: DEFAULT_WORKSPACE_NAME, session: sessionA }], + }); + }); + + it('migrates a legacy v2 bare session through readPersistedSession (panes preserved)', () => { + const v2 = { + version: 2 as const, + layout: { panels: { 'pane-a': {} } }, + panes: [{ id: 'pane-a', title: 'A', cwd: null, scrollback: null, resumeCommand: null }], + }; + const win = readPersistedWindow(v2); + expect(win?.workspaces).toHaveLength(1); + expect(win?.workspaces[0].session.version).toBe(3); + expect(win?.workspaces[0].session.panes[0].id).toBe('pane-a'); + expect(win?.workspaces[0].session.panes[0].untouched).toBe(false); + }); + + it('round-trips a canonical multi-Workspace window', () => { + const win: PersistedWindow = { + version: 1, + activeWorkspaceId: 'ws-b', + workspaces: [ + { id: 'ws-a', name: 'Left', session: sessionA }, + { id: 'ws-b', name: 'Right', session: sessionB }, + ], + }; + expect(readPersistedWindow(win)).toEqual(win); + }); + + it('parses a JSON-stringified window blob', () => { + const win = wrapSessionInWindow(sessionA); + expect(readPersistedWindow(JSON.stringify(win))).toEqual(win); + }); + + it('falls back to the first Workspace when activeWorkspaceId matches none', () => { + const win: PersistedWindow = { + version: 1, + activeWorkspaceId: 'gone', + workspaces: [{ id: 'ws-a', name: 'A', session: sessionA }], + }; + expect(readPersistedWindow(win)?.activeWorkspaceId).toBe('ws-a'); + }); + + it('drops Workspaces with an unreadable session, keeping the rest', () => { + const win = { + version: 1 as const, + activeWorkspaceId: 'ws-a', + workspaces: [ + { id: 'ws-a', name: 'A', session: sessionA }, + { id: 'ws-bad', name: 'Bad', session: { nonsense: true } }, + ], + }; + const read = readPersistedWindow(win); + expect(read?.workspaces).toHaveLength(1); + expect(read?.workspaces[0].id).toBe('ws-a'); + }); + + it('returns null for unusable input', () => { + expect(readPersistedWindow(null)).toBeNull(); + expect(readPersistedWindow({ random: 'junk' })).toBeNull(); + expect(readPersistedWindow('not json')).toBeNull(); + }); +}); + +describe('activeWorkspaceSession', () => { + it('returns the active Workspace session', () => { + const win: PersistedWindow = { + version: 1, + activeWorkspaceId: 'ws-b', + workspaces: [ + { id: 'ws-a', name: 'A', session: sessionA }, + { id: 'ws-b', name: 'B', session: sessionB }, + ], + }; + expect(activeWorkspaceSession(win)).toBe(sessionB); + }); + + it('falls back to the first Workspace when the active id is missing', () => { + const win: PersistedWindow = { + version: 1, + activeWorkspaceId: 'gone', + workspaces: [{ id: 'ws-a', name: 'A', session: sessionA }], + }; + expect(activeWorkspaceSession(win)).toBe(sessionA); + }); +}); diff --git a/lib/src/lib/window-persistence.test.ts b/lib/src/lib/window-persistence.test.ts new file mode 100644 index 00000000..b47e61c7 --- /dev/null +++ b/lib/src/lib/window-persistence.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { activeSessionFromStored, loadSessionState, saveSessionState, storedValueForSession } from './window-persistence'; +import { + DEFAULT_WORKSPACE_ID, + wrapSessionInWindow, + type PersistedSession, + type PersistedWindow, +} from './session-types'; +import { setWorkspacesEnabled } from './feature-flags'; + +function stubLocalStorage(): void { + const store = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), + setItem: (k: string, v: string) => store.set(k, v), + removeItem: (k: string) => store.delete(k), + }); +} + +const sessionA: PersistedSession = { + version: 3, + layout: { panels: { 'pane-a': {} } }, + panes: [{ id: 'pane-a', title: 'A', cwd: null, scrollback: null, resumeCommand: null, untouched: false }], +}; +const sessionB: PersistedSession = { + version: 3, + layout: { panels: { 'pane-b': {} } }, + panes: [{ id: 'pane-b', title: 'B', cwd: null, scrollback: null, resumeCommand: null, untouched: false }], +}; + +describe('window-persistence', () => { + beforeEach(stubLocalStorage); + afterEach(() => vi.unstubAllGlobals()); + + describe('flag off (passthrough — identical to today)', () => { + beforeEach(() => setWorkspacesEnabled(false)); + + it('load returns the stored value unchanged', () => { + expect(activeSessionFromStored(sessionA)).toBe(sessionA); + }); + + it('save returns the session unchanged (bare, not wrapped)', () => { + expect(storedValueForSession(null, sessionA)).toBe(sessionA); + }); + }); + + describe('flag on (Window container)', () => { + beforeEach(() => setWorkspacesEnabled(true)); + + it('save wraps a fresh session into a single-Workspace Window', () => { + const stored = storedValueForSession(null, sessionA) as PersistedWindow; + expect(stored.version).toBe(1); + expect(stored.workspaces).toHaveLength(1); + expect(stored.activeWorkspaceId).toBe(DEFAULT_WORKSPACE_ID); + expect(stored.workspaces[0].session.panes[0].id).toBe('pane-a'); + }); + + it('round-trips: save then load yields the same active session', () => { + const stored = storedValueForSession(null, sessionA); + expect(activeSessionFromStored(stored)).toEqual(sessionA); + }); + + it('load migrates a pre-workspace bare session transparently', () => { + expect(activeSessionFromStored(sessionA)).toEqual(sessionA); + }); + + it('save replaces only the active Workspace, preserving the others', () => { + const existing: PersistedWindow = { + version: 1, + activeWorkspaceId: 'ws-b', + workspaces: [ + { id: 'ws-a', name: 'A', session: sessionA }, + { id: 'ws-b', name: 'B', session: sessionA }, + ], + }; + const stored = storedValueForSession(existing, sessionB) as PersistedWindow; + expect(stored.workspaces.find((w) => w.id === 'ws-a')!.session).toEqual(sessionA); + expect(stored.workspaces.find((w) => w.id === 'ws-b')!.session).toEqual(sessionB); + }); + + it('load returns the active Workspace session from a multi-Workspace Window', () => { + const win = wrapSessionInWindow(sessionA); + const multi: PersistedWindow = { + version: 1, + activeWorkspaceId: 'ws-b', + workspaces: [...win.workspaces, { id: 'ws-b', name: 'B', session: sessionB }], + }; + expect(activeSessionFromStored(multi)).toEqual(sessionB); + }); + + it('load returns null for unusable stored input', () => { + expect(activeSessionFromStored(null)).toBeNull(); + expect(activeSessionFromStored({ junk: true })).toBeNull(); + }); + }); + + describe('storage round trip (loadSessionState / saveSessionState)', () => { + function spyStorage(initial: string | null = null) { + let value = initial; + const getItem = vi.fn((_key: string) => value); + const setItem = vi.fn((_key: string, next: string) => { value = next; }); + const storage = { getItem, setItem, removeItem: vi.fn() } as unknown as Storage; + return { storage, getItem, setItem }; + } + + it('flag off: saves the bare session WITHOUT reading the existing blob', () => { + setWorkspacesEnabled(false); + const { storage, getItem, setItem } = spyStorage(JSON.stringify(sessionB)); + saveSessionState(storage, 'k', sessionA); + // The efficiency win: no wasted read/parse of the (scrollback-bearing) blob. + expect(getItem).not.toHaveBeenCalled(); + expect(JSON.parse(setItem.mock.calls[0]![1])).toEqual(sessionA); + }); + + it('flag off: load returns the bare stored session', () => { + setWorkspacesEnabled(false); + const { storage } = spyStorage(JSON.stringify(sessionA)); + expect(loadSessionState(storage, 'k')).toEqual(sessionA); + }); + + it('flag on: save wraps into a Window and load round-trips the active session', () => { + setWorkspacesEnabled(true); + const { storage } = spyStorage(); + saveSessionState(storage, 'k', sessionA); + const stored = JSON.parse((storage.getItem('k'))!) as PersistedWindow; + expect(stored.version).toBe(1); + expect(stored.activeWorkspaceId).toBe(DEFAULT_WORKSPACE_ID); + expect(loadSessionState(storage, 'k')).toEqual(sessionA); + }); + + it('flag on: save preserves other Workspaces by reading the existing Window', () => { + setWorkspacesEnabled(true); + const existing: PersistedWindow = { + version: 1, + activeWorkspaceId: 'ws-b', + workspaces: [ + { id: 'ws-a', name: 'A', session: sessionA }, + { id: 'ws-b', name: 'B', session: sessionA }, + ], + }; + const { storage } = spyStorage(JSON.stringify(existing)); + saveSessionState(storage, 'k', sessionB); + const stored = JSON.parse((storage.getItem('k'))!) as PersistedWindow; + expect(stored.workspaces.find((w) => w.id === 'ws-a')!.session).toEqual(sessionA); + expect(stored.workspaces.find((w) => w.id === 'ws-b')!.session).toEqual(sessionB); + }); + + it('load returns null when storage is empty', () => { + const { storage } = spyStorage(null); + expect(loadSessionState(storage, 'k')).toBeNull(); + }); + }); +}); diff --git a/lib/src/lib/window-persistence.ts b/lib/src/lib/window-persistence.ts new file mode 100644 index 00000000..f17c303d --- /dev/null +++ b/lib/src/lib/window-persistence.ts @@ -0,0 +1,69 @@ +import { isWorkspacesEnabled } from './feature-flags'; +import { + activeWorkspaceSession, + readPersistedSession, + readPersistedWindow, + replaceActiveSession, + wrapSessionInWindow, +} from './session-types'; + +/** + * Translate between the standalone host's stored top-level blob and the bare + * `PersistedSession` the shared persistence code (`reconnect.ts`, + * `session-save.ts`) operates on (stage 2b). + * + * With the workspaces flag **off** these are identity passthroughs, so the host + * stores and restores a bare `PersistedSession` exactly as before. With the flag + * **on**, the stored blob is a `PersistedWindow`; load returns the active + * Workspace's session, and save merges the new session back into the active + * Workspace slot while preserving every other Workspace. + * + * The flag is read per call, so toggling it mid-run is consistent within a save + * or load. (Turning the flag off while a Window is stored makes that blob look + * unparseable to the bare-session reader — acceptable for a dev-only flag.) + * + * Source of truth: `docs/specs/transport.md`. VS Code does not use this — it + * persists one bare `PersistedSession` per webview. + */ + +/** Parsed stored blob → the `PersistedSession` to restore (or null). */ +export function activeSessionFromStored(stored: unknown): unknown { + if (!isWorkspacesEnabled()) return stored; + const window = readPersistedWindow(stored); + return window ? activeWorkspaceSession(window) : null; +} + +/** Existing stored blob + new active session → the blob to store. */ +export function storedValueForSession(existingStored: unknown, session: unknown): unknown { + if (!isWorkspacesEnabled()) return session; + const next = readPersistedSession(session); + if (!next) return session; + const existingWindow = readPersistedWindow(existingStored); + return existingWindow ? replaceActiveSession(existingWindow, next) : wrapSessionInWindow(next); +} + +// Storage-level round trip shared by the standalone adapters (Tauri + the +// browser-dev sidecar). Owns the JSON parse/stringify and the `Storage` access +// so each adapter's get/save collapses to one call instead of re-implementing +// the read-merge-write dance. + +/** Read the stored blob and return the `PersistedSession` to restore (or null). */ +export function loadSessionState(storage: Storage, key: string): unknown { + const raw = storage.getItem(key); + if (raw === null) return null; + return activeSessionFromStored(JSON.parse(raw)); +} + +/** Persist `session` under `key`, merging into the active Workspace when the flag is on. */ +export function saveSessionState(storage: Storage, key: string, session: unknown): void { + // Flag off (the default): store the bare session without reading the existing + // blob — its previous value is irrelevant, so skip parsing the (potentially + // large, scrollback-bearing) stored snapshot. + if (!isWorkspacesEnabled()) { + storage.setItem(key, JSON.stringify(session)); + return; + } + const raw = storage.getItem(key); + const existing = raw === null ? null : JSON.parse(raw); + storage.setItem(key, JSON.stringify(storedValueForSession(existing, session))); +} diff --git a/lib/src/lib/workspace-store.test.ts b/lib/src/lib/workspace-store.test.ts new file mode 100644 index 00000000..89756274 --- /dev/null +++ b/lib/src/lib/workspace-store.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + closeWorkspace, + createWorkspace, + getActiveWorkspaceId, + getWorkspacesSnapshot, + renameWorkspace, + resetWorkspaces, + setActiveWorkspace, + setWorkspaces, + subscribeToWorkspaces, +} from './workspace-store'; +import { DEFAULT_WORKSPACE_ID, DEFAULT_WORKSPACE_NAME } from './session-types'; + +describe('workspace-store', () => { + beforeEach(() => resetWorkspaces()); + + it('defaults to a single "Workspace 1", active', () => { + expect(getWorkspacesSnapshot()).toEqual({ + workspaces: [{ id: DEFAULT_WORKSPACE_ID, name: DEFAULT_WORKSPACE_NAME }], + activeId: DEFAULT_WORKSPACE_ID, + }); + }); + + it('returns a stable snapshot reference until a mutation', () => { + const first = getWorkspacesSnapshot(); + expect(getWorkspacesSnapshot()).toBe(first); + createWorkspace({ id: 'ws-2' }); + expect(getWorkspacesSnapshot()).not.toBe(first); + }); + + it('createWorkspace appends, auto-names "Workspace N", and activates by default', () => { + const meta = createWorkspace(); + expect(meta.name).toBe('Workspace 2'); + expect(getActiveWorkspaceId()).toBe(meta.id); + expect(getWorkspacesSnapshot().workspaces).toHaveLength(2); + }); + + it('createWorkspace with activate:false leaves the active workspace unchanged', () => { + createWorkspace({ id: 'ws-2', activate: false }); + expect(getActiveWorkspaceId()).toBe(DEFAULT_WORKSPACE_ID); + }); + + it('generates unique ids that never collide with the default', () => { + const a = createWorkspace(); + const b = createWorkspace(); + expect(a.id).not.toBe(b.id); + expect(a.id).not.toBe(DEFAULT_WORKSPACE_ID); + }); + + it('setActiveWorkspace switches and ignores unknown ids', () => { + createWorkspace({ id: 'ws-2', activate: false }); + setActiveWorkspace('ws-2'); + expect(getActiveWorkspaceId()).toBe('ws-2'); + setActiveWorkspace('nope'); + expect(getActiveWorkspaceId()).toBe('ws-2'); + }); + + it('renameWorkspace updates the name; ignores empty and unknown', () => { + renameWorkspace(DEFAULT_WORKSPACE_ID, ' Build '); + expect(getWorkspacesSnapshot().workspaces[0].name).toBe('Build'); + renameWorkspace(DEFAULT_WORKSPACE_ID, ' '); + expect(getWorkspacesSnapshot().workspaces[0].name).toBe('Build'); + renameWorkspace('nope', 'X'); // no throw + expect(getWorkspacesSnapshot().workspaces).toHaveLength(1); + }); + + it('closeWorkspace refuses to close the last Workspace', () => { + expect(closeWorkspace(DEFAULT_WORKSPACE_ID)).toBe(false); + expect(getWorkspacesSnapshot().workspaces).toHaveLength(1); + }); + + it('closeWorkspace removes a non-last Workspace and activates the previous neighbor', () => { + createWorkspace({ id: 'ws-2' }); + createWorkspace({ id: 'ws-3' }); // active = ws-3 + expect(closeWorkspace('ws-3')).toBe(true); + expect(getActiveWorkspaceId()).toBe('ws-2'); // previous neighbor + expect(getWorkspacesSnapshot().workspaces.map((w) => w.id)).toEqual([DEFAULT_WORKSPACE_ID, 'ws-2']); + }); + + it('closing an inactive Workspace keeps the active one', () => { + createWorkspace({ id: 'ws-2' }); // active = ws-2 + expect(closeWorkspace(DEFAULT_WORKSPACE_ID)).toBe(true); + expect(getActiveWorkspaceId()).toBe('ws-2'); + }); + + it('setWorkspaces loads a list; bad activeId falls back to first; empty resets to default', () => { + setWorkspaces({ workspaces: [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }], activeId: 'gone' }); + expect(getActiveWorkspaceId()).toBe('a'); + setWorkspaces({ workspaces: [], activeId: 'x' }); + expect(getWorkspacesSnapshot()).toEqual({ + workspaces: [{ id: DEFAULT_WORKSPACE_ID, name: DEFAULT_WORKSPACE_NAME }], + activeId: DEFAULT_WORKSPACE_ID, + }); + }); + + it('notifies subscribers on change', () => { + const listener = vi.fn(); + const unsub = subscribeToWorkspaces(listener); + createWorkspace({ id: 'ws-2' }); + expect(listener).toHaveBeenCalledTimes(1); + unsub(); + createWorkspace({ id: 'ws-3' }); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/src/lib/workspace-store.ts b/lib/src/lib/workspace-store.ts new file mode 100644 index 00000000..81a10bb5 --- /dev/null +++ b/lib/src/lib/workspace-store.ts @@ -0,0 +1,120 @@ +import { DEFAULT_WORKSPACE_ID, DEFAULT_WORKSPACE_NAME, type WorkspaceId } from './session-types'; + +/** + * In-memory model of the Window's Workspaces (stage 2b). Holds the ordered list + * and which one is active, plus the container verbs (`docs/specs/glossary.md`). + * Stage 3 binds the standalone strip to this via `useSyncExternalStore`; stage 4 + * wires the verbs to actual Wall mount/unmount. Until then the model defaults to + * a single Workspace and the verbs only mutate the model. + */ + +export interface WorkspaceMeta { + id: WorkspaceId; + name: string; +} + +export interface WorkspacesState { + workspaces: WorkspaceMeta[]; + activeId: WorkspaceId; +} + +function defaultState(): WorkspacesState { + return { + workspaces: [{ id: DEFAULT_WORKSPACE_ID, name: DEFAULT_WORKSPACE_NAME }], + activeId: DEFAULT_WORKSPACE_ID, + }; +} + +let state: WorkspacesState = defaultState(); +const listeners = new Set<() => void>(); + +function emit(next: WorkspacesState): void { + state = next; + listeners.forEach((listener) => listener()); +} + +export function subscribeToWorkspaces(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Stable snapshot reference (changes only on mutation) for `useSyncExternalStore`. */ +export function getWorkspacesSnapshot(): WorkspacesState { + return state; +} + +export function getActiveWorkspaceId(): WorkspaceId { + return state.activeId; +} + +/** A process-unique WorkspaceId. The random suffix never equals `DEFAULT_WORKSPACE_ID`. */ +export function generateWorkspaceId(): WorkspaceId { + return `workspace-${Math.random().toString(36).slice(2, 10)}`; +} + +/** "Workspace N", one past the highest existing `Workspace ` name. */ +function nextDefaultName(): string { + let max = 0; + for (const ws of state.workspaces) { + const match = /^Workspace (\d+)$/.exec(ws.name); + if (match) max = Math.max(max, Number(match[1])); + } + return `Workspace ${max + 1}`; +} + +/** Replace the whole model (used on restore to load the persisted Window). */ +export function setWorkspaces(next: WorkspacesState): void { + if (next.workspaces.length === 0) { + emit(defaultState()); + return; + } + const activeId = next.workspaces.some((ws) => ws.id === next.activeId) + ? next.activeId + : next.workspaces[0].id; + emit({ workspaces: [...next.workspaces], activeId }); +} + +export function setActiveWorkspace(id: WorkspaceId): void { + if (id === state.activeId) return; + if (!state.workspaces.some((ws) => ws.id === id)) return; + emit({ ...state, activeId: id }); +} + +export function createWorkspace(opts?: { id?: WorkspaceId; name?: string; activate?: boolean }): WorkspaceMeta { + const meta: WorkspaceMeta = { id: opts?.id ?? generateWorkspaceId(), name: opts?.name ?? nextDefaultName() }; + const activeId = opts?.activate === false ? state.activeId : meta.id; + emit({ workspaces: [...state.workspaces, meta], activeId }); + return meta; +} + +export function renameWorkspace(id: WorkspaceId, name: string): void { + const trimmed = name.trim(); + if (!trimmed) return; + if (!state.workspaces.some((ws) => ws.id === id)) return; + emit({ + ...state, + workspaces: state.workspaces.map((ws) => (ws.id === id ? { ...ws, name: trimmed } : ws)), + }); +} + +/** + * Remove a Workspace. The last remaining Workspace cannot be closed (there is + * always one active Workspace — glossary lifecycle). Closing the active one + * activates its previous neighbor. Returns whether a Workspace was removed. + */ +export function closeWorkspace(id: WorkspaceId): boolean { + if (state.workspaces.length <= 1) return false; + const index = state.workspaces.findIndex((ws) => ws.id === id); + if (index === -1) return false; + const workspaces = state.workspaces.filter((ws) => ws.id !== id); + const activeId = state.activeId === id ? workspaces[Math.max(0, index - 1)].id : state.activeId; + emit({ workspaces, activeId }); + return true; +} + +/** Reset to the single default Workspace (fresh start / tests). */ +export function resetWorkspaces(): void { + emit(defaultState()); +} diff --git a/lib/src/lib/workspace-union.test.ts b/lib/src/lib/workspace-union.test.ts new file mode 100644 index 00000000..92a9d1d9 --- /dev/null +++ b/lib/src/lib/workspace-union.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { computeWorkspaceUnion, EMPTY_WORKSPACE_UNION } from './workspace-union'; +import type { ActivityState } from './session-activity-store'; + +function activity(entries: Record>): Map { + const base: ActivityState = { status: 'WATCHING_DISABLED', watchingEnabled: false, todo: false, notification: null }; + return new Map(Object.entries(entries).map(([id, partial]) => [id, { ...base, ...partial }])); +} + +describe('computeWorkspaceUnion', () => { + it('is empty when no surface owes attention', () => { + const union = computeWorkspaceUnion(['a', 'b'], activity({ a: {}, b: { status: 'BUSY' } })); + expect(union).toEqual(EMPTY_WORKSPACE_UNION); + }); + + it('reports ringing when any terminal Session is ALERT_RINGING', () => { + const union = computeWorkspaceUnion(['a', 'b'], activity({ a: {}, b: { status: 'ALERT_RINGING' } })); + expect(union).toEqual({ ringing: true, todo: false, count: 1 }); + }); + + it('reports todo for a flagged terminal Session', () => { + const union = computeWorkspaceUnion(['a'], activity({ a: { todo: true } })); + expect(union).toEqual({ ringing: false, todo: true, count: 1 }); + }); + + it('counts a browser Surface TODO (no ring) — status stays WATCHING_DISABLED', () => { + const union = computeWorkspaceUnion(['web'], activity({ web: { status: 'WATCHING_DISABLED', todo: true } })); + expect(union).toEqual({ ringing: false, todo: true, count: 1 }); + }); + + it('counts a surface that is both ringing and todo only once', () => { + const union = computeWorkspaceUnion(['a'], activity({ a: { status: 'ALERT_RINGING', todo: true } })); + expect(union).toEqual({ ringing: true, todo: true, count: 1 }); + }); + + it('sums distinct surfaces owing attention', () => { + const union = computeWorkspaceUnion( + ['a', 'b', 'c', 'd'], + activity({ a: { status: 'ALERT_RINGING' }, b: { todo: true }, c: { status: 'BUSY' }, d: {} }), + ); + expect(union).toEqual({ ringing: true, todo: true, count: 2 }); + }); + + it('ignores surface ids with no activity entry', () => { + const union = computeWorkspaceUnion(['a', 'missing'], activity({ a: { todo: true } })); + expect(union).toEqual({ ringing: false, todo: true, count: 1 }); + }); + + it('is empty for an empty surface set', () => { + expect(computeWorkspaceUnion([], activity({ a: { todo: true } }))).toEqual(EMPTY_WORKSPACE_UNION); + }); +}); diff --git a/lib/src/lib/workspace-union.ts b/lib/src/lib/workspace-union.ts new file mode 100644 index 00000000..2e59886c --- /dev/null +++ b/lib/src/lib/workspace-union.ts @@ -0,0 +1,42 @@ +import type { ActivityState } from './session-activity-store'; + +/** + * A Workspace's display-only **union status** over its member Surfaces' + * Activity (`docs/specs/glossary.md`, `docs/specs/alert.md`). Derived; it never + * enters the Activity state machine and never fires a ring. + */ +export interface WorkspaceUnion { + /** Any member terminal Session is `ALERT_RINGING`. Browser Surfaces never ring. */ + ringing: boolean; + /** Any member Surface (terminal or browser) has `todo === true`. */ + todo: boolean; + /** Number of member Surfaces owing attention (ringing or todo); each counts once. */ + count: number; +} + +export const EMPTY_WORKSPACE_UNION: WorkspaceUnion = { ringing: false, todo: false, count: 0 }; + +/** + * Project the union over a Workspace's member Surfaces. `surfaceIds` are the + * Workspace's panes + doors; `activity` is `getActivitySnapshot()`. Surfaces + * with no activity entry contribute nothing. A Surface that is both ringing and + * TODO is counted once. + */ +export function computeWorkspaceUnion( + surfaceIds: Iterable, + activity: Map, +): WorkspaceUnion { + let ringing = false; + let todo = false; + let count = 0; + for (const id of surfaceIds) { + const state = activity.get(id); + if (!state) continue; + const isRinging = state.status === 'ALERT_RINGING'; + const isTodo = state.todo === true; + if (isRinging) ringing = true; + if (isTodo) todo = true; + if (isRinging || isTodo) count += 1; + } + return { ringing, todo, count }; +} diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index a34877e5..6760275a 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -14,6 +14,7 @@ import type { } from "dormouse-lib/lib/platform/types"; import { AlertManager, type SessionStatus } from "dormouse-lib/lib/alert-manager"; import { normalizeExternalUri } from "dormouse-lib/lib/external-links"; +import { loadSessionState, saveSessionState } from "dormouse-lib/lib/window-persistence"; import { applyTerminalProtocolEvents, collectTerminalSemanticEvents, @@ -218,15 +219,17 @@ export class BrowserSidecarAdapter implements PlatformAdapter { private static STATE_KEY = 'dormouse.browser-sidecar.session'; + // See TauriAdapter: PersistedWindow when the workspaces flag is on, bare + // PersistedSession when off; the helpers own the translation + JSON/storage + // plumbing (docs/specs/transport.md). saveState(state: unknown): void { - try { localStorage.setItem(BrowserSidecarAdapter.STATE_KEY, JSON.stringify(state)); } + try { saveSessionState(localStorage, BrowserSidecarAdapter.STATE_KEY, state); } catch { console.error('[browser-sidecar] Failed to save session state'); } } getState(): unknown { try { - const raw = localStorage.getItem(BrowserSidecarAdapter.STATE_KEY); - return raw ? JSON.parse(raw) : null; + return loadSessionState(localStorage, BrowserSidecarAdapter.STATE_KEY); } catch { return null; } diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 13661c08..4c6e8c53 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -17,6 +17,7 @@ import type { } from "dormouse-lib/lib/platform/types"; import { AlertManager, type SessionStatus } from "dormouse-lib/lib/alert-manager"; import { normalizeExternalUri } from "dormouse-lib/lib/external-links"; +import { loadSessionState, saveSessionState } from "dormouse-lib/lib/window-persistence"; import { applyTerminalProtocolEvents, collectTerminalSemanticEvents, @@ -428,9 +429,12 @@ export class TauriAdapter implements PlatformAdapter { private static STATE_KEY = 'dormouse.session'; + // Persisted blob is a PersistedWindow when the workspaces flag is on, a bare + // PersistedSession when off (docs/specs/transport.md). The window-persistence + // helpers own the translation + JSON/storage plumbing. saveState(state: unknown): void { try { - localStorage.setItem(TauriAdapter.STATE_KEY, JSON.stringify(state)); + saveSessionState(localStorage, TauriAdapter.STATE_KEY, state); } catch { console.error('[tauri-adapter] Failed to save session state'); } @@ -438,8 +442,7 @@ export class TauriAdapter implements PlatformAdapter { getState(): unknown { try { - const raw = localStorage.getItem(TauriAdapter.STATE_KEY); - return raw ? JSON.parse(raw) : null; + return loadSessionState(localStorage, TauriAdapter.STATE_KEY); } catch { return null; }