From 64844f47c4aa18b49c77b6f35653c04582bf6ab9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 29 Jun 2026 16:25:16 -0700 Subject: [PATCH 1/4] =?UTF-8?q?stage=202b=20(1/2):=20Workspace/Window=20pr?= =?UTF-8?q?imitives=20=E2=80=94=20types,=20migration,=20union,=20store,=20?= =?UTF-8?q?flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure additive lib core for the Workspace container. No app wiring yet (that is 2b part 2, in the standalone adapter), so this is fully dormant and changes no behavior. - session-types: PersistedWorkspace, PersistedWindow, WorkspaceId, and readPersistedWindow() — reads a canonical/JSON window or migrates a bare PersistedSession (any version) to a single "Workspace 1" window; drops unreadable inner sessions; repairs a dangling activeWorkspaceId. Plus wrapSessionInWindow() and activeWorkspaceSession() helpers. - feature-flags: isWorkspacesEnabled() / setWorkspacesEnabled(), a localStorage flag (dormouse.flags.workspaces) off by default — gates the whole container (stage 2b) and the UI built on it (3/4). - workspace-union: computeWorkspaceUnion(surfaceIds, activitySnapshot) → { ringing, todo, count }, the display-only projection. Only terminals ring; any surface may carry TODO; each attention-owing surface counts once. - workspace-store: in-memory model (list + activeId) with the container verbs (create/close/rename/switch), default single Workspace, subscribable for the stage-3 strip. closeWorkspace refuses the last; reactivates a neighbor. Tests: +35 across the four units. Full lib suite green (710), tsc -b clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/lib/feature-flags.test.ts | 39 +++++++++ lib/src/lib/feature-flags.ts | 37 +++++++++ lib/src/lib/session-types.ts | 97 ++++++++++++++++++++++ lib/src/lib/session-window.test.ts | 115 ++++++++++++++++++++++++++ lib/src/lib/workspace-store.test.ts | 106 ++++++++++++++++++++++++ lib/src/lib/workspace-store.ts | 123 ++++++++++++++++++++++++++++ lib/src/lib/workspace-union.test.ts | 52 ++++++++++++ lib/src/lib/workspace-union.ts | 42 ++++++++++ 8 files changed, 611 insertions(+) create mode 100644 lib/src/lib/feature-flags.test.ts create mode 100644 lib/src/lib/feature-flags.ts create mode 100644 lib/src/lib/session-window.test.ts create mode 100644 lib/src/lib/workspace-store.test.ts create mode 100644 lib/src/lib/workspace-store.ts create mode 100644 lib/src/lib/workspace-union.test.ts create mode 100644 lib/src/lib/workspace-union.ts 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 fdac47ef..b191f8ad 100644 --- a/lib/src/lib/session-types.ts +++ b/lib/src/lib/session-types.ts @@ -48,6 +48,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 { @@ -261,3 +289,72 @@ function parseJsonString(raw: unknown): unknown { return raw; } } + +// --- Window container (stage 2b) --- + +// Structural check only — id/name strings and a session object. Whether the +// inner session is actually readable is decided per-Workspace in +// readPersistedWindow, which drops unreadable ones rather than rejecting the +// whole Window. +function isPersistedWorkspaceShape(value: unknown): boolean { + if (!isRecord(value)) return false; + return typeof value.id === 'string' && typeof value.name === 'string' && isRecord(value.session); +} + +function isPersistedWindowShape(value: unknown): boolean { + if (!isRecord(value) || value.version !== 1) return false; + return ( + Array.isArray(value.workspaces) && + value.workspaces.length > 0 && + value.workspaces.every(isPersistedWorkspaceShape) && + 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 PersistedWorkspace[]) + .map((ws) => { + 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; +} 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/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..85189ee4 --- /dev/null +++ b/lib/src/lib/workspace-store.ts @@ -0,0 +1,123 @@ +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>(); +let idSeq = 0; + +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. Never collides with `DEFAULT_WORKSPACE_ID`. */ +export function generateWorkspaceId(): WorkspaceId { + idSeq += 1; + return `workspace-${idSeq}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** "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 { + idSeq = 0; + 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 }; +} From de7338bd1211123937028479063bd35588c3dff1 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 29 Jun 2026 16:30:14 -0700 Subject: [PATCH 2/4] stage 2b (2/2): wire the Window container into the standalone adapters (flag-gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone now persists a PersistedWindow when the workspaces flag is on, and a bare PersistedSession when off — so with the flag off (the default) the stored blob and all behavior are byte-identical to today. Dormant. - window-persistence (lib): activeSessionFromStored / storedValueForSession — flag-gated, pure translators between the host's stored top-level blob and the bare PersistedSession the shared restore/save code (reconnect, session-save) operates on. Flag off = identity passthrough. Flag on: load returns the active Workspace's session; save merges back into the active slot, preserving the other Workspaces. Migrates a pre-workspace bare session transparently on load. - session-types: replaceActiveSession(window, session) helper. - tauri-adapter + browser-sidecar-adapter: getState/saveState route through the helpers. No change to shared lib persistence (no session-save.ts touch). The Window container is standalone-only; VS Code keeps one bare PersistedSession per webview, untouched. Tests: +8 (window-persistence, both flag states + multi-Workspace merge/load). Full lib suite green (718); standalone tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/lib/session-types.ts | 9 +++ lib/src/lib/window-persistence.test.ts | 96 +++++++++++++++++++++++ lib/src/lib/window-persistence.ts | 43 ++++++++++ standalone/src/browser-sidecar-adapter.ts | 12 ++- standalone/src/tauri-adapter.ts | 10 ++- 5 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 lib/src/lib/window-persistence.test.ts create mode 100644 lib/src/lib/window-persistence.ts diff --git a/lib/src/lib/session-types.ts b/lib/src/lib/session-types.ts index b191f8ad..dfef556a 100644 --- a/lib/src/lib/session-types.ts +++ b/lib/src/lib/session-types.ts @@ -358,3 +358,12 @@ export function activeWorkspaceSession(window: PersistedWindow): PersistedSessio 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/window-persistence.test.ts b/lib/src/lib/window-persistence.test.ts new file mode 100644 index 00000000..c124736e --- /dev/null +++ b/lib/src/lib/window-persistence.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { activeSessionFromStored, 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(); + }); + }); +}); diff --git a/lib/src/lib/window-persistence.ts b/lib/src/lib/window-persistence.ts new file mode 100644 index 00000000..8857cdc6 --- /dev/null +++ b/lib/src/lib/window-persistence.ts @@ -0,0 +1,43 @@ +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); +} diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index a34877e5..c763364b 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 { activeSessionFromStored, storedValueForSession } from "dormouse-lib/lib/window-persistence"; import { applyTerminalProtocolEvents, collectTerminalSemanticEvents, @@ -218,15 +219,20 @@ 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 translate (docs/specs/transport.md). saveState(state: unknown): void { - try { localStorage.setItem(BrowserSidecarAdapter.STATE_KEY, JSON.stringify(state)); } - catch { console.error('[browser-sidecar] Failed to save session state'); } + try { + const raw = localStorage.getItem(BrowserSidecarAdapter.STATE_KEY); + const existing = raw ? JSON.parse(raw) : null; + localStorage.setItem(BrowserSidecarAdapter.STATE_KEY, JSON.stringify(storedValueForSession(existing, 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 activeSessionFromStored(raw ? JSON.parse(raw) : null); } catch { return null; } diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 13661c08..afc8f4a7 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 { activeSessionFromStored, storedValueForSession } from "dormouse-lib/lib/window-persistence"; import { applyTerminalProtocolEvents, collectTerminalSemanticEvents, @@ -428,9 +429,14 @@ 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 translate so the shared restore/save code only sees a session. saveState(state: unknown): void { try { - localStorage.setItem(TauriAdapter.STATE_KEY, JSON.stringify(state)); + const raw = localStorage.getItem(TauriAdapter.STATE_KEY); + const existing = raw ? JSON.parse(raw) : null; + localStorage.setItem(TauriAdapter.STATE_KEY, JSON.stringify(storedValueForSession(existing, state))); } catch { console.error('[tauri-adapter] Failed to save session state'); } @@ -439,7 +445,7 @@ export class TauriAdapter implements PlatformAdapter { getState(): unknown { try { const raw = localStorage.getItem(TauriAdapter.STATE_KEY); - return raw ? JSON.parse(raw) : null; + return activeSessionFromStored(raw ? JSON.parse(raw) : null); } catch { return null; } From 4bdfda1933e903c8134c82d5c3861958f9c065d7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 29 Jun 2026 16:32:42 -0700 Subject: [PATCH 3/4] docs: mark stage 2b implemented (dormant behind flag); strip/switching/VS Code = 3/4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the Workspace/Window container, migration, model, union projection, and standalone persistence wiring are in place behind `dormouse.flags.workspaces`, update the spec status markers to match reality: - glossary Implementation status: three buckets — live (2a), implemented-but- dormant-behind-flag (2b container/migration/model/union/persistence), and not yet built (strip + union surfacing = 3, Wall mount/unmount on switch + >1 Workspace = 4) - transport: container/migration paragraphs flip to implemented; document that the wrapping lives at the standalone adapter boundary (window-persistence.ts), flag off = byte-identical bare PersistedSession - layout: Workspaces section is partially implemented (model behind flag; strip + switch-rerender still 3/4) - alert: union projection implemented (computeWorkspaceUnion); surfacing still 3 - vscode: union primitive exists but VS Code does not yet feed/reflect it; the Window container is standalone-only and doesn't touch VS Code Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/specs/alert.md | 2 +- docs/specs/glossary.md | 5 +++-- docs/specs/layout.md | 2 +- docs/specs/transport.md | 8 +++++--- docs/specs/vscode.md | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) 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. From 9d28cc8d8f5040cb7b21489ec70e167d08a8dd14 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 29 Jun 2026 22:29:57 -0700 Subject: [PATCH 4/4] simplify: dedup adapter persistence, skip flag-off read, tidy window guards/id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup pass over the stage-2b diff (4 review angles). Quality only, no behavior change. - window-persistence: add storage-level loadSessionState/saveSessionState that own the JSON parse/stringify + localStorage access, so both standalone adapters collapse to a one-liner instead of re-implementing the identical read-merge-write dance. saveSessionState skips reading the existing blob when the flag is off (the default) — previously every debounced save needlessly re-parsed the scrollback-bearing snapshot on the hot path. - session-types: isPersistedWindowShape is now a structural gate only; per- Workspace validation (id/name/readable session) moved into readPersistedWindow so malformed elements are dropped uniformly instead of a single bad element rejecting the whole Window. Deletes the now-redundant isPersistedWorkspaceShape. - workspace-store: generateWorkspaceId uses the random suffix only (drops the belt-and-suspenders monotonic counter); the suffix never equals "workspace-1". Skipped (intentional, noted): the hand-rolled subscribe/snapshot store and localStorage-guard patterns (codebase convention, no shared helper exists), the flags-module reader and dormant createWorkspace option, and the multi-validation cost at N>1 (proportionate for dormant N=1). Tests: +5 (storage round trip incl. flag-off no-read). Full lib suite green (729); lib + standalone tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/lib/session-types.ts | 22 +++------ lib/src/lib/window-persistence.test.ts | 59 ++++++++++++++++++++++- lib/src/lib/window-persistence.ts | 26 ++++++++++ lib/src/lib/workspace-store.ts | 7 +-- standalone/src/browser-sidecar-adapter.ts | 15 +++--- standalone/src/tauri-adapter.ts | 11 ++--- 6 files changed, 104 insertions(+), 36 deletions(-) diff --git a/lib/src/lib/session-types.ts b/lib/src/lib/session-types.ts index 574a83d4..69a57807 100644 --- a/lib/src/lib/session-types.ts +++ b/lib/src/lib/session-types.ts @@ -315,21 +315,14 @@ function parseJsonString(raw: unknown): unknown { // --- Window container (stage 2b) --- -// Structural check only — id/name strings and a session object. Whether the -// inner session is actually readable is decided per-Workspace in -// readPersistedWindow, which drops unreadable ones rather than rejecting the -// whole Window. -function isPersistedWorkspaceShape(value: unknown): boolean { - if (!isRecord(value)) return false; - return typeof value.id === 'string' && typeof value.name === 'string' && isRecord(value.session); -} - +// 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 { - if (!isRecord(value) || value.version !== 1) return false; return ( + isRecord(value) && + value.version === 1 && Array.isArray(value.workspaces) && - value.workspaces.length > 0 && - value.workspaces.every(isPersistedWorkspaceShape) && typeof value.activeWorkspaceId === 'string' ); } @@ -358,8 +351,9 @@ export function readPersistedWindow(raw: unknown): PersistedWindow | null { if (!isRecord(value)) return null; if (isPersistedWindowShape(value)) { - const workspaces = (value.workspaces as PersistedWorkspace[]) - .map((ws) => { + 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; }) diff --git a/lib/src/lib/window-persistence.test.ts b/lib/src/lib/window-persistence.test.ts index c124736e..b47e61c7 100644 --- a/lib/src/lib/window-persistence.test.ts +++ b/lib/src/lib/window-persistence.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { activeSessionFromStored, storedValueForSession } from './window-persistence'; +import { activeSessionFromStored, loadSessionState, saveSessionState, storedValueForSession } from './window-persistence'; import { DEFAULT_WORKSPACE_ID, wrapSessionInWindow, @@ -93,4 +93,61 @@ describe('window-persistence', () => { 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 index 8857cdc6..f17c303d 100644 --- a/lib/src/lib/window-persistence.ts +++ b/lib/src/lib/window-persistence.ts @@ -41,3 +41,29 @@ export function storedValueForSession(existingStored: unknown, session: unknown) 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.ts b/lib/src/lib/workspace-store.ts index 85189ee4..81a10bb5 100644 --- a/lib/src/lib/workspace-store.ts +++ b/lib/src/lib/workspace-store.ts @@ -27,7 +27,6 @@ function defaultState(): WorkspacesState { let state: WorkspacesState = defaultState(); const listeners = new Set<() => void>(); -let idSeq = 0; function emit(next: WorkspacesState): void { state = next; @@ -50,10 +49,9 @@ export function getActiveWorkspaceId(): WorkspaceId { return state.activeId; } -/** A process-unique WorkspaceId. Never collides with `DEFAULT_WORKSPACE_ID`. */ +/** A process-unique WorkspaceId. The random suffix never equals `DEFAULT_WORKSPACE_ID`. */ export function generateWorkspaceId(): WorkspaceId { - idSeq += 1; - return `workspace-${idSeq}-${Math.random().toString(36).slice(2, 8)}`; + return `workspace-${Math.random().toString(36).slice(2, 10)}`; } /** "Workspace N", one past the highest existing `Workspace ` name. */ @@ -118,6 +116,5 @@ export function closeWorkspace(id: WorkspaceId): boolean { /** Reset to the single default Workspace (fresh start / tests). */ export function resetWorkspaces(): void { - idSeq = 0; emit(defaultState()); } diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index c763364b..6760275a 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -14,7 +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 { activeSessionFromStored, storedValueForSession } from "dormouse-lib/lib/window-persistence"; +import { loadSessionState, saveSessionState } from "dormouse-lib/lib/window-persistence"; import { applyTerminalProtocolEvents, collectTerminalSemanticEvents, @@ -220,19 +220,16 @@ 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 translate (docs/specs/transport.md). + // PersistedSession when off; the helpers own the translation + JSON/storage + // plumbing (docs/specs/transport.md). saveState(state: unknown): void { - try { - const raw = localStorage.getItem(BrowserSidecarAdapter.STATE_KEY); - const existing = raw ? JSON.parse(raw) : null; - localStorage.setItem(BrowserSidecarAdapter.STATE_KEY, JSON.stringify(storedValueForSession(existing, state))); - } catch { console.error('[browser-sidecar] Failed to save session 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 activeSessionFromStored(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 afc8f4a7..4c6e8c53 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -17,7 +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 { activeSessionFromStored, storedValueForSession } from "dormouse-lib/lib/window-persistence"; +import { loadSessionState, saveSessionState } from "dormouse-lib/lib/window-persistence"; import { applyTerminalProtocolEvents, collectTerminalSemanticEvents, @@ -431,12 +431,10 @@ export class TauriAdapter implements PlatformAdapter { // Persisted blob is a PersistedWindow when the workspaces flag is on, a bare // PersistedSession when off (docs/specs/transport.md). The window-persistence - // helpers translate so the shared restore/save code only sees a session. + // helpers own the translation + JSON/storage plumbing. saveState(state: unknown): void { try { - const raw = localStorage.getItem(TauriAdapter.STATE_KEY); - const existing = raw ? JSON.parse(raw) : null; - localStorage.setItem(TauriAdapter.STATE_KEY, JSON.stringify(storedValueForSession(existing, state))); + saveSessionState(localStorage, TauriAdapter.STATE_KEY, state); } catch { console.error('[tauri-adapter] Failed to save session state'); } @@ -444,8 +442,7 @@ export class TauriAdapter implements PlatformAdapter { getState(): unknown { try { - const raw = localStorage.getItem(TauriAdapter.STATE_KEY); - return activeSessionFromStored(raw ? JSON.parse(raw) : null); + return loadSessionState(localStorage, TauriAdapter.STATE_KEY); } catch { return null; }