Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/specs/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):

Expand Down
5 changes: 3 additions & 2 deletions docs/specs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
8 changes: 5 additions & 3 deletions docs/specs/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 2bimplemented, 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.

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/vscode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
39 changes: 39 additions & 0 deletions lib/src/lib/feature-flags.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const store = new Map<string, string>();
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();
});
});
});
37 changes: 37 additions & 0 deletions lib/src/lib/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -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.
}
}
100 changes: 100 additions & 0 deletions lib/src/lib/session-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PersistedPane, 'untouched'> & { untouched?: boolean };

interface PersistedSessionV3Input {
Expand Down Expand Up @@ -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)),
};
}
Loading
Loading