From 28fe85519d90728572e2329b709c2ed337963c63 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 18:14:44 -0700 Subject: [PATCH 01/13] Add the Workspace membership, session, and Wall-handle registries Stage A needs three module-global registries before any Wall can be Workspace-aware: which Surfaces belong to which Workspace (the piece `computeWorkspaceUnion` was missing, since the Activity store is window-wide), the per-Workspace `PersistedSession` collector that PR B will attach a writer to, and the imperative handle a mounted Wall exposes to the strip, the persistence owner, and the `dor` router. `workspace-store` gains the reorder verb and the positional `workspace:` ref mapping both the strip and the router resolve through; `terminal-state-store` gains the id-scoped running count the Workspace close confirmation asks of one Workspace. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- lib/src/components/wall/use-dor-control.ts | 4 +- lib/src/components/wall/wall-handles.ts | 67 ++++++++++++++++ lib/src/lib/terminal-registry.ts | 1 + lib/src/lib/terminal-state-store.ts | 9 +++ lib/src/lib/window-session-aggregator.test.ts | 80 +++++++++++++++++++ lib/src/lib/window-session-aggregator.ts | 57 +++++++++++++ lib/src/lib/workspace-store.test.ts | 35 ++++++++ lib/src/lib/workspace-store.ts | 43 ++++++++-- lib/src/lib/workspace-surfaces.test.ts | 56 +++++++++++++ lib/src/lib/workspace-surfaces.ts | 68 ++++++++++++++++ 10 files changed, 413 insertions(+), 7 deletions(-) create mode 100644 lib/src/components/wall/wall-handles.ts create mode 100644 lib/src/lib/window-session-aggregator.test.ts create mode 100644 lib/src/lib/window-session-aggregator.ts create mode 100644 lib/src/lib/workspace-surfaces.test.ts create mode 100644 lib/src/lib/workspace-surfaces.ts diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index f690af08c..4d48f5076 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -28,7 +28,7 @@ import { dorDirectionForEdge, type LathWallEngine } from './lath-wall-engine'; import type { WallNav } from './keyboard/types'; import type { CloseSurfaceMode, DooredItem } from './wall-types'; -type DorControlParams = { +export type DorControlParams = { command?: unknown; confirmation?: unknown; cwd?: unknown; @@ -61,7 +61,7 @@ type DorControlParams = { // A handler that parks (a long `dor await`) must listen to it and release // whatever it armed; nothing it responds with afterwards can reach the client. // Both are supplied by `lib/src/lib/platform/dor-control-dispatch.ts`. -type DorControlRequest = Omit & { +export type DorControlRequest = Omit & { params?: DorControlParams; respond: (response: DorControlResult) => void; /** Absent on the in-process dispatch path (and in tests), which has no diff --git a/lib/src/components/wall/wall-handles.ts b/lib/src/components/wall/wall-handles.ts new file mode 100644 index 000000000..3ad1fd0da --- /dev/null +++ b/lib/src/components/wall/wall-handles.ts @@ -0,0 +1,67 @@ +import type { PersistedSession, WorkspaceId } from '../../lib/session-types'; +import type { CloseSurfaceMode } from './wall-types'; +import type { DorControlRequest } from './use-dor-control'; + +/** + * The imperative surface a mounted `` exposes to code outside its React + * tree: the strip, the window-level persistence owner, and the `dor` router + * (`docs/specs/layout.md` → "Workspaces"). Every Wall registers one, a bare Wall + * under `DEFAULT_WORKSPACE_ID`, so exactly one handle answers a `dor` request + * even in the single-Workspace hosts. + */ +export interface WallHandle { + workspaceId: WorkspaceId; + /** The Wall's member Surfaces: visible panes ∪ Doors. */ + surfaceIds(): string[]; + ownsSurface(id: string): boolean; + /** Any member terminal Session the user has typed into (the close confirmation + * gate, alongside `runningCount`). */ + hasTouchedSurfaces(): boolean; + runningCount(): number; + serialize(): Promise; + flushPersistence(): Promise; + /** Put DOM focus back on this Wall's selected Surface, honoring its own mode. */ + focusSelected(): void; + /** Close every member Surface through the closure coordinator. Resolves null + * once the Wall is empty, else the first refusal's message with the Workspace + * left as it was. */ + closeAll(mode?: CloseSurfaceMode): Promise; + handleDorControl(detail: DorControlRequest): void; +} + +const handles = new Map(); + +/** + * Register a Wall's handle, replacing any entry under the same Workspace id. The + * returned disposer removes the entry only while it is still this handle, so + * StrictMode's mount/unmount/mount cannot deregister the live Wall. + */ +export function registerWallHandle(handle: WallHandle): () => void { + handles.set(handle.workspaceId, handle); + return () => { + if (handles.get(handle.workspaceId) === handle) handles.delete(handle.workspaceId); + }; +} + +export function getWallHandle(workspaceId: WorkspaceId): WallHandle | null { + return handles.get(workspaceId) ?? null; +} + +/** Every registered handle, in registration order. */ +export function listWallHandles(): WallHandle[] { + return [...handles.values()]; +} + +/** The handle whose Wall owns `surfaceId`, or null. A Wall answers false for a + * foreign id, so the first true answer is the owner. */ +export function wallHandleOwning(surfaceId: string): WallHandle | null { + for (const handle of handles.values()) { + if (handle.ownsSurface(surfaceId)) return handle; + } + return null; +} + +/** Forget every handle (tests). */ +export function resetWallHandles(): void { + handles.clear(); +} diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 6a0bbe028..b9f0069b6 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -96,6 +96,7 @@ export { export { applyTerminalSemanticEvents, countRunningSessions, + countRunningSessionsIn, ensureTerminalPaneState, fillTerminalProcessCwd, getRunningCommandArgv0, diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index f29d35f95..6b4fa8ba0 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -77,8 +77,17 @@ export function getRunningCommandArgv0(id: string): string | null { // shell at a prompt). The standalone quit orchestrator uses this to decide // whether a quit needs a confirmation (docs/specs/standalone.md §Quit flow). export function countRunningSessions(): number { + return countRunningSessionsIn(null); +} + +/** The same count restricted to `ids` — the Workspace close confirmation asks it + * of one Workspace's member Surfaces (`docs/specs/layout.md` → "Workspaces"). + * `null` means every Session in the Window. */ +export function countRunningSessionsIn(ids: Iterable | null): number { + const scope = ids === null ? null : new Set(ids); let count = 0; for (const [id, state] of paneStates) { + if (scope && !scope.has(id)) continue; const entry = registry.get(id); if (state.activity.kind === 'running' || (entry?.helper && !entry.exited && entry.helperBusy !== false)) count++; } diff --git a/lib/src/lib/window-session-aggregator.test.ts b/lib/src/lib/window-session-aggregator.test.ts new file mode 100644 index 000000000..16956110f --- /dev/null +++ b/lib/src/lib/window-session-aggregator.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + forgetWorkspaceSession, + getWindowSnapshot, + installWindowSessionWriter, + publishWorkspaceSession, + resetWindowSessionAggregator, +} from './window-session-aggregator'; +import type { PersistedSession } from './session-types'; +import { + createWorkspace, + moveWorkspace, + resetWorkspaces, + setActiveWorkspace, + getWorkspacesSnapshot, +} from './workspace-store'; + +function session(paneId: string): PersistedSession { + return { version: 3, panes: [{ id: paneId, title: paneId, cwd: null, untouched: true, alert: null }], doors: [] }; +} + +beforeEach(() => { + resetWindowSessionAggregator(); + resetWorkspaces(); +}); + +describe('window session aggregator', () => { + it('orders Workspaces by the store and carries the active id', () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + const second = createWorkspace({ name: 'Second' }).id; + publishWorkspaceSession(first, session('a')); + publishWorkspaceSession(second, session('b')); + + expect(getWindowSnapshot()).toMatchObject({ + version: 1, + activeWorkspaceId: second, + workspaces: [{ id: first }, { id: second, name: 'Second' }], + }); + + moveWorkspace(second, 0); + expect(getWindowSnapshot().workspaces.map((ws) => ws.id)).toEqual([second, first]); + setActiveWorkspace(first); + expect(getWindowSnapshot().activeWorkspaceId).toBe(first); + }); + + it('drops a Workspace that has published nothing rather than writing it empty', () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + createWorkspace({ name: 'Second' }); + publishWorkspaceSession(first, session('a')); + expect(getWindowSnapshot().workspaces.map((ws) => ws.id)).toEqual([first]); + }); + + it('forgets a Workspace session', () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + publishWorkspaceSession(first, session('a')); + forgetWorkspaceSession(first); + expect(getWindowSnapshot().workspaces).toEqual([]); + }); + + it('hands each snapshot to the installed writer until it is uninstalled', () => { + const write = vi.fn(); + const uninstall = installWindowSessionWriter(write); + const first = getWorkspacesSnapshot().workspaces[0].id; + publishWorkspaceSession(first, session('a')); + expect(write).toHaveBeenCalledTimes(1); + expect(write.mock.calls[0][0].workspaces).toHaveLength(1); + forgetWorkspaceSession(first); + expect(write).toHaveBeenCalledTimes(2); + forgetWorkspaceSession(first); + expect(write).toHaveBeenCalledTimes(2); + uninstall(); + publishWorkspaceSession(first, session('a')); + expect(write).toHaveBeenCalledTimes(2); + }); + + it('ships with no writer installed', () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + expect(() => publishWorkspaceSession(first, session('a'))).not.toThrow(); + }); +}); diff --git a/lib/src/lib/window-session-aggregator.ts b/lib/src/lib/window-session-aggregator.ts new file mode 100644 index 000000000..388cfe6df --- /dev/null +++ b/lib/src/lib/window-session-aggregator.ts @@ -0,0 +1,57 @@ +import { getWorkspacesSnapshot } from './workspace-store'; +import type { PersistedSession, PersistedWindow, PersistedWorkspace, WorkspaceId } from './session-types'; + +/** + * Collects each Workspace's latest `PersistedSession` into one `PersistedWindow` + * (`docs/specs/transport.md` → "Persisted session"). The Wall's persistence hook + * publishes here instead of writing the platform slot when it runs under a + * Workspace; the writer that turns snapshots into a host write is installed + * separately, and standalone installs none yet. + */ + +const sessions = new Map(); +let writer: ((snapshot: PersistedWindow) => void) | null = null; + +/** Record a Workspace's latest session and hand the whole Window to the writer. */ +export function publishWorkspaceSession(workspaceId: WorkspaceId, session: PersistedSession): void { + sessions.set(workspaceId, session); + writer?.(getWindowSnapshot()); +} + +/** Drop a Workspace's session (its Workspace was closed or moved away). */ +export function forgetWorkspaceSession(workspaceId: WorkspaceId): void { + if (!sessions.delete(workspaceId)) return; + writer?.(getWindowSnapshot()); +} + +/** + * The Window as it stands: Workspaces in strip order carrying the id, name, and + * latest published session of each. A Workspace whose Wall has published nothing + * yet is omitted rather than written empty, so a crash mid-boot cannot replace a + * restored layout with a blank one. + */ +export function getWindowSnapshot(): PersistedWindow { + const { workspaces, activeId } = getWorkspacesSnapshot(); + const collected: PersistedWorkspace[] = []; + for (const workspace of workspaces) { + const session = sessions.get(workspace.id); + if (!session) continue; + collected.push({ id: workspace.id, name: workspace.name, session }); + } + return { version: 1, workspaces: collected, activeWorkspaceId: activeId }; +} + +/** Install the sink that persists a Window snapshot; returns its uninstaller. + * Replace-on-repeat: only one writer is live, so a re-install cannot double-write. */ +export function installWindowSessionWriter(write: (snapshot: PersistedWindow) => void): () => void { + writer = write; + return () => { + if (writer === write) writer = null; + }; +} + +/** Forget every published session and any installed writer (tests). */ +export function resetWindowSessionAggregator(): void { + sessions.clear(); + writer = null; +} diff --git a/lib/src/lib/workspace-store.test.ts b/lib/src/lib/workspace-store.test.ts index 6b002691b..bb4f81ff8 100644 --- a/lib/src/lib/workspace-store.test.ts +++ b/lib/src/lib/workspace-store.test.ts @@ -4,11 +4,14 @@ import { createWorkspace, getActiveWorkspaceId, getWorkspacesSnapshot, + moveWorkspace, renameWorkspace, resetWorkspaces, setActiveWorkspace, setWorkspaces, subscribeToWorkspaces, + workspaceIdForRef, + workspaceRefFor, } from './workspace-store'; import { DEFAULT_WORKSPACE_ID, DEFAULT_WORKSPACE_NAME } from './session-types'; @@ -134,4 +137,36 @@ describe('workspace-store', () => { createWorkspace({ id: 'ws-3' }); expect(listener).toHaveBeenCalledTimes(1); }); + + it('moveWorkspace reorders and clamps, and reports whether the list changed', () => { + createWorkspace({ id: 'ws-2' }); + createWorkspace({ id: 'ws-3' }); + const ids = () => getWorkspacesSnapshot().workspaces.map((w) => w.id); + + expect(moveWorkspace('ws-3', 0)).toBe(true); + expect(ids()).toEqual(['ws-3', DEFAULT_WORKSPACE_ID, 'ws-2']); + // Clamped into range rather than refused. + expect(moveWorkspace('ws-3', 99)).toBe(true); + expect(ids()).toEqual([DEFAULT_WORKSPACE_ID, 'ws-2', 'ws-3']); + expect(moveWorkspace('ws-3', 2)).toBe(false); + expect(moveWorkspace('missing', 0)).toBe(false); + // Reordering never changes which Workspace is active. + expect(getActiveWorkspaceId()).toBe('ws-3'); + }); + + it('workspace refs are positional and renumber on reorder', () => { + createWorkspace({ id: 'ws-2' }); + expect(workspaceRefFor(DEFAULT_WORKSPACE_ID)).toBe('workspace:1'); + expect(workspaceRefFor('ws-2')).toBe('workspace:2'); + expect(workspaceRefFor('missing')).toBeNull(); + expect(workspaceIdForRef('workspace:2')).toBe('ws-2'); + expect(workspaceIdForRef('2')).toBe('ws-2'); + expect(workspaceIdForRef('workspace:9')).toBeNull(); + expect(workspaceIdForRef('workspace:0')).toBeNull(); + expect(workspaceIdForRef('nonsense')).toBeNull(); + + moveWorkspace('ws-2', 0); + expect(workspaceRefFor('ws-2')).toBe('workspace:1'); + expect(workspaceIdForRef('workspace:1')).toBe('ws-2'); + }); }); diff --git a/lib/src/lib/workspace-store.ts b/lib/src/lib/workspace-store.ts index 20a9f10a0..cc85874ce 100644 --- a/lib/src/lib/workspace-store.ts +++ b/lib/src/lib/workspace-store.ts @@ -1,11 +1,10 @@ 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. + * In-memory model of the Window's Workspaces: the ordered list, which one is + * active, and the container verbs (`docs/specs/glossary.md`). `WorkspaceWindow` + * mounts one Wall per entry and the standalone strip renders it; both subscribe + * through `useSyncExternalStore`. */ export interface WorkspaceMeta { @@ -125,6 +124,40 @@ export function closeWorkspace(id: WorkspaceId): boolean { return true; } +/** + * Move a Workspace to `toIndex` (clamped into range), keeping every other + * Workspace's relative order. Returns whether the list changed. Reordering + * renumbers `workspace:` refs, which are positional by design + * (`docs/specs/dor-cli.md` → "Handle Model"). + */ +export function moveWorkspace(id: WorkspaceId, toIndex: number): boolean { + const from = state.workspaces.findIndex((ws) => ws.id === id); + if (from === -1) return false; + const to = Math.max(0, Math.min(state.workspaces.length - 1, Math.trunc(toIndex))); + if (from === to) return false; + const workspaces = [...state.workspaces]; + const [moved] = workspaces.splice(from, 1); + workspaces.splice(to, 0, moved); + emit({ ...state, workspaces }); + return true; +} + +/** The only Window this build addresses; `window:` beyond it is an error. */ +export const WINDOW_REF = 'window:1'; + +/** A Workspace's positional `dor` ref, or null when it is not in this Window. */ +export function workspaceRefFor(id: WorkspaceId): string | null { + const index = state.workspaces.findIndex((ws) => ws.id === id); + return index === -1 ? null : `workspace:${index + 1}`; +} + +/** Resolve `workspace:` or a bare `` (1-based) to a Workspace id. */ +export function workspaceIdForRef(ref: string): WorkspaceId | null { + const match = /^(?:workspace:)?([1-9]\d*)$/.exec(ref.trim()); + if (!match) return null; + return state.workspaces[Number(match[1]) - 1]?.id ?? null; +} + /** Reset to the single default Workspace (fresh start / tests). */ export function resetWorkspaces(): void { emit(defaultState()); diff --git a/lib/src/lib/workspace-surfaces.test.ts b/lib/src/lib/workspace-surfaces.test.ts new file mode 100644 index 000000000..e5755d1ec --- /dev/null +++ b/lib/src/lib/workspace-surfaces.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearWorkspaceSurfaces, + getWorkspaceSurfacesSnapshot, + resetWorkspaceSurfaces, + setWorkspaceSurfaces, + subscribeToWorkspaceSurfaces, + workspaceIdForSurface, +} from './workspace-surfaces'; + +beforeEach(() => { + resetWorkspaceSurfaces(); +}); + +describe('workspace membership store', () => { + it('keeps the snapshot reference stable when the ids are element-wise equal', () => { + setWorkspaceSurfaces('ws-1', ['a', 'b']); + const first = getWorkspaceSurfacesSnapshot(); + setWorkspaceSurfaces('ws-1', ['a', 'b']); + expect(getWorkspaceSurfacesSnapshot()).toBe(first); + setWorkspaceSurfaces('ws-1', ['a', 'c']); + expect(getWorkspaceSurfacesSnapshot()).not.toBe(first); + }); + + it('notifies only on a real change, and on clear', () => { + const listener = vi.fn(); + const unsubscribe = subscribeToWorkspaceSurfaces(listener); + setWorkspaceSurfaces('ws-1', ['a']); + setWorkspaceSurfaces('ws-1', ['a']); + expect(listener).toHaveBeenCalledTimes(1); + clearWorkspaceSurfaces('ws-1'); + expect(listener).toHaveBeenCalledTimes(2); + clearWorkspaceSurfaces('ws-1'); + expect(listener).toHaveBeenCalledTimes(2); + unsubscribe(); + setWorkspaceSurfaces('ws-1', ['b']); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it('resolves a Surface to the Workspace that published it', () => { + setWorkspaceSurfaces('ws-1', ['a', 'b']); + setWorkspaceSurfaces('ws-2', ['c']); + expect(workspaceIdForSurface('b')).toBe('ws-1'); + expect(workspaceIdForSurface('c')).toBe('ws-2'); + expect(workspaceIdForSurface('missing')).toBeNull(); + clearWorkspaceSurfaces('ws-2'); + expect(workspaceIdForSurface('c')).toBeNull(); + }); + + it('copies the published array so a later mutation by the caller cannot leak in', () => { + const ids = ['a']; + setWorkspaceSurfaces('ws-1', ids); + ids.push('b'); + expect(getWorkspaceSurfacesSnapshot().get('ws-1')).toEqual(['a']); + }); +}); diff --git a/lib/src/lib/workspace-surfaces.ts b/lib/src/lib/workspace-surfaces.ts new file mode 100644 index 000000000..d82c76059 --- /dev/null +++ b/lib/src/lib/workspace-surfaces.ts @@ -0,0 +1,68 @@ +import type { WorkspaceId } from './session-types'; + +/** + * Which Surfaces belong to which Workspace, published by every mounted Wall on + * each Lath commit (`docs/specs/layout.md` → "Workspaces"). The Activity store is + * module-global and spans every Workspace, so this membership map is what turns it + * into a per-Workspace projection (`computeWorkspaceUnion` in + * `lib/src/lib/workspace-union.ts`). + */ + +type Membership = ReadonlyMap; + +let membership: Membership = new Map(); +const listeners = new Set<() => void>(); + +function emit(next: Membership): void { + membership = next; + listeners.forEach((listener) => listener()); +} + +function sameIds(a: readonly string[] | undefined, b: readonly string[]): boolean { + if (!a || a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false; + return true; +} + +/** Publish a Workspace's member Surfaces (panes ∪ doors). Element-wise equal input + * is dropped, so a Wall may call this on every commit without waking the strip. */ +export function setWorkspaceSurfaces(workspaceId: WorkspaceId, surfaceIds: readonly string[]): void { + if (sameIds(membership.get(workspaceId), surfaceIds)) return; + const next = new Map(membership); + next.set(workspaceId, [...surfaceIds]); + emit(next); +} + +/** Drop a Workspace's membership entirely (its Wall is gone). */ +export function clearWorkspaceSurfaces(workspaceId: WorkspaceId): void { + if (!membership.has(workspaceId)) return; + const next = new Map(membership); + next.delete(workspaceId); + emit(next); +} + +/** Stable snapshot reference (changes only on mutation) for `useSyncExternalStore`. */ +export function getWorkspaceSurfacesSnapshot(): Membership { + return membership; +} + +export function subscribeToWorkspaceSurfaces(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** The Workspace a Surface belongs to, or null when no Wall claims it. */ +export function workspaceIdForSurface(surfaceId: string): WorkspaceId | null { + for (const [workspaceId, ids] of membership) { + if (ids.includes(surfaceId)) return workspaceId; + } + return null; +} + +/** Forget every Workspace's membership (tests). */ +export function resetWorkspaceSurfaces(): void { + if (membership.size === 0) return; + emit(new Map()); +} From c092eaf0108137aa8e2745e2b52bca6541b9aef5 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 18:39:45 -0700 Subject: [PATCH 02/13] Make one Wall a Workspace: gating, handle, closeAll, persistence seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Wall may now be one of several mounted at once. Everything that assumed it was the page's only Wall is scoped: the notepad meta-resolver is a set whose first non-null answer is the owning Wall's; window input, host New Terminal, the blur that clears attention, and the modal hosts all defer to the visible Workspace; `useSurfaceVisibility` idles a hidden Workspace's screencasts; and the GL context claim is deferred to a Workspace's first activation so the budget scales with visited Workspaces. The per-Wall `dormouse:control-request` listener is replaced by one window listener in `dor-control-router.ts`, which picks the answering Wall: an explicit `workspace:`, else the caller's own Workspace, else the active one. Every Wall registers a handle — a bare one under `DEFAULT_WORKSPACE_ID` — so the single-Wall hosts are unchanged. `saveSession` gains a sink so a Workspace's record is compared against and published beside its own previous record rather than the Window's active one; the aggregator's writer is still uninstalled. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- lib/src/components/TerminalPane.tsx | 17 +- lib/src/components/Wall.test.tsx | 14 + lib/src/components/Wall.tsx | 249 ++++- .../wall/AgentBrowserPanel.test.tsx | 52 +- .../wall/dor-control-router.test.ts | 123 +++ lib/src/components/wall/dor-control-router.ts | 77 ++ .../keyboard/handle-pane-shortcuts.test.ts | 1 + lib/src/components/wall/keyboard/types.ts | 8 +- lib/src/components/wall/use-dor-control.ts | 877 +++++++++--------- .../wall/use-session-persistence.ts | 78 +- .../components/wall/use-surface-visibility.ts | 16 +- lib/src/components/wall/use-wall-keyboard.ts | 5 + lib/src/components/wall/wall-context.tsx | 6 + lib/src/components/wall/wall-types.ts | 19 + lib/src/lib/notepad/close-coordinator.test.ts | 4 +- lib/src/lib/notepad/notepad-store.test.ts | 29 +- lib/src/lib/notepad/notepad-store.ts | 34 +- lib/src/lib/session-save.ts | 77 +- lib/src/lib/terminal-lifecycle.ts | 22 +- lib/src/lib/terminal-registry.ts | 1 + 20 files changed, 1158 insertions(+), 551 deletions(-) create mode 100644 lib/src/components/wall/dor-control-router.test.ts create mode 100644 lib/src/components/wall/dor-control-router.ts diff --git a/lib/src/components/TerminalPane.tsx b/lib/src/components/TerminalPane.tsx index e863cc4fe..be4a450c5 100644 --- a/lib/src/components/TerminalPane.tsx +++ b/lib/src/components/TerminalPane.tsx @@ -1,6 +1,7 @@ -import { useEffect, useRef } from 'react'; +import { useContext, useEffect, useRef } from 'react'; import '@xterm/xterm/css/xterm.css'; import { + claimWebglRenderer, getOrCreateTerminal, mountElement, unmountElement, @@ -12,6 +13,7 @@ import { SelectionPopup } from './SelectionPopup'; import { MouseOverrideBanner } from './wall/MouseOverrideBanner'; import { TERMINAL_BOTTOM_RADIUS_CLASS } from './design'; import { throttleTrailing } from '../lib/throttle'; +import { WorkspaceActiveContext } from './wall/wall-context'; interface TerminalPaneProps { id: string; @@ -34,13 +36,18 @@ const REFIT_THROTTLE_MS = 150; */ export function TerminalPane({ id, isFocused = true }: TerminalPaneProps) { const containerRef = useRef(null); + const workspaceActive = useContext(WorkspaceActiveContext); + // Read through a ref so the mount effect keeps its `[id]` deps: a Workspace + // activation must not remount the terminal, only claim its GL context. + const workspaceActiveRef = useRef(workspaceActive); + workspaceActiveRef.current = workspaceActive; useEffect(() => { const container = containerRef.current; if (!container) return; getOrCreateTerminal(id); - mountElement(id, container); + mountElement(id, container, { claimWebgl: workspaceActiveRef.current }); // Throttled (see REFIT_THROTTLE_MS) so animated/dragged geometry doesn't // reflow the buffer on every frame. @@ -57,6 +64,12 @@ export function TerminalPane({ id, isFocused = true }: TerminalPaneProps) { }; }, [id]); + // A Workspace's first activation claims the GL context its hidden mount + // deferred (docs/specs/layout.md → "Workspaces"); already-claimed is a no-op. + useEffect(() => { + if (workspaceActive) claimWebglRenderer(id); + }, [id, workspaceActive]); + useEffect(() => { focusSession(id, isFocused); }, [id, isFocused]); diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 8b8379a02..b7baaebdc 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -23,6 +23,8 @@ import { __resetArchiveServiceForTests } from '../lib/notepad/archive-service'; import { addPlainNote, beginClosing, clearAllNotepads, getNotes } from '../lib/notepad/notepad-store'; import type { NotepadArchiveV1 } from '../lib/notepad/types'; import { createTerminalPaneState, type TerminalPaneState } from '../lib/terminal-state'; +import { getWallHandle, listWallHandles } from './wall/wall-handles'; +import { DEFAULT_WORKSPACE_ID } from '../lib/session-types'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -2053,4 +2055,16 @@ describe('Wall on the Lath engine', () => { HTMLElement.prototype.getBoundingClientRect = origRect; } }); + + it('registers exactly one handle, under the default Workspace, for a bare Wall', async () => { + await act(async () => root.render()); + await flush(); + // The compatibility rule: a Wall with no `workspaceId` still registers, so + // the `dor` router always finds one (docs/specs/layout.md → "Workspaces"). + expect(listWallHandles()).toHaveLength(1); + const handle = getWallHandle(DEFAULT_WORKSPACE_ID)!; + expect(handle.surfaceIds()).toEqual(['pane-a']); + expect(handle.ownsSurface('pane-a')).toBe(true); + expect(handle.ownsSurface('pane-elsewhere')).toBe(false); + }); }); diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index ce29bf16d..85daeb4ac 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -27,7 +27,7 @@ import { KILL_CONFIRM_MS, KILL_SHAKE_MS, KillConfirmOverlay, randomKillChar, typ import { NotepadArchiveFailureModal, type NotepadArchiveFailure } from './NotepadArchiveFailure'; import { messageOf } from '../lib/errors'; import { archiveSurfaceNotes } from '../lib/notepad/close-coordinator'; -import { beginClosing, isSurfaceClosing, removeSurface, setNotepadSurfaceMetaResolver, transferNotepad } from '../lib/notepad/notepad-store'; +import { beginClosing, isSurfaceClosing, registerNotepadSurfaceMetaResolver, removeSurface, transferNotepad } from '../lib/notepad/notepad-store'; import { clearSessionAttention, clearLocalSurfaceActivity, @@ -44,6 +44,8 @@ import { getActivitySnapshot, isUntouched, getOrCreateTerminal, + getTerminalInstance, + countRunningSessionsIn, setTerminalUserTitle, UNNAMED_PANEL_TITLE, type SessionStatus, @@ -62,7 +64,11 @@ import type { SurfaceView as DorSurfaceView, } from 'dor/commands/types'; import { hasBrowser, hasTerminal } from 'dor/commands/types'; -import type { PersistedDoor, PersistedSurfaceRefs } from '../lib/session-types'; +import { DEFAULT_WORKSPACE_ID, type PersistedDoor, type PersistedSurfaceRefs, type WorkspaceId } from '../lib/session-types'; +import { clearWorkspaceSurfaces, setWorkspaceSurfaces } from '../lib/workspace-surfaces'; +import { WINDOW_REF, workspaceRefFor } from '../lib/workspace-store'; +import { registerWallHandle, type WallHandle } from './wall/wall-handles'; +import { installDorControlRouter } from './wall/dor-control-router'; import type { DropTarget, RestoreToken } from '../lib/lath/ops'; import type { Edge } from '../lib/lath/model'; import { useDynamicPalette } from '../lib/themes/use-dynamic-palette'; @@ -96,6 +102,7 @@ import { DialogKeyboardContext, DoorElementsContext, ModeContext, + WorkspaceActiveContext, PaneElementsContext, PaneWriteContext, WallActionsContext, @@ -108,7 +115,7 @@ import { type PaneWriteActions, type WallActions, } from './wall/wall-context'; -import type { CloseSurfaceMode, DoorAfterRestoreAction, DoorChip, DooredItem, WallEvent, WallMode, WallSelectionKind } from './wall/wall-types'; +import type { CloseSurfaceMode, DoorAfterRestoreAction, DoorChip, DooredItem, WallEvent, WallMode, WallSelectionKind, WorkspaceCommands } from './wall/wall-types'; type ShellSpawnRequest = { shell?: string; @@ -124,11 +131,12 @@ type ShellSpawnNoticeState = { nonce: number; }; -export type { DoorAfterRestoreAction, DoorChip, DooredItem, WallEvent, WallMode, WallSelectionKind } from './wall/wall-types'; +export type { DoorAfterRestoreAction, DoorChip, DooredItem, WallEvent, WallMode, WallSelectionKind, WorkspaceCommands } from './wall/wall-types'; export { DialogKeyboardContext, DoorElementsContext, ModeContext, + WorkspaceActiveContext, WallActionsContext, RenamingIdContext, SelectedIdContext, @@ -252,6 +260,9 @@ export function Wall({ dialogHost, showBaseboard = true, enableBurrow = false, + workspaceId, + active = true, + workspaceCommands, }: { initialPaneIds?: string[]; initialMode?: WallMode; @@ -279,7 +290,27 @@ export function Wall({ * `window.dormouseBurrow` console hook never load there. */ enableBurrow?: boolean; + /** + * The Workspace this Wall renders. Absent means the host mounts one Wall for + * the whole page (VS Code, the website playground): it still registers a + * handle, under `DEFAULT_WORKSPACE_ID`, so the `dor` router always finds it. + */ + workspaceId?: WorkspaceId; + /** + * Whether this Wall's Workspace is the visible one. An inactive Wall stays + * mounted and live but dispatches no window input and renders no modal host + * (docs/specs/layout.md → "Workspaces"). + */ + active?: boolean; + /** The Window's Workspace verbs, for the command-mode Workspace shortcuts. + * Absent (a bare Wall) leaves those keys unbound. */ + workspaceCommands?: WorkspaceCommands; } = {}) { + const effectiveWorkspaceId = workspaceId ?? DEFAULT_WORKSPACE_ID; + const activeRef = useRef(active); + activeRef.current = active; + const workspaceCommandsRef = useRef(workspaceCommands); + workspaceCommandsRef.current = workspaceCommands; const [terminalContext, setTerminalContext] = useState(null); // Remove a closing context once its exit has played. A reopen or replacement // changes the state object, so the cleanup cancels the stale removal; the @@ -831,8 +862,10 @@ export function Wall({ // Surface kind, and the Session's live CWD. An archive batch and the volatile // mirror both read through this, so they describe a Surface identically // (docs/specs/notepad.md → "Closure"). + // Every mounted Wall registers one; a Wall answers null for a Surface it does + // not own, so the resolver set resolves to the owning Workspace's answer. useEffect(() => { - setNotepadSurfaceMetaResolver((surfaceId) => { + return registerNotepadSurfaceMetaResolver((surfaceId: string) => { const meta = lath.getMeta(surfaceId); if (!meta) return null; const kind = surfaceKindFromParams(meta.params); @@ -846,7 +879,6 @@ export function Wall({ cwd: getTerminalPaneState(surfaceId).cwd, }; }); - return () => setNotepadSurfaceMetaResolver(null); }, [lath]); // A refused closure owns the keyboard while it is up, like the other modal @@ -863,6 +895,7 @@ export function Wall({ // on a real blur, else focusing an iframe wipes attention // (docs/specs/layout.md → Corner cases #2). const handleBlur = () => { + if (!activeRef.current) return; if (document.hasFocus()) return; clearSessionAttention(); }; @@ -907,11 +940,32 @@ export function Wall({ for (const id of paneIds) fireEvent({ type: 'paneAdded', id }); }, [lath, generatePaneId, fireEvent, surfaceRefForId]); + /** Whether a `closeAll` is walking this Wall's Surfaces. It short-circuits the + * auto-spawn refill, which would otherwise repopulate the Workspace being + * destroyed the moment its last pane goes. */ + const closingWorkspaceRef = useRef(false); + + /** Restore the Wall's "always one pane" rule after a commit empties the tree + * (last pane killed or minimized). A no-op while the tree is non-empty. */ + const refillEmptyTree = useCallback(() => { + if (lath.store.getSnapshot().tree.root !== null) return; + const id = generatePaneId(); + surfaceRefForId(id); + const defaults = getDefaultShellOpts(); + if (defaults?.shell) setPendingShellOpts(id, { shell: defaults.shell, args: defaults.args }); + lath.store.setEnterHint(id, 'top-left'); // grows from the top-left as the killed pane shrank to the bottom-right + lath.store.addLeaf(id, terminalLeafMeta(), null); // becomes the root + // Adopt selection only when it points at nothing real: null, or dangling (a + // just-killed pane). A live door (last pane minimized) keeps selection. + const sel = selectedIdRef.current; + const selDangling = sel !== null && selectedTypeRef.current === 'pane' && !lath.store.has(sel); + if (sel === null || selDangling) selectPane(id); + }, [lath, generatePaneId, surfaceRefForId, selectPane]); + // Auto-spawn: whenever a commit empties the tree (last pane killed/minimized), // spawn one to keep a pane visible — the Wall's "always one pane" rule. useEffect(() => { return lath.store.subscribe(() => { - const snap = lath.store.getSnapshot(); // `paneAdded` for any leaf new since the last commit. Runs post-commit, so the // pane exists. Meta/zoom/resize commits leave the id set unchanged (no fire). // The auto-spawn below commits re-entrantly, so its new leaf is caught here too. @@ -927,31 +981,72 @@ export function Wall({ // The size check also catches pure removals, purging dead ids so a later // re-add of the same id fires again. if (leavesChanged) prevLeafIdsRef.current = new Set(currentIds); - if (snap.tree.root !== null) return; - const id = generatePaneId(); - surfaceRefForId(id); - const defaults = getDefaultShellOpts(); - if (defaults?.shell) setPendingShellOpts(id, { shell: defaults.shell, args: defaults.args }); - lath.store.setEnterHint(id, 'top-left'); // grows from the top-left as the killed pane shrank to the bottom-right - lath.store.addLeaf(id, terminalLeafMeta(), null); // becomes the root - // Adopt selection only when it points at nothing real: null, or dangling (a - // just-killed pane). A live door (last pane minimized) keeps selection. - const sel = selectedIdRef.current; - const selDangling = sel !== null && selectedTypeRef.current === 'pane' && !lath.store.has(sel); - if (sel === null || selDangling) selectPane(id); + // Publish membership for the union projection: the Activity store is + // window-wide, so this map is what scopes it to one Workspace. + setWorkspaceSurfaces(effectiveWorkspaceId, [...currentIds, ...doorsRef.current.map((door) => door.id)]); + if (closingWorkspaceRef.current) return; + refillEmptyTree(); }); - }, [lath, generatePaneId, surfaceRefForId, selectPane, fireEvent]); + }, [lath, fireEvent, refillEmptyTree, effectiveWorkspaceId]); + + // Doors change without a leaf-id change (minimize keeps the leaf parked, a kill + // of a doored Surface removes only the chip), so publish on that edge too. + useEffect(() => { + setWorkspaceSurfaces(effectiveWorkspaceId, [...lath.store.leafIds(), ...doors.map((door) => door.id)]); + }, [doors, lath, effectiveWorkspaceId]); // --- Session persistence --- - useSessionPersistence({ + const persistence = useSessionPersistence({ lath, doors, doorsRef, selectedIdRef, selectedTypeRef, surfaceRefsForSave, + workspaceId, + // A Wall inside a Window leaves the host flush to `WorkspaceWindow`: the + // adapter's first `notifySessionFlushComplete` wins, so N Walls answering + // would let a quit proceed after only the first had written. + ownsHostFlush: workspaceId === undefined, }); + /** This Wall's member Surfaces: visible panes then Doors. */ + const memberSurfaceIds = useCallback( + (): string[] => [...lath.store.leafIds(), ...doorsRef.current.map((door) => door.id)], + [lath], + ); + + /** + * Close every Surface in this Workspace, each through the same coordinator a + * manual close uses (helper guard → notepad archive → kill, + * docs/specs/notepad.md → "Closure"). Resolves null once the Wall is empty and + * safe to unmount, or the first refusal's message with the Workspace left as + * it was — the strip then reveals it so the refusal is visible. + */ + const closeAll = useCallback(async (mode: CloseSurfaceMode = 'prompt'): Promise => { + closingWorkspaceRef.current = true; + for (const id of memberSurfaceIds()) { + // Re-checked per iteration: an earlier closure can take a Surface with it + // (a helper's source, a replaced leaf). + if (!lath.store.has(id) && !doorsRef.current.some((door) => door.id === id)) continue; + const refusal = await closeSurfaceRef.current(id, mode); + if (refusal) { + closingWorkspaceRef.current = false; + refillEmptyTree(); + return refusal; + } + } + // `killPaneImmediately` defers the tree removal by the exit animation; + // unmounting the Wall before that lands would leave Orphaned Sessions + // (docs/specs/glossary.md → I4). Bounded so a stuck fade cannot hang a quit. + const deadline = Date.now() + lath.exitMs + 50; + while (lath.store.leafIds().length > 0 || doorsRef.current.length > 0) { + if (Date.now() >= deadline) break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return null; + }, [lath, memberSurfaceIds, refillEmptyTree]); + // --- Dev-server port → pane correlation (browser header connection chip) --- useDevServerPortCorrelation({ lath, doorsRef }); @@ -1325,6 +1420,9 @@ export function Wall({ // Listen for external "new terminal" requests (e.g. from the standalone AppBar) useEffect(() => { const handler = (e: Event) => { + // Host New Terminal (and shell replacement) targets the Workspace the user + // is looking at, not every mounted one. + if (!activeRef.current) return; const detail = ((e as CustomEvent).detail ?? {}) as ShellSpawnRequest; const newId = generatePaneId(); surfaceRefForId(newId); @@ -1389,7 +1487,7 @@ export function Wall({ }, [generatePaneId, surfaceRefForId, forgetSurfaceRef, selectPane, enterTerminalMode, showShellSpawnNotice, lath, nav]); // --- dor control plane (the `dor` CLI's webview handler) --- - const { findSurfaceByParams, updateSurfaceParams } = useDorControl({ + const { findSurfaceByParams, updateSurfaceParams, handleDorControl } = useDorControl({ lath, nav, doorsRef, @@ -1401,8 +1499,88 @@ export function Wall({ isClosingSurface, closeSurface, lastAgentBrowserBinaryPathRef, + workspaceRef: useCallback( + () => workspaceRefFor(effectiveWorkspaceId) ?? 'workspace:1', + [effectiveWorkspaceId], + ), + windowRef: useCallback(() => WINDOW_REF, []), }); + // --- Workspace handle --- + + /** Put DOM focus back on this Wall's selection, honoring its own mode: each + * Workspace keeps the mode it was left in across a switch. */ + const focusSelected = useCallback(() => { + const id = selectedIdRef.current; + if (!id || selectedTypeRef.current !== 'pane' || !nav.hasPane(id)) return; + focusSession(id, modeRef.current === 'passthrough'); + }, [nav]); + + // The methods are rebuilt each render so they close over current state; the + // handle the registry holds is one stable object delegating to them, so a + // re-render never replaces a registered entry. + const handleMethodsRef = useRef | null>(null); + handleMethodsRef.current = { + surfaceIds: memberSurfaceIds, + ownsSurface: (id) => lath.store.has(id) || doorsRef.current.some((door) => door.id === id), + hasTouchedSurfaces: () => memberSurfaceIds().some((id) => { + // A browser Surface has no "untouched" notion and always holds a page, so + // it counts; a terminal counts once its Session exists and has input. + if (!hasTerminal(surfaceKindFromParams(lath.getMeta(id)?.params))) return true; + return getTerminalInstance(id) !== null && !isReplaceableShell(id); + }), + runningCount: () => countRunningSessionsIn(memberSurfaceIds()), + serialize: () => persistence.buildSession(), + flushPersistence: () => persistence.flush(), + focusSelected, + closeAll, + handleDorControl, + }; + const handleRef = useRef(null); + if (handleRef.current === null) { + handleRef.current = { + workspaceId: effectiveWorkspaceId, + surfaceIds: () => handleMethodsRef.current!.surfaceIds(), + ownsSurface: (id) => handleMethodsRef.current!.ownsSurface(id), + hasTouchedSurfaces: () => handleMethodsRef.current!.hasTouchedSurfaces(), + runningCount: () => handleMethodsRef.current!.runningCount(), + serialize: () => handleMethodsRef.current!.serialize(), + flushPersistence: () => handleMethodsRef.current!.flushPersistence(), + focusSelected: () => handleMethodsRef.current!.focusSelected(), + closeAll: (mode) => handleMethodsRef.current!.closeAll(mode), + handleDorControl: (detail) => handleMethodsRef.current!.handleDorControl(detail), + }; + } + + useEffect(() => { + const handle = handleRef.current!; + const unregister = registerWallHandle(handle); + // The router is the one window listener for `dor` requests; every Wall holds + // a share of it so a lone Wall installs it too. + const releaseRouter = installDorControlRouter(); + return () => { + unregister(); + releaseRouter(); + clearWorkspaceSurfaces(handle.workspaceId); + }; + }, []); + + // Focus handoff on a switch. Deactivating blurs the selected pane; activating + // focuses it a frame later, since focus into a hidden subtree is a no-op. + // Skipped on mount (`active` has not changed), so a bare Wall is untouched. + const prevActiveRef = useRef(active); + useEffect(() => { + if (prevActiveRef.current === active) return; + prevActiveRef.current = active; + if (!active) { + const id = selectedIdRef.current; + if (id && selectedTypeRef.current === 'pane' && nav.hasPane(id)) focusSession(id, false); + return; + } + const frame = requestAnimationFrame(focusSelected); + return () => cancelAnimationFrame(frame); + }, [active, focusSelected, nav]); + const addSplitPanel = useCallback(( id: string | null, direction: 'right' | 'below', @@ -1700,6 +1878,8 @@ export function Wall({ useWallKeyboard({ nav, + activeRef, + workspaces: workspaceCommands, swapWithNeighbor, modeRef, selectedIdRef, @@ -1792,6 +1972,7 @@ export function Wall({ // --- Render --- return ( + @@ -1863,14 +2044,23 @@ export function Wall({ version={paneElementsVersion} /> - - - {enableBurrow ? ( - - - + {/* Modal hosts belong to the visible Workspace only — each is a + window-level dialog, and each needs THIS Wall's + `DialogKeyboardContext` to suppress command-mode dispatch, so + they are gated rather than hoisted. Their state lives in stores, + so it survives a switch. */} + {active ? ( + <> + + + {enableBurrow ? ( + + + + ) : null} + {dialogHost} + ) : null} - {dialogHost} @@ -1884,5 +2074,6 @@ export function Wall({ + ); } diff --git a/lib/src/components/wall/AgentBrowserPanel.test.tsx b/lib/src/components/wall/AgentBrowserPanel.test.tsx index 9daf53f00..b39e0d9e0 100644 --- a/lib/src/components/wall/AgentBrowserPanel.test.tsx +++ b/lib/src/components/wall/AgentBrowserPanel.test.tsx @@ -10,7 +10,7 @@ import type { PaneProps } from './pane-props'; import { AgentBrowserPanel, HIDDEN_PARK_DELAY_MS } from './AgentBrowserPanel'; import { getAgentBrowserScreenController } from './agent-browser-screen'; import { disposeAllAgentBrowserSurfaceControllers } from './agent-browser-surface-controller'; -import { ModeContext, PaneWriteContext, SelectedIdContext, WallActionsContext, type PaneWriteActions } from './wall-context'; +import { ModeContext, PaneWriteContext, SelectedIdContext, WallActionsContext, WorkspaceActiveContext, type PaneWriteActions } from './wall-context'; import { stubWallActions as stubActions } from './wall-test-utils'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -439,11 +439,11 @@ describe('AgentBrowserPanel render mode controller', () => { }); describe('AgentBrowserPanel visibility parking', () => { - // Two things hide a surface (`useSurfaceVisibility`): a backgrounded window, and a - // PARKED leaf — minimized, so mounted but out of the tree - // (docs/specs/tiling-engine.md → "Parked leaves"). A window transition is a + // Three things hide a surface (`useSurfaceVisibility`): a backgrounded window, + // a hidden Workspace, and a PARKED leaf — minimized, so mounted but out of the + // tree (docs/specs/tiling-engine.md → "Parked leaves"). A window transition is a // `visibilitychange` event, driven here by overriding `document.visibilityState`; - // a park transition is the `parked` pane prop, driven by re-rendering. + // a Workspace and a park transition are both props, driven by re-rendering. function setDocumentHidden(hidden: boolean): void { Object.defineProperty(document, 'visibilityState', { configurable: true, @@ -453,16 +453,22 @@ describe('AgentBrowserPanel visibility parking', () => { async function renderVisibilityPanel( params: TestPanelParams, - ): Promise<{ setVisible: (visible: boolean) => void; setParked: (parked: boolean) => void }> { + ): Promise<{ + setVisible: (visible: boolean) => void; + setParked: (parked: boolean) => void; + setWorkspaceActive: (active: boolean) => void; + }> { setDocumentHidden(false); // mount on-screen - const render = (parked: boolean) => { + const render = (parked: boolean, workspaceActive = true) => { root.render( - {})}> - - - - + + {})}> + + + + + , ); }; @@ -473,6 +479,7 @@ describe('AgentBrowserPanel visibility parking', () => { document.dispatchEvent(new Event('visibilitychange')); }, setParked: (parked) => { render(parked); }, + setWorkspaceActive: (active) => { render(false, active); }, }; } @@ -526,6 +533,27 @@ describe('AgentBrowserPanel visibility parking', () => { expect(liveStreamSocket(4321)?.readyState).toBe(1); }); + it('idles a screencast whose Workspace is hidden, and resumes it on return', async () => { + const { setWorkspaceActive } = await renderVisibilityPanel({ + surfaceType: 'browser', session: 'browser-session', wsPort: 4321, + }); + + const socket = liveStreamSocket(4321); + expect(socket?.readyState).toBe(1); + const before = streamSockets(4321).length; + + // The Wall stays mounted and live; only its visibility changed. + await act(async () => { setWorkspaceActive(false); }); + await act(async () => { await vi.advanceTimersByTimeAsync(HIDDEN_PARK_DELAY_MS + 50); }); + expect(socket?.readyState).toBe(3); + expect(streamSockets(4321).length).toBe(before); + + await act(async () => { setWorkspaceActive(true); }); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + expect(streamSockets(4321).length).toBeGreaterThan(before); + expect(liveStreamSocket(4321)?.readyState).toBe(1); + }); + it('never queries stream status while parked', async () => { const streamStatus = vi.fn(async () => ({ ok: false })); const platform = new FakePtyAdapter() as FakePtyAdapter & Pick; diff --git a/lib/src/components/wall/dor-control-router.test.ts b/lib/src/components/wall/dor-control-router.test.ts new file mode 100644 index 000000000..00b0d4aff --- /dev/null +++ b/lib/src/components/wall/dor-control-router.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { installDorControlRouter, resolveDorControlRoute } from './dor-control-router'; +import { registerWallHandle, resetWallHandles, type WallHandle } from './wall-handles'; +import type { DorControlRequest } from './use-dor-control'; +import { createWorkspace, getWorkspacesSnapshot, resetWorkspaces, setActiveWorkspace } from '../../lib/workspace-store'; + +const disposers: Array<() => void> = []; + +function handleFor(workspaceId: string, ownedSurfaceIds: string[] = []): WallHandle & { handleDorControl: ReturnType } { + const handle = { + workspaceId, + surfaceIds: () => [...ownedSurfaceIds], + ownsSurface: (id: string) => ownedSurfaceIds.includes(id), + hasTouchedSurfaces: () => false, + runningCount: () => 0, + serialize: async () => ({ version: 3 as const, panes: [], doors: [] }), + flushPersistence: async () => {}, + focusSelected: () => {}, + closeAll: async () => null, + handleDorControl: vi.fn(), + }; + disposers.push(registerWallHandle(handle)); + return handle; +} + +function request(overrides: Partial = {}): DorControlRequest & { respond: ReturnType } { + return { + requestId: 'r1', + method: 'surface.list', + respond: vi.fn(), + ...overrides, + } as DorControlRequest & { respond: ReturnType }; +} + +beforeEach(() => { + resetWallHandles(); + resetWorkspaces(); +}); + +afterEach(() => { + disposers.splice(0).forEach((dispose) => dispose()); +}); + +describe('dor control routing', () => { + it('delivers to the Wall that owns the caller, not the active one', () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + const second = createWorkspace({ id: 'ws-2' }).id; // becomes active + const owner = handleFor(first, ['pane-a']); + handleFor(second, ['pane-b']); + expect(resolveDorControlRoute(request({ surfaceId: 'pane-a' }))).toEqual({ kind: 'handle', handle: owner }); + }); + + it('falls back to the active Workspace for an unknown caller', () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + const second = createWorkspace({ id: 'ws-2' }).id; + handleFor(first); + const active = handleFor(second); + expect(resolveDorControlRoute(request({ surfaceId: 'gone' }))).toEqual({ kind: 'handle', handle: active }); + expect(resolveDorControlRoute(request())).toEqual({ kind: 'handle', handle: active }); + setActiveWorkspace(first); + expect(resolveDorControlRoute(request()).kind).toBe('handle'); + expect((resolveDorControlRoute(request()) as { handle: WallHandle }).handle.workspaceId).toBe(first); + }); + + it('routes an explicit workspace target positionally, over the caller', () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + createWorkspace({ id: 'ws-2' }); + const target = handleFor('ws-2'); + handleFor(first, ['pane-a']); + for (const value of ['workspace:2', '2']) { + expect(resolveDorControlRoute(request({ surfaceId: 'pane-a', params: { workspace: value } }))) + .toEqual({ kind: 'handle', handle: target }); + } + }); + + it('errors on a workspace or window target this Window does not have', () => { + handleFor(getWorkspacesSnapshot().workspaces[0].id); + expect(resolveDorControlRoute(request({ params: { workspace: 'workspace:9' } }))) + .toEqual({ kind: 'error', message: "unknown workspace target 'workspace:9'" }); + expect(resolveDorControlRoute(request({ params: { window: 'window:2' } }))) + .toEqual({ kind: 'error', message: "unknown window target 'window:2'" }); + // The only Window this build addresses still resolves, in both spellings. + expect(resolveDorControlRoute(request({ params: { window: 'window:1' } })).kind).toBe('handle'); + expect(resolveDorControlRoute(request({ params: { window: '1' } })).kind).toBe('handle'); + }); + + it('does nothing when no Wall is mounted', () => { + expect(resolveDorControlRoute(request())).toEqual({ kind: 'none' }); + }); + + it('shares one window listener across every Wall that holds it', () => { + const handle = handleFor(getWorkspacesSnapshot().workspaces[0].id); + const releaseA = installDorControlRouter(); + const releaseB = installDorControlRouter(); + const detail = request(); + const dispatch = () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail })); + + dispatch(); + expect(handle.handleDorControl).toHaveBeenCalledTimes(1); + + // One release of two leaves the listener installed; the second removes it. + releaseA(); + releaseA(); // idempotent — must not double-decrement + dispatch(); + expect(handle.handleDorControl).toHaveBeenCalledTimes(2); + releaseB(); + dispatch(); + expect(handle.handleDorControl).toHaveBeenCalledTimes(2); + }); + + it('answers a bad container target instead of handing it to a Wall', () => { + const handle = handleFor(getWorkspacesSnapshot().workspaces[0].id); + const release = installDorControlRouter(); + const detail = request({ params: { workspace: 'workspace:9' } }); + window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail })); + expect(handle.handleDorControl).not.toHaveBeenCalled(); + expect(detail.respond).toHaveBeenCalledWith({ ok: false, error: "unknown workspace target 'workspace:9'" }); + release(); + }); +}); diff --git a/lib/src/components/wall/dor-control-router.ts b/lib/src/components/wall/dor-control-router.ts new file mode 100644 index 000000000..6df167f62 --- /dev/null +++ b/lib/src/components/wall/dor-control-router.ts @@ -0,0 +1,77 @@ +import { getActiveWorkspaceId, WINDOW_REF, workspaceIdForRef } from '../../lib/workspace-store'; +import { getWallHandle, wallHandleOwning, type WallHandle } from './wall-handles'; +import type { DorControlRequest } from './use-dor-control'; + +/** + * The one window listener for `dormouse:control-request`, deciding which Wall + * answers (`docs/specs/dor-cli.md` → "Handle Model"). It replaces the per-Wall + * listener, which would have every mounted Workspace answer the same request. + */ + +/** Where one control request lands. */ +export type DorControlRoute = + | { kind: 'handle'; handle: WallHandle } + | { kind: 'error'; message: string } + /** Nothing is mounted that could answer; the request is left to time out. */ + | { kind: 'none' }; + +/** + * Resolution order: an explicit container target, else the caller's own + * Workspace, else the active one. `surface:` targets are resolved by the chosen + * Wall, within its own Workspace. + */ +export function resolveDorControlRoute(detail: DorControlRequest): DorControlRoute { + const params = detail.params ?? {}; + if (params.window !== undefined && params.window !== WINDOW_REF && params.window !== '1') { + return { kind: 'error', message: `unknown window target '${params.window}'` }; + } + if (params.workspace !== undefined) { + const workspaceId = workspaceIdForRef(params.workspace); + if (!workspaceId) return { kind: 'error', message: `unknown workspace target '${params.workspace}'` }; + const handle = getWallHandle(workspaceId); + return handle ? { kind: 'handle', handle } : { kind: 'error', message: `unknown workspace target '${params.workspace}'` }; + } + // The caller's own Workspace: `dor split` from a background Workspace lands + // beside its caller, not in whichever Workspace the user is looking at. + const owner = detail.surfaceId ? wallHandleOwning(detail.surfaceId) : null; + if (owner) return { kind: 'handle', handle: owner }; + // An unknown caller (a shell started outside Dormouse, a killed Surface's + // late request) is served by the Workspace the user is in. + const active = getWallHandle(getActiveWorkspaceId()); + return active ? { kind: 'handle', handle: active } : { kind: 'none' }; +} + +let installCount = 0; +let listener: ((event: Event) => void) | null = null; + +/** + * Install the router's window listener, reference-counted so N Walls share one. + * Returns its (idempotent) release. + */ +export function installDorControlRouter(): () => void { + installCount += 1; + if (installCount === 1) { + listener = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail) return; + const route = resolveDorControlRoute(detail); + if (route.kind === 'error') { + detail.respond({ ok: false, error: route.message }); + return; + } + if (route.kind === 'none') return; + route.handle.handleDorControl(detail); + }; + window.addEventListener('dormouse:control-request', listener); + } + let released = false; + return () => { + if (released) return; + released = true; + installCount -= 1; + if (installCount === 0 && listener) { + window.removeEventListener('dormouse:control-request', listener); + listener = null; + } + }; +} diff --git a/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts b/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts index 5d92fc07d..aa65a9920 100644 --- a/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts @@ -40,6 +40,7 @@ function makeNav(overrides: Partial = {}): WallKeyboardC function makeCtx(overrides: Partial = {}): WallKeyboardCtx { return { nav: makeNav(), + activeRef: { current: true }, swapWithNeighbor: vi.fn(), modeRef: { current: 'command' }, selectedIdRef: { current: 'pane-a' }, diff --git a/lib/src/components/wall/keyboard/types.ts b/lib/src/components/wall/keyboard/types.ts index 48da3fd6f..4721090b3 100644 --- a/lib/src/components/wall/keyboard/types.ts +++ b/lib/src/components/wall/keyboard/types.ts @@ -1,6 +1,6 @@ import type { Dispatch, RefObject, SetStateAction } from 'react'; import type { ConfirmKill } from '../../KillConfirm'; -import type { DoorAfterRestoreAction, DooredItem, WallEvent, WallMode, WallSelectionKind } from '../wall-types'; +import type { DoorAfterRestoreAction, DooredItem, WallEvent, WallMode, WallSelectionKind, WorkspaceCommands } from '../wall-types'; import type { WallActions } from '../wall-context'; /** The navigation/query seam the keyboard handlers read, backed by the Lath engine @@ -20,6 +20,12 @@ export interface WallNav { * signatures on each handler. */ export interface WallKeyboardCtx { nav: WallNav; + /** Whether this Wall's Workspace is the visible one. Listeners stay per Wall; + * only dispatch is gated, so a hidden Workspace sees no window input. */ + activeRef: RefObject; + /** The Window's Workspace verbs. Absent on a bare Wall, which leaves the + * Workspace keys unbound. */ + workspaces?: WorkspaceCommands; /** Swap two panes' surfaces (Cmd-Arrow): swap leaf identities (meta follows ids, * so no companion title swap). */ swapWithNeighbor: (fromId: string, toId: string) => void; diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index 4d48f5076..dcda879f8 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, type MutableRefObject } from 'react'; +import { useCallback, type MutableRefObject } from 'react'; import { getPlatform, PLATFORM_STRING } from '../../lib/platform'; import type { DorControlRequestPayload, DorControlResult } from 'dor/protocol'; import { SURFACE_CONTROL_METHODS } from 'dor/protocol'; @@ -97,14 +97,6 @@ type EnsureAgentBrowserSurface = (args: { minimized?: boolean; }) => EnsureAgentBrowserSurfaceResult; -function isSingletonWorkspaceTarget(target: string | undefined): boolean { - return !target || target === 'workspace:1' || target === '1'; -} - -function isSingletonWindowTarget(target: string | undefined): boolean { - return !target || target === 'window:1' || target === '1'; -} - function matchesDorSurfaceTarget( target: string | undefined, surface: DorSurface, @@ -373,6 +365,8 @@ export function useDorControl({ isClosingSurface, closeSurface, lastAgentBrowserBinaryPathRef, + workspaceRef, + windowRef, }: { /** The Lath engine — visible-pane projection (`lath.listPanes()`), aspect-ratio * split resolution (`autoEdgeFor`), and per-leaf param writes. */ @@ -411,6 +405,10 @@ export function useDorControl({ closeSurface: (id: string, mode?: CloseSurfaceMode) => Promise; /** The last binary path a `dor ab` surface resolved on a terminal's PATH. */ lastAgentBrowserBinaryPathRef: MutableRefObject; + /** This Wall's own container refs, reported by `dor list` so a caller learns + * which Workspace answered (docs/specs/dor-cli.md → "Handle Model"). */ + workspaceRef: () => string; + windowRef: () => string; }): { /** The live surface (visible pane or minimized door) whose params match, or * null. Shared with the context's port launches in Wall.tsx. */ @@ -419,6 +417,9 @@ export function useDorControl({ * one write path a background daemon boot uses to hand a session-less pane * its `{session, wsPort, binaryPath}`. */ updateSurfaceParams: (id: string, patch: Record) => void; + /** Run one `dor` request against this Wall. `dor-control-router.ts` owns the + * window listener that chooses which Wall's handler runs. */ + handleDorControl: (detail: DorControlRequest) => void; } { const resolveVisibleSurface = useCallback(( target: string | undefined, @@ -586,138 +587,115 @@ export function useDorControl({ }, [createContentSurface, findAgentBrowserSurface, updateSurfaceParams, surfaceRefForId]); - useEffect(() => { - const handler = async (event: Event) => { - const detail = (event as CustomEvent).detail; - if (!detail) return; + // The request handler itself. The window listener that picks WHICH Wall runs it + // lives in `dor-control-router.ts`, so exactly one Workspace answers. + const handleDorControl = useCallback(async (detail: DorControlRequest) => { + const params = detail.params ?? {}; + + // Resolve the split reference surface across listed Surfaces. A minimized + // reference is valid: the Wall creates the new split as a sibling Door. + const resolveSplitTarget = () => { + const target = resolveListedSurface(stringParam(params.surface), detail.surfaceId); + if (!target.ok) { + detail.respond({ ok: false, error: target.message }); + return null; + } + return { target: target.value }; + }; + + // The `direction: 'auto'` aspect-ratio split resolution. + const autoDorDirection = (surface: DorSurface): DorResolvedSplitDirection => + nav.hasPane(surface.id) ? dorDirectionForEdge(lath.store.autoEdgeFor(surface.id)) : 'right'; + + if (detail.method === SURFACE_CONTROL_METHODS.list) { + const matched = buildDorSurfaceList() + .filter((surface) => matchesDorSurfaceTarget(params.pane, surface, detail.surfaceId)); + const surfaces = booleanParam(params.includePorts) + ? await attachSurfacePorts(matched) + : matched; + detail.respond({ + ok: true, + result: { + surfaces, + workspaceRef: workspaceRef(), + windowRef: windowRef(), + }, + }); + return; + } - const params = detail.params ?? {}; - if (!isSingletonWorkspaceTarget(params.workspace)) { - detail.respond({ ok: false, error: `unsupported workspace target '${params.workspace}'` }); + if (detail.method === SURFACE_CONTROL_METHODS.split) { + const directionParam = parseDorSplitDirection(params.direction); + if (!directionParam) { + detail.respond({ ok: false, error: `invalid split direction '${String(params.direction)}'` }); return; } - if (!isSingletonWindowTarget(params.window)) { - detail.respond({ ok: false, error: `unsupported window target '${params.window}'` }); + const resolved = resolveSplitTarget(); + if (!resolved) return; + const direction = directionParam === 'auto' + ? autoDorDirection(resolved.target) + : directionParam; + const command = dorCommandString(stringArrayParam(params.command)); + if (params.command !== undefined && !command) { + detail.respond({ ok: false, error: 'command cannot be empty' }); return; } - - // Resolve the split reference surface across listed Surfaces. A minimized - // reference is valid: the Wall creates the new split as a sibling Door. - const resolveSplitTarget = () => { - const target = resolveListedSurface(stringParam(params.surface), detail.surfaceId); - if (!target.ok) { - detail.respond({ ok: false, error: target.message }); - return null; - } - return { target: target.value }; - }; - - // The `direction: 'auto'` aspect-ratio split resolution. - const autoDorDirection = (surface: DorSurface): DorResolvedSplitDirection => - nav.hasPane(surface.id) ? dorDirectionForEdge(lath.store.autoEdgeFor(surface.id)) : 'right'; - - if (detail.method === SURFACE_CONTROL_METHODS.list) { - const matched = buildDorSurfaceList() - .filter((surface) => matchesDorSurfaceTarget(params.pane, surface, detail.surfaceId)); - const surfaces = booleanParam(params.includePorts) - ? await attachSurfacePorts(matched) - : matched; - detail.respond({ - ok: true, - result: { - surfaces, - workspaceRef: 'workspace:1', - windowRef: 'window:1', - }, - }); + const result = createSplitSurface({ + command, + direction, + minimized: booleanParam(params.minimized), + reference: resolved.target, + // The CLI computes the focus intent — a bare `dor split` steals focus; + // a `--` tail or an initial command does not — and sends it as + // focusNeutral. Honor it. + focusNeutral: booleanParam(params.focusNeutral), + }); + if (!result.ok) { + detail.respond({ ok: false, error: result.message }); return; } - - if (detail.method === SURFACE_CONTROL_METHODS.split) { - const directionParam = parseDorSplitDirection(params.direction); - if (!directionParam) { - detail.respond({ ok: false, error: `invalid split direction '${String(params.direction)}'` }); - return; - } - const resolved = resolveSplitTarget(); - if (!resolved) return; - const direction = directionParam === 'auto' - ? autoDorDirection(resolved.target) - : directionParam; - const command = dorCommandString(stringArrayParam(params.command)); - if (params.command !== undefined && !command) { - detail.respond({ ok: false, error: 'command cannot be empty' }); - return; - } - const result = createSplitSurface({ - command, + detail.respond({ + ok: true, + result: { + status: 'created', + surfaceId: result.value.id, + surfaceRef: result.value.ref, direction, - minimized: booleanParam(params.minimized), - reference: resolved.target, - // The CLI computes the focus intent — a bare `dor split` steals focus; - // a `--` tail or an initial command does not — and sends it as - // focusNeutral. Honor it. - focusNeutral: booleanParam(params.focusNeutral), - }); - if (!result.ok) { - detail.respond({ ok: false, error: result.message }); - return; - } - detail.respond({ - ok: true, - result: { - status: 'created', - surfaceId: result.value.id, - surfaceRef: result.value.ref, - direction, - minimized: result.value.minimized, - ...(command ? { command } : {}), - }, - }); + minimized: result.value.minimized, + ...(command ? { command } : {}), + }, + }); + return; + } + + if (detail.method === SURFACE_CONTROL_METHODS.ensure) { + if (detail.signal?.aborted) { + detail.respond({ ok: false, error: ENSURE_CANCELLED }); return; } - - if (detail.method === SURFACE_CONTROL_METHODS.ensure) { - if (detail.signal?.aborted) { - detail.respond({ ok: false, error: ENSURE_CANCELLED }); - return; - } - const command = dorCommandString(stringArrayParam(params.command)); - if (!command) { - detail.respond({ ok: false, error: 'command cannot be empty' }); - return; - } - const cwd = stringParam(params.cwd)?.trim(); - if (!cwd) { - detail.respond({ ok: false, error: 'cwd is required' }); - return; - } - const existingId = findSurfaceIdRunningCommand(command, cwd); - if (existingId) { - const minimized = doorsRef.current.some((door) => door.id === existingId); - if (booleanParam(params.restart)) { - const restarted = await restartSurfaceInPlace(existingId, command, cwd, detail.signal); - if (!restarted.ok) { - detail.respond({ ok: false, error: `surface '${surfaceRefForId(existingId)}' ${restarted.message}` }); - return; - } - detail.respond({ - ok: true, - result: { - status: 'restarted', - surfaceId: existingId, - surfaceRef: surfaceRefForId(existingId), - command, - cwd, - minimized, - }, - }); + const command = dorCommandString(stringArrayParam(params.command)); + if (!command) { + detail.respond({ ok: false, error: 'command cannot be empty' }); + return; + } + const cwd = stringParam(params.cwd)?.trim(); + if (!cwd) { + detail.respond({ ok: false, error: 'cwd is required' }); + return; + } + const existingId = findSurfaceIdRunningCommand(command, cwd); + if (existingId) { + const minimized = doorsRef.current.some((door) => door.id === existingId); + if (booleanParam(params.restart)) { + const restarted = await restartSurfaceInPlace(existingId, command, cwd, detail.signal); + if (!restarted.ok) { + detail.respond({ ok: false, error: `surface '${surfaceRefForId(existingId)}' ${restarted.message}` }); return; } detail.respond({ ok: true, result: { - status: 'existing', + status: 'restarted', surfaceId: existingId, surfaceRef: surfaceRefForId(existingId), command, @@ -727,365 +705,374 @@ export function useDorControl({ }); return; } - // ensure needs OSC 633 to track the command. cmd.exe provably has none, - // so when the configured shell is explicitly cmd, fail immediately without - // even spawning a split. Only short-circuit on an explicit shell — an - // unset shell classifies as 'cmd' on Windows but the sidecar may actually - // spawn PowerShell, so let those fall through to the generic OSC wait. - const ensureShell = getDefaultShellOpts()?.shell; - if (ensureShell && shellCommandKind(ensureShell, PLATFORM_STRING) === 'cmd') { - detail.respond({ ok: false, error: missingIntegrationError(ensureShell) }); - return; - } - const resolved = resolveSplitTarget(); - if (!resolved) return; - const direction = autoDorDirection(resolved.target); - const result = createSplitSurface({ - command, - direction, - minimized: booleanParam(params.minimized), - reference: resolved.target, - cwd, - requireIntegration: true, - // ensure never steals focus from the caller, matched or freshly created. - focusNeutral: true, - }); - if (!result.ok) { - detail.respond({ ok: false, error: result.message }); - return; - } - // ensure is only useful if the new shell reports OSC 633 — otherwise it - // can never be matched or restarted. A non-cmd shell can still lack - // integration (misconfigured, exotic); wait for the signal, and if it - // never arrives kill the throwaway split and fail cleanly rather than - // half-run an untrackable command. typeCommandWhenPromptReady drops the - // command in the same case, so nothing executes. - const integrated = await waitForTerminalState( - result.value.id, - () => isPaneOscDriven(result.value.id), - INTEGRATION_DETECT_TIMEOUT_MS, - detail.signal, - ); - if (detail.signal?.aborted || integrated !== 'ready') { - // The temporary pane is visible during integration detection and may - // have acquired notes. Preserve the ordinary closure contract even - // when the client has gone away (docs/specs/notepad.md → "Closure"). - const reason = detail.signal?.aborted || integrated === 'aborted' ? ENSURE_CANCELLED : missingIntegrationError(ensureShell); - const refused = await closeSurface(result.value.id, 'silent'); - detail.respond({ ok: false, error: refused ? `${reason}; temporary surface kept open: ${refused}` : reason }); - return; - } detail.respond({ ok: true, result: { - status: 'created', - surfaceId: result.value.id, - surfaceRef: result.value.ref, + status: 'existing', + surfaceId: existingId, + surfaceRef: surfaceRefForId(existingId), command, cwd, - minimized: result.value.minimized, + minimized, }, }); return; } - - if (detail.method === SURFACE_CONTROL_METHODS.send) { - const input = stringParam(params.input); - if (input === undefined) { - detail.respond({ ok: false, error: 'input is required' }); - return; - } - const target = requireTerminalSurface(params.surface, detail); - if (!target) return; - getPlatform().writePty(target.id, input); - detail.respond({ - ok: true, - result: { - status: 'sent', - surfaceId: target.id, - surfaceRef: target.ref, - inputCount: typeof params.inputCount === 'number' ? params.inputCount : 1, - }, - }); + // ensure needs OSC 633 to track the command. cmd.exe provably has none, + // so when the configured shell is explicitly cmd, fail immediately without + // even spawning a split. Only short-circuit on an explicit shell — an + // unset shell classifies as 'cmd' on Windows but the sidecar may actually + // spawn PowerShell, so let those fall through to the generic OSC wait. + const ensureShell = getDefaultShellOpts()?.shell; + if (ensureShell && shellCommandKind(ensureShell, PLATFORM_STRING) === 'cmd') { + detail.respond({ ok: false, error: missingIntegrationError(ensureShell) }); return; } - - if (detail.method === SURFACE_CONTROL_METHODS.read) { - const target = requireTerminalSurface(params.surface, detail); - if (!target) return; - const lines = numberParam(params.lines); - const scrollback = booleanParam(params.scrollback); - const text = readSurfaceText(target.id, lines, scrollback); - detail.respond({ - ok: true, - result: { - workspaceRef: 'workspace:1', - surfaceId: target.id, - surfaceRef: target.ref, - text, - }, - }); + const resolved = resolveSplitTarget(); + if (!resolved) return; + const direction = autoDorDirection(resolved.target); + const result = createSplitSurface({ + command, + direction, + minimized: booleanParam(params.minimized), + reference: resolved.target, + cwd, + requireIntegration: true, + // ensure never steals focus from the caller, matched or freshly created. + focusNeutral: true, + }); + if (!result.ok) { + detail.respond({ ok: false, error: result.message }); + return; + } + // ensure is only useful if the new shell reports OSC 633 — otherwise it + // can never be matched or restarted. A non-cmd shell can still lack + // integration (misconfigured, exotic); wait for the signal, and if it + // never arrives kill the throwaway split and fail cleanly rather than + // half-run an untrackable command. typeCommandWhenPromptReady drops the + // command in the same case, so nothing executes. + const integrated = await waitForTerminalState( + result.value.id, + () => isPaneOscDriven(result.value.id), + INTEGRATION_DETECT_TIMEOUT_MS, + detail.signal, + ); + if (detail.signal?.aborted || integrated !== 'ready') { + // The temporary pane is visible during integration detection and may + // have acquired notes. Preserve the ordinary closure contract even + // when the client has gone away (docs/specs/notepad.md → "Closure"). + const reason = detail.signal?.aborted || integrated === 'aborted' ? ENSURE_CANCELLED : missingIntegrationError(ensureShell); + const refused = await closeSurface(result.value.id, 'silent'); + detail.respond({ ok: false, error: refused ? `${reason}; temporary surface kept open: ${refused}` : reason }); return; } + detail.respond({ + ok: true, + result: { + status: 'created', + surfaceId: result.value.id, + surfaceRef: result.value.ref, + command, + cwd, + minimized: result.value.minimized, + }, + }); + return; + } - // `dor await` — park until the Session finishes what it is doing - // (`docs/specs/alert.md` → Await). Everything that makes this a *wait* — - // the wake condition, the grace window, the `timeoutMs` ceiling, and the - // absorption of the completion it consumes — lives in the host's - // `AlertManager`; this branch only validates, parks, and reports. - if (detail.method === SURFACE_CONTROL_METHODS.await) { - const target = requireTerminalSurface(params.surface, detail); - if (!target) return; - const until = params.until; - if (until !== 'quiet' && until !== 'exit') { - detail.respond({ ok: false, error: `invalid await condition '${String(until)}'` }); - return; - } - // The host re-checks this, but a bad ceiling there settles `cancelled` - // silently (no response ever reaches the caller); rejecting here turns - // that into a visible error. - const timeoutMs = numberParam(params.timeoutMs); - if (timeoutMs === undefined || timeoutMs <= 0 || timeoutMs > MAX_AWAIT_TIMEOUT_MS) { - detail.respond({ ok: false, error: `timeoutMs must be a positive number no greater than ${MAX_AWAIT_TIMEOUT_MS}` }); - return; - } + if (detail.method === SURFACE_CONTROL_METHODS.send) { + const input = stringParam(params.input); + if (input === undefined) { + detail.respond({ ok: false, error: 'input is required' }); + return; + } + const target = requireTerminalSurface(params.surface, detail); + if (!target) return; + getPlatform().writePty(target.id, input); + detail.respond({ + ok: true, + result: { + status: 'sent', + surfaceId: target.id, + surfaceRef: target.ref, + inputCount: typeof params.inputCount === 'number' ? params.inputCount : 1, + }, + }); + return; + } - const handle = getPlatform().alertAwait(target.id, { until, timeoutMs }); - // The client hung up (Ctrl-C) or the control server's deadline passed: - // release the wait so it stops absorbing completions nobody can receive. - // Guarded because in-process callers may dispatch a request without one. - detail.signal?.addEventListener('abort', () => handle.cancel()); + if (detail.method === SURFACE_CONTROL_METHODS.read) { + const target = requireTerminalSurface(params.surface, detail); + if (!target) return; + const lines = numberParam(params.lines); + const scrollback = booleanParam(params.scrollback); + const text = readSurfaceText(target.id, lines, scrollback); + detail.respond({ + ok: true, + result: { + workspaceRef: workspaceRef(), + surfaceId: target.id, + surfaceRef: target.ref, + text, + }, + }); + return; + } - const outcome = await handle.promise; - // `cancelled` has no wire outcome of its own — it means the host tore - // the wait down (manager disposed, webview released). Answering with an - // error rather than returning silently is what forgets the request: - // `respond` is the only thing that clears `dor-control-dispatch`'s - // in-flight entry, and a client that is somehow still listening gets an - // answer instead of blocking to its own deadline. - if (outcome.kind === 'cancelled') { - detail.respond({ ok: false, error: `await on '${target.ref}' was cancelled by the host` }); - return; - } - detail.respond({ - ok: true, - result: { - workspaceRef: 'workspace:1', - surfaceId: target.id, - surfaceRef: target.ref, - outcome: outcome.kind, - ...(outcome.kind === 'resolved' ? { cause: outcome.cause } : {}), - // The host measured the wait; re-measuring here would only add the - // transport hop and disagree with what it absorbed. - waitedMs: outcome.waitedMs, - }, - }); + // `dor await` — park until the Session finishes what it is doing + // (`docs/specs/alert.md` → Await). Everything that makes this a *wait* — + // the wake condition, the grace window, the `timeoutMs` ceiling, and the + // absorption of the completion it consumes — lives in the host's + // `AlertManager`; this branch only validates, parks, and reports. + if (detail.method === SURFACE_CONTROL_METHODS.await) { + const target = requireTerminalSurface(params.surface, detail); + if (!target) return; + const until = params.until; + if (until !== 'quiet' && until !== 'exit') { + detail.respond({ ok: false, error: `invalid await condition '${String(until)}'` }); + return; + } + // The host re-checks this, but a bad ceiling there settles `cancelled` + // silently (no response ever reaches the caller); rejecting here turns + // that into a visible error. + const timeoutMs = numberParam(params.timeoutMs); + if (timeoutMs === undefined || timeoutMs <= 0 || timeoutMs > MAX_AWAIT_TIMEOUT_MS) { + detail.respond({ ok: false, error: `timeoutMs must be a positive number no greater than ${MAX_AWAIT_TIMEOUT_MS}` }); return; } - if (detail.method === SURFACE_CONTROL_METHODS.kill) { - const confirmation = killConfirmationParam(params.confirmation); - if (!confirmation) { - detail.respond({ ok: false, error: 'invalid kill confirmation' }); - return; - } - const target = requireListedSurface(params.surface, detail); - if (!target) return; - if (confirmation.mode === 'if-read') { - const text = readSurfaceText(target.id, undefined, false); - if (!text.includes(confirmation.text)) { - detail.respond({ ok: false, error: `surface '${target.ref}' read text did not contain confirmation text` }); - return; - } - } - // `dor kill` is a user-visible permanent closure, so it archives the - // Surface's notes first. A refused archive leaves the Surface running - // and answers with the error rather than silently dropping the notes — - // and raises no pane prompt, because the caller is a command, not - // someone looking at the Wall (docs/specs/notepad.md → "Closure"). - const refused = await closeSurface(target.id, 'silent'); - if (refused) { - detail.respond({ ok: false, error: refused }); - return; - } - detail.respond({ - ok: true, - result: { - status: 'killed', - surfaceId: target.id, - surfaceRef: target.ref, - }, - }); + const handle = getPlatform().alertAwait(target.id, { until, timeoutMs }); + // The client hung up (Ctrl-C) or the control server's deadline passed: + // release the wait so it stops absorbing completions nobody can receive. + // Guarded because in-process callers may dispatch a request without one. + detail.signal?.addEventListener('abort', () => handle.cancel()); + + const outcome = await handle.promise; + // `cancelled` has no wire outcome of its own — it means the host tore + // the wait down (manager disposed, webview released). Answering with an + // error rather than returning silently is what forgets the request: + // `respond` is the only thing that clears `dor-control-dispatch`'s + // in-flight entry, and a client that is somehow still listening gets an + // answer instead of blocking to its own deadline. + if (outcome.kind === 'cancelled') { + detail.respond({ ok: false, error: `await on '${target.ref}' was cancelled by the host` }); return; } + detail.respond({ + ok: true, + result: { + workspaceRef: workspaceRef(), + surfaceId: target.id, + surfaceRef: target.ref, + outcome: outcome.kind, + ...(outcome.kind === 'resolved' ? { cause: outcome.cause } : {}), + // The host measured the wait; re-measuring here would only add the + // transport hop and disagree with what it absorbed. + waitedMs: outcome.waitedMs, + }, + }); + return; + } - if (detail.method === SURFACE_CONTROL_METHODS.iframe) { - const raw = stringParam(params.url); - if (!raw) { - detail.respond({ ok: false, error: 'url is required' }); - return; - } - // The control socket is a wire protocol, not the CLI: `dor iframe` - // validates its argument, but anything holding the control token - // reaches this method directly (`browserSurfaceUrl`). - const url = browserSurfaceUrl(raw); - if (!url) { - detail.respond({ ok: false, error: 'url must be an http:// or https:// URL' }); - return; - } - const target = resolveVisibleSurface(stringParam(params.surface), detail.surfaceId); - if (!target.ok) { - detail.respond({ ok: false, error: target.message }); - return; - } - const result = createContentSurface({ - minimized: booleanParam(params.minimized), - params: { surfaceType: 'browser', renderMode: 'iframe', url }, - reference: target.value, - title: hostPathDisplay(url, true), - // `dor iframe` opens the embed in the background; caller keeps focus. - focusNeutral: true, - }); - if (!result.ok) { - detail.respond({ ok: false, error: result.message }); + if (detail.method === SURFACE_CONTROL_METHODS.kill) { + const confirmation = killConfirmationParam(params.confirmation); + if (!confirmation) { + detail.respond({ ok: false, error: 'invalid kill confirmation' }); + return; + } + const target = requireListedSurface(params.surface, detail); + if (!target) return; + if (confirmation.mode === 'if-read') { + const text = readSurfaceText(target.id, undefined, false); + if (!text.includes(confirmation.text)) { + detail.respond({ ok: false, error: `surface '${target.ref}' read text did not contain confirmation text` }); return; } - detail.respond({ - ok: true, - result: { - status: result.value.status, - surfaceId: result.value.id, - surfaceRef: result.value.ref, - url, - minimized: booleanParam(params.minimized), - }, - }); + } + // `dor kill` is a user-visible permanent closure, so it archives the + // Surface's notes first. A refused archive leaves the Surface running + // and answers with the error rather than silently dropping the notes — + // and raises no pane prompt, because the caller is a command, not + // someone looking at the Wall (docs/specs/notepad.md → "Closure"). + const refused = await closeSurface(target.id, 'silent'); + if (refused) { + detail.respond({ ok: false, error: refused }); return; } + detail.respond({ + ok: true, + result: { + status: 'killed', + surfaceId: target.id, + surfaceRef: target.ref, + }, + }); + return; + } - if (detail.method === SURFACE_CONTROL_METHODS.agentBrowser) { - const session = stringParam(params.session); - if (!session) { - detail.respond({ ok: false, error: 'session is required' }); - return; - } - // `binaryPath` names a program the host will spawn and is persisted into - // the pane's params, so it is checked before it is stored rather than - // only at the spawn (`lib/src/lib/agent-browser-binary.ts`). - // - // Dropped rather than fatal, like `allowedBinaryPath` in - // agent-browser-surface-controller.ts and `runWithBinaryFallback`: the - // host resolves its own candidate instead, and it can accept a path - // this realm cannot — `DORMOUSE_AGENT_BROWSER_BIN` matches by exact - // value, and only the host can read its own environment. Refusing the - // request here would mean no browser surface at all for an operator who - // set that variable to a differently-named wrapper. - const requestedBinaryPath = stringParam(params.binaryPath); - const binaryPath = isAllowedAgentBrowserBinary(requestedBinaryPath) - ? requestedBinaryPath - : undefined; - const result = ensureAgentBrowserSurface({ - key: stringParam(params.key), - session, - wsPort: numberParam(params.wsPort), - binaryPath, - reference: () => resolveVisibleSurface(stringParam(params.surface), detail.surfaceId), + if (detail.method === SURFACE_CONTROL_METHODS.iframe) { + const raw = stringParam(params.url); + if (!raw) { + detail.respond({ ok: false, error: 'url is required' }); + return; + } + // The control socket is a wire protocol, not the CLI: `dor iframe` + // validates its argument, but anything holding the control token + // reaches this method directly (`browserSurfaceUrl`). + const url = browserSurfaceUrl(raw); + if (!url) { + detail.respond({ ok: false, error: 'url must be an http:// or https:// URL' }); + return; + } + const target = resolveVisibleSurface(stringParam(params.surface), detail.surfaceId); + if (!target.ok) { + detail.respond({ ok: false, error: target.message }); + return; + } + const result = createContentSurface({ + minimized: booleanParam(params.minimized), + params: { surfaceType: 'browser', renderMode: 'iframe', url }, + reference: target.value, + title: hostPathDisplay(url, true), + // `dor iframe` opens the embed in the background; caller keeps focus. + focusNeutral: true, + }); + if (!result.ok) { + detail.respond({ ok: false, error: result.message }); + return; + } + detail.respond({ + ok: true, + result: { + status: result.value.status, + surfaceId: result.value.id, + surfaceRef: result.value.ref, + url, minimized: booleanParam(params.minimized), - }); - if (!result.ok) { - detail.respond({ ok: false, error: result.message }); - return; - } - detail.respond({ - ok: true, - result: { - status: result.status, - surfaceId: result.surfaceId, - surfaceRef: result.surfaceRef, - session, - minimized: result.minimized, - }, - }); + }, + }); + return; + } + + if (detail.method === SURFACE_CONTROL_METHODS.agentBrowser) { + const session = stringParam(params.session); + if (!session) { + detail.respond({ ok: false, error: 'session is required' }); + return; + } + // `binaryPath` names a program the host will spawn and is persisted into + // the pane's params, so it is checked before it is stored rather than + // only at the spawn (`lib/src/lib/agent-browser-binary.ts`). + // + // Dropped rather than fatal, like `allowedBinaryPath` in + // agent-browser-surface-controller.ts and `runWithBinaryFallback`: the + // host resolves its own candidate instead, and it can accept a path + // this realm cannot — `DORMOUSE_AGENT_BROWSER_BIN` matches by exact + // value, and only the host can read its own environment. Refusing the + // request here would mean no browser surface at all for an operator who + // set that variable to a differently-named wrapper. + const requestedBinaryPath = stringParam(params.binaryPath); + const binaryPath = isAllowedAgentBrowserBinary(requestedBinaryPath) + ? requestedBinaryPath + : undefined; + const result = ensureAgentBrowserSurface({ + key: stringParam(params.key), + session, + wsPort: numberParam(params.wsPort), + binaryPath, + reference: () => resolveVisibleSurface(stringParam(params.surface), detail.surfaceId), + minimized: booleanParam(params.minimized), + }); + if (!result.ok) { + detail.respond({ ok: false, error: result.message }); return; } + detail.respond({ + ok: true, + result: { + status: result.status, + surfaceId: result.surfaceId, + surfaceRef: result.surfaceRef, + session, + minimized: result.minimized, + }, + }); + return; + } - if (detail.method === SURFACE_CONTROL_METHODS.resolveOpen) { - // Resolve a terminal Surface handle to the dev-server URL it owns, for - // `dor ab open ` / `dor iframe `. Same port scan as - // `dor list --ports`; minimized doors are valid targets. Ports ride the - // terminal, so a target without one is rejected by the guard. - const target = requireTerminalSurface(params.surface, detail); - if (!target) return; - let ports: OpenPort[]; - try { - ports = await getPlatform().getOpenPorts(target.id); - } catch { - ports = []; - } - // Group every TCP listener into one openable URL per distinct port - // (loopback-reachable bind wins localhost; otherwise the bound - // LAN/Tailnet address). Shared with the pane context menu's port list. - const entries = listenerUrlsByPort(ports); - if (entries.length === 0) { - detail.respond({ ok: false, error: `surface '${target.ref}' is not serving any port` }); - return; - } - if (entries.length > 1) { - detail.respond({ - ok: false, - error: `surface '${target.ref}' is serving multiple ports (${entries.map((entry) => entry.port).join(', ')}); open one explicitly, e.g. http://localhost:${entries[0].port}`, - }); - return; - } + if (detail.method === SURFACE_CONTROL_METHODS.resolveOpen) { + // Resolve a terminal Surface handle to the dev-server URL it owns, for + // `dor ab open ` / `dor iframe `. Same port scan as + // `dor list --ports`; minimized doors are valid targets. Ports ride the + // terminal, so a target without one is rejected by the guard. + const target = requireTerminalSurface(params.surface, detail); + if (!target) return; + let ports: OpenPort[]; + try { + ports = await getPlatform().getOpenPorts(target.id); + } catch { + ports = []; + } + // Group every TCP listener into one openable URL per distinct port + // (loopback-reachable bind wins localhost; otherwise the bound + // LAN/Tailnet address). Shared with the pane context menu's port list. + const entries = listenerUrlsByPort(ports); + if (entries.length === 0) { + detail.respond({ ok: false, error: `surface '${target.ref}' is not serving any port` }); + return; + } + if (entries.length > 1) { detail.respond({ - ok: true, - result: { - surfaceId: target.id, - surfaceRef: target.ref, - port: entries[0].port, - url: entries[0].url, - }, + ok: false, + error: `surface '${target.ref}' is serving multiple ports (${entries.map((entry) => entry.port).join(', ')}); open one explicitly, e.g. http://localhost:${entries[0].port}`, }); return; } + detail.respond({ + ok: true, + result: { + surfaceId: target.id, + surfaceRef: target.ref, + port: entries[0].port, + url: entries[0].url, + }, + }); + return; + } - if (detail.method === SURFACE_CONTROL_METHODS.resolveAgentBrowser) { - // Resolve a browser Surface handle to the agent-browser session bound to - // it, for `dor ab --surface `. Past the browser gate, - // web verbs stay renderMode-gated: an `iframe` renderer is a browser - // with nothing to drive (docs/specs/glossary.md → Panes and Surfaces). - const target = requireBrowserSurface(params.surface, detail); - if (!target) return; - if (target.renderMode === 'iframe') { - detail.respond({ - ok: false, - error: `surface '${target.ref}' is not agent-browser rendered (render_mode: ${target.renderMode})`, - }); - return; - } - // The session is the one row field the projection deliberately withholds - // (it is an identifier, not a capability), so read it from the params — - // live metadata for panes and parked doors alike. - const session = agentBrowserSessionFromParams(lath.getMeta(target.id)?.params); - if (!session) { - // An eagerly-created connect pane whose daemon boot has not yet named - // it (docs/specs/dor-browser.md → Pane Context Menu Connect). - detail.respond({ ok: false, error: `surface '${target.ref}' has no agent-browser session yet` }); - return; - } + if (detail.method === SURFACE_CONTROL_METHODS.resolveAgentBrowser) { + // Resolve a browser Surface handle to the agent-browser session bound to + // it, for `dor ab --surface `. Past the browser gate, + // web verbs stay renderMode-gated: an `iframe` renderer is a browser + // with nothing to drive (docs/specs/glossary.md → Panes and Surfaces). + const target = requireBrowserSurface(params.surface, detail); + if (!target) return; + if (target.renderMode === 'iframe') { detail.respond({ - ok: true, - result: { surfaceId: target.id, surfaceRef: target.ref, session }, + ok: false, + error: `surface '${target.ref}' is not agent-browser rendered (render_mode: ${target.renderMode})`, }); return; } + // The session is the one row field the projection deliberately withholds + // (it is an identifier, not a capability), so read it from the params — + // live metadata for panes and parked doors alike. + const session = agentBrowserSessionFromParams(lath.getMeta(target.id)?.params); + if (!session) { + // An eagerly-created connect pane whose daemon boot has not yet named + // it (docs/specs/dor-browser.md → Pane Context Menu Connect). + detail.respond({ ok: false, error: `surface '${target.ref}' has no agent-browser session yet` }); + return; + } + detail.respond({ + ok: true, + result: { surfaceId: target.id, surfaceRef: target.ref, session }, + }); + return; + } - detail.respond({ ok: false, error: `unsupported Dormouse control method '${detail.method}'` }); - }; - - window.addEventListener('dormouse:control-request', handler); - return () => window.removeEventListener('dormouse:control-request', handler); - }, [buildDorSurfaces, buildDorSurfaceList, closeSurface, createContentSurface, createSplitSurface, ensureAgentBrowserSurface, findSurfaceIdRunningCommand, requireBrowserSurface, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav]); + detail.respond({ ok: false, error: `unsupported Dormouse control method '${detail.method}'` }); + }, [buildDorSurfaces, buildDorSurfaceList, closeSurface, createContentSurface, createSplitSurface, ensureAgentBrowserSurface, findSurfaceIdRunningCommand, requireBrowserSurface, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav, workspaceRef, windowRef]); - return { findSurfaceByParams, updateSurfaceParams }; + return { findSurfaceByParams, updateSurfaceParams, handleDorControl }; } diff --git a/lib/src/components/wall/use-session-persistence.ts b/lib/src/components/wall/use-session-persistence.ts index 37d8afd63..767e48cbf 100644 --- a/lib/src/components/wall/use-session-persistence.ts +++ b/lib/src/components/wall/use-session-persistence.ts @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useRef, type RefObject } from 'react'; +import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react'; import { pasteFilePaths } from '../../lib/clipboard'; import { getPlatform } from '../../lib/platform'; -import { saveSession } from '../../lib/session-save'; +import { buildPersistedSession, saveSession, type SaveSink } from '../../lib/session-save'; import { createSessionDirtyTracker } from '../../lib/session-dirty'; +import { publishWorkspaceSession } from '../../lib/window-session-aggregator'; import { subscribeToActivity, subscribeToTerminalPaneState, @@ -11,7 +12,14 @@ import { import { surfaceKindFromParams } from './browser-surface'; import type { LathWallEngine } from './lath-wall-engine'; import type { DooredItem, WallSelectionKind } from './wall-types'; -import type { PersistedDoor, PersistedSurfaceRefs } from '../../lib/session-types'; +import type { PersistedDoor, PersistedSession, PersistedSurfaceRefs, WorkspaceId } from '../../lib/session-types'; + +export interface SessionPersistenceHandle { + /** This Workspace's record right now, built but not written. */ + buildSession: () => Promise; + /** Persist immediately, awaiting the whole queued pipeline. */ + flush: () => Promise; +} export function useSessionPersistence({ lath, @@ -20,6 +28,8 @@ export function useSessionPersistence({ selectedIdRef, selectedTypeRef, surfaceRefsForSave, + workspaceId, + ownsHostFlush = true, }: { /** The Lath engine — the layout authority written on every commit, and the source * of the visible-pane projection (`lath.listPanes()`). Stable identity, so the @@ -34,22 +44,47 @@ export function useSessionPersistence({ selectedIdRef: RefObject; selectedTypeRef: RefObject; surfaceRefsForSave?: () => { refs: PersistedSurfaceRefs; next: number }; -}): void { + /** Present when this Wall belongs to a Workspace: its record then goes to the + * Window collector instead of the platform slot, and is compared against its + * own Workspace's previous record. */ + workspaceId?: WorkspaceId; + /** Whether this Wall answers the host's flush request itself. `WorkspaceWindow` + * sets this false and owns the one subscription for the whole Window — the + * adapter's first `notifySessionFlushComplete` wins, so N Walls answering + * would let a quit proceed after the first. */ + ownsHostFlush?: boolean; +}): SessionPersistenceHandle { const sessionSaveTimerRef = useRef | null>(null); const sessionSavePromiseRef = useRef | null>(null); const pendingSaveNeededRef = useRef(false); // See session-dirty.ts for the conservative-under-races generation model. const trackerRef = useRef(createSessionDirtyTracker()); + // This Workspace's last published record: `getPreviousPaneMap`'s source, which + // must be this Workspace's own (a dead PTY's cwd is retained there), not the + // Window's active Workspace. + const publishedRef = useRef(null); - const doSave = useCallback((): Promise => { + const sink = useMemo(() => { + if (workspaceId === undefined) return undefined; + return { + previous: () => publishedRef.current, + publish: (session) => { + publishedRef.current = session; + publishWorkspaceSession(workspaceId, session); + }, + }; + }, [workspaceId]); + + /** The pane + Door projection every save and serialization is built from. The + * runtime Door is id + token; its metadata is materialized HERE, from the + * store that owned it all along, so a Surface persists where it navigated to + * rather than where it was minimized and a restart cold-loads it there. */ + const collect = useCallback(() => { const panes = lath.listPanes().map((p) => ({ id: p.id, title: p.title ?? UNNAMED_PANEL_TITLE, surfaceType: surfaceKindFromParams(p.params), })); - // The runtime Door is id + token; its metadata is materialized HERE, from the - // store that owned it all along, so a Surface persists where it navigated to - // rather than where it was minimized and a restart cold-loads it there. const doors: PersistedDoor[] = (doorsRef.current ?? []).map((door) => { const meta = lath.getMeta(door.id); return { @@ -63,9 +98,27 @@ export function useSessionPersistence({ }); const surfaceRefs = surfaceRefsForSave?.(); // The Lath tree is the sole persisted layout; doors ride through with their tokens. - return saveSession(getPlatform(), panes, doors, lath.serializeLayout(), surfaceRefs?.refs, surfaceRefs?.next); + return { panes, doors, lathLayout: lath.serializeLayout(), surfaceRefs }; }, [lath, doorsRef, surfaceRefsForSave]); + const doSave = useCallback((): Promise => { + const { panes, doors, lathLayout, surfaceRefs } = collect(); + return saveSession(getPlatform(), panes, doors, lathLayout, surfaceRefs?.refs, surfaceRefs?.next, sink); + }, [collect, sink]); + + const buildSession = useCallback((): Promise => { + const { panes, doors, lathLayout, surfaceRefs } = collect(); + return buildPersistedSession( + getPlatform(), + panes, + doors, + lathLayout, + surfaceRefs?.refs, + surfaceRefs?.next, + publishedRef.current, + ); + }, [collect]); + const persistSessionNow = useCallback(async (): Promise => { const runSave = (): Promise => { pendingSaveNeededRef.current = false; @@ -162,7 +215,7 @@ export function useSessionPersistence({ if (isDirty()) scheduleSessionSave(); }, 30_000); platform.onPtyExit(handlePtyExit); - platform.onRequestSessionFlush(handleSessionFlushRequest); + if (ownsHostFlush) platform.onRequestSessionFlush(handleSessionFlushRequest); window.addEventListener('pagehide', handlePageHide); // Inert in Tauri standalone today; see diffplug/dormouse#38 and tauri-apps/tauri#14373. @@ -181,7 +234,7 @@ export function useSessionPersistence({ } window.removeEventListener('pagehide', handlePageHide); unsubFilesDropped?.(); - platform.offRequestSessionFlush(handleSessionFlushRequest); + if (ownsHostFlush) platform.offRequestSessionFlush(handleSessionFlushRequest); platform.offPtyExit(handlePtyExit); platform.offPtyData(markDirty); unsubActivity(); @@ -193,9 +246,12 @@ export function useSessionPersistence({ }, [ lath, flushSessionSave, + ownsHostFlush, persistSessionNow, scheduleSessionSave, selectedIdRef, selectedTypeRef, ]); + + return { buildSession, flush: flushSessionSave }; } diff --git a/lib/src/components/wall/use-surface-visibility.ts b/lib/src/components/wall/use-surface-visibility.ts index f759c5265..13d60eacf 100644 --- a/lib/src/components/wall/use-surface-visibility.ts +++ b/lib/src/components/wall/use-surface-visibility.ts @@ -1,17 +1,19 @@ -import { useEffect, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; +import { WorkspaceActiveContext } from './wall-context'; /** - * Whether a Surface is actually on screen. Two things can hide one: the window is - * backgrounded, or the leaf is **parked** — mounted but out of the tree, so its DOM - * survives while it paints nothing (docs/specs/tiling-engine.md → "Parked leaves"). - * Callers gate streaming work on it so a hidden pane stops consuming resources while - * its daemon/session stays alive. + * Whether a Surface is actually on screen. Three things can hide one: the window is + * backgrounded, its Workspace is not the visible one, or the leaf is **parked** — + * mounted but out of the tree, so its DOM survives while it paints nothing + * (docs/specs/tiling-engine.md → "Parked leaves"). Callers gate streaming work on it + * so a hidden pane stops consuming resources while its daemon/session stays alive. * * Pass the pane's `parked` prop; omitting it means "never parked", which is right for * any surface rendered outside LathHost. */ export function useSurfaceVisibility(parked = false): boolean { const [docVisible, setDocVisible] = useState(() => document.visibilityState !== 'hidden'); + const workspaceActive = useContext(WorkspaceActiveContext); useEffect(() => { const onChange = () => setDocVisible(document.visibilityState !== 'hidden'); @@ -19,5 +21,5 @@ export function useSurfaceVisibility(parked = false): boolean { return () => document.removeEventListener('visibilitychange', onChange); }, []); - return docVisible && !parked; + return docVisible && workspaceActive && !parked; } diff --git a/lib/src/components/wall/use-wall-keyboard.ts b/lib/src/components/wall/use-wall-keyboard.ts index c7734ea65..8a1a04df9 100644 --- a/lib/src/components/wall/use-wall-keyboard.ts +++ b/lib/src/components/wall/use-wall-keyboard.ts @@ -26,6 +26,10 @@ export function useWallKeyboard(ctx: WallKeyboardCtx): void { const handler = (e: KeyboardEvent) => { const c = ctxRef.current; + // A hidden Workspace's Wall keeps its listeners but dispatches nothing: + // exactly one Wall answers window input (docs/specs/layout.md → + // "Workspaces"). + if (!c.activeRef.current) return; const context = (e.target as HTMLElement | null)?.closest?.('[data-terminal-context]'); if (context) { @@ -57,6 +61,7 @@ export function useWallKeyboard(ctx: WallKeyboardCtx): void { if (!data || data.__dormouse !== 'leader') return; if (!isProxyOrigin(e.origin)) return; const c = ctxRef.current; + if (!c.activeRef.current) return; if (c.modeRef.current === 'passthrough') c.exitTerminalMode(); }; diff --git a/lib/src/components/wall/wall-context.tsx b/lib/src/components/wall/wall-context.tsx index 74a62b329..4e21e62d6 100644 --- a/lib/src/components/wall/wall-context.tsx +++ b/lib/src/components/wall/wall-context.tsx @@ -89,6 +89,12 @@ export const PaneWriteContext = createContext({ updateParams: () => {}, }); +/** Whether this Wall's Workspace is the visible one. A hidden Workspace stays + * mounted and live, so streaming bodies read this to idle + * (`docs/specs/layout.md` → "Workspaces"). Default true: a bare Wall, and any + * component rendered outside one, is always active. */ +export const WorkspaceActiveContext = createContext(true); + export const RenamingIdContext = createContext(null); /** Exact zoom owner for pane-local chrome. Pane chrome compares against its own id * rather than reading a boolean, so a partially exposed pane does not render diff --git a/lib/src/components/wall/wall-types.ts b/lib/src/components/wall/wall-types.ts index 0bc898d15..16540d74c 100644 --- a/lib/src/components/wall/wall-types.ts +++ b/lib/src/components/wall/wall-types.ts @@ -45,6 +45,25 @@ export type DoorAfterRestoreAction = announce: boolean; }; +/** + * The Window's Workspace verbs as a Wall's keyboard sees them. `WorkspaceWindow` + * builds the single instance; targets resolve through the active Workspace, so a + * hidden Wall's stale keystroke could not act on the wrong one. Absent on a bare + * Wall, which leaves the Workspace keys unbound (docs/specs/shortcuts.md → + * "Workspaces (command mode)"). + */ +export interface WorkspaceCommands { + create(): void; + /** `+1` next, `-1` previous; wraps at both ends. */ + cycle(delta: 1 | -1): void; + /** Activate the nth Workspace (0-based); out of range does nothing. */ + selectIndex(index: number): void; + /** Ask the strip to run its close flow for the active Workspace. */ + requestClose(): void; + /** Ask the strip to open its rename editor on the active Workspace. */ + requestRename(): void; +} + export type WallEvent = | { type: 'modeChange'; mode: WallMode } | { type: 'zoomChange'; zoomed: boolean } diff --git a/lib/src/lib/notepad/close-coordinator.test.ts b/lib/src/lib/notepad/close-coordinator.test.ts index 2fa2c733e..a4083ca8c 100644 --- a/lib/src/lib/notepad/close-coordinator.test.ts +++ b/lib/src/lib/notepad/close-coordinator.test.ts @@ -16,7 +16,7 @@ import { deleteNote, getNotes, isSurfaceClosing, - setNotepadSurfaceMetaResolver, + registerNotepadSurfaceMetaResolver, setNoteText, } from './notepad-store'; import type { NotepadArchiveV1, RuntimeTerminalSource } from './types'; @@ -39,7 +39,7 @@ const PANE_IDS = ['s1', 's2', 's3']; * not fixed, so a refresh the closure performs shows up in the batch it builds. */ function installMetaResolver(surfaceKind: SurfaceKind = 'terminal', surfaceTitle = 'pnpm dev'): void { - setNotepadSurfaceMetaResolver((id) => ({ + registerNotepadSurfaceMetaResolver((id) => ({ surfaceTitle, surfaceKind, cwd: getTerminalPaneState(id).cwd, diff --git a/lib/src/lib/notepad/notepad-store.test.ts b/lib/src/lib/notepad/notepad-store.test.ts index fff3d7849..d610b4b35 100644 --- a/lib/src/lib/notepad/notepad-store.test.ts +++ b/lib/src/lib/notepad/notepad-store.test.ts @@ -20,7 +20,7 @@ import { pendingBatchId, pruneEmptyNote, removeSurface, - setNotepadSurfaceMetaResolver, + registerNotepadSurfaceMetaResolver, setNoteText, setOpenNotepadId, setStagedArchiveDeletions, @@ -424,7 +424,7 @@ const CWD: CwdState = { describe('volatile mirror', () => { it('mirrors every Surface holding notes, without markers, once per burst', async () => { const sync = vi.spyOn(adapter.notepadArchive, 'syncVolatile'); - setNotepadSurfaceMetaResolver((surfaceId) => + registerNotepadSurfaceMetaResolver((surfaceId) => surfaceId === 's1' ? { surfaceTitle: 'zsh', surfaceKind: 'terminal', cwd: CWD } : null, ); @@ -463,7 +463,7 @@ describe('volatile mirror', () => { }); it('carries the PTY id for terminal Surfaces only, resolved to the Session', async () => { - setNotepadSurfaceMetaResolver((surfaceId) => ({ + registerNotepadSurfaceMetaResolver((surfaceId) => ({ surfaceTitle: surfaceId, surfaceKind: surfaceId === 's1' ? 'terminal' : 'browser', cwd: null, @@ -479,6 +479,29 @@ describe('volatile mirror', () => { expect(Object.keys(surfaces[1])).not.toContain('terminalId'); }); + it('asks every registered resolver and takes the owning one\u2019s answer', async () => { + // One resolver per mounted Wall; a Wall answers null for a Surface it does + // not own, so the first non-null answer is the owner\u2019s. + const dispose = registerNotepadSurfaceMetaResolver((surfaceId) => + surfaceId === 's1' ? { surfaceTitle: 'from ws-1', surfaceKind: 'terminal', cwd: null } : null, + ); + registerNotepadSurfaceMetaResolver((surfaceId) => + surfaceId === 's2' ? { surfaceTitle: 'from ws-2', surfaceKind: 'terminal', cwd: null } : null, + ); + addPlainNote('s1', 'a'); + addPlainNote('s2', 'b'); + await flush(); + expect(adapter.notepadArchive.lastVolatileSnapshot()!.surfaces.map((s) => s.surfaceTitle)) + .toEqual(['from ws-1', 'from ws-2']); + + // Unregistering one Wall leaves the other answering. + dispose(); + addPlainNote('s1', 'c'); + await flush(); + expect(adapter.notepadArchive.lastVolatileSnapshot()!.surfaces.map((s) => s.surfaceTitle)) + .toEqual(['', 'from ws-2']); + }); + it('carries staged archive deletions', async () => { setStagedArchiveDeletions({ deleteBatchIds: ['b1'], deleteNotes: [{ batchId: 'b2', noteId: 'n7' }] }); addPlainNote('s1', 'a'); diff --git a/lib/src/lib/notepad/notepad-store.ts b/lib/src/lib/notepad/notepad-store.ts index 40cfae438..1457f7471 100644 --- a/lib/src/lib/notepad/notepad-store.ts +++ b/lib/src/lib/notepad/notepad-store.ts @@ -19,7 +19,7 @@ import type { } from './types'; /** What the volatile mirror needs about a Surface that the notes themselves do - * not carry. The Wall owns this; see `setNotepadSurfaceMetaResolver`. */ + * not carry. The Wall owns this; see `registerNotepadSurfaceMetaResolver`. */ export interface NotepadSurfaceMeta { surfaceTitle: string; surfaceKind: SurfaceKind; @@ -369,7 +369,7 @@ export function clearAllNotepads(): void { notesBySurface.clear(); closingSurfaces.clear(); pendingBatchIdBySurface.clear(); - metaResolver = null; + metaResolvers.clear(); stagedDeletions = {}; setOpenNotepadId(null); notify(); @@ -404,22 +404,32 @@ export function setOpenNotepadId(surfaceId: string | null): void { // --- Volatile mirror --- -let metaResolver: NotepadSurfaceMetaResolver | null = null; +const metaResolvers = new Set(); let stagedDeletions: Pick = {}; -/** The Wall installs this; until it does, the mirror carries empty metadata - * rather than nothing, so notes still survive a live resume. */ -export function setNotepadSurfaceMetaResolver(resolver: NotepadSurfaceMetaResolver | null): void { - metaResolver = resolver; +/** Each mounted Wall installs one; until any does, the mirror carries empty + * metadata rather than nothing, so notes still survive a live resume. Returns + * the disposer. */ +export function registerNotepadSurfaceMetaResolver(resolver: NotepadSurfaceMetaResolver): () => void { + metaResolvers.add(resolver); scheduleVolatileSync(); + return () => { + if (!metaResolvers.delete(resolver)) return; + scheduleVolatileSync(); + }; } -/** One Surface's metadata as the Wall sees it right now, or `null` when no - * resolver is installed. The volatile mirror and the close coordinator both - * read through here, so a mirrored batch and an archived one describe the - * Surface identically (docs/specs/notepad.md → "Closure"). */ +/** One Surface's metadata as its owning Wall sees it right now, or `null` when + * no resolver claims it. A Wall answers null for a Surface it does not own, so + * the first non-null answer is the owner's. The volatile mirror and the close + * coordinator both read through here, so a mirrored batch and an archived one + * describe the Surface identically (docs/specs/notepad.md → "Closure"). */ export function getNotepadSurfaceMeta(surfaceId: string): NotepadSurfaceMeta | null { - return metaResolver?.(surfaceId) ?? null; + for (const resolver of metaResolvers) { + const meta = resolver(surfaceId); + if (meta) return meta; + } + return null; } /** Archive deletions staged in an open Archive view, mirrored so a host that diff --git a/lib/src/lib/session-save.ts b/lib/src/lib/session-save.ts index 2cf2928e7..3295fca6d 100644 --- a/lib/src/lib/session-save.ts +++ b/lib/src/lib/session-save.ts @@ -3,19 +3,38 @@ import { browserPersistedPane, readPersistedSession, toPersistedAlertState, type import { getActivity, getLivePersistedAlertState, getTerminalPaneState, isUntouched } from './terminal-registry'; import { UNNAMED_PANEL_TITLE } from './terminal-state'; -function getPreviousPaneMap(platform: PlatformAdapter): Map { - const saved = readPersistedSession(platform.getState()); - if (!saved || !Array.isArray(saved.panes)) { - return new Map(); - } - return new Map(saved.panes.map((pane) => [pane.id, pane])); +/** + * Where a save reads its previous record from and where it writes the new one. + * A Workspace supplies both, so its record is compared against and published + * beside its own Workspace's rather than the Window's active one + * (`docs/specs/transport.md` → "Persisted session"). + */ +export interface SaveSink { + /** This Workspace's last persisted record; `getPreviousPaneMap` reads a dead + * PTY's retained cwd out of it. */ + previous?: () => PersistedSession | null; + publish?: (session: PersistedSession) => void; } -// Every input read here needs a dirty trigger in use-session-persistence.ts; -// the unconditional flushes + store-level compare only bound the staleness. -export async function saveSession( +function previousPaneMap(previous: PersistedSession | null): Map { + if (!previous || !Array.isArray(previous.panes)) return new Map(); + return new Map(previous.panes.map((pane) => [pane.id, pane])); +} + +export interface SavePaneInput { + id: string; + title: string; + surfaceType?: PersistedSurfaceType; +} + +/** + * Build one Workspace's `PersistedSession` from its live panes and Doors. Split + * out of `saveSession` because a Wall's handle serializes on demand (a Window + * snapshot, a quit) without writing anything. + */ +export async function buildPersistedSession( platform: PlatformAdapter, - panes: Array<{ id: string; title: string; surfaceType?: PersistedSurfaceType }>, + panes: SavePaneInput[], doors: PersistedDoor[] = [], // The native Lath persisted layout (docs/specs/tiling-engine.md → "Persistence"). // The only layout Dormouse writes. @@ -24,14 +43,9 @@ export async function saveSession( // The Workspace's next `surface:N` counter, persisted independently of // `surfaceRefs` so pruned (killed) entries never cause a number to be reused. surfaceRefsNext?: number, -): Promise { - // Gate the work, not just the write. Building the record costs a `getCwd` - // round trip per terminal pane — on standalone that lands on a synchronous - // `lsof` in the sidecar — and a host that persists nothing would spend all of - // it on every debounced save, every 30s heartbeat, and twice more per quit, - // only for `saveState` to drop the result. - if (platform.persistsSession === false) return; - const previousPanes = getPreviousPaneMap(platform); + previous?: PersistedSession | null, +): Promise { + const previousPanes = previousPaneMap(previous ?? null); const allPanes = new Map(); for (const pane of panes) { allPanes.set(pane.id, { id: pane.id, title: persistedVisiblePaneTitle(pane.title), surfaceType: pane.surfaceType ?? 'terminal' }); @@ -65,7 +79,7 @@ export async function saveSession( }; }), ); - const session: PersistedSession = { + return { version: 3, panes: persisted, doors: persistedDoors, @@ -73,7 +87,30 @@ export async function saveSession( ...(surfaceRefs && Object.keys(surfaceRefs).length > 0 ? { surfaceRefs } : {}), ...(surfaceRefsNext !== undefined && surfaceRefsNext > 1 ? { surfaceRefsNext } : {}), }; - platform.saveState(session); +} + +// Every input read here needs a dirty trigger in use-session-persistence.ts; +// the unconditional flushes + store-level compare only bound the staleness. +export async function saveSession( + platform: PlatformAdapter, + panes: SavePaneInput[], + doors: PersistedDoor[] = [], + lathLayout?: unknown, + surfaceRefs?: PersistedSurfaceRefs, + surfaceRefsNext?: number, + /** Defaults to the platform's own slot; a Workspace substitutes its own. */ + sink?: SaveSink, +): Promise { + // Gate the work, not just the write. Building the record costs a `getCwd` + // round trip per terminal pane — on standalone that lands on a synchronous + // `lsof` in the sidecar — and a host that persists nothing would spend all of + // it on every debounced save, every 30s heartbeat, and twice more per quit, + // only for `saveState` to drop the result. + if (platform.persistsSession === false) return; + const previous = sink?.previous ? sink.previous() : readPersistedSession(platform.getState()); + const session = await buildPersistedSession(platform, panes, doors, lathLayout, surfaceRefs, surfaceRefsNext, previous); + if (sink?.publish) sink.publish(session); + else platform.saveState(session); } function persistedVisiblePaneTitle(title: string): string { diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 5c94542d4..30a4b9e14 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -573,19 +573,27 @@ export function restoreTerminal( return entry; } -export function mountElement(id: string, container: HTMLElement): void { +export function mountElement(id: string, container: HTMLElement, opts?: { claimWebgl?: boolean }): void { const entry = registry.get(id); if (!entry) return; container.appendChild(entry.element); - // First paint is the earliest point worth claiming a GL context — see - // `tryEnableWebglRenderer` on why create is too early. - if (!entry.webglAttempted) { - entry.webglAttempted = true; - tryEnableWebglRenderer(entry.terminal, entry.element); - } + // A Session mounted inside a hidden Workspace defers its claim to the + // Workspace's first activation, so the GL context budget scales with visited + // Workspaces rather than with every mounted one. + if (opts?.claimWebgl !== false) claimWebglRenderer(id); requestAnimationFrame(() => entry.fit.fit()); } +/** Claim a GL context for a mounted Session, once. First paint is the earliest + * point worth claiming one — see `tryEnableWebglRenderer` on why create is too + * early — so a deferred claim runs when the Session first becomes visible. */ +export function claimWebglRenderer(id: string): void { + const entry = registry.get(id); + if (!entry || entry.webglAttempted) return; + entry.webglAttempted = true; + tryEnableWebglRenderer(entry.terminal, entry.element); +} + /** Where a hidden helper's xterm element waits between reveals: still in the * document, so its renderer and scrollback survive * (docs/specs/terminal-context.md → Helper lifecycle). */ diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index b9f0069b6..779d2131e 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -46,6 +46,7 @@ export { getTerminalOverlayDims, isUntouched, markSessionTouched, + claimWebglRenderer, mountElement, refitSession, registerSurfaceFocusHandle, From 0a96b050a3407ba3c2fdeea5438a9e23b3b0a1f7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 18:44:07 -0700 Subject: [PATCH 03/13] Compose the Window from one Wall per Workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WorkspaceWindow` renders every Workspace's Wall into one grid cell, so switching flips `active` rather than changing any Wall's box — no re-seed, no remount, and no xterm refit. Only the boot Workspace receives the restored record; every Workspace created later takes Lath's fresh branch and spawns one default-shell pane. The Window, not each Wall, answers the host's session-flush request: the adapter completes on the first notification, so a per-Wall answer would let a quit proceed once one Workspace had written. `App` gains `multiWorkspace`, which only the standalone host sets; VS Code and the website playground keep mounting a bare Wall. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- lib/src/App.tsx | 9 +- lib/src/components/WorkspaceWindow.test.tsx | 234 ++++++++++++++++++++ lib/src/components/WorkspaceWindow.tsx | 116 ++++++++++ lib/src/lib/workspace-strip-intent.ts | 26 +++ standalone/src/main.tsx | 1 + 5 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 lib/src/components/WorkspaceWindow.test.tsx create mode 100644 lib/src/components/WorkspaceWindow.tsx create mode 100644 lib/src/lib/workspace-strip-intent.ts diff --git a/lib/src/App.tsx b/lib/src/App.tsx index ff05b6bd5..8adbddfb4 100644 --- a/lib/src/App.tsx +++ b/lib/src/App.tsx @@ -1,5 +1,6 @@ import { Component, type ReactNode } from "react"; import { Wall } from "./components/Wall"; +import { WorkspaceWindow } from "./components/WorkspaceWindow"; import { ThemeDebuggerGlobal } from "./components/ThemeDebugger"; import type { PersistedDoor, PersistedSurfaceRefs } from "./lib/session-types"; @@ -31,6 +32,7 @@ export default function App({ baseboardNotice, dialogHost, enableBurrow, + multiWorkspace = false, }: { initialPaneIds?: string[]; restoredLathLayout?: unknown; @@ -40,10 +42,15 @@ export default function App({ baseboardNotice?: ReactNode; dialogHost?: ReactNode; enableBurrow?: boolean; + /** Render one Wall per Workspace instead of one for the whole page. Only the + * standalone host sets it; VS Code and the website playground mount a bare + * Wall (docs/specs/layout.md → "Workspaces"). */ + multiWorkspace?: boolean; }) { + const Shell = multiWorkspace ? WorkspaceWindow : Wall; return ( - + diff --git a/lib/src/components/WorkspaceWindow.test.tsx b/lib/src/components/WorkspaceWindow.test.tsx new file mode 100644 index 000000000..ee6223d0a --- /dev/null +++ b/lib/src/components/WorkspaceWindow.test.tsx @@ -0,0 +1,234 @@ +/** + * @vitest-environment jsdom + * + * The Window composition: one mounted Wall per Workspace, exactly one active, + * and a switch that costs no Session anything (docs/specs/layout.md → + * "Workspaces"). + */ +import { StrictMode, act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WorkspaceWindow } from './WorkspaceWindow'; +import { setPlatform } from '../lib/platform'; +import { FakePtyAdapter } from '../lib/platform/fake-adapter'; +import { clearAllNotepads, addPlainNote } from '../lib/notepad/notepad-store'; +import { __resetArchiveServiceForTests } from '../lib/notepad/archive-service'; +import { getActivitySnapshot, setTerminalActivity } from '../lib/terminal-registry'; +import { getWallHandle, listWallHandles, resetWallHandles } from './wall/wall-handles'; +import { getWorkspaceSurfacesSnapshot, resetWorkspaceSurfaces } from '../lib/workspace-surfaces'; +import { resetWindowSessionAggregator } from '../lib/window-session-aggregator'; +import { + closeWorkspace, + createWorkspace, + getActiveWorkspaceId, + getWorkspacesSnapshot, + resetWorkspaces, + setActiveWorkspace, +} from '../lib/workspace-store'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('./TerminalPane', () => ({ + TerminalPane: ({ id, isFocused }: { id: string; isFocused?: boolean }) => ( +
+ ), +})); + +let container: HTMLDivElement; +let root: Root; +let fake: FakePtyAdapter; + +beforeEach(() => { + __resetArchiveServiceForTests(); + clearAllNotepads(); + resetWallHandles(); + resetWorkspaces(); + resetWorkspaceSurfaces(); + resetWindowSessionAggregator(); + fake = new FakePtyAdapter(); + setPlatform(fake); + globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + globalThis.matchMedia = ((query: string) => ({ + matches: query.includes('prefers-reduced-motion'), + media: query, + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent() { return false; }, + })) as unknown as typeof matchMedia; + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: vi.fn(() => null), + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); + vi.restoreAllMocks(); + __resetArchiveServiceForTests(); + clearAllNotepads(); +}); + +async function flush(): Promise { + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); +} + +function walls(): HTMLElement[] { + return [...container.querySelectorAll('[data-workspace-wall]')]; +} + +function wallFor(workspaceId: string): HTMLElement { + return container.querySelector(`[data-workspace-wall="${workspaceId}"]`)!; +} + +function leafIdsIn(workspaceId: string): string[] { + return [...wallFor(workspaceId).querySelectorAll('[data-lath-leaf]')] + .map((leaf) => leaf.getAttribute('data-lath-leaf')!); +} + +async function render(node = ): Promise { + await act(async () => root.render(node)); + await flush(); +} + +describe('WorkspaceWindow', () => { + it('mounts one Wall per Workspace with exactly one active, and seeds only the boot Workspace', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await render(); + expect(leafIdsIn(first)).toEqual(['pane-a']); + + const second = await act(async () => createWorkspace({ id: 'ws-2' }).id); + await flush(); + + expect(walls().map((wall) => wall.dataset.workspaceActive)).toEqual(['false', 'true']); + expect(getActiveWorkspaceId()).toBe(second); + // A Workspace created later gets no boot record: Lath's fresh branch spawns + // exactly one pane, and the boot Workspace is untouched. + expect(leafIdsIn(first)).toEqual(['pane-a']); + expect(leafIdsIn(second)).toHaveLength(1); + expect(leafIdsIn(second)[0]).not.toBe('pane-a'); + // Both Walls are in the same grid cell, so the box never changes on a switch. + expect(walls().every((wall) => wall.className.includes('col-start-1 row-start-1'))).toBe(true); + expect(wallFor(first).className).toContain('invisible'); + expect(wallFor(first).hasAttribute('inert')).toBe(true); + expect(wallFor(second).className).not.toContain('invisible'); + expect(wallFor(second).hasAttribute('inert')).toBe(false); + }); + + it('registers one handle per Workspace, publishing membership, even under StrictMode', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await render(); + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + await flush(); + + expect(listWallHandles().map((handle) => handle.workspaceId).sort()).toEqual([first, 'ws-2'].sort()); + expect(getWallHandle(first)!.surfaceIds()).toEqual(['pane-a']); + expect(getWorkspaceSurfacesSnapshot().get(first)).toEqual(['pane-a']); + expect(getWorkspaceSurfacesSnapshot().get('ws-2')).toHaveLength(1); + }); + + it('costs a Session nothing to switch: the leaf is never remounted and no ring replays', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await render(); + setTerminalActivity('pane-a', { status: 'ALERT_RINGING' }); + const ringBefore = getActivitySnapshot().get('pane-a')!.ringSeq; + const leafBefore = wallFor(first).querySelector('[data-lath-leaf="pane-a"]'); + const paneBefore = wallFor(first).querySelector('[data-session-id="pane-a"]'); + + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + await flush(); + await act(async () => { setActiveWorkspace(first); }); + await flush(); + + // A switch flips a prop; it never unmounts a leaf, so nothing calls + // mountElement / resumeTerminal / restoreTerminal and `ringSeq` cannot + // advance (docs/specs/glossary.md → I8). + expect(wallFor(first).querySelector('[data-lath-leaf="pane-a"]')).toBe(leafBefore); + expect(wallFor(first).querySelector('[data-session-id="pane-a"]')).toBe(paneBefore); + expect(getActivitySnapshot().get('pane-a')!.ringSeq).toBe(ringBefore); + }); + + it('gives host New Terminal and the dialog hosts to the visible Workspace only', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await render( + } />, + ); + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + await flush(); + + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:new-terminal', { detail: {} })); + }); + await flush(); + + expect(leafIdsIn(first)).toEqual(['pane-a']); + expect(leafIdsIn('ws-2')).toHaveLength(2); + // One dialog host for the Window, rendered by the visible Workspace's Wall + // so it sits in that Wall's DialogKeyboardContext. + const hosts = container.querySelectorAll('[data-testid="dialog-host"]'); + expect(hosts).toHaveLength(1); + expect(wallFor('ws-2').contains(hosts[0])).toBe(true); + }); + + it('closeAll empties a Workspace without the auto-spawn refilling it', async () => { + await render(); + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + await flush(); + + const handle = getWallHandle('ws-2')!; + expect(handle.surfaceIds()).toHaveLength(1); + await act(async () => { expect(await handle.closeAll('silent')).toBeNull(); }); + await flush(); + expect(handle.surfaceIds()).toEqual([]); + expect(leafIdsIn('ws-2')).toEqual([]); + }); + + it('a refused closure leaves the Workspace intact and re-arms its auto-spawn', async () => { + await render(); + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + await flush(); + const handle = getWallHandle('ws-2')!; + const [paneId] = handle.surfaceIds(); + addPlainNote(paneId, 'unsaved'); + vi.spyOn(fake.notepadArchive, 'save').mockRejectedValue(new Error('disk is full')); + + let refusal: string | null = null; + await act(async () => { refusal = await handle.closeAll('silent'); }); + await flush(); + expect(refusal).toContain('notepad archive failed'); + expect(handle.surfaceIds()).toEqual([paneId]); + + // The flag is cleared, so the Wall's "always one pane" rule works again. + vi.mocked(fake.notepadArchive.save).mockResolvedValue(undefined); + await act(async () => { await handle.closeAll('discard'); }); + await flush(); + expect(handle.surfaceIds()).toEqual([]); + }); + + it('refuses to close the last Workspace', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await render(); + expect(closeWorkspace(first)).toBe(false); + expect(getWorkspacesSnapshot().workspaces).toHaveLength(1); + expect(walls()).toHaveLength(1); + }); + + it('reports a fresh Workspace as untouched with nothing running', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await render(); + const handle = getWallHandle(first)!; + expect(handle.hasTouchedSurfaces()).toBe(false); + expect(handle.runningCount()).toBe(0); + }); +}); diff --git a/lib/src/components/WorkspaceWindow.tsx b/lib/src/components/WorkspaceWindow.tsx new file mode 100644 index 000000000..7f65c3d31 --- /dev/null +++ b/lib/src/components/WorkspaceWindow.tsx @@ -0,0 +1,116 @@ +import { useEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from 'react'; +import { clsx } from 'clsx'; +import { Wall } from './Wall'; +import { listWallHandles } from './wall/wall-handles'; +import { getPlatform } from '../lib/platform'; +import { + createWorkspace, + getActiveWorkspaceId, + getWorkspacesSnapshot, + setActiveWorkspace, + subscribeToWorkspaces, +} from '../lib/workspace-store'; +import { requestWorkspaceStripIntent } from '../lib/workspace-strip-intent'; +import type { WorkspaceCommands } from './wall/wall-types'; +import type { PersistedDoor, PersistedSurfaceRefs } from '../lib/session-types'; + +/** + * One Window's Workspaces: a mounted `` each, all in the same grid cell so + * a switch never changes a Wall's box and no xterm refits + * (docs/specs/layout.md → "Workspaces"). Switching flips which Wall is `active`; + * nothing re-seeds, re-parents, or unmounts. + */ +export function WorkspaceWindow({ + initialPaneIds, + restoredLathLayout, + initialDoors, + initialSurfaceRefs, + initialSurfaceRefsNext, + baseboardNotice, + dialogHost, + enableBurrow, +}: { + initialPaneIds?: string[]; + restoredLathLayout?: unknown; + initialDoors?: PersistedDoor[]; + initialSurfaceRefs?: PersistedSurfaceRefs; + initialSurfaceRefsNext?: number; + baseboardNotice?: ReactNode; + dialogHost?: ReactNode; + enableBurrow?: boolean; +}) { + const { workspaces, activeId } = useSyncExternalStore(subscribeToWorkspaces, getWorkspacesSnapshot); + // The boot record belongs to the Workspace that was active at first render. + // Every Workspace created later gets no boot props, so its Wall takes Lath's + // fresh branch and spawns exactly one default-shell pane. + const bootWorkspaceIdRef = useRef(activeId); + + const commands = useMemo(() => ({ + create: () => { createWorkspace(); }, + cycle: (delta) => { + const { workspaces: list, activeId: current } = getWorkspacesSnapshot(); + const index = list.findIndex((workspace) => workspace.id === current); + if (index === -1) return; + setActiveWorkspace(list[(index + delta + list.length) % list.length].id); + }, + selectIndex: (index) => { + const target = getWorkspacesSnapshot().workspaces[index]; + if (target) setActiveWorkspace(target.id); + }, + requestClose: () => requestWorkspaceStripIntent({ kind: 'close', workspaceId: getActiveWorkspaceId() }), + requestRename: () => requestWorkspaceStripIntent({ kind: 'rename', workspaceId: getActiveWorkspaceId() }), + }), []); + + // The Window, not each Wall, answers the host's flush request: the adapter + // completes on the FIRST notification, so a per-Wall answer would let a quit + // proceed once one Workspace had written. + useEffect(() => { + const platform = getPlatform(); + const handleFlushRequest = (detail: { requestId: string }) => { + void Promise.all(listWallHandles().map((handle) => handle.flushPersistence().catch(() => undefined))) + .finally(() => platform.notifySessionFlushComplete(detail.requestId)); + }; + platform.onRequestSessionFlush(handleFlushRequest); + return () => platform.offRequestSessionFlush(handleFlushRequest); + }, []); + + return ( + // One grid cell holds every Wall, so each keeps the same box whether or not + // it is the visible one. +
+ {workspaces.map((workspace) => { + const isActive = workspace.id === activeId; + const isBoot = workspace.id === bootWorkspaceIdRef.current; + return ( + + ); + })} +
+ ); +} diff --git a/lib/src/lib/workspace-strip-intent.ts b/lib/src/lib/workspace-strip-intent.ts new file mode 100644 index 000000000..99dab72fd --- /dev/null +++ b/lib/src/lib/workspace-strip-intent.ts @@ -0,0 +1,26 @@ +import type { WorkspaceId } from './session-types'; + +/** + * The one-way bridge from a Wall's keyboard to the strip: `&` and `$` in command + * mode open the strip's close confirmation and rename editor, which live in the + * AppBar, outside every Wall (`docs/specs/layout.md` → "Workspaces"). Nothing + * listening simply drops the intent. + */ +export type WorkspaceStripIntent = + | { kind: 'close'; workspaceId: WorkspaceId } + | { kind: 'rename'; workspaceId: WorkspaceId }; + +const listeners = new Set<(intent: WorkspaceStripIntent) => void>(); + +export function requestWorkspaceStripIntent(intent: WorkspaceStripIntent): void { + listeners.forEach((listener) => listener(intent)); +} + +export function subscribeToWorkspaceStripIntent( + listener: (intent: WorkspaceStripIntent) => void, +): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index bd6e693d8..466843882 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -140,6 +140,7 @@ async function bootstrap() { baseboardNotice={} dialogHost={} enableBurrow + multiWorkspace /> , ); From ac3d77e411d385dc1dd4f9d03603c8939fd2f1d3 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 18:50:16 -0700 Subject: [PATCH 04/13] Add the Workspace strip to the standalone app bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strip replaces the placeholder button that opened the tracking issue. It is store-driven end to end — Workspaces, membership, Activity — so it renders in the AppBar, outside every Wall: click activates, double-click renames, middle-click or the × closes, + creates, and a drag past the shared threshold reorders live with Escape restoring the original index. A hidden Workspace's tab carries the union's TODO pill and bell; the visible one shows none, since its panes already say it. `WorkspaceUnion` gains `ringSeq` so a new ring replays the tab's burst while returning to the Workspace does not. The rename editor and the close confirmation sit outside every Wall, where `stopPropagation` cannot reach the Wall's capture-phase window listener, so they take a reference-counted chrome keyboard lease instead. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- lib/src/components/WorkspaceStrip.test.tsx | 243 +++++++++++++ lib/src/components/WorkspaceStrip.tsx | 333 ++++++++++++++++++ lib/src/components/design.tsx | 6 + .../components/wall/lath-drag-controller.ts | 6 +- lib/src/components/wall/use-wall-keyboard.ts | 5 +- lib/src/components/workspace-strip-drag.ts | 134 +++++++ lib/src/lib/chrome-keyboard-lease.ts | 32 ++ lib/src/lib/workspace-union.test.ts | 20 +- lib/src/lib/workspace-union.ts | 9 +- standalone/src/AppBar.tsx | 32 +- 10 files changed, 786 insertions(+), 34 deletions(-) create mode 100644 lib/src/components/WorkspaceStrip.test.tsx create mode 100644 lib/src/components/WorkspaceStrip.tsx create mode 100644 lib/src/components/workspace-strip-drag.ts create mode 100644 lib/src/lib/chrome-keyboard-lease.ts diff --git a/lib/src/components/WorkspaceStrip.test.tsx b/lib/src/components/WorkspaceStrip.test.tsx new file mode 100644 index 000000000..c74d9f2cb --- /dev/null +++ b/lib/src/components/WorkspaceStrip.test.tsx @@ -0,0 +1,243 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WorkspaceStrip } from './WorkspaceStrip'; +import { chromeKeyboardHeld, resetChromeKeyboardLeases } from '../lib/chrome-keyboard-lease'; +import { registerWallHandle, resetWallHandles, type WallHandle } from './wall/wall-handles'; +import { resetWorkspaceSurfaces, setWorkspaceSurfaces } from '../lib/workspace-surfaces'; +import { clearTerminalActivity, setTerminalActivity } from '../lib/terminal-registry'; +import { requestWorkspaceStripIntent } from '../lib/workspace-strip-intent'; +import { + createWorkspace, + getActiveWorkspaceId, + getWorkspacesSnapshot, + resetWorkspaces, +} from '../lib/workspace-store'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; + +function stubHandle(workspaceId: string, overrides: Partial = {}): WallHandle { + const handle: WallHandle = { + workspaceId, + surfaceIds: () => [], + ownsSurface: () => false, + hasTouchedSurfaces: () => false, + runningCount: () => 0, + serialize: async () => ({ version: 3, panes: [], doors: [] }), + flushPersistence: async () => {}, + focusSelected: () => {}, + closeAll: async () => null, + handleDorControl: () => {}, + ...overrides, + }; + registerWallHandle(handle); + return handle; +} + +function tabs(): HTMLElement[] { + return [...container.querySelectorAll('[data-workspace-tab]')]; +} + +function tabNames(): string[] { + return tabs().map((tab) => tab.querySelector('span')!.textContent!); +} + +function tabFor(id: string): HTMLElement { + return container.querySelector(`[data-workspace-tab="${id}"]`)!; +} + +function activateButton(id: string): HTMLButtonElement { + return tabFor(id).querySelector('button')!; +} + +async function render(node = ): Promise { + await act(async () => root.render(node)); +} + +function pointer(type: string, init: Partial = {}): PointerEvent { + return new MouseEvent(type, { bubbles: true, cancelable: true, ...init }) as unknown as PointerEvent; +} + +/** React tracks a controlled input's value, so a plain assignment is invisible + * to `onChange`; go through the prototype setter it patched. */ +function typeInto(input: HTMLInputElement, value: string): void { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); +} + +beforeEach(() => { + globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + resetWorkspaces(); + resetWorkspaceSurfaces(); + resetWallHandles(); + resetChromeKeyboardLeases(); + clearTerminalActivity(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); +}); + +describe('WorkspaceStrip', () => { + it('renders a tab per Workspace, marks the active one, and creates from +', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await render(); + expect(tabNames()).toEqual(['Workspace 1']); + expect(tabFor(first).dataset.workspaceTabActive).toBe('true'); + + await act(async () => { + container.querySelector('[data-workspace-new]')!.click(); + }); + expect(tabNames()).toEqual(['Workspace 1', 'Workspace 2']); + expect(tabFor(first).dataset.workspaceTabActive).toBe('false'); + }); + + it('activates on click', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + await render(); + expect(getActiveWorkspaceId()).toBe('ws-2'); + await act(async () => { activateButton(first).click(); }); + expect(getActiveWorkspaceId()).toBe(first); + }); + + it('shows indicators for a hidden Workspace only, counting them in its label', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + setWorkspaceSurfaces(first, ['pane-a', 'pane-b']); + setWorkspaceSurfaces('ws-2', ['pane-c']); + setTerminalActivity('pane-a', { status: 'ALERT_RINGING' }); + setTerminalActivity('pane-b', { todo: true }); + setTerminalActivity('pane-c', { status: 'ALERT_RINGING' }); + await render(); + + expect(activateButton(first).getAttribute('aria-label')).toBe('Workspace 1, 2 needing attention'); + expect(tabFor(first).querySelector('.todo-pill-shell')).not.toBeNull(); + expect(tabFor(first).querySelector('svg')).not.toBeNull(); + // The visible Workspace shows its Surfaces, so its tab stays plain. + expect(tabFor('ws-2').querySelector('.todo-pill-shell')).toBeNull(); + }); + + it('renames on double-click, holding the chrome keyboard lease while the editor is open', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await render(); + expect(chromeKeyboardHeld()).toBe(false); + + await act(async () => { + activateButton(first).dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + }); + const input = container.querySelector(`[data-workspace-rename-for="${first}"]`)!; + expect(chromeKeyboardHeld()).toBe(true); + + await act(async () => { + typeInto(input, 'Deploys'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + }); + expect(tabNames()).toEqual(['Deploys']); + expect(chromeKeyboardHeld()).toBe(false); + }); + + it('hides the close button with one Workspace and closes an untouched one outright', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await render(); + expect(container.querySelector('[data-workspace-tab-close]')).toBeNull(); + + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + const closed = vi.fn(async () => null); + stubHandle('ws-2', { closeAll: closed }); + await act(async () => { + container.querySelector('[data-workspace-tab-close="ws-2"]')!.click(); + }); + expect(closed).toHaveBeenCalledWith('prompt'); + expect(getWorkspacesSnapshot().workspaces.map((workspace) => workspace.id)).toEqual([first]); + }); + + it('confirms before closing a Workspace holding work, and a refusal reveals it', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + stubHandle('ws-2', { hasTouchedSurfaces: () => true, closeAll: async () => 'notepad archive failed' }); + await render(); + // Close the INACTIVE one so the reveal is observable. + await act(async () => { activateButton(first).click(); }); + + await act(async () => { + container.querySelector('[data-workspace-tab-close="ws-2"]')!.click(); + }); + expect(container.querySelector('#kill-confirm-title')).not.toBeNull(); + expect(chromeKeyboardHeld()).toBe(true); + const char = container.querySelector('.text-xl')!.textContent!; + + await act(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: char, bubbles: true })); + }); + await act(async () => { await Promise.resolve(); }); + // Refused: the Workspace survives and is revealed so its prompt is visible. + expect(getWorkspacesSnapshot().workspaces).toHaveLength(2); + expect(getActiveWorkspaceId()).toBe('ws-2'); + expect(chromeKeyboardHeld()).toBe(false); + }); + + it('reorders on a drag past the threshold, and Escape restores the original index', async () => { + createWorkspace({ id: 'ws-2' }); + createWorkspace({ id: 'ws-3' }); + await render(); + const order = () => getWorkspacesSnapshot().workspaces.map((workspace) => workspace.id); + const first = order()[0]; + + // jsdom lays nothing out; stub each tab's box so the center crossings are real. + const boxes = new Map(order().map((id, index) => [id, { left: index * 100, width: 100 }])); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + const id = this.dataset.workspaceTab; + const box = id ? boxes.get(id) : undefined; + const left = box?.left ?? 0; + const width = box?.width ?? 300; + return { left, right: left + width, width, top: 0, bottom: 24, height: 24, x: left, y: 0, toJSON: () => ({}) } as DOMRect; + }); + + await act(async () => { tabFor(first).dispatchEvent(pointer('pointerdown', { button: 0, clientX: 50, clientY: 12 })); }); + // Below the threshold: nothing moves. + await act(async () => { window.dispatchEvent(pointer('pointermove', { clientX: 52, clientY: 12 })); }); + expect(order()[0]).toBe(first); + // Past the second tab's center (150). + await act(async () => { window.dispatchEvent(pointer('pointermove', { clientX: 160, clientY: 12 })); }); + expect(order()).toEqual(['ws-2', first, 'ws-3']); + + await act(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + expect(order()).toEqual([first, 'ws-2', 'ws-3']); + }); + + it('answers the keyboard intents that come from inside a Wall', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + await render(); + + await act(async () => { requestWorkspaceStripIntent({ kind: 'rename', workspaceId: first }); }); + expect(container.querySelector(`[data-workspace-rename-for="${first}"]`)).not.toBeNull(); + await act(async () => { + container.querySelector(`[data-workspace-rename-for="${first}"]`)! + .dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + + const closed = vi.fn(async () => null); + stubHandle('ws-2', { closeAll: closed }); + await act(async () => { requestWorkspaceStripIntent({ kind: 'close', workspaceId: 'ws-2' }); }); + expect(closed).toHaveBeenCalled(); + }); +}); diff --git a/lib/src/components/WorkspaceStrip.tsx b/lib/src/components/WorkspaceStrip.tsx new file mode 100644 index 000000000..a1c66caad --- /dev/null +++ b/lib/src/components/WorkspaceStrip.tsx @@ -0,0 +1,333 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type PointerEvent as ReactPointerEvent, +} from 'react'; +import { clsx } from 'clsx'; +import { PlusIcon, XIcon } from '@phosphor-icons/react'; +import { AlertBell } from './AlertBell'; +import { InlineEditInput } from './wall/InlineEditInput'; +import { KillConfirmModal, randomKillChar } from './KillConfirm'; +import { useTodoPillContent } from './TodoPillBody'; +import { chromeButton, TERMINAL_TOP_RADIUS_CLASS, TODO_PILL_TRACKING_CLASS } from './design'; +import { createWorkspaceStripDrag } from './workspace-strip-drag'; +import { getWallHandle } from './wall/wall-handles'; +import { acquireChromeKeyboardLease } from '../lib/chrome-keyboard-lease'; +import { forgetWorkspaceSession } from '../lib/window-session-aggregator'; +import { getActivitySnapshot, subscribeToActivity } from '../lib/terminal-registry'; +import { clearWorkspaceSurfaces, getWorkspaceSurfacesSnapshot, subscribeToWorkspaceSurfaces } from '../lib/workspace-surfaces'; +import { computeWorkspaceUnion, EMPTY_WORKSPACE_UNION, type WorkspaceUnion } from '../lib/workspace-union'; +import { subscribeToWorkspaceStripIntent } from '../lib/workspace-strip-intent'; +import { + closeWorkspace, + createWorkspace, + getWorkspacesSnapshot, + moveWorkspace, + renameWorkspace, + setActiveWorkspace, + subscribeToWorkspaces, +} from '../lib/workspace-store'; +import type { WorkspaceId } from '../lib/session-types'; + +/** + * The Window's Workspace tabs. Store-driven end to end (Workspaces, membership, + * Activity), so it renders in the AppBar — outside every Wall's React tree + * (`docs/specs/layout.md` → "Workspaces"; `docs/specs/standalone.md` → AppBar). + */ +export function WorkspaceStrip({ + className, + onDragOutsideWindow, + onDropOnOtherWindow, +}: { + className?: string; + /** PR C: the reorder drag left this Window's strip. */ + onDragOutsideWindow?: (id: WorkspaceId, point: { clientX: number; clientY: number }) => void; + /** PR C: released over another Window; true means that Window took it. */ + onDropOnOtherWindow?: (id: WorkspaceId, point: { clientX: number; clientY: number }) => boolean; +}) { + const { workspaces, activeId } = useSyncExternalStore(subscribeToWorkspaces, getWorkspacesSnapshot); + const membership = useSyncExternalStore(subscribeToWorkspaceSurfaces, getWorkspaceSurfacesSnapshot); + const activity = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); + const [renamingId, setRenamingId] = useState(null); + const [draggingId, setDraggingId] = useState(null); + const [confirmClose, setConfirmClose] = useState<{ id: WorkspaceId; char: string } | null>(null); + + const stripRef = useRef(null); + const tabElementsRef = useRef(new Map()); + + const unions = useMemo(() => { + const byId = new Map(); + for (const workspace of workspaces) { + const ids = membership.get(workspace.id); + byId.set(workspace.id, ids ? computeWorkspaceUnion(ids, activity) : EMPTY_WORKSPACE_UNION); + } + return byId; + }, [workspaces, membership, activity]); + + // The editor and the confirmation both sit outside every Wall, so a + // capture-phase command-mode shortcut would still fire behind them. + const keyboardHeld = renamingId !== null || confirmClose !== null; + useEffect(() => (keyboardHeld ? acquireChromeKeyboardLease() : undefined), [keyboardHeld]); + + const activate = useCallback((id: WorkspaceId) => { + setActiveWorkspace(id); + tabElementsRef.current.get(id)?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); + }, []); + + /** + * Close a Workspace: confirm first when it holds work, then close every member + * Surface through the closure coordinator, then drop the Workspace itself. A + * refusal reveals the Workspace so its prompt is visible. + */ + const closeNow = useCallback(async (id: WorkspaceId) => { + const handle = getWallHandle(id); + if (handle) { + const refusal = await handle.closeAll('prompt'); + if (refusal) { + setActiveWorkspace(id); + return; + } + } + clearWorkspaceSurfaces(id); + forgetWorkspaceSession(id); + closeWorkspace(id); + }, []); + + const requestClose = useCallback((id: WorkspaceId) => { + // The last Workspace never closes — there is always one active + // (docs/specs/glossary.md → Workspace lifecycle). + if (getWorkspacesSnapshot().workspaces.length <= 1) return; + const handle = getWallHandle(id); + if (handle && (handle.hasTouchedSurfaces() || handle.runningCount() > 0)) { + setConfirmClose({ id, char: randomKillChar() }); + return; + } + void closeNow(id); + }, [closeNow]); + + const dragRef = useRef | null>(null); + if (dragRef.current === null) { + dragRef.current = createWorkspaceStripDrag({ + order: () => getWorkspacesSnapshot().workspaces.map((workspace) => workspace.id), + tabElement: (id) => tabElementsRef.current.get(id) ?? null, + stripRect: () => stripRef.current?.getBoundingClientRect() ?? null, + move: (id, toIndex) => { moveWorkspace(id, toIndex); }, + setDragging: setDraggingId, + onDragOutsideWindow, + onDropOnOtherWindow, + }); + } + const drag = dragRef.current; + useEffect(() => () => drag.dispose(), [drag]); + + // `&` and `$` in command mode reach the strip's own affordances, which live + // out here rather than in the Wall that heard the key. + useEffect(() => subscribeToWorkspaceStripIntent((intent) => { + if (intent.kind === 'close') requestClose(intent.workspaceId); + else setRenamingId(intent.workspaceId); + }), [requestClose]); + + // The confirmation is a typed letter, exactly as a pane kill is. The Wall's + // own handler is behind the chrome lease this dialog holds, so the strip + // listens for its own char. + useEffect(() => { + if (!confirmClose) return; + const { id, char } = confirmClose; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== char) return; + event.preventDefault(); + event.stopPropagation(); + setConfirmClose(null); + void closeNow(id); + }; + window.addEventListener('keydown', onKeyDown, true); + return () => window.removeEventListener('keydown', onKeyDown, true); + }, [confirmClose, closeNow]); + + const confirmTarget = confirmClose ? tabElementsRef.current.get(confirmClose.id) ?? null : null; + + return ( +
+ {workspaces.map((workspace) => { + const isActive = workspace.id === activeId; + const union = unions.get(workspace.id) ?? EMPTY_WORKSPACE_UNION; + return ( + 1} + registerElement={(element) => { + if (element) tabElementsRef.current.set(workspace.id, element); + else tabElementsRef.current.delete(workspace.id); + return undefined; + }} + onActivate={() => activate(workspace.id)} + onStartRename={() => setRenamingId(workspace.id)} + onFinishRename={(value) => { + renameWorkspace(workspace.id, value); + setRenamingId(null); + }} + onCancelRename={() => setRenamingId(null)} + onRequestClose={() => requestClose(workspace.id)} + onPress={(event) => drag.press(workspace.id, event.nativeEvent)} + wasDragged={() => drag.dragged()} + /> + ); + })} + + {confirmClose && ( + setConfirmClose(null)} + /> + )} +
+ ); +} + +function WorkspaceTab({ + id, + name, + active, + union, + renaming, + dragging, + closable, + registerElement, + onActivate, + onStartRename, + onFinishRename, + onCancelRename, + onRequestClose, + onPress, + wasDragged, +}: { + id: WorkspaceId; + name: string; + active: boolean; + union: WorkspaceUnion; + renaming: boolean; + dragging: boolean; + closable: boolean; + registerElement: (element: HTMLElement | null) => void; + onActivate: () => void; + onStartRename: () => void; + onFinishRename: (value: string) => void; + onCancelRename: () => void; + onRequestClose: () => void; + onPress: (event: ReactPointerEvent) => void; + wasDragged: () => boolean; +}) { + const todoPill = useTodoPillContent(union.todo); + // The visible Workspace shows its Surfaces, so its indicators would say what + // the panes already say; only a hidden one needs them. + const showIndicators = !active && (union.ringing || todoPill.visible); + const label = union.count > 0 ? `${name}, ${union.count} needing attention` : name; + + return ( +
{ + if (event.target instanceof Element && event.target.closest('[data-workspace-tab-close]')) return; + onPress(event); + }} + onAuxClick={(event) => { + if (event.button !== 1 || !closable) return; + event.preventDefault(); + onRequestClose(); + }} + > + {renaming ? ( + onFinishRename(value)} + onCancel={onCancelRename} + /> + ) : ( + + )} + {closable && !renaming && ( + + )} +
+ ); +} diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx index 2277544c4..5048a88c0 100644 --- a/lib/src/components/design.tsx +++ b/lib/src/components/design.tsx @@ -33,6 +33,12 @@ export const TERMINAL_SELECTION_BORDER_RADIUS = `${TERMINAL_BORDER_RADIUS_REM}re // (`*-1.75` = 7px) — keep them in sync. export const PANE_GUTTER_PX = 7; +/** Pointer travel before a press becomes a drag; below it the element's own + * click behavior (select / enter passthrough / rename / activate) is untouched. + * Shared by the pane/Door drag and the Workspace strip's reorder, so both feel + * like one gesture vocabulary. */ +export const DRAG_THRESHOLD_PX = 5; + // Concentric-corners rule: when a rounded outline wraps a rounded edge, both // arcs must share a corner center — outer radius = inner radius + offset. // Never tighten the inner radius to compensate. The pane focus ring draws on diff --git a/lib/src/components/wall/lath-drag-controller.ts b/lib/src/components/wall/lath-drag-controller.ts index 83995fb61..262bf8c20 100644 --- a/lib/src/components/wall/lath-drag-controller.ts +++ b/lib/src/components/wall/lath-drag-controller.ts @@ -6,10 +6,8 @@ import { type Rect, rectKey } from '../../lib/lath/model'; import type { DropTarget } from '../../lib/lath/ops'; import { type DropCandidate, hitTest } from '../../lib/lath/hit-test'; import { type LathWallSnapshot, LATH_LAYOUT_OPTS } from './lath-wall-store'; +import { DRAG_THRESHOLD_PX } from '../design'; -/** Pointer travel (px) before a header press becomes a pane drag; below it the - * header's own click behavior (select / enter passthrough / rename) is untouched. */ -const DRAG_THRESHOLD = 5; /** Opacity applied to the dragged leaf while its drop preview floats elsewhere. */ const DRAG_DIM = '0.6'; @@ -206,7 +204,7 @@ export function createDragController(deps: DragControllerDeps): DragController { d.lastX = e.clientX; d.lastY = e.clientY; if (!d.active) { - if (Math.hypot(e.clientX - d.startX, e.clientY - d.startY) < DRAG_THRESHOLD) return; + if (Math.hypot(e.clientX - d.startX, e.clientY - d.startY) < DRAG_THRESHOLD_PX) return; d.active = true; if (!d.external) { deps.latestRef.current.onDragStart?.(d.id); // Wall applies its selection policy diff --git a/lib/src/components/wall/use-wall-keyboard.ts b/lib/src/components/wall/use-wall-keyboard.ts index 8a1a04df9..d30b8016c 100644 --- a/lib/src/components/wall/use-wall-keyboard.ts +++ b/lib/src/components/wall/use-wall-keyboard.ts @@ -6,6 +6,7 @@ import { handleKillConfirm } from './keyboard/handle-kill-confirm'; import { handlePaneShortcuts } from './keyboard/handle-pane-shortcuts'; import { handlePaneNavigation } from './keyboard/handle-pane-navigation'; import { isProxyOrigin } from '../../lib/iframe-proxy-registry'; +import { chromeKeyboardHeld } from '../../lib/chrome-keyboard-lease'; import type { NavHistoryRef, WallKeyboardCtx } from './keyboard/types'; export function useWallKeyboard(ctx: WallKeyboardCtx): void { @@ -44,7 +45,9 @@ export function useWallKeyboard(ctx: WallKeyboardCtx): void { if (handleEditableClipboard(e)) return; if (handleMouseSelectionKeys(e, c)) return; if (c.modeRef.current === 'passthrough') return; - if (c.renamingRef.current) return; + // A pane rename, or chrome outside every Wall holding the lease (the + // Workspace strip's rename editor / close confirmation). + if (c.renamingRef.current || chromeKeyboardHeld()) return; if (handleKillConfirm(e, c)) return; if (c.dialogKeyboardActiveRef.current) return; if (handlePaneShortcuts(e, c, navHistory)) return; diff --git a/lib/src/components/workspace-strip-drag.ts b/lib/src/components/workspace-strip-drag.ts new file mode 100644 index 000000000..9acedee76 --- /dev/null +++ b/lib/src/components/workspace-strip-drag.ts @@ -0,0 +1,134 @@ +import { DRAG_THRESHOLD_PX } from './design'; +import type { WorkspaceId } from '../lib/session-types'; + +/** + * The Workspace strip's reorder gesture: a self-contained pointer controller, so + * the strip component stays a render of store state + * (`docs/specs/layout.md` → "Workspaces"). + * + * It reorders live — the model moves as tab centers are crossed, and the strip + * re-renders from the store — rather than drawing a floating copy. + */ +export interface StripDragHost { + /** Workspace ids in strip order, read fresh each frame. */ + order(): WorkspaceId[]; + /** The tab element for a Workspace, or null when it is not rendered. */ + tabElement(id: WorkspaceId): HTMLElement | null; + /** The strip's own box, for deciding the pointer has left it. */ + stripRect(): DOMRect | null; + /** Commit a reorder (the store's `moveWorkspace`). */ + move(id: WorkspaceId, toIndex: number): void; + /** Which Workspace is being dragged, for the dimmed tab. Null ends the drag. */ + setDragging(id: WorkspaceId | null): void; + /** PR C: the pointer left the window's strip entirely. */ + onDragOutsideWindow?(id: WorkspaceId, point: { clientX: number; clientY: number }): void; + /** PR C: released over another Window; true means that Window took it. */ + onDropOnOtherWindow?(id: WorkspaceId, point: { clientX: number; clientY: number }): boolean; +} + +export interface WorkspaceStripDrag { + /** Begin tracking a primary-button press on a tab. Below the threshold the + * tab's own click behavior (activate / rename) is untouched. */ + press(id: WorkspaceId, event: PointerEvent): void; + /** Whether the gesture passed the threshold, so the click that follows the + * release is a drag's tail rather than an activate. */ + dragged(): boolean; + dispose(): void; +} + +export function createWorkspaceStripDrag(host: StripDragHost): WorkspaceStripDrag { + let dragId: WorkspaceId | null = null; + let startIndex = 0; + let startX = 0; + let startY = 0; + let active = false; + let capturedBy: HTMLElement | null = null; + + function end(restore: boolean): void { + if (dragId === null) return; + if (restore) host.move(dragId, startIndex); + // jsdom (and a pointer that never captured) throws here; the gesture is + // over either way. + try { capturedBy?.releasePointerCapture?.(pointerId); } catch { /* not captured */ } + capturedBy = null; + dragId = null; + host.setDragging(null); + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointercancel', onPointerCancel); + window.removeEventListener('keydown', onKeyDown, true); + } + + let pointerId = -1; + + function onPointerMove(event: PointerEvent): void { + if (dragId === null || event.pointerId !== pointerId) return; + if (!active) { + if (Math.hypot(event.clientX - startX, event.clientY - startY) < DRAG_THRESHOLD_PX) return; + active = true; + host.setDragging(dragId); + } + const order = host.order(); + const from = order.indexOf(dragId); + if (from === -1) return; + // Swap with the neighbor whose CENTER the pointer has crossed: the tab it is + // over would flicker back and forth as the dragged tab takes its place. + for (let index = 0; index < order.length; index += 1) { + if (index === from) continue; + const rect = host.tabElement(order[index])?.getBoundingClientRect(); + if (!rect) continue; + const center = rect.left + rect.width / 2; + if ((index < from && event.clientX < center) || (index > from && event.clientX > center)) { + host.move(dragId, index); + return; + } + } + const strip = host.stripRect(); + if (host.onDragOutsideWindow && strip + && (event.clientX < strip.left || event.clientX > strip.right + || event.clientY < strip.top || event.clientY > strip.bottom)) { + host.onDragOutsideWindow(dragId, { clientX: event.clientX, clientY: event.clientY }); + } + } + + function onPointerUp(event: PointerEvent): void { + if (dragId === null || event.pointerId !== pointerId) return; + // The order is already committed live, so a release inside this strip has + // nothing left to do; PR C's hook is what a release over another Window uses. + if (active) host.onDropOnOtherWindow?.(dragId, { clientX: event.clientX, clientY: event.clientY }); + end(false); + } + + function onPointerCancel(event: PointerEvent): void { + if (event.pointerId !== pointerId) return; + end(active); + } + + function onKeyDown(event: KeyboardEvent): void { + if (event.key !== 'Escape' || dragId === null) return; + event.preventDefault(); + event.stopPropagation(); + end(active); + } + + return { + press(id, event) { + if (event.button !== 0 || dragId !== null) return; + dragId = id; + pointerId = event.pointerId; + startIndex = host.order().indexOf(id); + startX = event.clientX; + startY = event.clientY; + active = false; + const target = event.currentTarget instanceof HTMLElement ? event.currentTarget : null; + // Capture so a fast drag off the tab keeps delivering moves to it. + try { target?.setPointerCapture?.(event.pointerId); capturedBy = target; } catch { capturedBy = null; } + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp); + window.addEventListener('pointercancel', onPointerCancel); + window.addEventListener('keydown', onKeyDown, true); + }, + dragged: () => active, + dispose: () => end(false), + }; +} diff --git a/lib/src/lib/chrome-keyboard-lease.ts b/lib/src/lib/chrome-keyboard-lease.ts new file mode 100644 index 000000000..c74123b1e --- /dev/null +++ b/lib/src/lib/chrome-keyboard-lease.ts @@ -0,0 +1,32 @@ +/** + * Command-mode keyboard suppression for chrome that lives OUTSIDE every Wall — + * today the Workspace strip's rename editor and close confirmation, which sit in + * the AppBar (`docs/specs/layout.md` → "Keyboard shortcuts (command mode)"). + * The Wall's dispatch listener is capture-phase on `window`, so a field up there + * cannot stop it with `stopPropagation`; it takes a lease instead. + * + * Reference-counted like `DialogKeyboardContext`, so overlapping holders each + * release only their own. + */ + +let holders = 0; + +/** Take one lease; the returned release drops it (idempotent). */ +export function acquireChromeKeyboardLease(): () => void { + holders += 1; + let released = false; + return () => { + if (released) return; + released = true; + holders = Math.max(0, holders - 1); + }; +} + +export function chromeKeyboardHeld(): boolean { + return holders > 0; +} + +/** Drop every lease (tests). */ +export function resetChromeKeyboardLeases(): void { + holders = 0; +} diff --git a/lib/src/lib/workspace-union.test.ts b/lib/src/lib/workspace-union.test.ts index 2577548f7..0b907a719 100644 --- a/lib/src/lib/workspace-union.test.ts +++ b/lib/src/lib/workspace-union.test.ts @@ -22,22 +22,22 @@ describe('computeWorkspaceUnion', () => { 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 }); + expect(union).toEqual({ ringing: true, todo: false, count: 1, ringSeq: 0 }); }); 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 }); + expect(union).toEqual({ ringing: false, todo: true, count: 1, ringSeq: 0 }); }); 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 }); + expect(union).toEqual({ ringing: false, todo: true, count: 1, ringSeq: 0 }); }); 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 }); + expect(union).toEqual({ ringing: true, todo: true, count: 1, ringSeq: 0 }); }); it('sums distinct surfaces owing attention', () => { @@ -45,12 +45,20 @@ describe('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 }); + expect(union).toEqual({ ringing: true, todo: true, count: 2, ringSeq: 0 }); }); 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 }); + expect(union).toEqual({ ringing: false, todo: true, count: 1, ringSeq: 0 }); + }); + + it('carries the largest member ringSeq, so a new ring replays and a return does not', () => { + const union = computeWorkspaceUnion( + ['a', 'b'], + activity({ a: { status: 'ALERT_RINGING', ringSeq: 3 }, b: { status: 'ALERT_RINGING', ringSeq: 7 } }), + ); + expect(union).toEqual({ ringing: true, todo: false, count: 2, ringSeq: 7 }); }); it('is empty for an empty surface set', () => { diff --git a/lib/src/lib/workspace-union.ts b/lib/src/lib/workspace-union.ts index 2e59886c8..0e969e025 100644 --- a/lib/src/lib/workspace-union.ts +++ b/lib/src/lib/workspace-union.ts @@ -12,9 +12,12 @@ export interface WorkspaceUnion { todo: boolean; /** Number of member Surfaces owing attention (ringing or todo); each counts once. */ count: number; + /** The largest member `ringSeq`. Read only for change: a new ring replays the + * Workspace indicator's burst, and returning to the Workspace does not. */ + ringSeq: number; } -export const EMPTY_WORKSPACE_UNION: WorkspaceUnion = { ringing: false, todo: false, count: 0 }; +export const EMPTY_WORKSPACE_UNION: WorkspaceUnion = { ringing: false, todo: false, count: 0, ringSeq: 0 }; /** * Project the union over a Workspace's member Surfaces. `surfaceIds` are the @@ -29,6 +32,7 @@ export function computeWorkspaceUnion( let ringing = false; let todo = false; let count = 0; + let ringSeq = 0; for (const id of surfaceIds) { const state = activity.get(id); if (!state) continue; @@ -37,6 +41,7 @@ export function computeWorkspaceUnion( if (isRinging) ringing = true; if (isTodo) todo = true; if (isRinging || isTodo) count += 1; + ringSeq = Math.max(ringSeq, state.ringSeq); } - return { ringing, todo, count }; + return { ringing, todo, count, ringSeq }; } diff --git a/standalone/src/AppBar.tsx b/standalone/src/AppBar.tsx index 36dd170d5..04b4fa291 100644 --- a/standalone/src/AppBar.tsx +++ b/standalone/src/AppBar.tsx @@ -1,9 +1,8 @@ import { useState, useEffect } from 'react'; -import { MinusIcon, CornersOutIcon, CornersInIcon, XIcon, PlusIcon } from '@phosphor-icons/react'; +import { MinusIcon, CornersOutIcon, CornersInIcon, XIcon } from '@phosphor-icons/react'; import { PopupButtonRow, chromeButton } from '../../lib/src/components/design'; -import { getPlatform, IS_MAC } from '../../lib/src/lib/platform'; - -const WORKSPACES_ISSUE_URL = 'https://github.com/diffplug/dormouse/issues/406'; +import { WorkspaceStrip } from '../../lib/src/components/WorkspaceStrip'; +import { IS_MAC } from '../../lib/src/lib/platform'; type AppWindow = { isFocused(): Promise; @@ -154,25 +153,16 @@ export function AppBar() { {/* On macOS, native traffic lights are shown by titleBarStyle "Overlay" — we just leave padding on the left (pl-[78px]) to avoid overlapping them. */} - {/* Placeholder for the workspace strip (workspaces-rollout scope, - docs/specs/layout.md `## Future`), occupying the spot the strip will - take: after the traffic lights on macOS, at the start of the bar on - Windows/Linux. Until then it opens the tracking issue externally. */} -
- - - + {/* The Workspace strip: after the traffic lights on macOS, at the start of + the bar on Windows/Linux. Its wrapper is also the draggable spacer, so + the bar past the last tab still moves the window. Tauri matches + `data-tauri-drag-region` on the event target alone, so no tab or tab + button may carry it — that is what leaves a press on a tab free to + activate, rename, or reorder. */} +
+
- {/* Draggable spacer */} -
- {/* Theme and shell selection live in the Settings dialog at the bottom-right of the window (docs/specs/theme.md, docs/specs/standalone.md), so the titlebar carries only the From 402dfe9b672a0dea6aa510b35da9f13024191101 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 18:52:16 -0700 Subject: [PATCH 05/13] Bind the command-mode Workspace shortcuts tmux's window bindings, minus the one this Wall already spends: `c` creates, `n`/`p` cycle, `1`-`9` select, `&` closes, `$` renames (tmux's `,` is pane rename here). The branch sits after the dialog gate and before the pane shortcuts, and is inert without the Window's verbs, so a bare Wall leaves every one of those keys unbound. A modified key is never claimed, so Cmd+C stays a clipboard chord. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- lib/src/components/WorkspaceWindow.test.tsx | 31 +++++++ .../handle-workspace-shortcuts.test.ts | 83 +++++++++++++++++++ .../keyboard/handle-workspace-shortcuts.ts | 32 +++++++ lib/src/components/wall/use-wall-keyboard.ts | 2 + 4 files changed, 148 insertions(+) create mode 100644 lib/src/components/wall/keyboard/handle-workspace-shortcuts.test.ts create mode 100644 lib/src/components/wall/keyboard/handle-workspace-shortcuts.ts diff --git a/lib/src/components/WorkspaceWindow.test.tsx b/lib/src/components/WorkspaceWindow.test.tsx index ee6223d0a..5843f8ebd 100644 --- a/lib/src/components/WorkspaceWindow.test.tsx +++ b/lib/src/components/WorkspaceWindow.test.tsx @@ -216,6 +216,37 @@ describe('WorkspaceWindow', () => { expect(handle.surfaceIds()).toEqual([]); }); + it('binds the command-mode Workspace keys through the active Wall only', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await render(); + const press = async (key: string) => { + await act(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + }); + await flush(); + }; + + await press('c'); + const ids = () => getWorkspacesSnapshot().workspaces.map((workspace) => workspace.id); + expect(ids()).toHaveLength(2); + const second = ids()[1]; + expect(getActiveWorkspaceId()).toBe(second); + + await press('p'); + expect(getActiveWorkspaceId()).toBe(first); + await press('n'); + expect(getActiveWorkspaceId()).toBe(second); + await press('1'); + expect(getActiveWorkspaceId()).toBe(first); + // Out of range does nothing rather than wrapping. + await press('9'); + expect(getActiveWorkspaceId()).toBe(first); + + // Exactly one Wall dispatches, so two mounted Walls create one Workspace. + await press('c'); + expect(ids()).toHaveLength(3); + }); + it('refuses to close the last Workspace', async () => { const first = getWorkspacesSnapshot().workspaces[0].id; await render(); diff --git a/lib/src/components/wall/keyboard/handle-workspace-shortcuts.test.ts b/lib/src/components/wall/keyboard/handle-workspace-shortcuts.test.ts new file mode 100644 index 000000000..9df7cc062 --- /dev/null +++ b/lib/src/components/wall/keyboard/handle-workspace-shortcuts.test.ts @@ -0,0 +1,83 @@ +/** + * @vitest-environment jsdom + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleWorkspaceShortcuts } from './handle-workspace-shortcuts'; +import type { WallKeyboardCtx } from './types'; +import type { WorkspaceCommands } from '../wall-types'; + +const KEYS = ['c', 'n', 'p', '&', '$', '1', '5', '9']; + +function commands(): WorkspaceCommands & Record> { + return { + create: vi.fn(), + cycle: vi.fn(), + selectIndex: vi.fn(), + requestClose: vi.fn(), + requestRename: vi.fn(), + } as unknown as WorkspaceCommands & Record>; +} + +function ctxWith(workspaces?: WorkspaceCommands): WallKeyboardCtx { + return { activeRef: { current: true }, workspaces } as unknown as WallKeyboardCtx; +} + +function keydown(key: string, init: KeyboardEventInit = {}): KeyboardEvent { + return new KeyboardEvent('keydown', { key, cancelable: true, ...init }); +} + +let verbs: ReturnType; + +beforeEach(() => { + verbs = commands(); +}); + +describe('handleWorkspaceShortcuts', () => { + it('leaves every key unbound without the Workspace verbs — the bare-Wall guard', () => { + const ctx = ctxWith(); + for (const key of KEYS) { + const event = keydown(key); + expect(handleWorkspaceShortcuts(event, ctx)).toBe(false); + expect(event.defaultPrevented).toBe(false); + } + }); + + it('binds create, cycle, select, close, and rename', () => { + const ctx = ctxWith(verbs); + expect(handleWorkspaceShortcuts(keydown('c'), ctx)).toBe(true); + expect(verbs.create).toHaveBeenCalledTimes(1); + + handleWorkspaceShortcuts(keydown('n'), ctx); + handleWorkspaceShortcuts(keydown('p'), ctx); + expect(verbs.cycle.mock.calls).toEqual([[1], [-1]]); + + handleWorkspaceShortcuts(keydown('1'), ctx); + handleWorkspaceShortcuts(keydown('9'), ctx); + // The digit is 1-based on screen and 0-based in the verb. + expect(verbs.selectIndex.mock.calls).toEqual([[0], [8]]); + + handleWorkspaceShortcuts(keydown('&'), ctx); + expect(verbs.requestClose).toHaveBeenCalledTimes(1); + handleWorkspaceShortcuts(keydown('$'), ctx); + expect(verbs.requestRename).toHaveBeenCalledTimes(1); + }); + + it('claims the key it handles and leaves every other one alone', () => { + const ctx = ctxWith(verbs); + const handled = keydown('c'); + handleWorkspaceShortcuts(handled, ctx); + expect(handled.defaultPrevented).toBe(true); + + for (const key of ['0', 'x', 'k', ',', 'z', 'Enter', '|']) { + expect(handleWorkspaceShortcuts(keydown(key), ctx)).toBe(false); + } + }); + + it('ignores a modified key, so Cmd+C stays a clipboard chord', () => { + const ctx = ctxWith(verbs); + for (const init of [{ metaKey: true }, { ctrlKey: true }, { altKey: true }]) { + expect(handleWorkspaceShortcuts(keydown('c', init), ctx)).toBe(false); + } + expect(verbs.create).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/src/components/wall/keyboard/handle-workspace-shortcuts.ts b/lib/src/components/wall/keyboard/handle-workspace-shortcuts.ts new file mode 100644 index 000000000..76637b04d --- /dev/null +++ b/lib/src/components/wall/keyboard/handle-workspace-shortcuts.ts @@ -0,0 +1,32 @@ +import type { WallKeyboardCtx } from './types'; + +/** + * Command-mode Workspace shortcuts, following tmux's window bindings (tmux's + * `,` is already pane rename here, so rename is `$`). The binding table is + * `docs/specs/shortcuts.md`; the behavior is `docs/specs/layout.md` → + * "Workspaces". + * + * Every key is inert without `ctx.workspaces`, which is what keeps a bare Wall — + * VS Code, the website playground — unbound. + */ +export function handleWorkspaceShortcuts(e: KeyboardEvent, ctx: WallKeyboardCtx): boolean { + const workspaces = ctx.workspaces; + if (!workspaces) return false; + // Bare keys only: a modified `c` is a clipboard or host chord, never create. + if (e.metaKey || e.ctrlKey || e.altKey) return false; + + const run = (action: () => void): true => { + e.preventDefault(); + e.stopPropagation(); + action(); + return true; + }; + + if (e.key === 'c') return run(() => workspaces.create()); + if (e.key === 'n') return run(() => workspaces.cycle(1)); + if (e.key === 'p') return run(() => workspaces.cycle(-1)); + if (e.key === '&') return run(() => workspaces.requestClose()); + if (e.key === '$') return run(() => workspaces.requestRename()); + if (e.key >= '1' && e.key <= '9') return run(() => workspaces.selectIndex(Number(e.key) - 1)); + return false; +} diff --git a/lib/src/components/wall/use-wall-keyboard.ts b/lib/src/components/wall/use-wall-keyboard.ts index d30b8016c..289857c41 100644 --- a/lib/src/components/wall/use-wall-keyboard.ts +++ b/lib/src/components/wall/use-wall-keyboard.ts @@ -5,6 +5,7 @@ import { handleMouseSelectionKeys } from './keyboard/handle-mouse-selection-keys import { handleKillConfirm } from './keyboard/handle-kill-confirm'; import { handlePaneShortcuts } from './keyboard/handle-pane-shortcuts'; import { handlePaneNavigation } from './keyboard/handle-pane-navigation'; +import { handleWorkspaceShortcuts } from './keyboard/handle-workspace-shortcuts'; import { isProxyOrigin } from '../../lib/iframe-proxy-registry'; import { chromeKeyboardHeld } from '../../lib/chrome-keyboard-lease'; import type { NavHistoryRef, WallKeyboardCtx } from './keyboard/types'; @@ -50,6 +51,7 @@ export function useWallKeyboard(ctx: WallKeyboardCtx): void { if (c.renamingRef.current || chromeKeyboardHeld()) return; if (handleKillConfirm(e, c)) return; if (c.dialogKeyboardActiveRef.current) return; + if (handleWorkspaceShortcuts(e, c)) return; if (handlePaneShortcuts(e, c, navHistory)) return; handlePaneNavigation(e, c, navHistory); }; From 8113d886fa16b9bbbe56d3ef0fcc21308a2c5293 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 18:54:50 -0700 Subject: [PATCH 06/13] Add Workspace strip and Window stories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip states that only exist in composition — the active tab's treatment, a hidden Workspace's indicators, the rename editor, tab overflow, the close confirmation — plus the app bar carrying it and one Window story with two real Walls, so a switch is snapshotted rather than described. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- lib/src/stories/AppBar.stories.tsx | 34 +++-- lib/src/stories/WorkspaceStrip.stories.tsx | 138 ++++++++++++++++++++ lib/src/stories/WorkspaceWindow.stories.tsx | 63 +++++++++ 3 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 lib/src/stories/WorkspaceStrip.stories.tsx create mode 100644 lib/src/stories/WorkspaceWindow.stories.tsx diff --git a/lib/src/stories/AppBar.stories.tsx b/lib/src/stories/AppBar.stories.tsx index c979745db..6299a6532 100644 --- a/lib/src/stories/AppBar.stories.tsx +++ b/lib/src/stories/AppBar.stories.tsx @@ -1,12 +1,20 @@ +import { useEffect, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { AppBar } from '../../../standalone/src/AppBar'; +import { resetWorkspaces, setWorkspaces } from '../lib/workspace-store'; -function AppBarStory() { - return ( -
- -
- ); +function AppBarStory({ names }: { names: string[] }) { + // The bar's strip reads the Workspace store, so the scenario is written before + // first paint and reset after. + const [ready, setReady] = useState(false); + useEffect(() => { + const workspaces = names.map((name, index) => ({ id: `story-ws-${index + 1}`, name })); + setWorkspaces({ workspaces, activeId: workspaces[0].id }); + setReady(true); + return () => resetWorkspaces(); + }, [names]); + + return
{ready && }
; } const meta: Meta = { @@ -17,7 +25,13 @@ const meta: Meta = { export default meta; type Story = StoryObj; -/** The left slot holds the `[New workspace]` placeholder; shell selection lives - * in the Settings dialog (`Modals/SettingsDialog`). No play fn clicks the - * button — it opens the tracking issue externally. */ -export const Default: Story = {}; +/** The left slot holds the Workspace strip; shell and theme selection live in + * the Settings dialog (`Modals/SettingsDialog`). */ +export const Default: Story = { + args: { names: ['Workspace 1', 'Deploys', 'Agents'] }, +}; + +/** One Workspace: no close button anywhere, because the last one cannot close. */ +export const SingleWorkspace: Story = { + args: { names: ['Workspace 1'] }, +}; diff --git a/lib/src/stories/WorkspaceStrip.stories.tsx b/lib/src/stories/WorkspaceStrip.stories.tsx new file mode 100644 index 000000000..4ccd244ee --- /dev/null +++ b/lib/src/stories/WorkspaceStrip.stories.tsx @@ -0,0 +1,138 @@ +import { useEffect, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { WorkspaceStrip } from '../components/WorkspaceStrip'; +import { registerWallHandle, resetWallHandles, type WallHandle } from '../components/wall/wall-handles'; +import { setTerminalActivity } from '../lib/terminal-registry'; +import { resetWorkspaceSurfaces, setWorkspaceSurfaces } from '../lib/workspace-surfaces'; +import { resetWorkspaces, setWorkspaces } from '../lib/workspace-store'; +import { requireElement } from './settle-terminals'; + +/** A stand-in for a mounted Wall, so the strip's close flow has something to + * ask about running work without a live Workspace behind it. */ +function stubHandle(workspaceId: string): WallHandle { + return { + workspaceId, + surfaceIds: () => [], + ownsSurface: () => false, + hasTouchedSurfaces: () => true, + runningCount: () => 1, + serialize: async () => ({ version: 3, panes: [], doors: [] }), + flushPersistence: async () => {}, + focusSelected: () => {}, + // Never resolves: the story is the confirmation, not what follows it. + closeAll: () => new Promise(() => {}), + handleDorControl: () => {}, + }; +} + +/** Activity primed onto a Workspace's member Surfaces, keyed by tab index. */ +type IndicatorSpec = Record; + +function StripStory({ + names, + activeIndex = 0, + indicators, + busyIndex, + width = 640, +}: { + names: string[]; + activeIndex?: number; + indicators?: IndicatorSpec; + busyIndex?: number; + width?: number; +}) { + // The strip reads module stores, so the scenario is written before first paint + // and torn down after — a story must not leak Workspaces into the next one. + const [ready, setReady] = useState(false); + useEffect(() => { + const ids = names.map((_, index) => `story-ws-${index + 1}`); + setWorkspaces({ + workspaces: names.map((name, index) => ({ id: ids[index], name })), + activeId: ids[activeIndex], + }); + resetWorkspaceSurfaces(); + resetWallHandles(); + for (const [index, spec] of Object.entries(indicators ?? {})) { + const id = ids[Number(index)]; + const surfaces = [ + `${id}-a`, + ...Array.from({ length: spec.extraTodos ?? 0 }, (_, n) => `${id}-todo-${n}`), + ]; + setWorkspaceSurfaces(id, surfaces); + setTerminalActivity(surfaces[0], { + status: spec.ringing ? 'ALERT_RINGING' : 'WATCHING_DISABLED', + todo: spec.todo === true, + }); + for (const extra of surfaces.slice(1)) setTerminalActivity(extra, { todo: true }); + } + if (busyIndex !== undefined) registerWallHandle(stubHandle(ids[busyIndex])); + setReady(true); + return () => { + resetWorkspaces(); + resetWorkspaceSurfaces(); + resetWallHandles(); + }; + }, [names, activeIndex, indicators, busyIndex]); + + return ( +
+ {ready && } +
+ ); +} + +const meta: Meta = { + title: 'Components/WorkspaceStrip', + component: StripStory, +}; + +export default meta; +type Story = StoryObj; + +/** Two Workspaces, the second active: the active tab takes the wall's own + * background and the terminal top radius, the other is transparent. */ +export const Default: Story = { + args: { names: ['Workspace 1', 'Deploys'], activeIndex: 1 }, +}; + +/** Only a HIDDEN Workspace shows indicators — the visible one's panes already + * say it (`docs/specs/alert.md` → the Workspace union). */ +export const Indicators: Story = { + args: { + names: ['Builds', 'Agents', 'Workspace 3'], + activeIndex: 2, + indicators: { 0: { ringing: true, extraTodos: 1 }, 1: { todo: true } }, + }, +}; + +export const Renaming: Story = { + args: { names: ['Workspace 1', 'Deploys'], activeIndex: 1 }, + play: async () => { + const tab = await requireElement('[data-workspace-tab] button', 'workspace tab'); + tab.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + await requireElement('[data-workspace-rename-for]', 'rename editor'); + }, +}; + +/** Past the point where tabs still fit: they shrink toward the floor and the + * strip scrolls. No overflow arrows. */ +export const Overflow: Story = { + args: { + names: ['Workspace 1', 'Deploys', 'Agents', 'Builds', 'Docs', 'Scratch'], + activeIndex: 3, + width: 420, + }, +}; + +/** Closing a Workspace that holds work asks first, anchored to its own tab. */ +export const CloseConfirm: Story = { + args: { names: ['Workspace 1', 'Deploys'], activeIndex: 1, busyIndex: 1 }, + play: async () => { + const close = await requireElement( + '[data-workspace-tab-active="true"] [data-workspace-tab-close]', + 'close button', + ); + close.click(); + await requireElement('#kill-confirm-title', 'kill confirmation'); + }, +}; diff --git a/lib/src/stories/WorkspaceWindow.stories.tsx b/lib/src/stories/WorkspaceWindow.stories.tsx new file mode 100644 index 000000000..ecc3f4003 --- /dev/null +++ b/lib/src/stories/WorkspaceWindow.stories.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { WorkspaceStrip } from '../components/WorkspaceStrip'; +import { WorkspaceWindow } from '../components/WorkspaceWindow'; +import { flattenScenario, SCENARIO_LS_OUTPUT } from '../lib/platform'; +import { resetWorkspaces, setWorkspaces } from '../lib/workspace-store'; +import { requireElement, settleTerminals, waitForCondition } from './settle-terminals'; + +const WORKSPACES = [ + { id: 'story-window-1', name: 'Workspace 1' }, + { id: 'story-window-2', name: 'Deploys' }, +]; + +/** The Window as the standalone host composes it: the strip in the bar, one + * mounted Wall per Workspace below it. */ +function WorkspaceWindowStory() { + const [ready, setReady] = useState(false); + useEffect(() => { + setWorkspaces({ workspaces: WORKSPACES, activeId: WORKSPACES[0].id }); + setReady(true); + return () => resetWorkspaces(); + }, []); + + if (!ready) return null; + return ( +
+
+ +
+ +
+ ); +} + +const meta: Meta = { + title: 'App/WorkspaceWindow', + component: WorkspaceWindowStory, + parameters: { fakePty: { scenario: flattenScenario(SCENARIO_LS_OUTPUT) } }, +}; + +export default meta; +type Story = StoryObj; + +/** + * Switching to the second Workspace: both Walls stay mounted in the same grid + * cell, so the first one's terminal is still live behind the visible one and + * never refits. + */ +export const TwoWorkspaces: Story = { + play: async () => { + await settleTerminals(); + const second = await requireElement( + `[data-workspace-tab="${WORKSPACES[1].id}"] button`, + 'second workspace tab', + ); + second.click(); + await waitForCondition( + () => document.querySelector(`[data-workspace-wall="${WORKSPACES[1].id}"]`) + ?.getAttribute('data-workspace-active') === 'true', + ); + await settleTerminals(); + }, +}; From 9212f8327e34cbeb243a389b43f502ad158edaad Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 19:11:31 -0700 Subject: [PATCH 07/13] Fix three strip defects found in the dogfood harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A press on a tab captured the pointer immediately, and a captured pointer retargets the following `click` to the capture element — so every plain tab press was swallowed and no tab could be activated by mouse. Capture now waits for the drag threshold, which is the only point that needs it. The close confirmation anchored to the 24px tab, leaving the dialog clipped off the top of the window; it anchors to the Workspace's own Wall instead, which is the same box for every Workspace. The active tab took the wall's background but kept the app bar's foreground, so its name was invisible under a light theme; it takes both halves of the palette now, and the tab bell takes the header's alarm color rather than the Door's, since that is the surface it sits on. The attention count leaves the active tab's label too, matching its indicators. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- lib/src/components/WorkspaceStrip.test.tsx | 22 +++++++++++ lib/src/components/WorkspaceStrip.tsx | 22 ++++++++--- lib/src/components/workspace-strip-drag.ts | 15 +++++--- lib/src/stories/WorkspaceStrip.stories.tsx | 43 +++++++++++----------- 4 files changed, 70 insertions(+), 32 deletions(-) diff --git a/lib/src/components/WorkspaceStrip.test.tsx b/lib/src/components/WorkspaceStrip.test.tsx index c74d9f2cb..0c4e00a67 100644 --- a/lib/src/components/WorkspaceStrip.test.tsx +++ b/lib/src/components/WorkspaceStrip.test.tsx @@ -223,6 +223,28 @@ describe('WorkspaceStrip', () => { expect(order()).toEqual([first, 'ws-2', 'ws-3']); }); + it('never captures the pointer before the drag activates, so a plain press still activates', async () => { + createWorkspace({ id: 'ws-2' }); + await render(); + const first = getWorkspacesSnapshot().workspaces[0].id; + // A captured pointer retargets the following `click` to the capture element, + // which would swallow the activate button's own click on every tab press. + const capture = vi.fn(); + Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { configurable: true, value: capture }); + Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', { configurable: true, value: () => {} }); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue( + { left: 0, right: 100, width: 100, top: 0, bottom: 24, height: 24, x: 0, y: 0, toJSON: () => ({}) } as DOMRect, + ); + + await act(async () => { tabFor(first).dispatchEvent(pointer('pointerdown', { button: 0, clientX: 50, clientY: 12 })); }); + await act(async () => { window.dispatchEvent(pointer('pointermove', { clientX: 52, clientY: 12 })); }); + expect(capture).not.toHaveBeenCalled(); + + await act(async () => { window.dispatchEvent(pointer('pointermove', { clientX: 90, clientY: 12 })); }); + expect(capture).toHaveBeenCalled(); + await act(async () => { window.dispatchEvent(pointer('pointerup', { clientX: 90, clientY: 12 })); }); + }); + it('answers the keyboard intents that come from inside a Wall', async () => { const first = getWorkspacesSnapshot().workspaces[0].id; await act(async () => { createWorkspace({ id: 'ws-2' }); }); diff --git a/lib/src/components/WorkspaceStrip.tsx b/lib/src/components/WorkspaceStrip.tsx index a1c66caad..a0e9cec8b 100644 --- a/lib/src/components/WorkspaceStrip.tsx +++ b/lib/src/components/WorkspaceStrip.tsx @@ -148,7 +148,14 @@ export function WorkspaceStrip({ return () => window.removeEventListener('keydown', onKeyDown, true); }, [confirmClose, closeNow]); - const confirmTarget = confirmClose ? tabElementsRef.current.get(confirmClose.id) ?? null : null; + // Anchored to the Workspace's own Wall, not its tab: a 24px tab is too small a + // box to center a dialog over, and every Wall shares one grid cell, so the + // confirmation lands in the same place whether or not that Workspace is + // visible. No Wall (Storybook) leaves it viewport-centered. + const confirmTarget = confirmClose + ? [...document.querySelectorAll('[data-workspace-wall]')] + .find((wall) => wall.dataset.workspaceWall === confirmClose.id) ?? null + : null; return (
@@ -241,7 +248,7 @@ function WorkspaceTab({ // The visible Workspace shows its Surfaces, so its indicators would say what // the panes already say; only a hidden one needs them. const showIndicators = !active && (union.ringing || todoPill.visible); - const label = union.count > 0 ? `${name}, ${union.count} needing attention` : name; + const label = showIndicators && union.count > 0 ? `${name}, ${union.count} needing attention` : name; return (
{ @@ -299,8 +307,10 @@ function WorkspaceTab({ {todoPill.body} )} + {/* An inactive tab sits on the app bar's header palette, not on a + Door, so the bell takes the header's alarm color. */} {union.ringing && ( - + )} diff --git a/lib/src/components/workspace-strip-drag.ts b/lib/src/components/workspace-strip-drag.ts index 9acedee76..64b86fd28 100644 --- a/lib/src/components/workspace-strip-drag.ts +++ b/lib/src/components/workspace-strip-drag.ts @@ -42,15 +42,18 @@ export function createWorkspaceStripDrag(host: StripDragHost): WorkspaceStripDra let startX = 0; let startY = 0; let active = false; + /** The tab the press landed on; capture goes here once the drag activates. */ + let pressedOn: HTMLElement | null = null; let capturedBy: HTMLElement | null = null; function end(restore: boolean): void { if (dragId === null) return; if (restore) host.move(dragId, startIndex); - // jsdom (and a pointer that never captured) throws here; the gesture is - // over either way. + // jsdom (and a gesture that never reached the threshold) throws here; the + // gesture is over either way. try { capturedBy?.releasePointerCapture?.(pointerId); } catch { /* not captured */ } capturedBy = null; + pressedOn = null; dragId = null; host.setDragging(null); window.removeEventListener('pointermove', onPointerMove); @@ -67,6 +70,10 @@ export function createWorkspaceStripDrag(host: StripDragHost): WorkspaceStripDra if (Math.hypot(event.clientX - startX, event.clientY - startY) < DRAG_THRESHOLD_PX) return; active = true; host.setDragging(dragId); + // Captured only NOW, never on the press: a captured pointer retargets the + // following `click` to the capture element, which would swallow the + // activate button's own click on every plain tab press. + try { pressedOn?.setPointerCapture?.(pointerId); capturedBy = pressedOn; } catch { capturedBy = null; } } const order = host.order(); const from = order.indexOf(dragId); @@ -120,9 +127,7 @@ export function createWorkspaceStripDrag(host: StripDragHost): WorkspaceStripDra startX = event.clientX; startY = event.clientY; active = false; - const target = event.currentTarget instanceof HTMLElement ? event.currentTarget : null; - // Capture so a fast drag off the tab keeps delivering moves to it. - try { target?.setPointerCapture?.(event.pointerId); capturedBy = target; } catch { capturedBy = null; } + pressedOn = event.currentTarget instanceof HTMLElement ? event.currentTarget : null; window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); window.addEventListener('pointercancel', onPointerCancel); diff --git a/lib/src/stories/WorkspaceStrip.stories.tsx b/lib/src/stories/WorkspaceStrip.stories.tsx index 4ccd244ee..35af88558 100644 --- a/lib/src/stories/WorkspaceStrip.stories.tsx +++ b/lib/src/stories/WorkspaceStrip.stories.tsx @@ -2,10 +2,9 @@ import { useEffect, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { WorkspaceStrip } from '../components/WorkspaceStrip'; import { registerWallHandle, resetWallHandles, type WallHandle } from '../components/wall/wall-handles'; -import { setTerminalActivity } from '../lib/terminal-registry'; import { resetWorkspaceSurfaces, setWorkspaceSurfaces } from '../lib/workspace-surfaces'; import { resetWorkspaces, setWorkspaces } from '../lib/workspace-store'; -import { requireElement } from './settle-terminals'; +import { requireElement, waitForPrimedState } from './settle-terminals'; /** A stand-in for a mounted Wall, so the strip's close flow has something to * ask about running work without a live Workspace behind it. */ @@ -25,19 +24,19 @@ function stubHandle(workspaceId: string): WallHandle { }; } -/** Activity primed onto a Workspace's member Surfaces, keyed by tab index. */ -type IndicatorSpec = Record; - function StripStory({ names, activeIndex = 0, - indicators, + membership, busyIndex, width = 640, }: { names: string[]; activeIndex?: number; - indicators?: IndicatorSpec; + /** Member Surface ids per tab index. Their Activity is primed through + * `parameters.primedSessionState`, which the preview decorator applies two + * frames after mount — anything written here would be cleared by it. */ + membership?: Record; busyIndex?: number; width?: number; }) { @@ -52,18 +51,8 @@ function StripStory({ }); resetWorkspaceSurfaces(); resetWallHandles(); - for (const [index, spec] of Object.entries(indicators ?? {})) { - const id = ids[Number(index)]; - const surfaces = [ - `${id}-a`, - ...Array.from({ length: spec.extraTodos ?? 0 }, (_, n) => `${id}-todo-${n}`), - ]; - setWorkspaceSurfaces(id, surfaces); - setTerminalActivity(surfaces[0], { - status: spec.ringing ? 'ALERT_RINGING' : 'WATCHING_DISABLED', - todo: spec.todo === true, - }); - for (const extra of surfaces.slice(1)) setTerminalActivity(extra, { todo: true }); + for (const [index, surfaces] of Object.entries(membership ?? {})) { + setWorkspaceSurfaces(ids[Number(index)], surfaces); } if (busyIndex !== undefined) registerWallHandle(stubHandle(ids[busyIndex])); setReady(true); @@ -72,7 +61,7 @@ function StripStory({ resetWorkspaceSurfaces(); resetWallHandles(); }; - }, [names, activeIndex, indicators, busyIndex]); + }, [names, activeIndex, membership, busyIndex]); return (
@@ -101,8 +90,20 @@ export const Indicators: Story = { args: { names: ['Builds', 'Agents', 'Workspace 3'], activeIndex: 2, - indicators: { 0: { ringing: true, extraTodos: 1 }, 1: { todo: true } }, + membership: { 0: ['builds-a', 'builds-b'], 1: ['agents-a'], 2: ['visible-a'] }, + }, + parameters: { + primedSessionState: { + byId: { + 'builds-a': { status: 'ALERT_RINGING' }, + 'builds-b': { todo: true }, + 'agents-a': { todo: true }, + // The visible Workspace owes attention too, and still shows nothing. + 'visible-a': { status: 'ALERT_RINGING', todo: true }, + }, + }, }, + play: () => waitForPrimedState(), }; export const Renaming: Story = { From 9063fd4dc521bbc910fb950da754edc437b9b4e4 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 19:21:54 -0700 Subject: [PATCH 08/13] Promote the Workspaces design above the fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every spec that described this feature as staged now describes what it is: one mounted Wall per Workspace sharing a grid cell, a switch that is a prop flip, the routing rule that picks the answering Wall, the strip and its gestures, the command-mode bindings, and the closure path that takes each member Surface through the coordinator. The built halves are deleted from `## Future`, which is now three items — persistence, multiple windows, and the `dor workspace` verbs. `WorkspaceUnion` gains `ringSeq`, alert.md loses its Reserved line for inactive Workspaces, glossary I8 loses its Reserved marker and its verbs gain real effects, and dor-cli.md states that `workspace:` is positional and Surface targets resolve inside the answering Workspace. Evidence from the dogfood pass moves to layout.rationale.md. Word budgets ratcheted for the seven specs that grew. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- DESIGN.md | 2 +- docs/specs/alert.md | 7 +-- docs/specs/dor-cli.md | 36 ++++++++------- docs/specs/glossary.md | 17 +++---- docs/specs/layout.md | 49 +++++++++++---------- docs/specs/layout.rationale.md | 14 ++++++ docs/specs/notepad.md | 6 ++- docs/specs/shortcuts.md | 18 ++++++-- docs/specs/standalone.md | 32 ++++++++++---- docs/specs/tiling-engine.md | 6 +-- docs/specs/transport.md | 2 +- docs/specs/vscode.md | 2 +- lib/src/components/Wall.tsx | 2 +- lib/src/components/WorkspaceWindow.test.tsx | 2 +- scripts/spec-word-budgets.json | 14 +++--- 15 files changed, 127 insertions(+), 82 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 9bbb9a482..ede5a26ad 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -240,7 +240,7 @@ The system uses **raised surfaces**, not "cards." There are no nested cards. The ### Navigation The system has no traditional product top-nav. Three surfaces play navigational roles: -- **Workspace strip** (standalone app bar, top): horizontal tabs, one per Workspace, for switching between Workspaces within one window. Inactive tabs carry the union alert/TODO indicators (bell + TODO pill) borrowed from the Door vocabulary; the active tab carries none. This is standalone app-bar chrome around the Wall — see `docs/specs/layout.md` and `docs/specs/alert.md` — and its exact visual treatment is being designed in Storybook. VS Code surfaces the same status on its own native tab/badge chrome instead (`docs/specs/vscode.md`). +- **Workspace strip** (standalone app bar, top): horizontal tabs, one per Workspace, for switching between Workspaces within one window. The active tab takes the wall's own palette and the terminal top radius — a tab is the top of its Workspace as a Door is the bottom of its Surface — and carries no indicators; an inactive tab is transparent and carries the union alert/TODO indicators (bell + TODO pill) borrowed from the Door vocabulary. This is standalone app-bar chrome around the Wall — see `docs/specs/layout.md` and `docs/specs/alert.md`. VS Code surfaces the same status on its own native tab/badge chrome instead (`docs/specs/vscode.md`). - **Baseboard** (bottom of the app): horizontal strip of doors representing minimized panes plus chrome action buttons. Doors are the primary navigation affordance to a minimized terminal. Buttons use `chromeButton` with 24px height, muted text, and `hover:text-foreground`; Settings icons use square buttons with 2px gaps, while labeled overflow buttons keep horizontal padding. - **Pane Header (TerminalPaneHeader)**: the tab-replacing strip at the top of each pane. Lath is a headless tiling engine with no tab-bar chrome of its own; the React header IS the tab. diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 3ac64d266..f9ff4f24a 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -332,17 +332,18 @@ Source of truth: `lib/src/components/SettingsDialog.tsx`; `SettingsPreview` in ` | `ringing` | Any member Session is `ALERT_RINGING`. | | `todo` | Any member Surface has `todo === true`. | | `count` | Number of members ringing or TODO; each Surface counts once. | +| `ringSeq` | The largest member `ringSeq`; read only for change, so a new ring replays the indicator's burst and returning to the Workspace does not. | **Must keep the projection display-only:** it never enters the Activity machine or fires its own ring. A Surface with no activity entry contributes nothing. Callers **must include** minimized (`Doored`) Surfaces. -Reserved: **Must include inactive Workspaces' Surfaces when projecting their unions** (`docs/specs/layout.md` → Future, workspaces-rollout). +**Must project every Workspace, active or not.** The Activity store spans the whole Window, so what scopes it to one Workspace is the membership each mounted Wall publishes — panes ∪ doors, on every layout commit. -Source of truth: `computeWorkspaceUnion` in `lib/src/lib/workspace-union.ts`; `lib/src/lib/workspace-union.test.ts`. +Source of truth: `computeWorkspaceUnion` in `lib/src/lib/workspace-union.ts`; `setWorkspaceSurfaces` in `lib/src/lib/workspace-surfaces.ts`; `lib/src/lib/workspace-union.test.ts`. Where it surfaces is host-specific: - **VS Code** reflects the terminal portion onto native chrome — `docs/specs/vscode.md`, which also owns why browser-surface TODO stays webview-local. -- **Standalone** shows terminal rings/TODOs on panes and doors, and a browser Surface's `todo` on its own door. The workspace-strip union indicators are staged with the strip — `docs/specs/layout.md` `## Future` (workspaces-rollout). +- **Standalone** shows terminal rings/TODOs on panes and doors, and a browser Surface's `todo` on its own door. A **hidden** Workspace's tab additionally carries its union's TODO pill and bell, with `count` in the tab's accessible name; the visible Workspace's tab carries none, its panes and doors already saying it (`WorkspaceStrip` in `lib/src/components/WorkspaceStrip.tsx`). ## UI Contract diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 8590e70af..6c27ff9f0 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -282,18 +282,23 @@ Invariants: - Text list output defaults to refs; commands that list handles accept `--id-format refs|ids|both` (`uuids` is a compatibility alias for `ids`). JSON list output always includes both refs and stable ids. -- Reserved: `workspace:` (and `workspace:` when exactly one Workspace - matches) and `window:` select a container. The grammar is reserved now so - Surface refs never collide with it; the flag and the commands consuming it are - staged — see [Future](#future). The webview handler already rejects any - workspace/window target other than the singleton `workspace:1` / `window:1`. - Today's handler resolves stable Surface ids within the mounted Workspace; - cross-Workspace routing is staged with Workspace-aware listing/targeting. - Cross-window duplicate ids follow `docs/specs/vscode.md` → "Peer surfaces - across windows". - -Source of truth: `dor/src/commands/shared.ts`, `dor/src/commands/types.ts`, and -`surfaceRefForId` / `transferSurfaceRef` in `lib/src/components/Wall.tsx`. +- `workspace:` selects a container and is **positional**, so a strip reorder + renumbers it; `workspace:` is the stable handle and is staged with the + `dor workspace` commands (see [Future](#future)). `window:` is rejected for + every `n` but 1. **Every Workspace has a `surface:1`**, so a Surface ref alone + never identifies a Workspace. +- **One Wall answers each request**, resolved in order: an explicit + `workspace:`, else the Workspace owning the calling Surface, else the + active one; nothing mounted leaves the request unanswered. **Surface targets + resolve within the answering Workspace** — refs are Workspace-scoped — so a + `dor split` from a background Workspace lands beside its caller rather than + wherever the user is looking. Cross-Workspace targeting is staged with + Workspace-aware listing. Cross-window duplicate ids follow + `docs/specs/vscode.md` → "Peer surfaces across windows". + +Source of truth: `dor/src/commands/shared.ts`, `dor/src/commands/types.ts`, +`surfaceRefForId` / `transferSurfaceRef` in `lib/src/components/Wall.tsx`, and +`resolveDorControlRoute` in `lib/src/components/wall/dor-control-router.ts`. ## Current Implemented Commands @@ -302,8 +307,8 @@ in `dor/src/protocol.ts` (`SURFACE_CONTROL_METHODS`)** so the emitting client and the dispatching webview cannot drift. `surface.list` joins the current Workspace's Surfaces — visible panes **plus minimized (doored)** ones, each tagged `view` (`paned` / `zoomed` / `minimized`) — with terminal state and -activity snapshots, and reports the single active Workspace as `workspace:1` / -`window:1` (Workspace-aware tagging is staged; see [Future](#future)). Per the +activity snapshots, and reports the answering Workspace's own `workspace:` +alongside `window:1`. Per the visible-vs-listed split [Handle Model](#handle-model) states, **a visible split reference adds a pane in Lath, a minimized one a sibling Door in the baseboard.** **`dor list` rows sort by the Workspace-stable `surface:N` ref**, a @@ -583,6 +588,3 @@ Source of truth: `buildDorSurfacesInternal` in `lib/src/components/Wall.tsx`; `d Like every command they ship with snapshot-tested help and the control methods that back them, not ahead of them. Staged with the workspaces rollout (`docs/specs/layout.md` `## Future`, workspaces-rollout). -- **Workspace-aware `surface.list`** — tags each surface with its real - `workspace:` / `window:` membership instead of reporting the single - active Workspace. diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md index 9f62aa7ad..10f7d78ca 100644 --- a/docs/specs/glossary.md +++ b/docs/specs/glossary.md @@ -69,7 +69,7 @@ Workspace and Window are containers, not Session layers — they group Surfaces How many Workspaces a Window shows at once is host-specific: -- **Standalone** renders one implicit Workspace. Multiple-Workspace presentation is staged (`docs/specs/layout.md` → Future, workspaces-rollout). +- **Standalone** mounts every Workspace's Wall at once and shows one, switching between them (`docs/specs/layout.md` → Workspaces). - **VS Code** maps one Workspace to one webview, several visible at once: the sidebar/panel `WebviewView` is the default Workspace, each `dormouse.open` editor-tab `WebviewPanel` an independent one owning its Sessions' PTYs and browser Surfaces (`docs/specs/vscode.md`). ### Wall chrome @@ -88,7 +88,7 @@ A Workspace's **union status** is its display projection of member Surfaces' Act ### Implementation status -The Pane / Surface model and surface kinds are live. The Workspace model is unwired; `dormouse.flags.workspaces` controls the dormant standalone Window wrapper (`docs/specs/layout.md` → Workspaces), so the app runs one implicit Workspace. Ledger: `docs/specs/layout.md` `## Future` (**Scope: workspaces-rollout**); this glossary does not track it. +The Pane / Surface model, surface kinds, and the Workspace model are live; a Window still means one OS window, and `dormouse.flags.workspaces` still controls the stored Window wrapper (`docs/specs/layout.md` → Workspaces). Ledger: `docs/specs/layout.md` `## Future` (**Scope: workspaces-rollout**); this glossary does not track it. ## Roles @@ -153,7 +153,7 @@ A **Session** is the tuple of its `SessionId` plus one state per layer (I1). | `Paned` | Rendered in the content area: a primary Lath leaf or its shown auxiliary helper | | `Zoomed` | Subset of `Paned` — the passthrough-focused pane is maximized; acquiring zoom gives focus, losing focus returns it to `Paned` | | `Doored` | Rendered as a door on the baseboard. DOM survival is a rendering decision, not part of this state: browser DOM retention follows **parking** and eviction (`docs/specs/tiling-engine.md` → "Parked leaves"); a terminal Surface unmounts its element (Registry: `Orphaned`) and remounts the same xterm on reattach — nothing replays | -| `Hidden` | In neither pane nor door — webview closed or mid-transition; inactive-Workspace presentation is staged (`docs/specs/layout.md` → Future). Process and Activity unaffected. | +| `Hidden` | In neither pane nor door — webview closed or mid-transition. A Surface in a hidden Workspace is **not** `Hidden`: it stays `Paned` or `Doored`, mounted and live. Process and Activity unaffected. | ### Link @@ -197,12 +197,13 @@ A user verb is an intentional action that produces a single observable change. | `rename` | Update title; layer-agnostic | | `zoom` / `unzoom` | Paned ↔ Zoomed | | `swap` | Exchange two Surfaces' layout slots; ids travel with them, so Registry entries, Processes, and titles are untouched | -| `switchWorkspace` | Set the model's active Workspace (`setActiveWorkspace`); no Surface or rendering change yet. | -| `createWorkspace` | Add Workspace metadata; activate by default, unless `activate: false`. | -| `closeWorkspace` | Remove Workspace metadata; the last remaining Workspace cannot be closed. | +| `switchWorkspace` | Set the active Workspace (`setActiveWorkspace`), revealing its Wall and hiding the outgoing one. No Surface changes state; I8 holds by construction. | +| `createWorkspace` | Add a Workspace and mount its Wall, which spawns one pane; activate by default, unless `activate: false`. | +| `closeWorkspace` | `kill` each member Surface, then remove the Workspace; the last remaining Workspace cannot be closed. | | `renameWorkspace` | Update a Workspace's `name`; touches no Session | +| `moveWorkspace` | Reorder a Workspace within its Window; renumbers the positional `workspace:` refs and touches no Session | -Source of truth: `setActiveWorkspace` / `createWorkspace` / `closeWorkspace` / `renameWorkspace` in `lib/src/lib/workspace-store.ts`; Surface lifecycle integration is staged in `docs/specs/layout.md` → Future, workspaces-rollout. +Source of truth: `setActiveWorkspace` / `createWorkspace` / `closeWorkspace` / `renameWorkspace` / `moveWorkspace` in `lib/src/lib/workspace-store.ts`; `closeAll` in `lib/src/components/Wall.tsx`. ### System verbs @@ -243,7 +244,7 @@ Source of truth: `focusSession` / `refitSession` in `lib/src/lib/terminal-lifecy - I5: `kill` is universally valid and always ends at `View: Hidden`; its per-kind effects are the [User verbs](#user-verbs) row. - I6: `rename` is universally valid including when `Process = Exited` and `View = Doored`. - I7: Every Surface sits in exactly one Pane; every Pane and its Surfaces belong to exactly one Workspace; every Workspace belongs to one Window. -- I8: Reserved: **Must preserve Process and Activity during `switchWorkspace`, without firing a fresh ring on mount** (I3; `docs/specs/layout.md` → Future, workspaces-rollout). +- I8: **Must preserve Process and Activity during `switchWorkspace`, without firing a fresh ring** (I3). A switch mounts nothing, so no ring can fire (`docs/specs/layout.md` → Workspaces). - I9: A Workspace's union status is a pure projection of its members' Activity: no independent state, destroyed with the Workspace. - I10: **Must preserve a terminal Surface's `SessionId`** (I1). **Must transfer the `surface:N` CLI ref when replacing a browser Surface**, minting a new id in the same layout slot with its target URL. An `ab-screencast` ⇄ `ab-popout` relaunch keeps the Surface id; render-mode changes do not universally imply replacement (rationale; `docs/specs/dor-browser.md` → Display Modal And Render Swaps). diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 2f6589961..160d70be4 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -10,7 +10,7 @@ ## Conceptual model -A Wall renders one Workspace's Surfaces as Panes in Content or Doors on the Baseboard. Pane↔Door preserves the Surface; a Doored browser Surface keeps its backing session while releasing its viewer resources ([Minimize and reattach](#minimize-and-reattach)). The standalone Workspace strip and switching are staged in [Future](#future) (**Scope: workspaces-rollout**); VS Code maps each Workspace to a webview (`docs/specs/vscode.md`). +A Wall renders one Workspace's Surfaces as Panes in Content or Doors on the Baseboard. Pane↔Door preserves the Surface; a Doored browser Surface keeps its backing session while releasing its viewer resources ([Minimize and reattach](#minimize-and-reattach)). Standalone mounts one Wall per Workspace and switches between them ([Workspaces](#workspaces)); what that feature still owes is staged in [Future](#future) (**Scope: workspaces-rollout**). VS Code maps each Workspace to a webview (`docs/specs/vscode.md`). ## Shell layout @@ -141,13 +141,24 @@ Source of truth: `lib/src/components/Baseboard.tsx`, `lib/src/components/Door.ts ## Workspaces -Each Wall renders one Workspace's Content (Lath layout) and Baseboard (doors). VS Code's per-webview mapping is owned by `docs/specs/vscode.md`. +Each Wall renders one Workspace's Content (Lath layout) and Baseboard (doors). Standalone mounts one Wall **per Workspace**; VS Code and the website playground mount a bare Wall with no Workspace id, which behaves exactly as a single-Workspace Window (VS Code's per-webview mapping is `docs/specs/vscode.md`). -The in-memory model, container verbs, and Window persistence wrapper are implemented but unwired. The live union projection and its host displays are owned by `docs/specs/alert.md` → Workspace union. **Must reject duplicate Workspace IDs before mutating the model**, preserving the last-Workspace close guard (`workspace-store.test.ts`). `dormouse.flags.workspaces` is off by default and selects the wrapper's bare `PersistedSession` versus `PersistedWindow` format (`docs/specs/transport.md`). **Both standalone adapters disable session persistence**, so the flag alone enables no storage or Workspace UI. No production code calls the container verbs; `setActiveWorkspace` does not re-render the Wall, and standalone runs one implicit Workspace. +- **Must mount every Workspace's Wall in one grid cell**, inactive Walls `visibility:hidden` (plus `inert`) and never `display:none` (rationale). +- **Must switch by flipping `active` alone**: no re-seed, no re-parent, no unmount, and no `mountElement` / `resumeTerminal` / `restoreTerminal`, which is what makes I8 hold by construction (`WorkspaceWindow.test.tsx`). +- **Only the active Wall dispatches window input** — the capture-phase `keydown`/`message` listeners, the host New Terminal event, and the blur that clears cross-session attention — **and only it renders the modal hosts** (rationale). Store-backed modal state survives a switch. +- **Exactly one Wall answers a `dor` request**, chosen by `docs/specs/dor-cli.md` → "Handle Model". Every Wall registers a handle, a bare one under `DEFAULT_WORKSPACE_ID`, so the router always finds one. +- **Never unmount a Wall before its Surfaces are disposed** — `closeAll` waits for the kill fade to commit, bounded by the engine's exit duration, since unmounting mid-fade would leave `Orphaned` Registry entries (`docs/specs/glossary.md` → "Invariants" I4). +- **Must reject duplicate Workspace IDs before mutating the model**, preserving the last-Workspace close guard (`workspace-store.test.ts`). +- Each Wall keeps its own mode and selection across switches: deactivating blurs its selected pane, activating focuses it a frame later, since focus into a hidden subtree is a no-op. +- A Workspace's first activation claims the GL context its hidden mount deferred ([Renderer](#renderer); rationale). -Source of truth: `createWorkspace` / `setWorkspaces` / `closeWorkspace` / `renameWorkspace` / `setActiveWorkspace` in `lib/src/lib/workspace-store.ts`; `WORKSPACES_FLAG_KEY` in `lib/src/lib/feature-flags.ts`; `loadSessionState` / `saveSessionState` in `lib/src/lib/window-persistence.ts`; `PERSIST_SESSION` in `standalone/src/tauri-adapter.ts` and `standalone/src/browser-sidecar-adapter.ts`. +**Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter anchored over the Workspace's own Wall, then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers the positional `workspace:` refs with it. -The strip UI, real switching, and lifecycle UX are staged in [Future](#future) — this spec's `## Future` is the single rollout ledger; other specs link here. +The union projection and its indicators are owned by `docs/specs/alert.md` → Workspace union; the strip that renders them by `docs/specs/standalone.md` → AppBar. Persisted containers are owned by `docs/specs/transport.md`; `dormouse.flags.workspaces` still selects the bare `PersistedSession` versus `PersistedWindow` stored format, and **both standalone adapters still disable session persistence**, so a relaunch restores one Workspace. + +Source of truth: `WorkspaceWindow` in `lib/src/components/WorkspaceWindow.tsx`; `registerWallHandle` in `lib/src/components/wall/wall-handles.ts`; `closeAll` in `lib/src/components/Wall.tsx`; `createWorkspace` / `closeWorkspace` / `renameWorkspace` / `moveWorkspace` / `setActiveWorkspace` in `lib/src/lib/workspace-store.ts`; `setWorkspaceSurfaces` in `lib/src/lib/workspace-surfaces.ts`; `PERSIST_SESSION` in `standalone/src/tauri-adapter.ts` and `standalone/src/browser-sidecar-adapter.ts`. + +What multi-window, per-Workspace persistence, and the `dor workspace` verbs still owe is staged in [Future](#future) — this spec's `## Future` is the single rollout ledger; other specs link here. ## Modes @@ -178,12 +189,14 @@ Wall starts in `command` mode. Embedders may pass `initialMode="passthrough"` wh `docs/specs/shortcuts.md` tables every binding; this section owns the dispatch behavior behind it. -All keys are handled in one capture-phase `keydown` listener on `window` (`use-wall-keyboard.ts`), which delegates in a fixed order to the modules in `lib/src/components/wall/keyboard/`: dual-tap → editable-field clipboard → mouse-selection keys → *(passthrough stops here)* → *(rename stops here)* → kill confirmation → *(an open dialog stops here)* → pane shortcuts → pane navigation. **Must prevent default and stop propagation for handled command keys.** Bare Meta/Shift presses stop only internal dispatch; the detector leaves their DOM event untouched. +All keys are handled in one capture-phase `keydown` listener on `window` (`use-wall-keyboard.ts`), which delegates in a fixed order to the modules in `lib/src/components/wall/keyboard/`: *(an inactive Workspace stops here)* → dual-tap → editable-field clipboard → mouse-selection keys → *(passthrough stops here)* → *(a rename or the chrome lease stops here)* → kill confirmation → *(an open dialog stops here)* → Workspace shortcuts → pane shortcuts → pane navigation. **Must prevent default and stop propagation for handled command keys.** Bare Meta/Shift presses stop only internal dispatch; the detector leaves their DOM event untouched. That order is load-bearing twice: a rename input suppresses the pane shortcuts but **not** the mode-exit gesture or the field's own clipboard chords; and a staged kill confirmation hijacks each key reaching it before the dialog gate, so the confirm letter works even though the modal is open. **Every open dialog holds its own reference-counted lease on that gate**, and command-mode dispatch resumes only once the last lease is released — so a dialog closing over another cannot lift the survivor's suppression (`createDialogKeyboardCoordinator` in `lib/src/components/wall/wall-context.tsx`). +**Chrome outside every Wall takes the chrome keyboard lease instead**: the Workspace strip's rename editor and close confirmation live in the app bar, where `stopPropagation` cannot reach a capture-phase window listener. **The Workspace branch is inert without the Window's verbs**, which is what leaves those keys unbound on a bare Wall. Source of truth: `acquireChromeKeyboardLease` in `lib/src/lib/chrome-keyboard-lease.ts`; `handleWorkspaceShortcuts` in `lib/src/components/wall/keyboard/handle-workspace-shortcuts.ts`. + ### Split cwd inheritance A split from an existing pane (`|`/`%`/`-`/`"` or the header split buttons) spawns the new pane with its source pane's last-known cwd, then selects it and enters passthrough; host New Terminal actions share that focus tail (rationale). Focus-neutral control-plane creation (`dor split -- …`, `dor ensure`, `dor iframe`, `dor ab`) keeps its documented background behavior. @@ -406,28 +419,16 @@ A store commit that empties the tree (last pane killed or minimized) triggers th 4. **Asymmetric back-navigation**: the breadcrumb ([Spatial navigation](#spatial-navigation)) makes every arrow move reversible even where no spatial query would return you. 5. **Door keeps selection through the auto-spawn refill** ([Auto-spawn refill](#auto-spawn-refill)). Explicit user selection of a pane — a click, a drag, or an embed focusing itself — still moves selection off a door. 6. **Focus-neutral surface creation (`dor ensure` / `dor iframe` / `dor ab`)**: unlike `dor split`, these open in the background without moving focus off the caller (`docs/specs/dor-cli.md`, `docs/specs/dor-browser.md`). An add never re-parents the caller's subtree or steals activation, and the create does not call `selectPane` (`settleAddSelection` returns false for a focus-neutral, non-selection-replacing add). **The one exception**: `dor iframe` / `dor ab` replacing the pane the user is *currently selected on* moves selection to the replacement, else it would dangle on the removed leaf; any other pane, or a door selection, is left untouched. Cleanup of a `dor ensure` temporary Surface follows `docs/specs/notepad.md` → "Closure"; any completed teardown preserves the caller's live selection. +7. **A hidden Workspace is not minimized**: its Surfaces stay `Paned` / `Doored` and its Sessions keep running; only painting stops, because `useSurfaceVisibility` reports a Workspace-inactive Surface as off screen and its streaming bodies idle. +8. **A refused close reveals its Workspace**: a `closeAll` that returns a refusal activates that Workspace, so the prompt behind the refusal is on screen rather than inside a hidden Wall. ## Future -**Scope: workspaces-rollout** — the remaining stages of the multi-Workspace feature. Current implementation: [Workspaces](#workspaces). Persisted containers are owned by `docs/specs/transport.md`; union projection by `docs/specs/alert.md`. This ledger is the single home for what remains; other specs link here rather than restating it. - -### Stage 3 — workspace strip and switching UI (standalone) - -The standalone app bar (`standalone/src/AppBar.tsx`) grows a horizontal **workspace strip**: one tab per Workspace, in the bar's draggable region. Each tab shows the Workspace `name` and, for **inactive** Workspaces only, the union `ringing` bell and `todo` pill from `docs/specs/alert.md`, reusing the Door indicator vocabulary — the active tab needs no union indicator, its alerts already being visible on its own panes and doors. Exact tab visuals settle in the Storybook UI pass. - -Switch/create/close/rename shortcuts are chosen alongside that pass. Command mode is their natural home, following the tmux *window* bindings the rest of the keymap mirrors (a Dormouse Workspace is the analogue of a tmux window). `docs/specs/shortcuts.md` lists them once bound. - -### Stage 4 — real switching and multi-Workspace activation - -`switchWorkspace` presents the target Workspace's panes and doors and hides the previously active Workspace's. Terminals reuse the `mount` / `unmount` path: the Registry entry, xterm buffer, and PTY survive, Process is unchanged, and nothing replays. Browser Surfaces keep their backing agent-browser session or proxy grant; parking follows below. - -Switching **parks** the outgoing Workspace's browser Surfaces rather than unmounting them, on exactly the terms minimize already does (`docs/specs/tiling-engine.md` → "Parked leaves"): the switch parks each one, then seeds the incoming Workspace's tree, which `seed` is already written to survive — it keeps parked leaves except any the seed itself admits. That is what makes an iframe survive a round trip through another Workspace. Open question: a switch parks a whole Workspace at a time, so `MAX_PARKED_SURFACES` may need raising, or becoming a per-Workspace budget. VS Code is out of reach either way — one webview per Workspace ([Workspaces](#workspaces)) bounds cross-Workspace DOM survival by webview lifetime, not by anything the Wall does. Because a terminal's Activity keeps flowing while unmounted, an inactive Workspace's tab can begin ringing or showing TODO while the user is elsewhere; **mounting must not fire a fresh ring** (glossary I8, mirroring the minimize/reattach rule I3). - -Stage 4 also enables multiple Workspaces in the standalone presentation and wires the lifecycle UX: +**Scope: workspaces-rollout** — what the multi-Workspace feature still owes. Current implementation: [Workspaces](#workspaces). Persisted containers are owned by `docs/specs/transport.md`; union projection by `docs/specs/alert.md`. This ledger is the single home for what remains; other specs link here rather than restating it. -- **Create** (`createWorkspace`): adds a Workspace, names it `Workspace N`, makes it active, and spawns a single fresh pane — matching the empty-state behavior in Session persistence. -- **Close** (`closeWorkspace`): `kill`s each member Surface and removes the Workspace. Closing one holding touched Surfaces confirms first, reusing the kill-confirm vocabulary; the confirmation surface settles in the Storybook UI pass. **The last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). -- **Rename** (`renameWorkspace`): edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. +- **Standalone persistence and agent recovery.** Every Workspace's record already reaches the Window collector, which has no writer, so nothing is stored and a relaunch restores one Workspace. Turning it on means seeding the collector at boot, debouncing and flushing its writes, adopting a restored `PersistedWindow` into the Workspace store, and lifting VS Code's agent-recovery capture into a host-agnostic module the sidecar bundles. +- **Multiple OS windows.** PTY ownership routing in Rust, window lifecycle, tearing a Workspace out into its own window, dropping one onto another window, and restoring N windows. `WorkspaceStrip`'s `onDragOutsideWindow` / `onDropOnOtherWindow` and the router's `window:` rejection are the seams; `WINDOW_REF` names the only Window this build addresses. +- **`dor workspace` verbs.** `new` / `rename` / `close` / `switch`, plus `dor list --all` for cross-Workspace targeting and `workspace:` as the stable handle beside today's positional `workspace:`. ### Re-arming the WebGL renderer after context loss diff --git a/docs/specs/layout.rationale.md b/docs/specs/layout.rationale.md index 0711c7af3..8184ab29f 100644 --- a/docs/specs/layout.rationale.md +++ b/docs/specs/layout.rationale.md @@ -14,6 +14,20 @@ xterm.js paints only its own rendered surface, and integer row fitting leaves a **Why header popovers are not a factor.** Every one — pane context menu, title candidates, notification preview, rename warning — portals to `document.body` with `position: fixed`, so it renders in the root stacking context above the whole wall regardless of leaf z-indices. +## Workspaces + +**Why `visibility: hidden` in one grid cell rather than `display: none`.** A `display:none` Wall has no box, so every xterm in it would refit on the way back — including a resize that happened while it was hidden. Sharing one grid cell keeps every Wall's box identical, so a resize refits all of them once and a switch refits nothing (checked in the browser-dev harness by resizing with Workspace 2 visible and finding Workspace 1's screen already at the new size, 2026-09). + +**Why `inert` is only defense in depth.** `visibility: hidden` already removes focusability, so the attribute exists for a future presentation that keeps the subtree visible. + +**Why the GL claim is deferred to first activation.** Browsers cap live WebGL contexts, and a Wall mounted hidden would spend one on a Session nobody has looked at; deferring makes the budget scale with *visited* Workspaces. It costs one extra claim check per activation, which is a no-op once claimed. + +**Why the strip's reorder drag does not capture the pointer on press.** A captured pointer retargets the following `click` to the capture element, so capturing on `pointerdown` swallowed the activate button's click and no tab could be activated by mouse (found in the browser-dev harness, 2026-09). Capture is only useful once the gesture is a drag, which is where it now happens. + +**Why the close confirmation anchors to the Wall, not the tab.** `ModalOverlay` centers inside the target's box and does not clamp to the viewport, so a 24px tab at the top of the window left the dialog clipped. Every Wall shares one grid cell, so the anchor lands in the same place whether or not that Workspace is visible. + +**Why the modal hosts are gated rather than hoisted.** Each calls `useDialogKeyboardOwner`, which reads the *active* Wall's `DialogKeyboardContext`; hoisting them above `WorkspaceWindow` would leave them with no coordinator to suppress command-mode dispatch through. The cost is that a modal's React-local state resets on a switch — accepted, since every modal that matters keeps its state in a store. + ## Baseboard **Why `showBaseboard={false}` is a seam.** The mobile Pocket composition — the obvious candidate — is a separate `MobileWall` (`docs/specs/mobile-terminal-ui.md`), not a baseboard-less Wall. diff --git a/docs/specs/notepad.md b/docs/specs/notepad.md index 7dd440630..b035a4410 100644 --- a/docs/specs/notepad.md +++ b/docs/specs/notepad.md @@ -130,9 +130,11 @@ On a failed archive: **An in-place replacement keeps the notepad instead of archiving it.** Renderer swaps, browser/terminal mode changes, and shell replacement each mint a new Surface id, so the notes migrate with the ref wherever `transferSurfaceRef` runs; pins into the disposed terminal are dropped on the way. -Reserved: Workspace and Window closure has no live code path today (`closeWorkspace` has only test callers and the workspaces flag is dormant), so nothing is wired to it; routing it through the coordinator belongs to the **workspaces-rollout** scope. +**A Workspace closure routes every member Surface through the coordinator**, one at a time, and the first refusal stops it with that Workspace intact (`docs/specs/layout.md` → Workspaces). -Source of truth: `archiveSurfaceNotes` in `lib/src/lib/notepad/close-coordinator.ts`; `closeSurface` and `killPaneImmediately` in `lib/src/components/Wall.tsx`; `NotepadArchiveFailureModal` in `lib/src/components/NotepadArchiveFailure.tsx`; `beginClosing` and `transferNotepad` in `lib/src/lib/notepad/notepad-store.ts`; `useSurfaceClosing` in `lib/src/components/use-notepad.ts`. +**Each mounted Wall registers one Surface-metadata resolver**, and a Wall answers `null` for a Surface it does not own, so the first non-null answer is the owning Workspace's — a batch and the volatile mirror describe a Surface identically no matter which Workspace holds it. + +Source of truth: `archiveSurfaceNotes` in `lib/src/lib/notepad/close-coordinator.ts`; `closeSurface` / `killPaneImmediately` / `closeAll` in `lib/src/components/Wall.tsx`; `NotepadArchiveFailureModal` in `lib/src/components/NotepadArchiveFailure.tsx`; `beginClosing`, `transferNotepad` and `registerNotepadSurfaceMetaResolver` in `lib/src/lib/notepad/notepad-store.ts`; `useSurfaceClosing` in `lib/src/components/use-notepad.ts`. ## Standalone quit diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md index b820bc667..f4bfd015f 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -28,6 +28,18 @@ A focused cross-origin iframe surface swallows the gesture; the proxy shim detec | `t` | Toggle todo | Toggle the TODO marker on the selected Surface, terminal or browser; doors excluded. | | `>` | Terminal context | Terminal panes only; consumed no-op on browser panes, inert on doors. | +## Workspaces (command mode) + +Standalone only — a bare Wall (VS Code, the website playground) leaves every key here unbound. Follows the tmux *window* bindings, except rename: tmux's `,` is already pane rename. + +| Key | Action | Description | +|-----|--------|-------------| +| `c` | Create Workspace | Adds `Workspace N`, activates it, and spawns its one pane. | +| `n` / `p` | Next / previous | Wraps at both ends. | +| `1`–`9` | Select by position | The nth Workspace in strip order; out of range is a consumed no-op. | +| `&` | Close Workspace | Opens the strip's close flow; confirms first when the Workspace holds work, and the last Workspace never closes. | +| `$` | Rename Workspace | Opens the strip's inline editor on the active tab. | + ## Navigation (command mode) | Key | Action | Description | @@ -86,12 +98,10 @@ The standalone host contributes no chords; `docs/specs/standalone.md` owns its n ## Implementation references - `lib/src/components/wall/use-wall-keyboard.ts` — the capture-phase listener; the iframe-shim leader `message` listener -- `lib/src/components/wall/keyboard/` — one module per dispatch branch: `handle-dual-tap.ts`, `handle-editable-clipboard.ts`, `handle-mouse-selection-keys.ts`, `handle-kill-confirm.ts`, `handle-pane-shortcuts.ts`, `handle-pane-navigation.ts`; platform modifiers in `chords.ts` +- `lib/src/components/wall/keyboard/` — one module per dispatch branch: `handle-dual-tap.ts`, `handle-editable-clipboard.ts`, `handle-mouse-selection-keys.ts`, `handle-kill-confirm.ts`, `handle-workspace-shortcuts.ts`, `handle-pane-shortcuts.ts`, `handle-pane-navigation.ts`; platform modifiers in `chords.ts` +- `lib/src/lib/chrome-keyboard-lease.ts`, `lib/src/lib/workspace-strip-intent.ts` — the strip's keyboard suppression, and the bridge that carries `&` / `$` out to it - `lib/src/lib/vscode-keybindings.ts` — the workbench mirror allowlist - `lib/src/lib/terminal-mouse-router.ts` — live Alt tracking during a drag - `lib/src/components/SelectionPopup.tsx`, `lib/src/components/wall/TerminalContextView.tsx`, `lib/src/components/wall/InlineEditInput.tsx` — the popover/dialog handlers - `lib/src/components/wall/agent-browser-surface-controller.ts` — browser key forwarding and the edit-chord bridge -## Future - -Workspace switch / create / close / rename shortcuts (command mode) are staged with the workspaces rollout ([layout.md](layout.md#future), **Scope: workspaces-rollout**), following the tmux *window* bindings the rest of the keymap mirrors; listed here once bound. diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index b4e7bf4de..475b8eace 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -50,10 +50,12 @@ Source of truth: `standalone/src/main.tsx` (`bootstrap()`). 8. `resumeOrRestore(platform)` — the priority-based recovery from `docs/specs/transport.md`. 9. `startUpdateCheck()` (`docs/specs/auto-update.md`), then render `AppBar` + - `App` with `enableBurrow` — the mount gate for the lazily-imported + `App` with `multiWorkspace` — one Wall per Workspace (`docs/specs/layout.md` + → Workspaces) — and `enableBurrow`, the mount gate for the lazily-imported Burrow UI chunk (§Burrow service); the Burrow itself runs in the sidecar regardless. `` rides the `baseboardNotice` - slot, `` the `dialogHost` slot. + slot, `` the `dialogHost` slot; both go to the + visible Workspace's Wall. ## Rust ↔ sidecar bridge @@ -235,19 +237,31 @@ orchestrator (§Quit flow, which owns the teardown/install/exit sequence); Tauri Source of truth: `standalone/src/AppBar.tsx`. -The AppBar is the draggable titlebar region, carrying left to right a -`[New workspace]` button and — Windows/Linux only, since macOS gets native traffic +The AppBar is the draggable titlebar region, carrying left to right the +**Workspace strip** and — Windows/Linux only, since macOS gets native traffic lights from `titleBarStyle: "Overlay"` and left padding instead — the window controls (minimize / maximize / close via `@tauri-apps/api/window`, dimmed by window-focus tracking). **Neither a theme picker nor a shell picker belongs here**: both live in the Settings dialog at the bottom-right of the window (`docs/specs/theme.md`). -`[New workspace]` is a placeholder holding the spot the workspace strip will take. -It creates nothing — it calls `openExternal` on -https://github.com/diffplug/dormouse/issues/406, the tracking issue. The strip -lands here at stage 3 of the rollout (`docs/specs/layout.md` `## Future`, -workspaces-rollout). +The strip is one tab per Workspace: click activates, double-click renames, +middle-click or the tab's `×` closes, `+` creates, and a drag past the shared +threshold reorders. Behavior is `docs/specs/layout.md` → Workspaces and its +indicators `docs/specs/alert.md` → Workspace union; tabs shrink to a floor and +then the strip scrolls, with no overflow arrows. + +- **Never put `data-tauri-drag-region` on a tab or anything inside one.** Tauri + matches that attribute on the event target alone, so a tab carrying it would + drag the window instead of activating, renaming, or reordering. Its wrapper — + the bar past the last tab — carries it, and is the draggable spacer. +- **Deferred to the multi-window stage:** `onDragOutsideWindow` and + `onDropOnOtherWindow` are the strip's seams for tearing a Workspace out and + dropping it on another Window; nothing passes them yet + (`docs/specs/layout.md` `## Future`, workspaces-rollout). + +Source of truth: `WorkspaceStrip` in `lib/src/components/WorkspaceStrip.tsx`; +`createWorkspaceStripDrag` in `lib/src/components/workspace-strip-drag.ts`. Shell selection lives in the Settings dialog's **Shell** row (`lib/src/components/ShellPicker.tsx` over `lib/src/lib/shell-store.ts`), hidden diff --git a/docs/specs/tiling-engine.md b/docs/specs/tiling-engine.md index df2b6a417..c37fdb399 100644 --- a/docs/specs/tiling-engine.md +++ b/docs/specs/tiling-engine.md @@ -132,12 +132,12 @@ A **parked** leaf is mounted by the adapter but absent from the split tree: its | `removeLeaf(id)` | out | destroyed | unmounted — a kill | | `forgetLeaf(id)` | — | destroyed | unmounted if parked — destroys a Door | -- **Parking must be one commit** — an id absent from both the tree and `parked` for even one render unmounts the leaf and loses its DOM state. Every re-admitting op (`addLeaf`, `restoreLeaf`, `insertLeaf`, `replaceLeaf`, `seed`) unparks in that same commit through the one shared `admit` helper, which also seeds the enter hint. **`seed` admits by tree membership**, never by the metadata it is handed (rationale). Dormant while `seed` runs once at startup; live in the workspaces-rollout switch. +- **Parking must be one commit** — an id absent from both the tree and `parked` for even one render unmounts the leaf and loses its DOM state. Every re-admitting op (`addLeaf`, `restoreLeaf`, `insertLeaf`, `replaceLeaf`, `seed`) unparks in that same commit through the one shared `admit` helper, which also seeds the enter hint. **`seed` admits by tree membership**, never by the metadata it is handed (rationale). **`seed` runs once per Wall mount**, so a Workspace switch — which mounts nothing — never re-seeds. - **One `leafMeta` map holds every leaf the Wall owns**, laid out or Doored; `parked` is pure render state (`Map`) naming the subset that keeps its DOM. Detachment is a fact about the *tree*, so **no Door record carries a metadata copy that can go stale** — `setTitle` / `updateParams` reach a Doored leaf by the same single path as a visible one, and every reader goes through `lath.getMeta(id)` (rationale). `serializeLayout` filters `leafMeta` to the tree's own leaves; a Door persists as its own row. - **The store holds a parked leaf's last rect, never the adapter** — `registerEl(null)` is a ref detach, not an unmount (rationale). `doorLeaf({ park: true })` captures the rect in the commit that removes the leaf from the tree, `admit` replays it into the animator on re-admission (Animation → Enter), and LathHost renders parked ids there behind `visibility: hidden; pointer-events: none` and `data-lath-parked`, so the guest never sees a zero-extent viewport (rationale). A leaf parked before the Wall reports geometry falls back to the whole wall rect. - **Parked is a visibility signal, not just a layout fact** — it reaches the body as `PaneProps.parked` (Pane props contract), so a minimized `ab-screencast` stays mounted, releases viewer resources, and retains its daemon session. - **Who parks**: `shouldParkOnMinimize` — browser Surfaces, not terminals, whose persistent xterm instance remounts without replay ([glossary.md → View](glossary.md#view)). -- **Bounded.** `MAX_PARKED_SURFACES` (8) caps the set and `doorLeaf` trims the oldest park in the same commit, because each parked leaf is a live document still running scripts, timers, and sockets. Only the **DOM** is capped: an evicted leaf is still a Door with live meta and reattaches by reloading with the latest URL/session params. The cap is sized for the workspaces-rollout switch, which parks a whole Workspace at a time (`docs/specs/layout.md` → Future). +- **Bounded.** `MAX_PARKED_SURFACES` (8) caps the set and `doorLeaf` trims the oldest park in the same commit, because each parked leaf is a live document still running scripts, timers, and sockets. Only the **DOM** is capped: an evicted leaf is still a Door with live meta and reattaches by reloading with the latest URL/session params. It budgets **minimized browser Surfaces only**: a hidden Workspace parks nothing — its leaves stay mounted and merely stop painting (`docs/specs/layout.md` → Workspaces). - **Hydration.** A restored session's Doors have no store entry yet, so `seed` puts the persisted rows' meta into `leafMeta` beside the tree's leaves (`leafMetaFromPersistedDoor`) — the only place a Door's wire row is read for metadata. The runtime record is `{ id, token }`. Source of truth: `parked` / `doorLeaf` / `addDoor` / `forgetLeaf` / `parkedIds` / `MAX_PARKED_SURFACES` in `lib/src/components/wall/lath-wall-store.ts`; `shouldParkOnMinimize` / `leafMetaFromPersistedDoor` in `lib/src/components/wall/lath-wall-engine.ts`; `minimizePane` in `lib/src/components/Wall.tsx`; the parked render branch in `lib/src/components/wall/LathHost.tsx`. @@ -205,6 +205,6 @@ Source of truth: `LeafMeta` / `LathPersistedLayout` / `lathLayoutFromStore` / `i ## Testing -Ordering constraint: the workspace-switching stages of the **workspaces-rollout** scope (defined in [layout.md](layout.md)) build on this engine — a workspace switch under Lath is "swap which tree renders." `onApiReady` (the old tiling-api ready callback) is gone and **must not come back**: its last consumer, the website tutorial, drives off the engine-neutral `WallEvent` stream (`paneAdded`, `selectionChange`). +Ordering constraint: the remaining stages of the **workspaces-rollout** scope (defined in [layout.md](layout.md)) build on this engine — one engine instance per mounted Wall, never a shared one. `onApiReady` (the old tiling-api ready callback) is gone and **must not come back**: its last consumer, the website tutorial, drives off the engine-neutral `WallEvent` stream (`paneAdded`, `selectionChange`). Source of truth: the DOM-free suites in `lib/src/lib/lath/`, the binding suites under `lib/src/components/wall/`, and `lib/src/components/Wall.test.tsx`; live acceptance evidence is retained in the rationale. diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 720d39ef0..7b920fb7f 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -153,7 +153,7 @@ OSC parsing/stripping rules for those rows, and the rule that **only the process **Surface kinds in the snapshot.** Each `PersistedPane` records a `surfaceType` (`docs/specs/glossary.md`): `'terminal'` — the default, **omitted from the row** so terminal snapshots stay byte-identical — or `'browser'`. It routes restore/resume, and **a pane lacking it reads as `'terminal'`**. `restoreSession` skips terminal restoration for a browser pane rather than minting a stray PTY + xterm per browser pane id, and the resume plan keeps browser panes and minimized browser doors despite their having no live PTY, so the saved layout's leaf set still matches and is not discarded. A browser pane rebuilds from the persisted layout (visible) or `PersistedDoor.params` (minimized) — its render params (`renderMode`, `url`, agent-browser `session`) live there, not in `PersistedPane`. **Must reject a layout whose leaves differ from the visible pane set during restore or resume, and omit visible browser ids from the terminal fallback.** Browser doors retain their independent render params; pinned by `lib/src/lib/session-restore.test.ts` and `lib/src/lib/reconnect.test.ts`. -**Workspace/Window container helpers are implemented but dormant while standalone disables Session persistence**, regardless of `dormouse.flags.workspaces` (rollout ledger in `docs/specs/layout.md` `## Future`). A `PersistedWorkspace` is a `WorkspaceId`, a user-facing `name`, and that Workspace's `PersistedSession`. The helper's top-level snapshot is a `PersistedWindow` (its own `version: 1`) wrapping v3 sessions: the ordered `PersistedWorkspace` list plus the active `WorkspaceId`. **VS Code does not use it** — each webview persists one bare `PersistedSession`, its single Workspace, through its own per-surface state API (`docs/specs/vscode.md`). +**Each mounted Workspace publishes its `PersistedSession` to a Window collector**, which orders them by the Workspace store and drops any Workspace that has published nothing rather than writing it empty. **The collector ships with no writer**, so standalone still stores nothing and a relaunch restores one Workspace (rollout ledger in `docs/specs/layout.md` `## Future`). A Workspace's save compares against its own previous record, never the Window's active one, or a dead PTY's retained cwd would come from the wrong Workspace. A `PersistedWorkspace` is a `WorkspaceId`, a user-facing `name`, and that Workspace's `PersistedSession`. Source of truth: `getWindowSnapshot` / `installWindowSessionWriter` in `lib/src/lib/window-session-aggregator.ts`; `SaveSink` in `lib/src/lib/session-save.ts`. The helper's top-level snapshot is a `PersistedWindow` (its own `version: 1`) wrapping v3 sessions: the ordered `PersistedWorkspace` list plus the active `WorkspaceId`. **VS Code does not use it** — each webview persists one bare `PersistedSession`, its single Workspace, through its own per-surface state API (`docs/specs/vscode.md`). **The wrapping lives at the standalone adapter boundary, never in the shared save/restore code.** `window-persistence.ts` translates between the host's stored top-level blob and the bare `PersistedSession` that `reconnect.ts` / `session-save.ts` operate on; both standalone adapters gate `getState` / `saveState` before reaching its `loadSessionState` / `saveSessionState`. The helpers accept a `SessionKeyValueStore` synchronous slot (`docs/specs/standalone.md` → Persistence). When called directly, flag **off** (the default) passes bare sessions through; flag **on** loads the active Workspace's session and merges saves while preserving the others. diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 6797f7ae8..28a7d0527 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -79,7 +79,7 @@ Consequences: > > Union reflection onto native chrome is always-on — the extension host has no `localStorage` for the standalone workspaces flag ([Future](#future)). The Window persistence container is standalone-only; VS Code keeps one bare `PersistedSession` per webview. -**One webview is one Workspace.** The bottom-panel `WebviewView` ("Dormouse") is the default Workspace; each `dormouse.open` editor-tab `WebviewPanel` is an independent Workspace. Several are visible at once, and VS Code — not Dormouse — owns their tabs, creation, and closing, so **Dormouse adds no create/rename/close affordances here**. A Workspace's Surfaces are the terminal Sessions whose PTYs its router tracks (`ownedPtyIds`, `docs/specs/transport.md`) plus the browser Surfaces rendered in it. +**One webview is one Workspace.** The bottom-panel `WebviewView` ("Dormouse") is the default Workspace; each `dormouse.open` editor-tab `WebviewPanel` is an independent Workspace. Several are visible at once, and VS Code — not Dormouse — owns their tabs, creation, and closing, so **Dormouse adds no create/rename/close affordances here**: the webview mounts a bare ``, which leaves the Workspace strip and its shortcuts to standalone (`docs/specs/layout.md` → Workspaces). A Workspace's Surfaces are the terminal Sessions whose PTYs its router tracks (`ownedPtyIds`, `docs/specs/transport.md`) plus the browser Surfaces rendered in it. #### Surfacing union status on native chrome diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 85daeb4ac..da2a7a306 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -1038,7 +1038,7 @@ export function Wall({ } // `killPaneImmediately` defers the tree removal by the exit animation; // unmounting the Wall before that lands would leave Orphaned Sessions - // (docs/specs/glossary.md → I4). Bounded so a stuck fade cannot hang a quit. + // (docs/specs/glossary.md → "Invariants" I4). Bounded so a stuck fade cannot hang a quit. const deadline = Date.now() + lath.exitMs + 50; while (lath.store.leafIds().length > 0 || doorsRef.current.length > 0) { if (Date.now() >= deadline) break; diff --git a/lib/src/components/WorkspaceWindow.test.tsx b/lib/src/components/WorkspaceWindow.test.tsx index 5843f8ebd..282495bdd 100644 --- a/lib/src/components/WorkspaceWindow.test.tsx +++ b/lib/src/components/WorkspaceWindow.test.tsx @@ -153,7 +153,7 @@ describe('WorkspaceWindow', () => { // A switch flips a prop; it never unmounts a leaf, so nothing calls // mountElement / resumeTerminal / restoreTerminal and `ringSeq` cannot - // advance (docs/specs/glossary.md → I8). + // advance (docs/specs/glossary.md → "Invariants" I8). expect(wallFor(first).querySelector('[data-lath-leaf="pane-a"]')).toBe(leafBefore); expect(wallFor(first).querySelector('[data-session-id="pane-a"]')).toBe(paneBefore); expect(getActivitySnapshot().get('pane-a')!.ringSeq).toBe(ringBefore); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index b67132a6c..908747dff 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -2,17 +2,17 @@ "AGENTS.md": 3350, "SECURITY.md": 200, "SELF_HOST.md": 6000, - "docs/specs/alert.md": 6600, + "docs/specs/alert.md": 6700, "docs/specs/auto-update.md": 1000, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4500, - "docs/specs/dor-cli.md": 4850, + "docs/specs/dor-cli.md": 4900, "docs/specs/dor-tool.md": 2100, "docs/specs/glossary.md": 2850, - "docs/specs/layout.md": 8000, + "docs/specs/layout.md": 8150, "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3750, - "docs/specs/notepad.md": 3700, + "docs/specs/notepad.md": 3750, "docs/specs/pocket-app.md": 4050, "docs/specs/relay.md": 9950, "docs/specs/remote-api.md": 3600, @@ -23,14 +23,14 @@ "docs/specs/security-remote.md": 4900, "docs/specs/security-supply-chain.md": 1150, "docs/specs/security.md": 1900, - "docs/specs/shortcuts.md": 1000, - "docs/specs/standalone.md": 4300, + "docs/specs/shortcuts.md": 1050, + "docs/specs/standalone.md": 4400, "docs/specs/terminal-context.md": 900, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2350, "docs/specs/theme.md": 2150, "docs/specs/tiling-engine.md": 4500, - "docs/specs/transport.md": 4450, + "docs/specs/transport.md": 4500, "docs/specs/tutorial.md": 1900, "docs/specs/vscode.md": 7350, "docs/specs/webgl-text.md": 1200, From 70b4465e16c100a45a71337981ccd3f77085379f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 20:06:46 -0700 Subject: [PATCH 09/13] Simplify the Workspace composition after review Quality-only pass over Stage A: same behavior, fewer moving parts. - Wall handle is one stable object mutated in place; drop `serialize`, `focusSelected`, `SessionPersistenceHandle.buildSession`, and un-export `buildPersistedSession`. `focusSelected(focused)` now covers both branches of the activation effect. - One membership expression (`memberSurfaceIds` / `publishMembership`) behind the two publish effects, `ownsSurface`, and `closeAll`; `closeAll` waits on a Lath store subscription instead of a 10 ms poll. - `ownsHostFlush` derives from `workspaceId`; `SaveSink.previous`/`publish` are required; the `onPtyData` dirty trigger is ownership-filtered like `handlePtyExit`. - One WebGL claim site: `mountElement` loses its `claimWebgl` option and `TerminalPane` keeps the single activation effect. - `dor` refs: `use-dor-control` reads `WINDOW_REF` directly, `workspaceRefFor` is total, and the router uses `isWindowRef` with one unknown-workspace branch. - `WallBootProps` is declared once and spread by `App` and `WorkspaceWindow`. - The chrome keyboard lease moves under `components/wall/` and reuses `createDialogKeyboardCoordinator`; the strip holds it via `useDialogKeyboardOwner`. The kill-confirm key rule is one shared predicate, so the strip's confirmation matches the pane's (Caps Lock, stray key). - `WorkspaceCommands` and the strip intent bus are replaced by direct store calls plus `workspace-ui-store`; the close/rename verbs move to `wall/workspace-lifecycle.ts`. `workspaceIdForSurface` is deleted. - Strip renders each union in the loop, keeps unchanged union objects, memoizes the tab, uses a stable `registerElement`, anchors the confirmation to `[data-workspace-content]`, and reads the PR-C drag hooks through a ref. Two fixes with user-visible consequences, each pinned by a test: - The agent-browser window key forwarder now honors Workspace visibility, so a hidden Workspace left in passthrough on a browser pane stops swallowing every keystroke. - `useAlertSpeech`, `useDynamicPalette`, and the dev-server port correlation are window singletons again: N Walls no longer speak each ring N times, fight over the document palette, or clobber and endlessly re-poll each other's port resolutions. Test and story reuse: `stubWallHandle`, `mountWallHarness`, `ensureResizeObserver`, and a `primedWorkspaces` Storybook parameter replacing the three stories' hand-rolled priming (verified by a Storybook build plus screenshots of the strip, AppBar, and Window stories). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- docs/specs/layout.md | 8 +- docs/specs/shortcuts.md | 4 +- lib/.storybook/preview.ts | 40 +++ lib/src/App.tsx | 17 +- lib/src/components/TerminalPane.tsx | 11 +- lib/src/components/Wall.test.tsx | 39 +- lib/src/components/Wall.tsx | 128 +++---- lib/src/components/WorkspaceStrip.test.tsx | 35 +- lib/src/components/WorkspaceStrip.tsx | 211 ++++++----- lib/src/components/WorkspaceWindow.test.tsx | 37 +- lib/src/components/WorkspaceWindow.tsx | 57 +-- .../wall/AgentBrowserPanel.test.tsx | 56 ++- lib/src/components/wall/AgentBrowserPanel.tsx | 8 +- .../components/wall/chrome-keyboard-lease.ts | 29 ++ .../wall/dor-control-router.test.ts | 13 +- lib/src/components/wall/dor-control-router.ts | 9 +- .../wall/keyboard/handle-kill-confirm.ts | 13 +- .../handle-workspace-shortcuts.test.ts | 83 +++-- .../keyboard/handle-workspace-shortcuts.ts | 28 +- lib/src/components/wall/keyboard/types.ts | 9 +- lib/src/components/wall/use-alert-speech.ts | 25 +- .../wall/use-dev-server-ports.test.tsx | 103 ++++++ .../components/wall/use-dev-server-ports.ts | 335 ++++++++++-------- lib/src/components/wall/use-dor-control.ts | 12 +- .../wall/use-session-persistence.ts | 45 +-- lib/src/components/wall/use-wall-keyboard.ts | 2 +- lib/src/components/wall/wall-handles.ts | 23 +- lib/src/components/wall/wall-test-utils.ts | 50 +++ lib/src/components/wall/wall-types.ts | 26 +- .../wall/window-singletons.test.tsx | 77 ++++ .../components/wall/workspace-lifecycle.ts | 59 +++ lib/src/lib/chrome-keyboard-lease.ts | 32 -- lib/src/lib/session-save.ts | 21 +- lib/src/lib/terminal-lifecycle.ts | 11 +- lib/src/lib/themes/use-dynamic-palette.ts | 78 ++-- lib/src/lib/window-session-aggregator.ts | 5 +- lib/src/lib/workspace-store.test.ts | 3 +- lib/src/lib/workspace-store.ts | 26 +- lib/src/lib/workspace-strip-intent.ts | 26 -- lib/src/lib/workspace-surfaces.test.ts | 11 - lib/src/lib/workspace-surfaces.ts | 8 - lib/src/lib/workspace-ui-store.ts | 53 +++ lib/src/stories/AppBar.stories.tsx | 24 +- lib/src/stories/WorkspaceStrip.stories.tsx | 112 +++--- lib/src/stories/WorkspaceWindow.stories.tsx | 18 +- scripts/spec-word-budgets.json | 2 +- 46 files changed, 1188 insertions(+), 834 deletions(-) create mode 100644 lib/src/components/wall/chrome-keyboard-lease.ts create mode 100644 lib/src/components/wall/use-dev-server-ports.test.tsx create mode 100644 lib/src/components/wall/window-singletons.test.tsx create mode 100644 lib/src/components/wall/workspace-lifecycle.ts delete mode 100644 lib/src/lib/chrome-keyboard-lease.ts delete mode 100644 lib/src/lib/workspace-strip-intent.ts create mode 100644 lib/src/lib/workspace-ui-store.ts diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 160d70be4..2484b1648 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -145,18 +145,18 @@ Each Wall renders one Workspace's Content (Lath layout) and Baseboard (doors). S - **Must mount every Workspace's Wall in one grid cell**, inactive Walls `visibility:hidden` (plus `inert`) and never `display:none` (rationale). - **Must switch by flipping `active` alone**: no re-seed, no re-parent, no unmount, and no `mountElement` / `resumeTerminal` / `restoreTerminal`, which is what makes I8 hold by construction (`WorkspaceWindow.test.tsx`). -- **Only the active Wall dispatches window input** — the capture-phase `keydown`/`message` listeners, the host New Terminal event, and the blur that clears cross-session attention — **and only it renders the modal hosts** (rationale). Store-backed modal state survives a switch. +- **A hidden Wall consumes no window input**: every listener it keeps is gated on `active`, so nothing it hears is dispatched, forwarded, or `preventDefault`ed. **Only the active Wall renders the modal hosts** (rationale); their store-backed state survives a switch. - **Exactly one Wall answers a `dor` request**, chosen by `docs/specs/dor-cli.md` → "Handle Model". Every Wall registers a handle, a bare one under `DEFAULT_WORKSPACE_ID`, so the router always finds one. - **Never unmount a Wall before its Surfaces are disposed** — `closeAll` waits for the kill fade to commit, bounded by the engine's exit duration, since unmounting mid-fade would leave `Orphaned` Registry entries (`docs/specs/glossary.md` → "Invariants" I4). - **Must reject duplicate Workspace IDs before mutating the model**, preserving the last-Workspace close guard (`workspace-store.test.ts`). - Each Wall keeps its own mode and selection across switches: deactivating blurs its selected pane, activating focuses it a frame later, since focus into a hidden subtree is a no-op. - A Workspace's first activation claims the GL context its hidden mount deferred ([Renderer](#renderer); rationale). -**Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter anchored over the Workspace's own Wall, then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers the positional `workspace:` refs with it. +**Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter and key rule over the Window's content area, then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers the positional `workspace:` refs with it. **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path. The union projection and its indicators are owned by `docs/specs/alert.md` → Workspace union; the strip that renders them by `docs/specs/standalone.md` → AppBar. Persisted containers are owned by `docs/specs/transport.md`; `dormouse.flags.workspaces` still selects the bare `PersistedSession` versus `PersistedWindow` stored format, and **both standalone adapters still disable session persistence**, so a relaunch restores one Workspace. -Source of truth: `WorkspaceWindow` in `lib/src/components/WorkspaceWindow.tsx`; `registerWallHandle` in `lib/src/components/wall/wall-handles.ts`; `closeAll` in `lib/src/components/Wall.tsx`; `createWorkspace` / `closeWorkspace` / `renameWorkspace` / `moveWorkspace` / `setActiveWorkspace` in `lib/src/lib/workspace-store.ts`; `setWorkspaceSurfaces` in `lib/src/lib/workspace-surfaces.ts`; `PERSIST_SESSION` in `standalone/src/tauri-adapter.ts` and `standalone/src/browser-sidecar-adapter.ts`. +Source of truth: `WorkspaceWindow` in `lib/src/components/WorkspaceWindow.tsx`; `registerWallHandle` in `lib/src/components/wall/wall-handles.ts`; `closeAll` in `lib/src/components/Wall.tsx`; `requestWorkspaceClose` in `lib/src/components/wall/workspace-lifecycle.ts`; `createWorkspace` / `closeWorkspace` / `renameWorkspace` / `moveWorkspace` / `setActiveWorkspace` in `lib/src/lib/workspace-store.ts`; `getWorkspaceUiSnapshot` in `lib/src/lib/workspace-ui-store.ts`; `setWorkspaceSurfaces` in `lib/src/lib/workspace-surfaces.ts`; `PERSIST_SESSION` in `standalone/src/tauri-adapter.ts` and `standalone/src/browser-sidecar-adapter.ts`. What multi-window, per-Workspace persistence, and the `dor workspace` verbs still owe is staged in [Future](#future) — this spec's `## Future` is the single rollout ledger; other specs link here. @@ -195,7 +195,7 @@ That order is load-bearing twice: a rename input suppresses the pane shortcuts b **Every open dialog holds its own reference-counted lease on that gate**, and command-mode dispatch resumes only once the last lease is released — so a dialog closing over another cannot lift the survivor's suppression (`createDialogKeyboardCoordinator` in `lib/src/components/wall/wall-context.tsx`). -**Chrome outside every Wall takes the chrome keyboard lease instead**: the Workspace strip's rename editor and close confirmation live in the app bar, where `stopPropagation` cannot reach a capture-phase window listener. **The Workspace branch is inert without the Window's verbs**, which is what leaves those keys unbound on a bare Wall. Source of truth: `acquireChromeKeyboardLease` in `lib/src/lib/chrome-keyboard-lease.ts`; `handleWorkspaceShortcuts` in `lib/src/components/wall/keyboard/handle-workspace-shortcuts.ts`. +**Chrome outside every Wall takes the chrome keyboard lease instead**: the Workspace strip's rename editor and close confirmation live in the app bar, where `stopPropagation` cannot reach a capture-phase window listener. **The Workspace branch is inert on a Wall with no Workspace id**, which is what leaves those keys unbound on a bare Wall. Source of truth: `acquireChromeKeyboardLease` in `lib/src/components/wall/chrome-keyboard-lease.ts`; `handleWorkspaceShortcuts` in `lib/src/components/wall/keyboard/handle-workspace-shortcuts.ts`. ### Split cwd inheritance diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md index f4bfd015f..f0616c566 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -37,7 +37,7 @@ Standalone only — a bare Wall (VS Code, the website playground) leaves every k | `c` | Create Workspace | Adds `Workspace N`, activates it, and spawns its one pane. | | `n` / `p` | Next / previous | Wraps at both ends. | | `1`–`9` | Select by position | The nth Workspace in strip order; out of range is a consumed no-op. | -| `&` | Close Workspace | Opens the strip's close flow; confirms first when the Workspace holds work, and the last Workspace never closes. | +| `&` | Close Workspace | Runs the close flow; confirms first when the Workspace holds work, and the last Workspace never closes. | | `$` | Rename Workspace | Opens the strip's inline editor on the active tab. | ## Navigation (command mode) @@ -99,7 +99,7 @@ The standalone host contributes no chords; `docs/specs/standalone.md` owns its n - `lib/src/components/wall/use-wall-keyboard.ts` — the capture-phase listener; the iframe-shim leader `message` listener - `lib/src/components/wall/keyboard/` — one module per dispatch branch: `handle-dual-tap.ts`, `handle-editable-clipboard.ts`, `handle-mouse-selection-keys.ts`, `handle-kill-confirm.ts`, `handle-workspace-shortcuts.ts`, `handle-pane-shortcuts.ts`, `handle-pane-navigation.ts`; platform modifiers in `chords.ts` -- `lib/src/lib/chrome-keyboard-lease.ts`, `lib/src/lib/workspace-strip-intent.ts` — the strip's keyboard suppression, and the bridge that carries `&` / `$` out to it +- `lib/src/components/wall/chrome-keyboard-lease.ts`, `lib/src/lib/workspace-ui-store.ts` — the strip's keyboard suppression, and the state `&` / `$` write for it to render - `lib/src/lib/vscode-keybindings.ts` — the workbench mirror allowlist - `lib/src/lib/terminal-mouse-router.ts` — live Alt tracking during a drag - `lib/src/components/SelectionPopup.tsx`, `lib/src/components/wall/TerminalContextView.tsx`, `lib/src/components/wall/InlineEditInput.tsx` — the popover/dialog handlers diff --git a/lib/.storybook/preview.ts b/lib/.storybook/preview.ts index dc9dda832..49dda4826 100644 --- a/lib/.storybook/preview.ts +++ b/lib/.storybook/preview.ts @@ -40,6 +40,37 @@ import { cfg } from '../src/cfg'; import type { DormouseTheme } from '../src/lib/themes'; import { clearPersistedShellSelection, seedShellStore } from '../src/lib/shell-store'; import type { ShellEntry } from '../src/lib/shell-defaults'; +import { getWorkspacesSnapshot, resetWorkspaces, setWorkspaces, type WorkspaceMeta } from '../src/lib/workspace-store'; +import { resetWorkspaceSurfaces, setWorkspaceSurfaces } from '../src/lib/workspace-surfaces'; +import { resetWorkspaceUi } from '../src/lib/workspace-ui-store'; + +/** `parameters.primedWorkspaces`: the Window a Workspace story renders. */ +interface PrimedWorkspaces { + workspaces: WorkspaceMeta[]; + /** Defaults to the first. */ + activeId?: string; + /** Member Surface ids per Workspace id, for the union indicators. */ + membership?: Record; +} + +/** Written during render, not in the effect below: the strip and the Window read + * the Workspace store on their FIRST render, so priming a frame later would + * paint the default single Workspace first. Re-applied only when it differs, so + * a re-render never notifies a subscriber mid-render. */ +function applyPrimedWorkspaces(primed: PrimedWorkspaces | undefined): void { + if (!primed) return; + const current = getWorkspacesSnapshot(); + const activeId = primed.activeId ?? primed.workspaces[0]?.id; + const same = current.activeId === activeId + && current.workspaces.length === primed.workspaces.length + && current.workspaces.every((ws, i) => ws.id === primed.workspaces[i].id && ws.name === primed.workspaces[i].name); + if (same) return; + setWorkspaces({ workspaces: primed.workspaces, activeId }); + resetWorkspaceSurfaces(); + for (const [id, surfaceIds] of Object.entries(primed.membership ?? {})) { + setWorkspaceSurfaces(id, surfaceIds); + } +} /** Fallback for one frame when the renderer is not painting (see `afterFrame`). * Matches `paintFrame()` in `settle-terminals.ts`: long enough that a slow-but-real @@ -318,6 +349,11 @@ const preview: Preview = { clearPersistedShellSelection(); seedShellStore(primedShells ?? []); + // The Workspace model a strip / Window story renders, and the membership + // its indicators project. Activity for those members is primed with the + // rest below, two frames in. + applyPrimedWorkspaces(context.parameters?.primedWorkspaces as PrimedWorkspaces | undefined); + useEffect(() => { let cancelled = false; let raf = 0; @@ -408,6 +444,10 @@ const preview: Preview = { } platform.clearDefaultScenario(); disposeAllSessions(); + // A story must not leak its Workspaces into the next one. + resetWorkspaces(); + resetWorkspaceSurfaces(); + resetWorkspaceUi(); }; }, [platform, primedSessionState, primedTerminalState, primedWatchedCommands, primedAlertSettings, primedPushDevices, primedAlertSpeech]); diff --git a/lib/src/App.tsx b/lib/src/App.tsx index 8adbddfb4..068c493fa 100644 --- a/lib/src/App.tsx +++ b/lib/src/App.tsx @@ -2,7 +2,7 @@ import { Component, type ReactNode } from "react"; import { Wall } from "./components/Wall"; import { WorkspaceWindow } from "./components/WorkspaceWindow"; import { ThemeDebuggerGlobal } from "./components/ThemeDebugger"; -import type { PersistedDoor, PersistedSurfaceRefs } from "./lib/session-types"; +import type { WallBootProps } from "./components/wall/wall-types"; class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { state: { error: Error | null } = { error: null }; @@ -24,21 +24,12 @@ class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | } export default function App({ - initialPaneIds, - restoredLathLayout, - initialDoors, - initialSurfaceRefs, - initialSurfaceRefsNext, baseboardNotice, dialogHost, enableBurrow, multiWorkspace = false, -}: { - initialPaneIds?: string[]; - restoredLathLayout?: unknown; - initialDoors?: PersistedDoor[]; - initialSurfaceRefs?: PersistedSurfaceRefs; - initialSurfaceRefsNext?: number; + ...boot +}: WallBootProps & { baseboardNotice?: ReactNode; dialogHost?: ReactNode; enableBurrow?: boolean; @@ -50,7 +41,7 @@ export default function App({ const Shell = multiWorkspace ? WorkspaceWindow : Wall; return ( - + diff --git a/lib/src/components/TerminalPane.tsx b/lib/src/components/TerminalPane.tsx index be4a450c5..395599b32 100644 --- a/lib/src/components/TerminalPane.tsx +++ b/lib/src/components/TerminalPane.tsx @@ -37,17 +37,13 @@ const REFIT_THROTTLE_MS = 150; export function TerminalPane({ id, isFocused = true }: TerminalPaneProps) { const containerRef = useRef(null); const workspaceActive = useContext(WorkspaceActiveContext); - // Read through a ref so the mount effect keeps its `[id]` deps: a Workspace - // activation must not remount the terminal, only claim its GL context. - const workspaceActiveRef = useRef(workspaceActive); - workspaceActiveRef.current = workspaceActive; useEffect(() => { const container = containerRef.current; if (!container) return; getOrCreateTerminal(id); - mountElement(id, container, { claimWebgl: workspaceActiveRef.current }); + mountElement(id, container); // Throttled (see REFIT_THROTTLE_MS) so animated/dragged geometry doesn't // reflow the buffer on every frame. @@ -64,8 +60,9 @@ export function TerminalPane({ id, isFocused = true }: TerminalPaneProps) { }; }, [id]); - // A Workspace's first activation claims the GL context its hidden mount - // deferred (docs/specs/layout.md → "Workspaces"); already-claimed is a no-op. + // The only GL claim site: a visible Workspace claims on mount, a hidden one on + // its first activation (docs/specs/layout.md → "Workspaces"). Kept out of the + // mount effect so an activation never remounts the terminal. useEffect(() => { if (workspaceActive) claimWebglRenderer(id); }, [id, workspaceActive]); diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index b7baaebdc..0ae63b948 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -7,7 +7,7 @@ * geometry — the acceptance matrix in tiling-engine.md is the live gate. */ import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; +import { type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SURFACE_CONTROL_METHODS } from 'dor/protocol'; import { sessionForKey } from 'dor-lib-common/agent-browser'; @@ -24,6 +24,7 @@ import { addPlainNote, beginClosing, clearAllNotepads, getNotes } from '../lib/n import type { NotepadArchiveV1 } from '../lib/notepad/types'; import { createTerminalPaneState, type TerminalPaneState } from '../lib/terminal-state'; import { getWallHandle, listWallHandles } from './wall/wall-handles'; +import { mountWallHarness, type WallHarness } from './wall/wall-test-utils'; import { DEFAULT_WORKSPACE_ID } from '../lib/session-types'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -40,6 +41,7 @@ vi.mock('./TerminalPane', () => ({ ), })); +let harness: WallHarness; let container: HTMLDivElement; let root: Root; let fake: FakePtyAdapter; @@ -53,46 +55,19 @@ beforeEach(() => { clearAllNotepads(); fake = new FakePtyAdapter(); setPlatform(fake); - // jsdom lacks these; Baseboard / dynamic-palette / reduced-motion need them. - globalThis.ResizeObserver ??= class { - observe() {} - unobserve() {} - disconnect() {} - } as unknown as typeof ResizeObserver; - // Reduced motion so the Lath engine runs a 0 duration: the two-phase kill's - // deferred removal fires on a setTimeout(0) and completes within `flush()` — the - // instant path is also stage 3's "reduced motion" acceptance requirement. - globalThis.matchMedia = ((query: string) => ({ - matches: query.includes('prefers-reduced-motion'), - media: query, - onchange: null, - addEventListener() {}, - removeEventListener() {}, - addListener() {}, - removeListener() {}, - dispatchEvent() { return false; }, - })) as unknown as typeof matchMedia; - Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { - configurable: true, - value: vi.fn(() => null), - }); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); + harness = mountWallHarness(); + ({ container, root } = harness); }); afterEach(() => { - act(() => root.unmount()); - container.remove(); + harness.dispose(); vi.clearAllMocks(); vi.restoreAllMocks(); __resetArchiveServiceForTests(); clearAllNotepads(); }); -async function flush(): Promise { - await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); -} +const flush = (): Promise => harness.flush(); async function flushFrame(): Promise { await act(async () => { await new Promise((r) => requestAnimationFrame(() => r(undefined))); }); diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index da2a7a306..659ba4002 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -64,9 +64,9 @@ import type { SurfaceView as DorSurfaceView, } from 'dor/commands/types'; import { hasBrowser, hasTerminal } from 'dor/commands/types'; -import { DEFAULT_WORKSPACE_ID, type PersistedDoor, type PersistedSurfaceRefs, type WorkspaceId } from '../lib/session-types'; +import { DEFAULT_WORKSPACE_ID, type PersistedSurfaceRefs, type WorkspaceId } from '../lib/session-types'; import { clearWorkspaceSurfaces, setWorkspaceSurfaces } from '../lib/workspace-surfaces'; -import { WINDOW_REF, workspaceRefFor } from '../lib/workspace-store'; +import { workspaceRefFor } from '../lib/workspace-store'; import { registerWallHandle, type WallHandle } from './wall/wall-handles'; import { installDorControlRouter } from './wall/dor-control-router'; import type { DropTarget, RestoreToken } from '../lib/lath/ops'; @@ -115,7 +115,7 @@ import { type PaneWriteActions, type WallActions, } from './wall/wall-context'; -import type { CloseSurfaceMode, DoorAfterRestoreAction, DoorChip, DooredItem, WallEvent, WallMode, WallSelectionKind, WorkspaceCommands } from './wall/wall-types'; +import type { CloseSurfaceMode, DoorAfterRestoreAction, DoorChip, DooredItem, WallBootProps, WallEvent, WallMode, WallSelectionKind } from './wall/wall-types'; type ShellSpawnRequest = { shell?: string; @@ -131,7 +131,7 @@ type ShellSpawnNoticeState = { nonce: number; }; -export type { DoorAfterRestoreAction, DoorChip, DooredItem, WallEvent, WallMode, WallSelectionKind, WorkspaceCommands } from './wall/wall-types'; +export type { DoorAfterRestoreAction, DoorChip, DooredItem, WallBootProps, WallEvent, WallMode, WallSelectionKind } from './wall/wall-types'; export { DialogKeyboardContext, DoorElementsContext, @@ -262,16 +262,8 @@ export function Wall({ enableBurrow = false, workspaceId, active = true, - workspaceCommands, -}: { - initialPaneIds?: string[]; +}: WallBootProps & { initialMode?: WallMode; - /** The restored Lath persisted layout (docs/specs/tiling-engine.md → - * "Persistence"). */ - restoredLathLayout?: unknown; - initialDoors?: PersistedDoor[]; - initialSurfaceRefs?: PersistedSurfaceRefs; - initialSurfaceRefsNext?: number; onEvent?: (event: WallEvent) => void; baseboardNotice?: ReactNode; /** @@ -302,15 +294,10 @@ export function Wall({ * (docs/specs/layout.md → "Workspaces"). */ active?: boolean; - /** The Window's Workspace verbs, for the command-mode Workspace shortcuts. - * Absent (a bare Wall) leaves those keys unbound. */ - workspaceCommands?: WorkspaceCommands; } = {}) { const effectiveWorkspaceId = workspaceId ?? DEFAULT_WORKSPACE_ID; const activeRef = useRef(active); activeRef.current = active; - const workspaceCommandsRef = useRef(workspaceCommands); - workspaceCommandsRef.current = workspaceCommands; const [terminalContext, setTerminalContext] = useState(null); // Remove a closing context once its exit has played. A reopen or replacement // changes the state object, so the cleanup cancels the stale removal; the @@ -945,6 +932,20 @@ export function Wall({ * destroyed the moment its last pane goes. */ const closingWorkspaceRef = useRef(false); + /** This Wall's member Surfaces: visible panes then Doors. The one expression + * behind membership, `ownsSurface`, and `closeAll`. */ + const memberSurfaceIds = useCallback( + (): string[] => [...lath.store.leafIds(), ...doorsRef.current.map((door) => door.id)], + [lath], + ); + + /** Publish membership for the union projection: the Activity store is + * window-wide, so this map is what scopes it to one Workspace. */ + const publishMembership = useCallback( + () => setWorkspaceSurfaces(effectiveWorkspaceId, memberSurfaceIds()), + [effectiveWorkspaceId, memberSurfaceIds], + ); + /** Restore the Wall's "always one pane" rule after a commit empties the tree * (last pane killed or minimized). A no-op while the tree is non-empty. */ const refillEmptyTree = useCallback(() => { @@ -981,19 +982,15 @@ export function Wall({ // The size check also catches pure removals, purging dead ids so a later // re-add of the same id fires again. if (leavesChanged) prevLeafIdsRef.current = new Set(currentIds); - // Publish membership for the union projection: the Activity store is - // window-wide, so this map is what scopes it to one Workspace. - setWorkspaceSurfaces(effectiveWorkspaceId, [...currentIds, ...doorsRef.current.map((door) => door.id)]); + publishMembership(); if (closingWorkspaceRef.current) return; refillEmptyTree(); }); - }, [lath, fireEvent, refillEmptyTree, effectiveWorkspaceId]); + }, [lath, fireEvent, refillEmptyTree, publishMembership]); // Doors change without a leaf-id change (minimize keeps the leaf parked, a kill // of a doored Surface removes only the chip), so publish on that edge too. - useEffect(() => { - setWorkspaceSurfaces(effectiveWorkspaceId, [...lath.store.leafIds(), ...doors.map((door) => door.id)]); - }, [doors, lath, effectiveWorkspaceId]); + useEffect(publishMembership, [doors, publishMembership]); // --- Session persistence --- const persistence = useSessionPersistence({ @@ -1004,18 +1001,8 @@ export function Wall({ selectedTypeRef, surfaceRefsForSave, workspaceId, - // A Wall inside a Window leaves the host flush to `WorkspaceWindow`: the - // adapter's first `notifySessionFlushComplete` wins, so N Walls answering - // would let a quit proceed after only the first had written. - ownsHostFlush: workspaceId === undefined, }); - /** This Wall's member Surfaces: visible panes then Doors. */ - const memberSurfaceIds = useCallback( - (): string[] => [...lath.store.leafIds(), ...doorsRef.current.map((door) => door.id)], - [lath], - ); - /** * Close every Surface in this Workspace, each through the same coordinator a * manual close uses (helper guard → notepad archive → kill, @@ -1038,11 +1025,24 @@ export function Wall({ } // `killPaneImmediately` defers the tree removal by the exit animation; // unmounting the Wall before that lands would leave Orphaned Sessions - // (docs/specs/glossary.md → "Invariants" I4). Bounded so a stuck fade cannot hang a quit. - const deadline = Date.now() + lath.exitMs + 50; - while (lath.store.leafIds().length > 0 || doorsRef.current.length > 0) { - if (Date.now() >= deadline) break; - await new Promise((resolve) => setTimeout(resolve, 10)); + // (docs/specs/glossary.md → "Invariants" I4). The commit that empties the + // tree is what resolves this; the deadline only bounds a stuck fade so it + // cannot hang a quit. + const emptied = () => memberSurfaceIds().length === 0; + if (!emptied()) { + await new Promise((resolve) => { + let done = false; + const settle = () => { + if (done) return; + done = true; + clearTimeout(timer); + unsubscribe(); + resolve(); + }; + const timer = setTimeout(settle, lath.exitMs + 50); + const unsubscribe = lath.store.subscribe(() => { if (emptied()) settle(); }); + if (emptied()) settle(); + }); } return null; }, [lath, memberSurfaceIds, refillEmptyTree]); @@ -1499,28 +1499,23 @@ export function Wall({ isClosingSurface, closeSurface, lastAgentBrowserBinaryPathRef, - workspaceRef: useCallback( - () => workspaceRefFor(effectiveWorkspaceId) ?? 'workspace:1', - [effectiveWorkspaceId], - ), - windowRef: useCallback(() => WINDOW_REF, []), + workspaceRef: useCallback(() => workspaceRefFor(effectiveWorkspaceId), [effectiveWorkspaceId]), }); // --- Workspace handle --- - /** Put DOM focus back on this Wall's selection, honoring its own mode: each - * Workspace keeps the mode it was left in across a switch. */ - const focusSelected = useCallback(() => { + /** Put DOM focus on — or off — this Wall's selection, honoring its own mode: + * each Workspace keeps the mode it was left in across a switch. */ + const focusSelected = useCallback((focused: boolean) => { const id = selectedIdRef.current; if (!id || selectedTypeRef.current !== 'pane' || !nav.hasPane(id)) return; - focusSession(id, modeRef.current === 'passthrough'); + focusSession(id, focused && modeRef.current === 'passthrough'); }, [nav]); - // The methods are rebuilt each render so they close over current state; the - // handle the registry holds is one stable object delegating to them, so a + // The methods close over current state, so they are rebuilt each render and + // assigned INTO one stable object: the registry holds that object, so a // re-render never replaces a registered entry. - const handleMethodsRef = useRef | null>(null); - handleMethodsRef.current = { + const methods: Omit = { surfaceIds: memberSurfaceIds, ownsSurface: (id) => lath.store.has(id) || doorsRef.current.some((door) => door.id === id), hasTouchedSurfaces: () => memberSurfaceIds().some((id) => { @@ -1530,27 +1525,13 @@ export function Wall({ return getTerminalInstance(id) !== null && !isReplaceableShell(id); }), runningCount: () => countRunningSessionsIn(memberSurfaceIds()), - serialize: () => persistence.buildSession(), flushPersistence: () => persistence.flush(), - focusSelected, closeAll, handleDorControl, }; const handleRef = useRef(null); - if (handleRef.current === null) { - handleRef.current = { - workspaceId: effectiveWorkspaceId, - surfaceIds: () => handleMethodsRef.current!.surfaceIds(), - ownsSurface: (id) => handleMethodsRef.current!.ownsSurface(id), - hasTouchedSurfaces: () => handleMethodsRef.current!.hasTouchedSurfaces(), - runningCount: () => handleMethodsRef.current!.runningCount(), - serialize: () => handleMethodsRef.current!.serialize(), - flushPersistence: () => handleMethodsRef.current!.flushPersistence(), - focusSelected: () => handleMethodsRef.current!.focusSelected(), - closeAll: (mode) => handleMethodsRef.current!.closeAll(mode), - handleDorControl: (detail) => handleMethodsRef.current!.handleDorControl(detail), - }; - } + if (handleRef.current === null) handleRef.current = { workspaceId: effectiveWorkspaceId, ...methods }; + else Object.assign(handleRef.current, methods); useEffect(() => { const handle = handleRef.current!; @@ -1573,13 +1554,12 @@ export function Wall({ if (prevActiveRef.current === active) return; prevActiveRef.current = active; if (!active) { - const id = selectedIdRef.current; - if (id && selectedTypeRef.current === 'pane' && nav.hasPane(id)) focusSession(id, false); + focusSelected(false); return; } - const frame = requestAnimationFrame(focusSelected); + const frame = requestAnimationFrame(() => focusSelected(true)); return () => cancelAnimationFrame(frame); - }, [active, focusSelected, nav]); + }, [active, focusSelected]); const addSplitPanel = useCallback(( id: string | null, @@ -1879,7 +1859,7 @@ export function Wall({ useWallKeyboard({ nav, activeRef, - workspaces: workspaceCommands, + workspaceId, swapWithNeighbor, modeRef, selectedIdRef, diff --git a/lib/src/components/WorkspaceStrip.test.tsx b/lib/src/components/WorkspaceStrip.test.tsx index 0c4e00a67..412131d12 100644 --- a/lib/src/components/WorkspaceStrip.test.tsx +++ b/lib/src/components/WorkspaceStrip.test.tsx @@ -5,11 +5,13 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { WorkspaceStrip } from './WorkspaceStrip'; -import { chromeKeyboardHeld, resetChromeKeyboardLeases } from '../lib/chrome-keyboard-lease'; -import { registerWallHandle, resetWallHandles, type WallHandle } from './wall/wall-handles'; +import { chromeKeyboardHeld, resetChromeKeyboardLeases } from './wall/chrome-keyboard-lease'; +import { registerWallHandle, resetWallHandles, stubWallHandle, type WallHandle } from './wall/wall-handles'; +import { ensureResizeObserver } from './wall/wall-test-utils'; +import { requestWorkspaceClose, requestWorkspaceRename } from './wall/workspace-lifecycle'; import { resetWorkspaceSurfaces, setWorkspaceSurfaces } from '../lib/workspace-surfaces'; import { clearTerminalActivity, setTerminalActivity } from '../lib/terminal-registry'; -import { requestWorkspaceStripIntent } from '../lib/workspace-strip-intent'; +import { resetWorkspaceUi } from '../lib/workspace-ui-store'; import { createWorkspace, getActiveWorkspaceId, @@ -23,19 +25,7 @@ let container: HTMLDivElement; let root: Root; function stubHandle(workspaceId: string, overrides: Partial = {}): WallHandle { - const handle: WallHandle = { - workspaceId, - surfaceIds: () => [], - ownsSurface: () => false, - hasTouchedSurfaces: () => false, - runningCount: () => 0, - serialize: async () => ({ version: 3, panes: [], doors: [] }), - flushPersistence: async () => {}, - focusSelected: () => {}, - closeAll: async () => null, - handleDorControl: () => {}, - ...overrides, - }; + const handle = stubWallHandle(workspaceId, overrides); registerWallHandle(handle); return handle; } @@ -72,12 +62,9 @@ function typeInto(input: HTMLInputElement, value: string): void { } beforeEach(() => { - globalThis.ResizeObserver ??= class { - observe() {} - unobserve() {} - disconnect() {} - } as unknown as typeof ResizeObserver; + ensureResizeObserver(); resetWorkspaces(); + resetWorkspaceUi(); resetWorkspaceSurfaces(); resetWallHandles(); resetChromeKeyboardLeases(); @@ -245,12 +232,12 @@ describe('WorkspaceStrip', () => { await act(async () => { window.dispatchEvent(pointer('pointerup', { clientX: 90, clientY: 12 })); }); }); - it('answers the keyboard intents that come from inside a Wall', async () => { + it('renders the rename editor and close flow the command-mode keys open', async () => { const first = getWorkspacesSnapshot().workspaces[0].id; await act(async () => { createWorkspace({ id: 'ws-2' }); }); await render(); - await act(async () => { requestWorkspaceStripIntent({ kind: 'rename', workspaceId: first }); }); + await act(async () => { requestWorkspaceRename(first); }); expect(container.querySelector(`[data-workspace-rename-for="${first}"]`)).not.toBeNull(); await act(async () => { container.querySelector(`[data-workspace-rename-for="${first}"]`)! @@ -259,7 +246,7 @@ describe('WorkspaceStrip', () => { const closed = vi.fn(async () => null); stubHandle('ws-2', { closeAll: closed }); - await act(async () => { requestWorkspaceStripIntent({ kind: 'close', workspaceId: 'ws-2' }); }); + await act(async () => { requestWorkspaceClose('ws-2'); }); expect(closed).toHaveBeenCalled(); }); }); diff --git a/lib/src/components/WorkspaceStrip.tsx b/lib/src/components/WorkspaceStrip.tsx index a0e9cec8b..97c8bd4cb 100644 --- a/lib/src/components/WorkspaceStrip.tsx +++ b/lib/src/components/WorkspaceStrip.tsx @@ -1,4 +1,5 @@ import { + memo, useCallback, useEffect, useMemo, @@ -11,19 +12,24 @@ import { clsx } from 'clsx'; import { PlusIcon, XIcon } from '@phosphor-icons/react'; import { AlertBell } from './AlertBell'; import { InlineEditInput } from './wall/InlineEditInput'; -import { KillConfirmModal, randomKillChar } from './KillConfirm'; +import { KillConfirmModal } from './KillConfirm'; import { useTodoPillContent } from './TodoPillBody'; import { chromeButton, TERMINAL_TOP_RADIUS_CLASS, TODO_PILL_TRACKING_CLASS } from './design'; import { createWorkspaceStripDrag } from './workspace-strip-drag'; -import { getWallHandle } from './wall/wall-handles'; -import { acquireChromeKeyboardLease } from '../lib/chrome-keyboard-lease'; -import { forgetWorkspaceSession } from '../lib/window-session-aggregator'; +import { acquireChromeKeyboardLease } from './wall/chrome-keyboard-lease'; +import { acceptsKillChar } from './wall/keyboard/handle-kill-confirm'; +import { useDialogKeyboardOwner } from './wall/wall-context'; +import { closeWorkspaceWithSurfaces, requestWorkspaceClose, requestWorkspaceRename } from './wall/workspace-lifecycle'; import { getActivitySnapshot, subscribeToActivity } from '../lib/terminal-registry'; -import { clearWorkspaceSurfaces, getWorkspaceSurfacesSnapshot, subscribeToWorkspaceSurfaces } from '../lib/workspace-surfaces'; +import { getWorkspaceSurfacesSnapshot, subscribeToWorkspaceSurfaces } from '../lib/workspace-surfaces'; import { computeWorkspaceUnion, EMPTY_WORKSPACE_UNION, type WorkspaceUnion } from '../lib/workspace-union'; -import { subscribeToWorkspaceStripIntent } from '../lib/workspace-strip-intent'; import { - closeWorkspace, + getWorkspaceUiSnapshot, + setPendingWorkspaceClose, + setRenamingWorkspace, + subscribeToWorkspaceUi, +} from '../lib/workspace-ui-store'; +import { createWorkspace, getWorkspacesSnapshot, moveWorkspace, @@ -35,8 +41,11 @@ import type { WorkspaceId } from '../lib/session-types'; /** * The Window's Workspace tabs. Store-driven end to end (Workspaces, membership, - * Activity), so it renders in the AppBar — outside every Wall's React tree + * Activity, and the strip's own UI state), so it renders in the AppBar — outside + * every Wall's React tree — and shows the same rename editor and confirmation + * whether the gesture came from a tab or from a command-mode key * (`docs/specs/layout.md` → "Workspaces"; `docs/specs/standalone.md` → AppBar). + * The close verb itself lives in `wall/workspace-lifecycle.ts`; this renders it. */ export function WorkspaceStrip({ className, @@ -52,62 +61,40 @@ export function WorkspaceStrip({ const { workspaces, activeId } = useSyncExternalStore(subscribeToWorkspaces, getWorkspacesSnapshot); const membership = useSyncExternalStore(subscribeToWorkspaceSurfaces, getWorkspaceSurfacesSnapshot); const activity = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); - const [renamingId, setRenamingId] = useState(null); + const { renamingId, pendingClose } = useSyncExternalStore(subscribeToWorkspaceUi, getWorkspaceUiSnapshot); const [draggingId, setDraggingId] = useState(null); - const [confirmClose, setConfirmClose] = useState<{ id: WorkspaceId; char: string } | null>(null); const stripRef = useRef(null); const tabElementsRef = useRef(new Map()); - const unions = useMemo(() => { - const byId = new Map(); - for (const workspace of workspaces) { - const ids = membership.get(workspace.id); - byId.set(workspace.id, ids ? computeWorkspaceUnion(ids, activity) : EMPTY_WORKSPACE_UNION); - } - return byId; - }, [workspaces, membership, activity]); - // The editor and the confirmation both sit outside every Wall, so a // capture-phase command-mode shortcut would still fire behind them. - const keyboardHeld = renamingId !== null || confirmClose !== null; - useEffect(() => (keyboardHeld ? acquireChromeKeyboardLease() : undefined), [keyboardHeld]); + useDialogKeyboardOwner(renamingId !== null || pendingClose !== null, acquireChromeKeyboardLease); const activate = useCallback((id: WorkspaceId) => { setActiveWorkspace(id); tabElementsRef.current.get(id)?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); }, []); - /** - * Close a Workspace: confirm first when it holds work, then close every member - * Surface through the closure coordinator, then drop the Workspace itself. A - * refusal reveals the Workspace so its prompt is visible. - */ - const closeNow = useCallback(async (id: WorkspaceId) => { - const handle = getWallHandle(id); - if (handle) { - const refusal = await handle.closeAll('prompt'); - if (refusal) { - setActiveWorkspace(id); - return; - } - } - clearWorkspaceSurfaces(id); - forgetWorkspaceSession(id); - closeWorkspace(id); + // Stable across renders: the tab's own `data-workspace-tab` says which entry + // it is, and the returned cleanup is what React 19 calls on detach. + const registerElement = useCallback((element: HTMLElement | null) => { + if (!element) return; + const id = element.dataset.workspaceTab!; + tabElementsRef.current.set(id, element); + return () => { tabElementsRef.current.delete(id); }; }, []); - const requestClose = useCallback((id: WorkspaceId) => { - // The last Workspace never closes — there is always one active - // (docs/specs/glossary.md → Workspace lifecycle). - if (getWorkspacesSnapshot().workspaces.length <= 1) return; - const handle = getWallHandle(id); - if (handle && (handle.hasTouchedSurfaces() || handle.runningCount() > 0)) { - setConfirmClose({ id, char: randomKillChar() }); - return; - } - void closeNow(id); - }, [closeNow]); + const finishRename = useCallback((id: WorkspaceId, value: string) => { + renameWorkspace(id, value); + setRenamingWorkspace(null); + }, []); + const cancelRename = useCallback(() => setRenamingWorkspace(null), []); + + // The PR C hooks are read through a ref refreshed each render, so a host that + // supplies them after first paint is not captured stale by the controller. + const windowHooksRef = useRef({ onDragOutsideWindow, onDropOnOtherWindow }); + windowHooksRef.current = { onDragOutsideWindow, onDropOnOtherWindow }; const dragRef = useRef | null>(null); if (dragRef.current === null) { @@ -117,76 +104,80 @@ export function WorkspaceStrip({ stripRect: () => stripRef.current?.getBoundingClientRect() ?? null, move: (id, toIndex) => { moveWorkspace(id, toIndex); }, setDragging: setDraggingId, - onDragOutsideWindow, - onDropOnOtherWindow, + onDragOutsideWindow: (id, point) => windowHooksRef.current.onDragOutsideWindow?.(id, point), + onDropOnOtherWindow: (id, point) => windowHooksRef.current.onDropOnOtherWindow?.(id, point) ?? false, }); } const drag = dragRef.current; useEffect(() => () => drag.dispose(), [drag]); + const press = useCallback( + (id: WorkspaceId, event: ReactPointerEvent) => drag.press(id, event.nativeEvent), + [drag], + ); - // `&` and `$` in command mode reach the strip's own affordances, which live - // out here rather than in the Wall that heard the key. - useEffect(() => subscribeToWorkspaceStripIntent((intent) => { - if (intent.kind === 'close') requestClose(intent.workspaceId); - else setRenamingId(intent.workspaceId); - }), [requestClose]); - - // The confirmation is a typed letter, exactly as a pane kill is. The Wall's - // own handler is behind the chrome lease this dialog holds, so the strip - // listens for its own char. + // The confirmation is a typed letter, exactly as a pane kill is, down to the + // key rule: a case-insensitive match accepts and any other key dismisses. The + // Wall's own handler is behind the chrome lease this dialog holds, so the + // strip listens for itself. useEffect(() => { - if (!confirmClose) return; - const { id, char } = confirmClose; + if (!pendingClose) return; + const { id, char } = pendingClose; const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== char) return; event.preventDefault(); event.stopPropagation(); - setConfirmClose(null); - void closeNow(id); + setPendingWorkspaceClose(null); + if (acceptsKillChar(event.key, char)) void closeWorkspaceWithSurfaces(id); }; window.addEventListener('keydown', onKeyDown, true); return () => window.removeEventListener('keydown', onKeyDown, true); - }, [confirmClose, closeNow]); + }, [pendingClose]); + + // Anchored to the Window's content area, not the tab: a 24px tab is too small + // a box to center a dialog over, and every Wall shares one grid cell, so the + // confirmation lands in the same place whichever Workspace it is about. No + // Window (Storybook) leaves it viewport-centered. + const confirmTarget = useMemo( + () => (pendingClose ? document.querySelector('[data-workspace-content]') : null), + [pendingClose], + ); - // Anchored to the Workspace's own Wall, not its tab: a 24px tab is too small a - // box to center a dialog over, and every Wall shares one grid cell, so the - // confirmation lands in the same place whether or not that Workspace is - // visible. No Wall (Storybook) leaves it viewport-centered. - const confirmTarget = confirmClose - ? [...document.querySelectorAll('[data-workspace-wall]')] - .find((wall) => wall.dataset.workspaceWall === confirmClose.id) ?? null - : null; + // One union per tab, computed in the loop it is rendered in. The visible + // Workspace never shows indicators, so it skips the projection entirely. + const unionsRef = useRef(new Map()); + const unionFor = (id: WorkspaceId, active: boolean): WorkspaceUnion => { + if (active) return EMPTY_WORKSPACE_UNION; + const next = computeWorkspaceUnion(membership.get(id) ?? [], activity); + // Hand back the previous object when nothing in it changed, so a memoized + // tab re-renders only when its own indicators do. + const previous = unionsRef.current.get(id); + if (previous && previous.ringing === next.ringing && previous.todo === next.todo + && previous.count === next.count && previous.ringSeq === next.ringSeq) return previous; + unionsRef.current.set(id, next); + return next; + }; return (
{workspaces.map((workspace) => { const isActive = workspace.id === activeId; - const union = unions.get(workspace.id) ?? EMPTY_WORKSPACE_UNION; return ( 1} - registerElement={(element) => { - if (element) tabElementsRef.current.set(workspace.id, element); - else tabElementsRef.current.delete(workspace.id); - return undefined; - }} - onActivate={() => activate(workspace.id)} - onStartRename={() => setRenamingId(workspace.id)} - onFinishRename={(value) => { - renameWorkspace(workspace.id, value); - setRenamingId(null); - }} - onCancelRename={() => setRenamingId(null)} - onRequestClose={() => requestClose(workspace.id)} - onPress={(event) => drag.press(workspace.id, event.nativeEvent)} - wasDragged={() => drag.dragged()} + registerElement={registerElement} + onActivate={activate} + onStartRename={requestWorkspaceRename} + onFinishRename={finishRename} + onCancelRename={cancelRename} + onRequestClose={requestWorkspaceClose} + onPress={press} + wasDragged={drag.dragged} /> ); })} @@ -200,18 +191,20 @@ export function WorkspaceStrip({ >
); } -function WorkspaceTab({ +/** Memoized: every callback below is stable and takes the Workspace id, so a tab + * re-renders only when its own name, state, or union changes. */ +const WorkspaceTab = memo(function WorkspaceTab({ id, name, active, @@ -235,13 +228,13 @@ function WorkspaceTab({ renaming: boolean; dragging: boolean; closable: boolean; - registerElement: (element: HTMLElement | null) => void; - onActivate: () => void; - onStartRename: () => void; - onFinishRename: (value: string) => void; + registerElement: (element: HTMLElement | null) => (() => void) | undefined; + onActivate: (id: WorkspaceId) => void; + onStartRename: (id: WorkspaceId) => void; + onFinishRename: (id: WorkspaceId, value: string) => void; onCancelRename: () => void; - onRequestClose: () => void; - onPress: (event: ReactPointerEvent) => void; + onRequestClose: (id: WorkspaceId) => void; + onPress: (id: WorkspaceId, event: ReactPointerEvent) => void; wasDragged: () => boolean; }) { const todoPill = useTodoPillContent(union.todo); @@ -269,12 +262,12 @@ function WorkspaceTab({ style={dragging ? { opacity: 0.6 } : undefined} onPointerDown={(event) => { if (event.target instanceof Element && event.target.closest('[data-workspace-tab-close]')) return; - onPress(event); + onPress(id, event); }} onAuxClick={(event) => { if (event.button !== 1 || !closable) return; event.preventDefault(); - onRequestClose(); + onRequestClose(id); }} > {renaming ? ( @@ -283,7 +276,7 @@ function WorkspaceTab({ initialValue={name} className="h-full min-w-0 flex-1 bg-transparent px-2 text-xs outline-none" blurAction="submit" - onSubmit={(value) => onFinishRename(value)} + onSubmit={(value) => onFinishRename(id, value)} onCancel={onCancelRename} /> ) : ( @@ -293,8 +286,8 @@ function WorkspaceTab({ aria-label={label} title={label} aria-current={active ? 'true' : undefined} - onClick={() => { if (!wasDragged()) onActivate(); }} - onDoubleClick={onStartRename} + onClick={() => { if (!wasDragged()) onActivate(id); }} + onDoubleClick={() => onStartRename(id)} > {name} {showIndicators && ( @@ -332,7 +325,7 @@ function WorkspaceTab({ title={`Close ${name}`} onClick={(event) => { event.stopPropagation(); - onRequestClose(); + onRequestClose(id); }} >
); -} +}); diff --git a/lib/src/components/WorkspaceWindow.test.tsx b/lib/src/components/WorkspaceWindow.test.tsx index 282495bdd..81f0e14b4 100644 --- a/lib/src/components/WorkspaceWindow.test.tsx +++ b/lib/src/components/WorkspaceWindow.test.tsx @@ -6,7 +6,7 @@ * "Workspaces"). */ import { StrictMode, act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; +import { type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { WorkspaceWindow } from './WorkspaceWindow'; import { setPlatform } from '../lib/platform'; @@ -15,8 +15,10 @@ import { clearAllNotepads, addPlainNote } from '../lib/notepad/notepad-store'; import { __resetArchiveServiceForTests } from '../lib/notepad/archive-service'; import { getActivitySnapshot, setTerminalActivity } from '../lib/terminal-registry'; import { getWallHandle, listWallHandles, resetWallHandles } from './wall/wall-handles'; +import { mountWallHarness, type WallHarness } from './wall/wall-test-utils'; import { getWorkspaceSurfacesSnapshot, resetWorkspaceSurfaces } from '../lib/workspace-surfaces'; import { resetWindowSessionAggregator } from '../lib/window-session-aggregator'; +import { resetWorkspaceUi } from '../lib/workspace-ui-store'; import { closeWorkspace, createWorkspace, @@ -34,6 +36,7 @@ vi.mock('./TerminalPane', () => ({ ), })); +let harness: WallHarness; let container: HTMLDivElement; let root: Root; let fake: FakePtyAdapter; @@ -44,45 +47,23 @@ beforeEach(() => { resetWallHandles(); resetWorkspaces(); resetWorkspaceSurfaces(); + resetWorkspaceUi(); resetWindowSessionAggregator(); fake = new FakePtyAdapter(); setPlatform(fake); - globalThis.ResizeObserver ??= class { - observe() {} - unobserve() {} - disconnect() {} - } as unknown as typeof ResizeObserver; - globalThis.matchMedia = ((query: string) => ({ - matches: query.includes('prefers-reduced-motion'), - media: query, - onchange: null, - addEventListener() {}, - removeEventListener() {}, - addListener() {}, - removeListener() {}, - dispatchEvent() { return false; }, - })) as unknown as typeof matchMedia; - Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { - configurable: true, - value: vi.fn(() => null), - }); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); + harness = mountWallHarness(); + ({ container, root } = harness); }); afterEach(() => { - act(() => root.unmount()); - container.remove(); + harness.dispose(); vi.clearAllMocks(); vi.restoreAllMocks(); __resetArchiveServiceForTests(); clearAllNotepads(); }); -async function flush(): Promise { - await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); -} +const flush = (): Promise => harness.flush(); function walls(): HTMLElement[] { return [...container.querySelectorAll('[data-workspace-wall]')]; diff --git a/lib/src/components/WorkspaceWindow.tsx b/lib/src/components/WorkspaceWindow.tsx index 7f65c3d31..3a1ca96ac 100644 --- a/lib/src/components/WorkspaceWindow.tsx +++ b/lib/src/components/WorkspaceWindow.tsx @@ -1,18 +1,10 @@ -import { useEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from 'react'; +import { useEffect, useRef, useSyncExternalStore, type ReactNode } from 'react'; import { clsx } from 'clsx'; import { Wall } from './Wall'; import { listWallHandles } from './wall/wall-handles'; import { getPlatform } from '../lib/platform'; -import { - createWorkspace, - getActiveWorkspaceId, - getWorkspacesSnapshot, - setActiveWorkspace, - subscribeToWorkspaces, -} from '../lib/workspace-store'; -import { requestWorkspaceStripIntent } from '../lib/workspace-strip-intent'; -import type { WorkspaceCommands } from './wall/wall-types'; -import type { PersistedDoor, PersistedSurfaceRefs } from '../lib/session-types'; +import { getWorkspacesSnapshot, subscribeToWorkspaces } from '../lib/workspace-store'; +import type { WallBootProps } from './wall/wall-types'; /** * One Window's Workspaces: a mounted `` each, all in the same grid cell so @@ -21,20 +13,11 @@ import type { PersistedDoor, PersistedSurfaceRefs } from '../lib/session-types'; * nothing re-seeds, re-parents, or unmounts. */ export function WorkspaceWindow({ - initialPaneIds, - restoredLathLayout, - initialDoors, - initialSurfaceRefs, - initialSurfaceRefsNext, baseboardNotice, dialogHost, enableBurrow, -}: { - initialPaneIds?: string[]; - restoredLathLayout?: unknown; - initialDoors?: PersistedDoor[]; - initialSurfaceRefs?: PersistedSurfaceRefs; - initialSurfaceRefsNext?: number; + ...boot +}: WallBootProps & { baseboardNotice?: ReactNode; dialogHost?: ReactNode; enableBurrow?: boolean; @@ -45,22 +28,6 @@ export function WorkspaceWindow({ // fresh branch and spawns exactly one default-shell pane. const bootWorkspaceIdRef = useRef(activeId); - const commands = useMemo(() => ({ - create: () => { createWorkspace(); }, - cycle: (delta) => { - const { workspaces: list, activeId: current } = getWorkspacesSnapshot(); - const index = list.findIndex((workspace) => workspace.id === current); - if (index === -1) return; - setActiveWorkspace(list[(index + delta + list.length) % list.length].id); - }, - selectIndex: (index) => { - const target = getWorkspacesSnapshot().workspaces[index]; - if (target) setActiveWorkspace(target.id); - }, - requestClose: () => requestWorkspaceStripIntent({ kind: 'close', workspaceId: getActiveWorkspaceId() }), - requestRename: () => requestWorkspaceStripIntent({ kind: 'rename', workspaceId: getActiveWorkspaceId() }), - }), []); - // The Window, not each Wall, answers the host's flush request: the adapter // completes on the FIRST notification, so a per-Wall answer would let a quit // proceed once one Workspace had written. @@ -76,8 +43,11 @@ export function WorkspaceWindow({ return ( // One grid cell holds every Wall, so each keeps the same box whether or not - // it is the visible one. -
+ // it is the visible one. The strip anchors its close confirmation here. +
{workspaces.map((workspace) => { const isActive = workspace.id === activeId; const isBoot = workspace.id === bootWorkspaceIdRef.current; @@ -96,14 +66,9 @@ export function WorkspaceWindow({ )} > { // is), so the FIRST click on the browser surface must still reach the page — // it is the click that selects the pane. Mouse-down/up therefore gate on // passthrough mode alone, not full `interactive` (mode && selected). - async function renderWithMode(mode: 'passthrough' | 'command', selectedId: string | null): Promise { + async function renderWithMode( + mode: 'passthrough' | 'command', + selectedId: string | null, + workspaceActive = true, + ): Promise { const props = paneProps('ab-panel', { surfaceType: 'agent-browser', session: 'browser-session', wsPort: 4321 }); await act(async () => { root.render( - {})}> - - - - - - - - + + {})}> + + + + + + + + + , ); }); @@ -705,6 +711,36 @@ describe('AgentBrowserPanel canvas input forwarding', () => { }); expect(sentMouseEvents()).toHaveLength(0); }); + + const sentKeyEvents = () => WebSocketMock.instances + .flatMap((ws) => ws.sent) + .filter((m) => m.includes('"type":"input_keyboard"')); + + // The window key forwarder is a capture-phase listener that preventDefaults + // what it takes, so a Workspace left in passthrough on a browser pane would go + // on eating every keystroke while hidden (docs/specs/layout.md → "Workspaces"). + it('stops forwarding window keys once its Workspace is hidden, and resumes on return', async () => { + await renderWithMode('passthrough', 'ab-panel'); + const press = async () => { + await act(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true, cancelable: true })); + }); + }; + + await press(); + expect(sentKeyEvents().length).toBeGreaterThan(0); + + await renderWithMode('passthrough', 'ab-panel', false); + const whileHidden = sentKeyEvents().length; + const swallowed = new KeyboardEvent('keydown', { key: 'a', bubbles: true, cancelable: true }); + await act(async () => { window.dispatchEvent(swallowed); }); + expect(sentKeyEvents().length).toBe(whileHidden); + expect(swallowed.defaultPrevented).toBe(false); + + await renderWithMode('passthrough', 'ab-panel', true); + await press(); + expect(sentKeyEvents().length).toBeGreaterThan(whileHidden); + }); }); describe('AgentBrowserPanel tab strip actions', () => { diff --git a/lib/src/components/wall/AgentBrowserPanel.tsx b/lib/src/components/wall/AgentBrowserPanel.tsx index 3267d5ec9..bc05f89cd 100644 --- a/lib/src/components/wall/AgentBrowserPanel.tsx +++ b/lib/src/components/wall/AgentBrowserPanel.tsx @@ -24,6 +24,7 @@ import { PaneWriteContext, SelectedIdContext, WallActionsContext, + WorkspaceActiveContext, } from './wall-context'; type AgentBrowserPanelParams = AgentBrowserSurfaceParams; @@ -67,7 +68,12 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r const snapshot = useSyncExternalStore(controller.subscribe, controller.snapshot); const { tabs, status, connectionLost, hasFrame, poppedOut, relaunching, streamPort } = snapshot; - const interactive = mode === 'passthrough' && selectedId === id; + // Gated on the same Workspace-aware visibility the streaming body reads, so a + // Workspace left in passthrough on a browser pane stops forwarding (and + // preventDefault-ing) window keystrokes the moment it is hidden. `parked` is + // deliberately not part of it: a parked leaf is never the selected pane. + const workspaceActive = useContext(WorkspaceActiveContext); + const interactive = workspaceActive && mode === 'passthrough' && selectedId === id; const interactiveRef = useRef(interactive); interactiveRef.current = interactive; // A direct mouse click on the canvas should reach the page even when this pane diff --git a/lib/src/components/wall/chrome-keyboard-lease.ts b/lib/src/components/wall/chrome-keyboard-lease.ts new file mode 100644 index 000000000..c16671bc3 --- /dev/null +++ b/lib/src/components/wall/chrome-keyboard-lease.ts @@ -0,0 +1,29 @@ +import { createDialogKeyboardCoordinator, type AcquireDialogKeyboard } from './wall-context'; + +/** + * Command-mode keyboard suppression for chrome that lives OUTSIDE every Wall — + * today the Workspace strip's rename editor and close confirmation, which sit in + * the AppBar (`docs/specs/layout.md` → "Keyboard shortcuts (command mode)"). + * The Wall's dispatch listener is capture-phase on `window`, so a field up there + * cannot stop it with `stopPropagation`; it takes a lease instead. + * + * One window-wide flag, reference-counted by the same coordinator a Wall uses for + * its own dialogs, so overlapping holders each release only their own. + */ +const held = { current: false }; +let coordinator = createDialogKeyboardCoordinator(held); + +/** Take one lease; the returned release drops it (idempotent). Stable identity, + * so `useDialogKeyboardOwner` can hold it in a dependency list. */ +export const acquireChromeKeyboardLease: AcquireDialogKeyboard = () => coordinator(); + +export function chromeKeyboardHeld(): boolean { + return held.current; +} + +/** Drop every lease (tests). The coordinator is replaced rather than zeroed, so a + * release still outstanding cannot resurrect the flag. */ +export function resetChromeKeyboardLeases(): void { + coordinator = createDialogKeyboardCoordinator(held); + held.current = false; +} diff --git a/lib/src/components/wall/dor-control-router.test.ts b/lib/src/components/wall/dor-control-router.test.ts index 00b0d4aff..27bdccdf4 100644 --- a/lib/src/components/wall/dor-control-router.test.ts +++ b/lib/src/components/wall/dor-control-router.test.ts @@ -3,25 +3,18 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { installDorControlRouter, resolveDorControlRoute } from './dor-control-router'; -import { registerWallHandle, resetWallHandles, type WallHandle } from './wall-handles'; +import { registerWallHandle, resetWallHandles, stubWallHandle, type WallHandle } from './wall-handles'; import type { DorControlRequest } from './use-dor-control'; import { createWorkspace, getWorkspacesSnapshot, resetWorkspaces, setActiveWorkspace } from '../../lib/workspace-store'; const disposers: Array<() => void> = []; function handleFor(workspaceId: string, ownedSurfaceIds: string[] = []): WallHandle & { handleDorControl: ReturnType } { - const handle = { - workspaceId, + const handle = stubWallHandle(workspaceId, { surfaceIds: () => [...ownedSurfaceIds], ownsSurface: (id: string) => ownedSurfaceIds.includes(id), - hasTouchedSurfaces: () => false, - runningCount: () => 0, - serialize: async () => ({ version: 3 as const, panes: [], doors: [] }), - flushPersistence: async () => {}, - focusSelected: () => {}, - closeAll: async () => null, handleDorControl: vi.fn(), - }; + }) as WallHandle & { handleDorControl: ReturnType }; disposers.push(registerWallHandle(handle)); return handle; } diff --git a/lib/src/components/wall/dor-control-router.ts b/lib/src/components/wall/dor-control-router.ts index 6df167f62..c8ff33b8d 100644 --- a/lib/src/components/wall/dor-control-router.ts +++ b/lib/src/components/wall/dor-control-router.ts @@ -1,4 +1,4 @@ -import { getActiveWorkspaceId, WINDOW_REF, workspaceIdForRef } from '../../lib/workspace-store'; +import { getActiveWorkspaceId, isWindowRef, workspaceIdForRef } from '../../lib/workspace-store'; import { getWallHandle, wallHandleOwning, type WallHandle } from './wall-handles'; import type { DorControlRequest } from './use-dor-control'; @@ -22,13 +22,14 @@ export type DorControlRoute = */ export function resolveDorControlRoute(detail: DorControlRequest): DorControlRoute { const params = detail.params ?? {}; - if (params.window !== undefined && params.window !== WINDOW_REF && params.window !== '1') { + if (params.window !== undefined && !isWindowRef(params.window)) { return { kind: 'error', message: `unknown window target '${params.window}'` }; } if (params.workspace !== undefined) { + // A ref outside the strip and one whose Wall is not mounted are the same + // answer: this Window has no such Workspace to route to. const workspaceId = workspaceIdForRef(params.workspace); - if (!workspaceId) return { kind: 'error', message: `unknown workspace target '${params.workspace}'` }; - const handle = getWallHandle(workspaceId); + const handle = workspaceId ? getWallHandle(workspaceId) : null; return handle ? { kind: 'handle', handle } : { kind: 'error', message: `unknown workspace target '${params.workspace}'` }; } // The caller's own Workspace: `dor split` from a background Workspace lands diff --git a/lib/src/components/wall/keyboard/handle-kill-confirm.ts b/lib/src/components/wall/keyboard/handle-kill-confirm.ts index eeef7a3c8..714204f38 100644 --- a/lib/src/components/wall/keyboard/handle-kill-confirm.ts +++ b/lib/src/components/wall/keyboard/handle-kill-confirm.ts @@ -1,5 +1,16 @@ import type { WallKeyboardCtx } from './types'; +/** + * Whether a key accepts a staged kill confirmation: a case-insensitive match of + * the confirm letter, so Caps Lock still confirms. Every other key rejects, which + * is why the confirmation hijacks each key it sees rather than testing for one. + * Shared with the Workspace strip's own confirmation, which listens outside every + * Wall (`docs/specs/shortcuts.md` → "Dialogs, menus & prompts"). + */ +export function acceptsKillChar(key: string, char: string): boolean { + return key.toLowerCase() === char.toLowerCase(); +} + /** * Kill-confirmation second-key handler. Once a kill is staged in confirmKillRef, * we hijack every key: matching letter accepts, anything else rejects. @@ -12,7 +23,7 @@ export function handleKillConfirm(e: KeyboardEvent, ctx: WallKeyboardCtx): boole e.stopPropagation(); if (ck.exit) return true; - if (e.key.toLowerCase() === ck.char.toLowerCase()) { + if (acceptsKillChar(e.key, ck.char)) { ctx.acceptKill(); return true; } diff --git a/lib/src/components/wall/keyboard/handle-workspace-shortcuts.test.ts b/lib/src/components/wall/keyboard/handle-workspace-shortcuts.test.ts index 9df7cc062..ca113f73e 100644 --- a/lib/src/components/wall/keyboard/handle-workspace-shortcuts.test.ts +++ b/lib/src/components/wall/keyboard/handle-workspace-shortcuts.test.ts @@ -1,69 +1,85 @@ /** * @vitest-environment jsdom */ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { handleWorkspaceShortcuts } from './handle-workspace-shortcuts'; +import { resetWallHandles } from '../wall-handles'; +import { getWorkspaceUiSnapshot, resetWorkspaceUi } from '../../../lib/workspace-ui-store'; +import { + createWorkspace, + getActiveWorkspaceId, + getWorkspacesSnapshot, + resetWorkspaces, +} from '../../../lib/workspace-store'; import type { WallKeyboardCtx } from './types'; -import type { WorkspaceCommands } from '../wall-types'; const KEYS = ['c', 'n', 'p', '&', '$', '1', '5', '9']; -function commands(): WorkspaceCommands & Record> { - return { - create: vi.fn(), - cycle: vi.fn(), - selectIndex: vi.fn(), - requestClose: vi.fn(), - requestRename: vi.fn(), - } as unknown as WorkspaceCommands & Record>; -} - -function ctxWith(workspaces?: WorkspaceCommands): WallKeyboardCtx { - return { activeRef: { current: true }, workspaces } as unknown as WallKeyboardCtx; +function ctxWith(workspaceId?: string): WallKeyboardCtx { + return { activeRef: { current: true }, workspaceId } as unknown as WallKeyboardCtx; } function keydown(key: string, init: KeyboardEventInit = {}): KeyboardEvent { return new KeyboardEvent('keydown', { key, cancelable: true, ...init }); } -let verbs: ReturnType; +function ids(): string[] { + return getWorkspacesSnapshot().workspaces.map((workspace) => workspace.id); +} + +let ctx: WallKeyboardCtx; beforeEach(() => { - verbs = commands(); + resetWorkspaces(); + resetWorkspaceUi(); + resetWallHandles(); + ctx = ctxWith(getActiveWorkspaceId()); }); describe('handleWorkspaceShortcuts', () => { - it('leaves every key unbound without the Workspace verbs — the bare-Wall guard', () => { - const ctx = ctxWith(); + it('leaves every key unbound on a Wall with no Workspace — the bare-Wall guard', () => { + const bare = ctxWith(); for (const key of KEYS) { const event = keydown(key); - expect(handleWorkspaceShortcuts(event, ctx)).toBe(false); + expect(handleWorkspaceShortcuts(event, bare)).toBe(false); expect(event.defaultPrevented).toBe(false); } + expect(ids()).toHaveLength(1); }); - it('binds create, cycle, select, close, and rename', () => { - const ctx = ctxWith(verbs); + it('creates, cycles, and selects by position through the store', () => { + const first = getActiveWorkspaceId(); expect(handleWorkspaceShortcuts(keydown('c'), ctx)).toBe(true); - expect(verbs.create).toHaveBeenCalledTimes(1); + expect(ids()).toHaveLength(2); + const second = ids()[1]; + expect(getActiveWorkspaceId()).toBe(second); - handleWorkspaceShortcuts(keydown('n'), ctx); - handleWorkspaceShortcuts(keydown('p'), ctx); - expect(verbs.cycle.mock.calls).toEqual([[1], [-1]]); + handleWorkspaceShortcuts(keydown('n'), ctx); // wraps at the end + expect(getActiveWorkspaceId()).toBe(first); + handleWorkspaceShortcuts(keydown('p'), ctx); // and at the start + expect(getActiveWorkspaceId()).toBe(second); handleWorkspaceShortcuts(keydown('1'), ctx); - handleWorkspaceShortcuts(keydown('9'), ctx); - // The digit is 1-based on screen and 0-based in the verb. - expect(verbs.selectIndex.mock.calls).toEqual([[0], [8]]); + expect(getActiveWorkspaceId()).toBe(first); + // Out of range is a consumed no-op rather than a wrap. + expect(handleWorkspaceShortcuts(keydown('9'), ctx)).toBe(true); + expect(getActiveWorkspaceId()).toBe(first); + }); - handleWorkspaceShortcuts(keydown('&'), ctx); - expect(verbs.requestClose).toHaveBeenCalledTimes(1); + it('opens the strip rename editor and close flow on the ACTIVE Workspace', () => { + createWorkspace({ id: 'ws-2' }); handleWorkspaceShortcuts(keydown('$'), ctx); - expect(verbs.requestRename).toHaveBeenCalledTimes(1); + expect(getWorkspaceUiSnapshot().renamingId).toBe('ws-2'); + + // No Wall is mounted, so nothing is touched and the close goes straight + // through — but the last Workspace still cannot be closed. + handleWorkspaceShortcuts(keydown('&'), ctx); + expect(ids()).toEqual([getWorkspacesSnapshot().workspaces[0].id]); + handleWorkspaceShortcuts(keydown('&'), ctx); + expect(ids()).toHaveLength(1); }); it('claims the key it handles and leaves every other one alone', () => { - const ctx = ctxWith(verbs); const handled = keydown('c'); handleWorkspaceShortcuts(handled, ctx); expect(handled.defaultPrevented).toBe(true); @@ -74,10 +90,9 @@ describe('handleWorkspaceShortcuts', () => { }); it('ignores a modified key, so Cmd+C stays a clipboard chord', () => { - const ctx = ctxWith(verbs); for (const init of [{ metaKey: true }, { ctrlKey: true }, { altKey: true }]) { expect(handleWorkspaceShortcuts(keydown('c', init), ctx)).toBe(false); } - expect(verbs.create).not.toHaveBeenCalled(); + expect(ids()).toHaveLength(1); }); }); diff --git a/lib/src/components/wall/keyboard/handle-workspace-shortcuts.ts b/lib/src/components/wall/keyboard/handle-workspace-shortcuts.ts index 76637b04d..f55570d82 100644 --- a/lib/src/components/wall/keyboard/handle-workspace-shortcuts.ts +++ b/lib/src/components/wall/keyboard/handle-workspace-shortcuts.ts @@ -1,3 +1,10 @@ +import { + activateAdjacentWorkspace, + activateWorkspaceAt, + createWorkspace, + getActiveWorkspaceId, +} from '../../../lib/workspace-store'; +import { requestWorkspaceClose, requestWorkspaceRename } from '../workspace-lifecycle'; import type { WallKeyboardCtx } from './types'; /** @@ -6,12 +13,11 @@ import type { WallKeyboardCtx } from './types'; * `docs/specs/shortcuts.md`; the behavior is `docs/specs/layout.md` → * "Workspaces". * - * Every key is inert without `ctx.workspaces`, which is what keeps a bare Wall — - * VS Code, the website playground — unbound. + * Every key is inert on a Wall with no `workspaceId`, which is what keeps a bare + * Wall — VS Code, the website playground — unbound. */ export function handleWorkspaceShortcuts(e: KeyboardEvent, ctx: WallKeyboardCtx): boolean { - const workspaces = ctx.workspaces; - if (!workspaces) return false; + if (ctx.workspaceId === undefined) return false; // Bare keys only: a modified `c` is a clipboard or host chord, never create. if (e.metaKey || e.ctrlKey || e.altKey) return false; @@ -22,11 +28,13 @@ export function handleWorkspaceShortcuts(e: KeyboardEvent, ctx: WallKeyboardCtx) return true; }; - if (e.key === 'c') return run(() => workspaces.create()); - if (e.key === 'n') return run(() => workspaces.cycle(1)); - if (e.key === 'p') return run(() => workspaces.cycle(-1)); - if (e.key === '&') return run(() => workspaces.requestClose()); - if (e.key === '$') return run(() => workspaces.requestRename()); - if (e.key >= '1' && e.key <= '9') return run(() => workspaces.selectIndex(Number(e.key) - 1)); + // Targets resolve through the ACTIVE Workspace, never the Wall that heard the + // key, so a stale keystroke from a hidden one could not act on the wrong one. + if (e.key === 'c') return run(() => { createWorkspace(); }); + if (e.key === 'n') return run(() => activateAdjacentWorkspace(1)); + if (e.key === 'p') return run(() => activateAdjacentWorkspace(-1)); + if (e.key === '&') return run(() => requestWorkspaceClose(getActiveWorkspaceId())); + if (e.key === '$') return run(() => requestWorkspaceRename(getActiveWorkspaceId())); + if (e.key >= '1' && e.key <= '9') return run(() => activateWorkspaceAt(Number(e.key) - 1)); return false; } diff --git a/lib/src/components/wall/keyboard/types.ts b/lib/src/components/wall/keyboard/types.ts index 4721090b3..c31b56098 100644 --- a/lib/src/components/wall/keyboard/types.ts +++ b/lib/src/components/wall/keyboard/types.ts @@ -1,7 +1,8 @@ import type { Dispatch, RefObject, SetStateAction } from 'react'; import type { ConfirmKill } from '../../KillConfirm'; -import type { DoorAfterRestoreAction, DooredItem, WallEvent, WallMode, WallSelectionKind, WorkspaceCommands } from '../wall-types'; +import type { DoorAfterRestoreAction, DooredItem, WallEvent, WallMode, WallSelectionKind } from '../wall-types'; import type { WallActions } from '../wall-context'; +import type { WorkspaceId } from '../../../lib/session-types'; /** The navigation/query seam the keyboard handlers read, backed by the Lath engine * (docs/specs/tiling-engine.md). */ @@ -23,9 +24,9 @@ export interface WallKeyboardCtx { /** Whether this Wall's Workspace is the visible one. Listeners stay per Wall; * only dispatch is gated, so a hidden Workspace sees no window input. */ activeRef: RefObject; - /** The Window's Workspace verbs. Absent on a bare Wall, which leaves the - * Workspace keys unbound. */ - workspaces?: WorkspaceCommands; + /** This Wall's Workspace. Absent on a bare Wall, which leaves the Workspace + * keys unbound. */ + workspaceId?: WorkspaceId; /** Swap two panes' surfaces (Cmd-Arrow): swap leaf identities (meta follows ids, * so no companion title swap). */ swapWithNeighbor: (fromId: string, toId: string) => void; diff --git a/lib/src/components/wall/use-alert-speech.ts b/lib/src/components/wall/use-alert-speech.ts index b89b8a92a..d632f094a 100644 --- a/lib/src/components/wall/use-alert-speech.ts +++ b/lib/src/components/wall/use-alert-speech.ts @@ -2,10 +2,27 @@ import { useEffect } from 'react'; import { startAlertSpeech } from '../../lib/alert-speech'; /** - * Arm spoken alarms for the lifetime of the desktop shell. Mounted once by - * `Wall`; the settings that gate it live in the Alarm settings dialog - * (`docs/specs/alert.md` -> Alarm settings). + * Arm spoken alarms for the lifetime of the desktop shell. The settings that + * gate it live in the Alarm settings dialog (`docs/specs/alert.md` -> Alarm + * settings). + * + * One watcher per WINDOW, not per Wall: `startAlertSpeech` installs a global + * activity handler and clears every Session's speech state, so N Walls would + * speak each ring N times and reset each other's delivery state. Reference + * counted, so the first mounted Wall arms it and the last one disarms it. */ +let holders = 0; +let stop: (() => void) | null = null; + export function useAlertSpeech(): void { - useEffect(() => startAlertSpeech(), []); + useEffect(() => { + holders += 1; + if (holders === 1) stop = startAlertSpeech(); + return () => { + holders -= 1; + if (holders > 0) return; + stop?.(); + stop = null; + }; + }, []); } diff --git a/lib/src/components/wall/use-dev-server-ports.test.tsx b/lib/src/components/wall/use-dev-server-ports.test.tsx new file mode 100644 index 000000000..a5f2943fb --- /dev/null +++ b/lib/src/components/wall/use-dev-server-ports.test.tsx @@ -0,0 +1,103 @@ +/** + * @vitest-environment jsdom + * + * The dev-server correlation loop is per WINDOW, not per Wall: the wanted-port + * store and the resolutions span every Workspace, so a per-Wall loop would + * answer another Workspace's port with "no match" and poll `lsof` forever + * (docs/specs/dor-browser.md → "Dev-Server Chip"). + */ +import { act, useMemo, useRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useDevServerPortCorrelation } from './use-dev-server-ports'; +import { + getDevServerResolution, + releaseDevServerPort, + requestDevServerPort, +} from './agent-browser-ports'; +import { FakePtyAdapter, setPlatform } from '../../lib/platform'; +import type { OpenPort } from '../../lib/platform/types'; +import type { LathWallEngine } from './lath-wall-engine'; +import type { DooredItem } from './wall-types'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const PORT = 5173; +let container: HTMLDivElement; +let root: Root; +let platform: FakePtyAdapter; +let openPorts: ReturnType; + +function tcp(port: number): OpenPort[] { + return [{ protocol: 'tcp', family: 'IPv4', address: '127.0.0.1', port, pid: 1 }]; +} + +/** Just enough engine for the loop: one Wall's visible terminal panes. */ +function fakeLath(paneIds: string[]): LathWallEngine { + return { + listPanes: () => paneIds.map((id) => ({ id, title: id, params: undefined })), + getMeta: () => undefined, + } as unknown as LathWallEngine; +} + +function Harness({ paneIds }: { paneIds: string[] }) { + const lath = useMemo(() => fakeLath(paneIds), [paneIds]); + const doorsRef = useRef([]); + useDevServerPortCorrelation({ lath, doorsRef }); + return null; +} + +beforeEach(() => { + vi.useFakeTimers(); + platform = new FakePtyAdapter(); + openPorts = vi.fn(async () => [] as OpenPort[]); + platform.getOpenPorts = openPorts as unknown as FakePtyAdapter['getOpenPorts']; + setPlatform(platform); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + releaseDevServerPort(PORT); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +async function settleScan(): Promise { + await act(async () => { await vi.advanceTimersByTimeAsync(1000); }); +} + +describe('dev-server port correlation across Walls', () => { + it('resolves a port served by another Wall, scanning every candidate once', async () => { + openPorts.mockImplementation(async (id: string) => (id === 'b1' ? tcp(PORT) : [])); + requestDevServerPort(PORT); + await act(async () => { + root.render(<>); + }); + await settleScan(); + + // The Wall that owns the serving pane wins, and the Wall that does not own it + // never publishes "no match" over the top. + expect(getDevServerResolution(PORT)?.paneId).toBe('b1'); + const ids = openPorts.mock.calls.map(([id]) => id); + expect([...ids].sort()).toEqual(['a1', 'b1']); + }); + + it('settles once matched, so a second Wall does not keep polling for it', async () => { + openPorts.mockImplementation(async (id: string) => (id === 'b1' ? tcp(PORT) : [])); + requestDevServerPort(PORT); + await act(async () => { + root.render(<>); + }); + await settleScan(); + const afterFirstScan = openPorts.mock.calls.length; + + // Well past the pending-refresh cadence: a settled port is never rescanned. + await act(async () => { await vi.advanceTimersByTimeAsync(20_000); }); + expect(openPorts.mock.calls.length).toBe(afterFirstScan); + expect(getDevServerResolution(PORT)?.paneId).toBe('b1'); + }); +}); diff --git a/lib/src/components/wall/use-dev-server-ports.ts b/lib/src/components/wall/use-dev-server-ports.ts index 64258af8a..f9d883bd3 100644 --- a/lib/src/components/wall/use-dev-server-ports.ts +++ b/lib/src/components/wall/use-dev-server-ports.ts @@ -1,16 +1,24 @@ /** - * Wall-side driver for the dev-server connection chip + * Dev-server connection chip driver * (docs/specs/dor-browser.md → "Dev-Server Chip"). * * A browser-surface header can't see other panes' open ports, so it registers * the loopback port it's showing in the shared store (`useDevServerMatch`) and - * the Wall resolves it here: scan every terminal pane's listening ports - * (`getOpenPorts`), find the single pane serving that port, and publish back + * this module resolves it: scan every terminal Surface's listening ports + * (`getOpenPorts`), find the single one serving that port, and publish back * `{ paneId, label }`. * - * This is **purely decorative and strictly off the hot path.** `getOpenPorts` - * shells out (per-OS `lsof`/PowerShell) on the host that also drives the live - * screencast, so scans must never pile onto tab-open or run on a timer forever: + * **One loop per WINDOW, over every mounted Wall's Surfaces.** The wanted-port + * store and the resolutions are window-wide, so a per-Wall loop would answer + * another Workspace's port with "no match", clobber the owner's resolution, and + * never settle — polling `lsof` forever. Each Wall registers its Surfaces as a + * candidate source instead, and the loop is reference-counted so a lone Wall + * still runs exactly one. + * + * The scan is **purely decorative and strictly off the hot path.** + * `getOpenPorts` shells out (per-OS `lsof`/PowerShell) on the host that also + * drives the live screencast, so scans must never pile onto tab-open or run on a + * timer forever: * - **deferred & debounced** — a loopback URL appearing schedules a scan a * beat later, coalescing rapid navigation, so tab-open finishes first; * - **idle-scheduled** — the scan runs in `requestIdleCallback` time (with a @@ -50,6 +58,9 @@ const IDLE_TIMEOUT_MS = 2000; type ResolveOutcome = 'busy' | 'idle' | 'pending'; +/** One Wall's terminal Surfaces, with the titles the label falls back on. */ +type CandidateSource = () => Array<{ id: string; title: string | null }>; + // Port scans are terminal-gated (`docs/specs/glossary.md` → Panes and Surfaces). function isTerminalParams(params: unknown): boolean { return hasTerminal(surfaceKindFromParams(params)); @@ -70,6 +81,155 @@ function cancelIdle(handle: number | undefined): void { else clearTimeout(handle); } +const sources = new Set(); +let holders = 0; +let stopLoop: (() => void) | null = null; +let scheduleScanNow: ((delay: number) => void) | null = null; +/** Ports already matched to a Surface. Not rescanned until a reload (clears the + * whole set), the port leaves "wanted" (navigation), or the set of Walls + * changes — a Wall arriving or leaving can change who owns a port. */ +const settled = new Set(); + +/** id → fallback title across every mounted Wall; the first Wall to claim an id + * owns it, and a Wall only ever lists its own Surfaces. */ +function collectCandidates(): Map { + const byId = new Map(); + for (const source of sources) { + for (const candidate of source()) { + if (!byId.has(candidate.id)) byId.set(candidate.id, candidate.title); + } + } + return byId; +} + +function startCorrelationLoop(): () => void { + let cancelled = false; + let running = false; + let debounceTimer: ReturnType | undefined; + let refreshTimer: ReturnType | undefined; + let idleHandle: number | undefined; + + const resolveOnce = async (): Promise => { + if (cancelled || running) return 'busy'; + + const wanted = getWantedDevServerPorts(); + // Drop settled ports that are no longer on screen (navigated away). + for (const port of [...settled]) { + if (!wanted.includes(port)) settled.delete(port); + } + if (wanted.length === 0) return 'idle'; + + // Only chase ports we haven't matched yet — matched ones stay put. + const unsettled = wanted.filter((port) => !settled.has(port)); + if (unsettled.length === 0) return 'idle'; + + const platform = getPlatform(); + if (!platform.getOpenPorts) { + // No port enumeration on this host: nothing will ever match, so settle + // to "no match" and stop (don't poll). + for (const port of unsettled) setDevServerResolution(port, null); + return 'idle'; + } + + running = true; + try { + const titles = collectCandidates(); + + // port → the surface ids that listen on it (loopback-reachable binds only). + const owners = new Map(); + await Promise.all([...titles.keys()].map(async (id) => { + let open; + try { + open = await platform.getOpenPorts!(id); + } catch { + return; + } + for (const entry of open) { + if (entry.protocol !== 'tcp' || !servesLoopback(entry.address)) continue; + const list = owners.get(entry.port) ?? []; + if (!list.includes(id)) list.push(id); + owners.set(entry.port, list); + } + })); + if (cancelled) return 'busy'; + + // Resolve only what's still wanted + unsettled — interest can churn + // during the await. + const stillWanted = new Set(getWantedDevServerPorts()); + for (const port of unsettled) { + if (!stillWanted.has(port)) continue; + const list = owners.get(port) ?? []; + // Exactly one owner ⇒ confident match; settle it. Zero (no pane) or + // two+ (ambiguous) ⇒ no match; leave it unsettled so we keep looking + // (e.g. the dev server is still starting up). + if (list.length === 1) { + settled.add(port); + setDevServerResolution(port, { paneId: list[0], label: deriveSessionLabel(list[0], titles.get(list[0]) ?? null) }); + } else { + setDevServerResolution(port, null); + } + } + + const remaining = getWantedDevServerPorts().some((port) => !settled.has(port)); + return remaining ? 'pending' : 'idle'; + } finally { + running = false; + } + }; + + const scheduleRefresh = (delay: number) => { + if (cancelled) return; + if (refreshTimer) clearTimeout(refreshTimer); + refreshTimer = setTimeout(() => scheduleScan(0), delay); + }; + + // Run a scan during idle time; keep polling only while ports are unmatched. + const runIdleScan = () => { + idleHandle = scheduleIdle(() => { + idleHandle = undefined; + void resolveOnce().then((outcome) => { + if (cancelled) return; + // 'busy' → an in-flight scan paces itself; 'idle' → all matched (or + // nothing wanted) so stop until reload/navigation wakes us. + if (outcome === 'pending') scheduleRefresh(PENDING_REFRESH_MS); + }); + }); + }; + + // Coalesce triggers: debounce, then scan at idle. Never scans synchronously + // on the triggering event (tab open / navigation / reload). + const scheduleScan = (delay: number) => { + if (cancelled) return; + if (debounceTimer) clearTimeout(debounceTimer); + cancelIdle(idleHandle); + idleHandle = undefined; + debounceTimer = setTimeout(runIdleScan, delay); + }; + scheduleScanNow = scheduleScan; + + // A header showing a new loopback URL bumps "wanted"; debounce + defer so the + // scan lands after the tab is up, not during its first paints. + const unsubscribeWanted = subscribeWantedDevServerPorts(() => scheduleScan(DEBOUNCE_MS)); + // A reload un-settles every port and re-validates — optimistically, since we + // leave the published resolutions in place until the rescan overwrites them. + const unsubscribeRescan = subscribeDevServerRescan(() => { + settled.clear(); + scheduleScan(DEBOUNCE_MS); + }); + scheduleScan(DEBOUNCE_MS); + + return () => { + cancelled = true; + if (debounceTimer) clearTimeout(debounceTimer); + if (refreshTimer) clearTimeout(refreshTimer); + cancelIdle(idleHandle); + unsubscribeWanted(); + unsubscribeRescan(); + settled.clear(); + scheduleScanNow = null; + }; +} + export function useDevServerPortCorrelation({ lath, doorsRef, @@ -79,146 +239,43 @@ export function useDevServerPortCorrelation({ doorsRef: React.MutableRefObject; }): void { useEffect(() => { - let cancelled = false; - let running = false; - let debounceTimer: ReturnType | undefined; - let refreshTimer: ReturnType | undefined; - let idleHandle: number | undefined; - // Ports already matched to a pane. We don't rescan these until a reload - // (clears the whole set) or the port leaves "wanted" (navigation). - const settled = new Set(); - - const resolveOnce = async (): Promise => { - if (cancelled || running) return 'busy'; - - const wanted = getWantedDevServerPorts(); - // Drop settled ports that are no longer on screen (navigated away). - for (const port of [...settled]) { - if (!wanted.includes(port)) settled.delete(port); - } - if (wanted.length === 0) return 'idle'; - - // Only chase ports we haven't matched yet — matched ones stay put. - const unsettled = wanted.filter((port) => !settled.has(port)); - if (unsettled.length === 0) return 'idle'; - - const platform = getPlatform(); - if (!platform.getOpenPorts) { - // No port enumeration on this host: nothing will ever match, so settle - // to "no match" and stop (don't poll). - for (const port of unsettled) setDevServerResolution(port, null); - return 'idle'; + const source: CandidateSource = () => { + const candidates: Array<{ id: string; title: string | null }> = []; + for (const panel of lath.listPanes()) { + if (!isTerminalParams(panel.params)) continue; + candidates.push({ id: panel.id, title: panel.title ?? null }); } - - running = true; - try { - const doors = doorsRef.current; - // title lookups for labelling/fallback, keyed by surface id. - const titles = new Map(); - const candidates: string[] = []; - for (const panel of lath.listPanes()) { - if (!isTerminalParams(panel.params)) continue; - candidates.push(panel.id); - titles.set(panel.id, panel.title ?? null); - } - // A Door's component/title live in the store, which stays their authority - // while the Surface is minimized. - for (const door of doors) { - const meta = lath.getMeta(door.id); - if ((meta?.component ?? 'terminal') !== 'terminal') continue; - if (!candidates.includes(door.id)) candidates.push(door.id); - titles.set(door.id, meta?.title ?? null); - } - - // port → the pane ids that listen on it (loopback-reachable binds only). - const owners = new Map(); - await Promise.all(candidates.map(async (id) => { - let open; - try { - open = await platform.getOpenPorts!(id); - } catch { - return; - } - for (const entry of open) { - if (entry.protocol !== 'tcp' || !servesLoopback(entry.address)) continue; - const list = owners.get(entry.port) ?? []; - if (!list.includes(id)) list.push(id); - owners.set(entry.port, list); - } - })); - if (cancelled) return 'busy'; - - // Resolve only what's still wanted + unsettled — interest can churn - // during the await. - const stillWanted = new Set(getWantedDevServerPorts()); - for (const port of unsettled) { - if (!stillWanted.has(port)) continue; - const list = owners.get(port) ?? []; - // Exactly one owner ⇒ confident match; settle it. Zero (no pane) or - // two+ (ambiguous) ⇒ no match; leave it unsettled so we keep looking - // (e.g. the dev server is still starting up). - if (list.length === 1) { - settled.add(port); - setDevServerResolution(port, { paneId: list[0], label: deriveSessionLabel(list[0], titles.get(list[0]) ?? null) }); - } else { - setDevServerResolution(port, null); - } - } - - const remaining = getWantedDevServerPorts().some((port) => !settled.has(port)); - return remaining ? 'pending' : 'idle'; - } finally { - running = false; + // A Door's component/title live in the store, which stays their authority + // while the Surface is minimized. + for (const door of doorsRef.current) { + const meta = lath.getMeta(door.id); + if ((meta?.component ?? 'terminal') !== 'terminal') continue; + if (candidates.some((candidate) => candidate.id === door.id)) continue; + candidates.push({ id: door.id, title: meta?.title ?? null }); } + return candidates; }; - const scheduleRefresh = (delay: number) => { - if (cancelled) return; - if (refreshTimer) clearTimeout(refreshTimer); - refreshTimer = setTimeout(() => scheduleScan(0), delay); - }; - - // Run a scan during idle time; keep polling only while ports are unmatched. - const runIdleScan = () => { - idleHandle = scheduleIdle(() => { - idleHandle = undefined; - void resolveOnce().then((outcome) => { - if (cancelled) return; - // 'busy' → an in-flight scan paces itself; 'idle' → all matched (or - // nothing wanted) so stop until reload/navigation wakes us. - if (outcome === 'pending') scheduleRefresh(PENDING_REFRESH_MS); - }); - }); - }; - - // Coalesce triggers: debounce, then scan at idle. Never scans synchronously - // on the triggering event (tab open / navigation / reload). - const scheduleScan = (delay: number) => { - if (cancelled) return; - if (debounceTimer) clearTimeout(debounceTimer); - cancelIdle(idleHandle); - idleHandle = undefined; - debounceTimer = setTimeout(runIdleScan, delay); - }; - - // A header showing a new loopback URL bumps "wanted"; debounce + defer so the - // scan lands after the tab is up, not during its first paints. - const unsubscribeWanted = subscribeWantedDevServerPorts(() => scheduleScan(DEBOUNCE_MS)); - // A reload un-settles every port and re-validates — optimistically, since we - // leave the published resolutions in place until the rescan overwrites them. - const unsubscribeRescan = subscribeDevServerRescan(() => { + sources.add(source); + holders += 1; + if (holders === 1) stopLoop = startCorrelationLoop(); + // A Wall arriving changes who can own a port, so re-validate: a resolution + // settled without it may now be ambiguous, or newly resolvable. + else { settled.clear(); - scheduleScan(DEBOUNCE_MS); - }); - scheduleScan(DEBOUNCE_MS); + scheduleScanNow?.(DEBOUNCE_MS); + } return () => { - cancelled = true; - if (debounceTimer) clearTimeout(debounceTimer); - if (refreshTimer) clearTimeout(refreshTimer); - cancelIdle(idleHandle); - unsubscribeWanted(); - unsubscribeRescan(); + sources.delete(source); + holders -= 1; + if (holders > 0) { + settled.clear(); + scheduleScanNow?.(DEBOUNCE_MS); + return; + } + stopLoop?.(); + stopLoop = null; }; }, [lath, doorsRef]); } diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index dcda879f8..ef3e4a51c 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -1,5 +1,6 @@ import { useCallback, type MutableRefObject } from 'react'; import { getPlatform, PLATFORM_STRING } from '../../lib/platform'; +import { WINDOW_REF } from '../../lib/workspace-store'; import type { DorControlRequestPayload, DorControlResult } from 'dor/protocol'; import { SURFACE_CONTROL_METHODS } from 'dor/protocol'; import type { @@ -366,7 +367,6 @@ export function useDorControl({ closeSurface, lastAgentBrowserBinaryPathRef, workspaceRef, - windowRef, }: { /** The Lath engine — visible-pane projection (`lath.listPanes()`), aspect-ratio * split resolution (`autoEdgeFor`), and per-leaf param writes. */ @@ -405,10 +405,10 @@ export function useDorControl({ closeSurface: (id: string, mode?: CloseSurfaceMode) => Promise; /** The last binary path a `dor ab` surface resolved on a terminal's PATH. */ lastAgentBrowserBinaryPathRef: MutableRefObject; - /** This Wall's own container refs, reported by `dor list` so a caller learns - * which Workspace answered (docs/specs/dor-cli.md → "Handle Model"). */ + /** This Wall's own positional Workspace ref, reported by `dor list` so a caller + * learns which Workspace answered (docs/specs/dor-cli.md → "Handle Model"). + * The Window is `WINDOW_REF` until there is more than one. */ workspaceRef: () => string; - windowRef: () => string; }): { /** The live surface (visible pane or minimized door) whose params match, or * null. Shared with the context's port launches in Wall.tsx. */ @@ -618,7 +618,7 @@ export function useDorControl({ result: { surfaces, workspaceRef: workspaceRef(), - windowRef: windowRef(), + windowRef: WINDOW_REF, }, }); return; @@ -1072,7 +1072,7 @@ export function useDorControl({ } detail.respond({ ok: false, error: `unsupported Dormouse control method '${detail.method}'` }); - }, [buildDorSurfaces, buildDorSurfaceList, closeSurface, createContentSurface, createSplitSurface, ensureAgentBrowserSurface, findSurfaceIdRunningCommand, requireBrowserSurface, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav, workspaceRef, windowRef]); + }, [buildDorSurfaces, buildDorSurfaceList, closeSurface, createContentSurface, createSplitSurface, ensureAgentBrowserSurface, findSurfaceIdRunningCommand, requireBrowserSurface, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav, workspaceRef]); return { findSurfaceByParams, updateSurfaceParams, handleDorControl }; } diff --git a/lib/src/components/wall/use-session-persistence.ts b/lib/src/components/wall/use-session-persistence.ts index 767e48cbf..a5c8be9e8 100644 --- a/lib/src/components/wall/use-session-persistence.ts +++ b/lib/src/components/wall/use-session-persistence.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react'; import { pasteFilePaths } from '../../lib/clipboard'; import { getPlatform } from '../../lib/platform'; -import { buildPersistedSession, saveSession, type SaveSink } from '../../lib/session-save'; +import { saveSession, type SaveSink } from '../../lib/session-save'; import { createSessionDirtyTracker } from '../../lib/session-dirty'; import { publishWorkspaceSession } from '../../lib/window-session-aggregator'; import { @@ -15,8 +15,6 @@ import type { DooredItem, WallSelectionKind } from './wall-types'; import type { PersistedDoor, PersistedSession, PersistedSurfaceRefs, WorkspaceId } from '../../lib/session-types'; export interface SessionPersistenceHandle { - /** This Workspace's record right now, built but not written. */ - buildSession: () => Promise; /** Persist immediately, awaiting the whole queued pipeline. */ flush: () => Promise; } @@ -29,7 +27,6 @@ export function useSessionPersistence({ selectedTypeRef, surfaceRefsForSave, workspaceId, - ownsHostFlush = true, }: { /** The Lath engine — the layout authority written on every commit, and the source * of the visible-pane projection (`lath.listPanes()`). Stable identity, so the @@ -46,14 +43,13 @@ export function useSessionPersistence({ surfaceRefsForSave?: () => { refs: PersistedSurfaceRefs; next: number }; /** Present when this Wall belongs to a Workspace: its record then goes to the * Window collector instead of the platform slot, and is compared against its - * own Workspace's previous record. */ + * own Workspace's previous record. It also hands the host's flush request to + * `WorkspaceWindow`, which owns the one subscription for the whole Window — + * the adapter's first `notifySessionFlushComplete` wins, so N Walls answering + * would let a quit proceed after the first had written. */ workspaceId?: WorkspaceId; - /** Whether this Wall answers the host's flush request itself. `WorkspaceWindow` - * sets this false and owns the one subscription for the whole Window — the - * adapter's first `notifySessionFlushComplete` wins, so N Walls answering - * would let a quit proceed after the first. */ - ownsHostFlush?: boolean; }): SessionPersistenceHandle { + const ownsHostFlush = workspaceId === undefined; const sessionSaveTimerRef = useRef | null>(null); const sessionSavePromiseRef = useRef | null>(null); const pendingSaveNeededRef = useRef(false); @@ -106,19 +102,6 @@ export function useSessionPersistence({ return saveSession(getPlatform(), panes, doors, lathLayout, surfaceRefs?.refs, surfaceRefs?.next, sink); }, [collect, sink]); - const buildSession = useCallback((): Promise => { - const { panes, doors, lathLayout, surfaceRefs } = collect(); - return buildPersistedSession( - getPlatform(), - panes, - doors, - lathLayout, - surfaceRefs?.refs, - surfaceRefs?.next, - publishedRef.current, - ); - }, [collect]); - const persistSessionNow = useCallback(async (): Promise => { const runSave = (): Promise => { pendingSaveNeededRef.current = false; @@ -182,9 +165,15 @@ export function useSessionPersistence({ const platform = getPlatform(); const { markDirty, isDirty } = trackerRef.current; + // Both PTY triggers are ownership-filtered: the adapter fans every Session's + // traffic to every mounted Wall, so an unfiltered one would have each + // Workspace persisting on every other Workspace's keystroke. + const ownsPane = (id: string) => lath.listPanes().some((p) => p.id === id); + const handlePtyData = (detail: { id: string }) => { + if (ownsPane(detail.id)) markDirty(); + }; const handlePtyExit = (detail: { id: string }) => { - const ownsPane = lath.listPanes().some((p) => p.id === detail.id); - if (!ownsPane) return; + if (!ownsPane(detail.id)) return; void flushSessionSave().catch(() => undefined); }; const handleSessionFlushRequest = (detail: { requestId: string }) => { @@ -206,7 +195,7 @@ export function useSessionPersistence({ // (docs/specs/layout.md → "Session persistence"). Untouched flips ride // the pty echo of the keystroke, not the pane-state store (the registry mutates // silently). - platform.onPtyData(markDirty); + platform.onPtyData(handlePtyData); const unsubActivity = subscribeToActivity(markDirty); const unsubPaneState = subscribeToTerminalPaneState(markDirty); @@ -236,7 +225,7 @@ export function useSessionPersistence({ unsubFilesDropped?.(); if (ownsHostFlush) platform.offRequestSessionFlush(handleSessionFlushRequest); platform.offPtyExit(handlePtyExit); - platform.offPtyData(markDirty); + platform.offPtyData(handlePtyData); unsubActivity(); unsubPaneState(); unsubscribeStore(); @@ -253,5 +242,5 @@ export function useSessionPersistence({ selectedTypeRef, ]); - return { buildSession, flush: flushSessionSave }; + return { flush: flushSessionSave }; } diff --git a/lib/src/components/wall/use-wall-keyboard.ts b/lib/src/components/wall/use-wall-keyboard.ts index 289857c41..6ac7dca12 100644 --- a/lib/src/components/wall/use-wall-keyboard.ts +++ b/lib/src/components/wall/use-wall-keyboard.ts @@ -7,7 +7,7 @@ import { handlePaneShortcuts } from './keyboard/handle-pane-shortcuts'; import { handlePaneNavigation } from './keyboard/handle-pane-navigation'; import { handleWorkspaceShortcuts } from './keyboard/handle-workspace-shortcuts'; import { isProxyOrigin } from '../../lib/iframe-proxy-registry'; -import { chromeKeyboardHeld } from '../../lib/chrome-keyboard-lease'; +import { chromeKeyboardHeld } from './chrome-keyboard-lease'; import type { NavHistoryRef, WallKeyboardCtx } from './keyboard/types'; export function useWallKeyboard(ctx: WallKeyboardCtx): void { diff --git a/lib/src/components/wall/wall-handles.ts b/lib/src/components/wall/wall-handles.ts index 3ad1fd0da..487babc95 100644 --- a/lib/src/components/wall/wall-handles.ts +++ b/lib/src/components/wall/wall-handles.ts @@ -1,4 +1,4 @@ -import type { PersistedSession, WorkspaceId } from '../../lib/session-types'; +import type { WorkspaceId } from '../../lib/session-types'; import type { CloseSurfaceMode } from './wall-types'; import type { DorControlRequest } from './use-dor-control'; @@ -18,10 +18,7 @@ export interface WallHandle { * gate, alongside `runningCount`). */ hasTouchedSurfaces(): boolean; runningCount(): number; - serialize(): Promise; flushPersistence(): Promise; - /** Put DOM focus back on this Wall's selected Surface, honoring its own mode. */ - focusSelected(): void; /** Close every member Surface through the closure coordinator. Resolves null * once the Wall is empty, else the first refusal's message with the Workspace * left as it was. */ @@ -65,3 +62,21 @@ export function wallHandleOwning(surfaceId: string): WallHandle | null { export function resetWallHandles(): void { handles.clear(); } + +/** An inert handle for a Wall that is not mounted (tests and Storybook), so a new + * `WallHandle` member is one edit here rather than one per fixture. Lives beside + * the interface, and not in a test util, because a story needs it too and must + * not pull vitest into the Storybook bundle. */ +export function stubWallHandle(workspaceId: WorkspaceId, overrides: Partial = {}): WallHandle { + return { + workspaceId, + surfaceIds: () => [], + ownsSurface: () => false, + hasTouchedSurfaces: () => false, + runningCount: () => 0, + flushPersistence: async () => {}, + closeAll: async () => null, + handleDorControl: () => {}, + ...overrides, + }; +} diff --git a/lib/src/components/wall/wall-test-utils.ts b/lib/src/components/wall/wall-test-utils.ts index 1c25340ba..82c9a84f5 100644 --- a/lib/src/components/wall/wall-test-utils.ts +++ b/lib/src/components/wall/wall-test-utils.ts @@ -1,3 +1,5 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; import { vi } from 'vitest'; import type { WallActions } from './wall-context'; import { @@ -38,6 +40,54 @@ export function ensureResizeObserver(): void { } as unknown as typeof ResizeObserver; } +export interface WallHarness { + container: HTMLDivElement; + root: Root; + /** Drain queued microtasks and 0ms timers inside `act`. */ + flush: () => Promise; + dispose: () => void; +} + +/** + * The jsdom setup any Wall composition needs: the browser APIs jsdom lacks, plus + * a mounted root. Call from `beforeEach` and `dispose()` from `afterEach`; a + * test file adds only its own platform and store resets. + */ +export function mountWallHarness(): WallHarness { + ensureResizeObserver(); + // Reduced motion so the Lath engine runs a 0 duration: the two-phase kill's + // deferred removal fires on a setTimeout(0) and completes within `flush()` — the + // instant path is also stage 3's "reduced motion" acceptance requirement. + globalThis.matchMedia = ((query: string) => ({ + matches: query.includes('prefers-reduced-motion'), + media: query, + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent() { return false; }, + })) as unknown as typeof matchMedia; + // Baseboard / dynamic-palette read a 2d context; jsdom has none. + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: vi.fn(() => null), + }); + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + return { + container, + root, + flush: async () => { await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); }, + dispose: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + export const STUB_SCREEN: ScreenSnapshot = { state: 'SYNCED', renderMode: 'ab-screencast', diff --git a/lib/src/components/wall/wall-types.ts b/lib/src/components/wall/wall-types.ts index 16540d74c..b7834404d 100644 --- a/lib/src/components/wall/wall-types.ts +++ b/lib/src/components/wall/wall-types.ts @@ -1,5 +1,6 @@ import type { SurfaceKind } from 'dor/commands/types'; import type { BrowserDisplayMode } from './agent-browser-screen'; +import type { PersistedDoor, PersistedSurfaceRefs } from '../../lib/session-types'; /** A minimized Surface's baseboard chip, at RUNTIME: an identity plus the Lath * restore `token` that says where it goes back. Deliberately carries no @@ -46,22 +47,17 @@ export type DoorAfterRestoreAction = }; /** - * The Window's Workspace verbs as a Wall's keyboard sees them. `WorkspaceWindow` - * builds the single instance; targets resolve through the active Workspace, so a - * hidden Wall's stale keystroke could not act on the wrong one. Absent on a bare - * Wall, which leaves the Workspace keys unbound (docs/specs/shortcuts.md → - * "Workspaces (command mode)"). + * The restored record a Wall boots from, passed through unchanged by every + * composition above it. Only the Workspace whose id was captured at first render + * receives one; every other Wall takes Lath's fresh branch + * (docs/specs/layout.md → "Workspaces"). */ -export interface WorkspaceCommands { - create(): void; - /** `+1` next, `-1` previous; wraps at both ends. */ - cycle(delta: 1 | -1): void; - /** Activate the nth Workspace (0-based); out of range does nothing. */ - selectIndex(index: number): void; - /** Ask the strip to run its close flow for the active Workspace. */ - requestClose(): void; - /** Ask the strip to open its rename editor on the active Workspace. */ - requestRename(): void; +export interface WallBootProps { + initialPaneIds?: string[]; + restoredLathLayout?: unknown; + initialDoors?: PersistedDoor[]; + initialSurfaceRefs?: PersistedSurfaceRefs; + initialSurfaceRefsNext?: number; } export type WallEvent = diff --git a/lib/src/components/wall/window-singletons.test.tsx b/lib/src/components/wall/window-singletons.test.tsx new file mode 100644 index 000000000..8ecb40a23 --- /dev/null +++ b/lib/src/components/wall/window-singletons.test.tsx @@ -0,0 +1,77 @@ +/** + * @vitest-environment jsdom + * + * Two hooks a Wall mounts that own WINDOW-level machinery — the spoken-alarm + * watcher and the dynamic palette — so N mounted Walls must still run one each + * (docs/specs/layout.md → "Workspaces"). + */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAlertSpeech } from './use-alert-speech'; +import { useDynamicPalette } from '../../lib/themes/use-dynamic-palette'; + +const stopSpeech = vi.fn(); +const startSpeech = vi.fn(() => stopSpeech); +vi.mock('../../lib/alert-speech', () => ({ startAlertSpeech: () => startSpeech() })); +vi.mock('../../lib/themes/dynamic-palette', () => ({ + computeDynamicPalette: () => ({ '--color-door-bg': 'rgb(1, 2, 3)' }), +})); + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; +let observers: number; + +function Consumer() { + useAlertSpeech(); + useDynamicPalette(); + return null; +} + +beforeEach(() => { + startSpeech.mockClear(); + stopSpeech.mockClear(); + observers = 0; + class CountingObserver { + constructor() { observers += 1; } + observe() {} + disconnect() {} + } + vi.stubGlobal('MutationObserver', CountingObserver); + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: vi.fn(() => ({})), + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('window-singleton Wall hooks', () => { + it('arms once for N Walls and disarms only when the last one goes', async () => { + await act(async () => { root.render(<>); }); + // One spoken-alarm watcher (it installs a global handler and clears every + // Session's speech state) and one palette observer for the document. + expect(startSpeech).toHaveBeenCalledTimes(1); + expect(observers).toBe(1); + expect(document.body.style.getPropertyValue('--color-door-bg')).toBe('rgb(1, 2, 3)'); + + // Dropping one Wall must not silence the survivors or strip the variables. + await act(async () => { root.render(<>); }); + expect(stopSpeech).not.toHaveBeenCalled(); + expect(document.body.style.getPropertyValue('--color-door-bg')).toBe('rgb(1, 2, 3)'); + + await act(async () => { root.render(<>); }); + expect(stopSpeech).toHaveBeenCalledTimes(1); + expect(document.body.style.getPropertyValue('--color-door-bg')).toBe(''); + }); +}); diff --git a/lib/src/components/wall/workspace-lifecycle.ts b/lib/src/components/wall/workspace-lifecycle.ts new file mode 100644 index 000000000..aea4b9eef --- /dev/null +++ b/lib/src/components/wall/workspace-lifecycle.ts @@ -0,0 +1,59 @@ +import { randomKillChar } from '../KillConfirm'; +import { getWallHandle } from './wall-handles'; +import { forgetWorkspaceSession } from '../../lib/window-session-aggregator'; +import { setPendingWorkspaceClose, setRenamingWorkspace } from '../../lib/workspace-ui-store'; +import { closeWorkspace, getWorkspacesSnapshot, setActiveWorkspace } from '../../lib/workspace-store'; +import type { WorkspaceId } from '../../lib/session-types'; + +/** + * The Workspace close and rename verbs, outside any component: the strip's + * buttons, the command-mode keys, and (later) `dor workspace` all take the same + * route (`docs/specs/layout.md` → "Workspaces"). The strip renders the + * confirmation these open; it decides nothing. + */ + +/** Whether closing this Workspace asks first: it holds a Surface the user has + * typed into, or a running Session. */ +export function workspaceNeedsCloseConfirmation(id: WorkspaceId): boolean { + const handle = getWallHandle(id); + return !!handle && (handle.hasTouchedSurfaces() || handle.runningCount() > 0); +} + +/** + * Close every member Surface through the closure coordinator, then drop the + * Workspace itself. Resolves the first refusal's message with the Workspace left + * as it was — revealed, so the prompt behind the refusal is on screen — or null + * once it is gone. Membership is cleared by the Wall's own unmount. + */ +export async function closeWorkspaceWithSurfaces(id: WorkspaceId): Promise { + const handle = getWallHandle(id); + if (handle) { + const refusal = await handle.closeAll('prompt'); + if (refusal) { + setActiveWorkspace(id); + return refusal; + } + } + forgetWorkspaceSession(id); + closeWorkspace(id); + return null; +} + +/** + * Begin closing a Workspace: the last one never closes (there is always one + * active Workspace), one holding work raises the typed confirmation, and any + * other goes immediately. + */ +export function requestWorkspaceClose(id: WorkspaceId): void { + if (getWorkspacesSnapshot().workspaces.length <= 1) return; + if (workspaceNeedsCloseConfirmation(id)) { + setPendingWorkspaceClose({ id, char: randomKillChar() }); + return; + } + void closeWorkspaceWithSurfaces(id); +} + +/** Open the strip's inline rename editor on a Workspace. */ +export function requestWorkspaceRename(id: WorkspaceId): void { + setRenamingWorkspace(id); +} diff --git a/lib/src/lib/chrome-keyboard-lease.ts b/lib/src/lib/chrome-keyboard-lease.ts deleted file mode 100644 index c74123b1e..000000000 --- a/lib/src/lib/chrome-keyboard-lease.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Command-mode keyboard suppression for chrome that lives OUTSIDE every Wall — - * today the Workspace strip's rename editor and close confirmation, which sit in - * the AppBar (`docs/specs/layout.md` → "Keyboard shortcuts (command mode)"). - * The Wall's dispatch listener is capture-phase on `window`, so a field up there - * cannot stop it with `stopPropagation`; it takes a lease instead. - * - * Reference-counted like `DialogKeyboardContext`, so overlapping holders each - * release only their own. - */ - -let holders = 0; - -/** Take one lease; the returned release drops it (idempotent). */ -export function acquireChromeKeyboardLease(): () => void { - holders += 1; - let released = false; - return () => { - if (released) return; - released = true; - holders = Math.max(0, holders - 1); - }; -} - -export function chromeKeyboardHeld(): boolean { - return holders > 0; -} - -/** Drop every lease (tests). */ -export function resetChromeKeyboardLeases(): void { - holders = 0; -} diff --git a/lib/src/lib/session-save.ts b/lib/src/lib/session-save.ts index 3295fca6d..ada679e5a 100644 --- a/lib/src/lib/session-save.ts +++ b/lib/src/lib/session-save.ts @@ -7,13 +7,14 @@ import { UNNAMED_PANEL_TITLE } from './terminal-state'; * Where a save reads its previous record from and where it writes the new one. * A Workspace supplies both, so its record is compared against and published * beside its own Workspace's rather than the Window's active one - * (`docs/specs/transport.md` → "Persisted session"). + * (`docs/specs/transport.md` → "Persisted session"). No sink at all is the + * platform slot; a half-supplied one would silently mix the two. */ export interface SaveSink { - /** This Workspace's last persisted record; `getPreviousPaneMap` reads a dead + /** This Workspace's last persisted record; the previous-pane map reads a dead * PTY's retained cwd out of it. */ - previous?: () => PersistedSession | null; - publish?: (session: PersistedSession) => void; + previous: () => PersistedSession | null; + publish: (session: PersistedSession) => void; } function previousPaneMap(previous: PersistedSession | null): Map { @@ -27,12 +28,8 @@ export interface SavePaneInput { surfaceType?: PersistedSurfaceType; } -/** - * Build one Workspace's `PersistedSession` from its live panes and Doors. Split - * out of `saveSession` because a Wall's handle serializes on demand (a Window - * snapshot, a quit) without writing anything. - */ -export async function buildPersistedSession( +/** Build one Workspace's `PersistedSession` from its live panes and Doors. */ +async function buildPersistedSession( platform: PlatformAdapter, panes: SavePaneInput[], doors: PersistedDoor[] = [], @@ -107,9 +104,9 @@ export async function saveSession( // it on every debounced save, every 30s heartbeat, and twice more per quit, // only for `saveState` to drop the result. if (platform.persistsSession === false) return; - const previous = sink?.previous ? sink.previous() : readPersistedSession(platform.getState()); + const previous = sink ? sink.previous() : readPersistedSession(platform.getState()); const session = await buildPersistedSession(platform, panes, doors, lathLayout, surfaceRefs, surfaceRefsNext, previous); - if (sink?.publish) sink.publish(session); + if (sink) sink.publish(session); else platform.saveState(session); } diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 30a4b9e14..f61e55091 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -573,20 +573,19 @@ export function restoreTerminal( return entry; } -export function mountElement(id: string, container: HTMLElement, opts?: { claimWebgl?: boolean }): void { +export function mountElement(id: string, container: HTMLElement): void { const entry = registry.get(id); if (!entry) return; container.appendChild(entry.element); - // A Session mounted inside a hidden Workspace defers its claim to the - // Workspace's first activation, so the GL context budget scales with visited - // Workspaces rather than with every mounted one. - if (opts?.claimWebgl !== false) claimWebglRenderer(id); requestAnimationFrame(() => entry.fit.fit()); } /** Claim a GL context for a mounted Session, once. First paint is the earliest * point worth claiming one — see `tryEnableWebglRenderer` on why create is too - * early — so a deferred claim runs when the Session first becomes visible. */ + * early — so a Session mounted inside a hidden Workspace claims on that + * Workspace's first activation, and the budget scales with visited Workspaces + * rather than with every mounted one. Idempotent: the mount path and the + * activation path both call it. */ export function claimWebglRenderer(id: string): void { const entry = registry.get(id); if (!entry || entry.webglAttempted) return; diff --git a/lib/src/lib/themes/use-dynamic-palette.ts b/lib/src/lib/themes/use-dynamic-palette.ts index 3f2e12e12..920e77f89 100644 --- a/lib/src/lib/themes/use-dynamic-palette.ts +++ b/lib/src/lib/themes/use-dynamic-palette.ts @@ -1,37 +1,61 @@ import { useEffect } from 'react'; import { computeDynamicPalette } from './dynamic-palette'; -export function useDynamicPalette(): void { - useEffect(() => { - const ctx = document.createElement('canvas').getContext('2d'); - if (!ctx) return; +/** + * Publish the derived palette onto `document.body` and keep it in step with the + * theme. + * + * One publisher per DOCUMENT, not per caller: the observers and the CSS + * variables are document-level, so N mounted Walls would each run their own + * MutationObserver over the same body and the first teardown would remove the + * variables the survivors still need. Reference counted, so the first caller + * starts it and the last one removes the variables. + */ +let holders = 0; +let stop: (() => void) | null = null; - const publish = (name: string, value: string) => { - // Hydration or another publisher can remove a value we already wrote. - if (document.body.style.getPropertyValue(name) === value) return; - document.body.style.setProperty(name, value); - }; +function start(): () => void { + const ctx = document.createElement('canvas').getContext('2d'); + if (!ctx) return () => {}; - const update = () => { - const dynamicPalette = computeDynamicPalette(getComputedStyle(document.body), ctx); - for (const [name, value] of Object.entries(dynamicPalette)) { - publish(name, value); - } - }; + const publish = (name: string, value: string) => { + // Hydration or another publisher can remove a value we already wrote. + if (document.body.style.getPropertyValue(name) === value) return; + document.body.style.setProperty(name, value); + }; + + const update = () => { + const dynamicPalette = computeDynamicPalette(getComputedStyle(document.body), ctx); + for (const [name, value] of Object.entries(dynamicPalette)) { + publish(name, value); + } + }; - update(); - const mo = new MutationObserver(update); - mo.observe(document.body, { attributes: true, attributeFilter: ['class', 'style'] }); - mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] }); + update(); + const mo = new MutationObserver(update); + mo.observe(document.body, { attributes: true, attributeFilter: ['class', 'style'] }); + mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] }); + return () => { + mo.disconnect(); + document.body.style.removeProperty('--color-door-bg'); + document.body.style.removeProperty('--color-door-fg'); + document.body.style.removeProperty('--color-focus-ring'); + document.body.style.removeProperty('--color-alarm-vs-header-active'); + document.body.style.removeProperty('--color-alarm-vs-header-inactive'); + document.body.style.removeProperty('--color-alarm-vs-door'); + document.body.style.removeProperty('--color-alarm-vs-terminal'); + }; +} + +export function useDynamicPalette(): void { + useEffect(() => { + holders += 1; + if (holders === 1) stop = start(); return () => { - mo.disconnect(); - document.body.style.removeProperty('--color-door-bg'); - document.body.style.removeProperty('--color-door-fg'); - document.body.style.removeProperty('--color-focus-ring'); - document.body.style.removeProperty('--color-alarm-vs-header-active'); - document.body.style.removeProperty('--color-alarm-vs-header-inactive'); - document.body.style.removeProperty('--color-alarm-vs-door'); - document.body.style.removeProperty('--color-alarm-vs-terminal'); + holders -= 1; + if (holders > 0) return; + stop?.(); + stop = null; }; }, []); } diff --git a/lib/src/lib/window-session-aggregator.ts b/lib/src/lib/window-session-aggregator.ts index 388cfe6df..14dd4ac39 100644 --- a/lib/src/lib/window-session-aggregator.ts +++ b/lib/src/lib/window-session-aggregator.ts @@ -6,7 +6,10 @@ import type { PersistedSession, PersistedWindow, PersistedWorkspace, WorkspaceId * (`docs/specs/transport.md` → "Persisted session"). The Wall's persistence hook * publishes here instead of writing the platform slot when it runs under a * Workspace; the writer that turns snapshots into a host write is installed - * separately, and standalone installs none yet. + * separately, and standalone installs none yet. The push path is deliberately + * complete ahead of its consumer: standalone persistence installs the writer and + * retires `window-persistence.ts`'s flag-gated merge (`docs/specs/layout.md` → + * "Future"). */ const sessions = new Map(); diff --git a/lib/src/lib/workspace-store.test.ts b/lib/src/lib/workspace-store.test.ts index bb4f81ff8..6420a0fac 100644 --- a/lib/src/lib/workspace-store.test.ts +++ b/lib/src/lib/workspace-store.test.ts @@ -158,7 +158,8 @@ describe('workspace-store', () => { createWorkspace({ id: 'ws-2' }); expect(workspaceRefFor(DEFAULT_WORKSPACE_ID)).toBe('workspace:1'); expect(workspaceRefFor('ws-2')).toBe('workspace:2'); - expect(workspaceRefFor('missing')).toBeNull(); + // A Workspace already gone (its Wall is mid-unmount) answers the first ref. + expect(workspaceRefFor('missing')).toBe('workspace:1'); expect(workspaceIdForRef('workspace:2')).toBe('ws-2'); expect(workspaceIdForRef('2')).toBe('ws-2'); expect(workspaceIdForRef('workspace:9')).toBeNull(); diff --git a/lib/src/lib/workspace-store.ts b/lib/src/lib/workspace-store.ts index cc85874ce..6749a107c 100644 --- a/lib/src/lib/workspace-store.ts +++ b/lib/src/lib/workspace-store.ts @@ -87,6 +87,20 @@ export function setActiveWorkspace(id: WorkspaceId): void { emit({ ...state, activeId: id }); } +/** Activate the Workspace `delta` places from the active one, wrapping at both ends. */ +export function activateAdjacentWorkspace(delta: 1 | -1): void { + const index = state.workspaces.findIndex((ws) => ws.id === state.activeId); + if (index === -1) return; + const { workspaces } = state; + setActiveWorkspace(workspaces[(index + delta + workspaces.length) % workspaces.length].id); +} + +/** Activate the nth Workspace in strip order (0-based); out of range does nothing. */ +export function activateWorkspaceAt(index: number): void { + const target = state.workspaces[index]; + if (target) setActiveWorkspace(target.id); +} + export function createWorkspace(opts?: { id?: WorkspaceId; name?: string; activate?: boolean }): WorkspaceMeta { let id = opts?.id ?? generateWorkspaceId(); while (state.workspaces.some((workspace) => workspace.id === id)) { @@ -145,10 +159,16 @@ export function moveWorkspace(id: WorkspaceId, toIndex: number): boolean { /** The only Window this build addresses; `window:` beyond it is an error. */ export const WINDOW_REF = 'window:1'; -/** A Workspace's positional `dor` ref, or null when it is not in this Window. */ -export function workspaceRefFor(id: WorkspaceId): string | null { +/** Whether `ref` names this Window — `window:1`, or the bare `1`. */ +export function isWindowRef(ref: string): boolean { + return ref.trim() === WINDOW_REF || ref.trim() === '1'; +} + +/** A Workspace's positional `dor` ref. One no longer in this Window — its Wall is + * mid-unmount — reports the first ref, which is what a lone Workspace answers. */ +export function workspaceRefFor(id: WorkspaceId): string { const index = state.workspaces.findIndex((ws) => ws.id === id); - return index === -1 ? null : `workspace:${index + 1}`; + return `workspace:${index === -1 ? 1 : index + 1}`; } /** Resolve `workspace:` or a bare `` (1-based) to a Workspace id. */ diff --git a/lib/src/lib/workspace-strip-intent.ts b/lib/src/lib/workspace-strip-intent.ts deleted file mode 100644 index 99dab72fd..000000000 --- a/lib/src/lib/workspace-strip-intent.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { WorkspaceId } from './session-types'; - -/** - * The one-way bridge from a Wall's keyboard to the strip: `&` and `$` in command - * mode open the strip's close confirmation and rename editor, which live in the - * AppBar, outside every Wall (`docs/specs/layout.md` → "Workspaces"). Nothing - * listening simply drops the intent. - */ -export type WorkspaceStripIntent = - | { kind: 'close'; workspaceId: WorkspaceId } - | { kind: 'rename'; workspaceId: WorkspaceId }; - -const listeners = new Set<(intent: WorkspaceStripIntent) => void>(); - -export function requestWorkspaceStripIntent(intent: WorkspaceStripIntent): void { - listeners.forEach((listener) => listener(intent)); -} - -export function subscribeToWorkspaceStripIntent( - listener: (intent: WorkspaceStripIntent) => void, -): () => void { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} diff --git a/lib/src/lib/workspace-surfaces.test.ts b/lib/src/lib/workspace-surfaces.test.ts index e5755d1ec..c110fd88e 100644 --- a/lib/src/lib/workspace-surfaces.test.ts +++ b/lib/src/lib/workspace-surfaces.test.ts @@ -5,7 +5,6 @@ import { resetWorkspaceSurfaces, setWorkspaceSurfaces, subscribeToWorkspaceSurfaces, - workspaceIdForSurface, } from './workspace-surfaces'; beforeEach(() => { @@ -37,16 +36,6 @@ describe('workspace membership store', () => { expect(listener).toHaveBeenCalledTimes(2); }); - it('resolves a Surface to the Workspace that published it', () => { - setWorkspaceSurfaces('ws-1', ['a', 'b']); - setWorkspaceSurfaces('ws-2', ['c']); - expect(workspaceIdForSurface('b')).toBe('ws-1'); - expect(workspaceIdForSurface('c')).toBe('ws-2'); - expect(workspaceIdForSurface('missing')).toBeNull(); - clearWorkspaceSurfaces('ws-2'); - expect(workspaceIdForSurface('c')).toBeNull(); - }); - it('copies the published array so a later mutation by the caller cannot leak in', () => { const ids = ['a']; setWorkspaceSurfaces('ws-1', ids); diff --git a/lib/src/lib/workspace-surfaces.ts b/lib/src/lib/workspace-surfaces.ts index d82c76059..6e8587464 100644 --- a/lib/src/lib/workspace-surfaces.ts +++ b/lib/src/lib/workspace-surfaces.ts @@ -53,14 +53,6 @@ export function subscribeToWorkspaceSurfaces(listener: () => void): () => void { }; } -/** The Workspace a Surface belongs to, or null when no Wall claims it. */ -export function workspaceIdForSurface(surfaceId: string): WorkspaceId | null { - for (const [workspaceId, ids] of membership) { - if (ids.includes(surfaceId)) return workspaceId; - } - return null; -} - /** Forget every Workspace's membership (tests). */ export function resetWorkspaceSurfaces(): void { if (membership.size === 0) return; diff --git a/lib/src/lib/workspace-ui-store.ts b/lib/src/lib/workspace-ui-store.ts new file mode 100644 index 000000000..2828ccecc --- /dev/null +++ b/lib/src/lib/workspace-ui-store.ts @@ -0,0 +1,53 @@ +import type { WorkspaceId } from './session-types'; + +/** + * The Workspace strip's two transient UI states, held outside it because the + * command-mode `$` and `&` are heard inside a Wall while the strip lives in the + * app bar (`docs/specs/layout.md` → "Workspaces"). The strip renders from this; + * nothing else reads it, and nothing mounted is required to write it. + */ +export interface WorkspaceUiState { + /** The Workspace whose inline rename editor is open. */ + renamingId: WorkspaceId | null; + /** The Workspace awaiting its typed close confirmation, and the letter that + * accepts it (minted once, so a re-render cannot change the letter on screen). */ + pendingClose: { id: WorkspaceId; char: string } | null; +} + +const EMPTY: WorkspaceUiState = { renamingId: null, pendingClose: null }; + +let state: WorkspaceUiState = EMPTY; +const listeners = new Set<() => void>(); + +function emit(next: WorkspaceUiState): void { + state = next; + listeners.forEach((listener) => listener()); +} + +export function subscribeToWorkspaceUi(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Stable snapshot reference (changes only on mutation) for `useSyncExternalStore`. */ +export function getWorkspaceUiSnapshot(): WorkspaceUiState { + return state; +} + +export function setRenamingWorkspace(id: WorkspaceId | null): void { + if (state.renamingId === id) return; + emit({ ...state, renamingId: id }); +} + +export function setPendingWorkspaceClose(pending: WorkspaceUiState['pendingClose']): void { + if (state.pendingClose?.id === pending?.id && state.pendingClose?.char === pending?.char) return; + emit({ ...state, pendingClose: pending }); +} + +/** Drop both (a Workspace closed, or tests). */ +export function resetWorkspaceUi(): void { + if (state === EMPTY) return; + emit(EMPTY); +} diff --git a/lib/src/stories/AppBar.stories.tsx b/lib/src/stories/AppBar.stories.tsx index 6299a6532..fffd67f10 100644 --- a/lib/src/stories/AppBar.stories.tsx +++ b/lib/src/stories/AppBar.stories.tsx @@ -1,20 +1,14 @@ -import { useEffect, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { AppBar } from '../../../standalone/src/AppBar'; -import { resetWorkspaces, setWorkspaces } from '../lib/workspace-store'; -function AppBarStory({ names }: { names: string[] }) { - // The bar's strip reads the Workspace store, so the scenario is written before - // first paint and reset after. - const [ready, setReady] = useState(false); - useEffect(() => { - const workspaces = names.map((name, index) => ({ id: `story-ws-${index + 1}`, name })); - setWorkspaces({ workspaces, activeId: workspaces[0].id }); - setReady(true); - return () => resetWorkspaces(); - }, [names]); +/** The bar's strip reads the Workspace store, primed by the preview decorator + * from `parameters.primedWorkspaces` before first render and reset after. */ +function AppBarStory() { + return
; +} - return
{ready && }
; +function primed(names: string[]) { + return { workspaces: names.map((name, index) => ({ id: `story-ws-${index + 1}`, name })) }; } const meta: Meta = { @@ -28,10 +22,10 @@ type Story = StoryObj; /** The left slot holds the Workspace strip; shell and theme selection live in * the Settings dialog (`Modals/SettingsDialog`). */ export const Default: Story = { - args: { names: ['Workspace 1', 'Deploys', 'Agents'] }, + parameters: { primedWorkspaces: primed(['Workspace 1', 'Deploys', 'Agents']) }, }; /** One Workspace: no close button anywhere, because the last one cannot close. */ export const SingleWorkspace: Story = { - args: { names: ['Workspace 1'] }, + parameters: { primedWorkspaces: primed(['Workspace 1']) }, }; diff --git a/lib/src/stories/WorkspaceStrip.stories.tsx b/lib/src/stories/WorkspaceStrip.stories.tsx index 35af88558..7c72188bf 100644 --- a/lib/src/stories/WorkspaceStrip.stories.tsx +++ b/lib/src/stories/WorkspaceStrip.stories.tsx @@ -1,71 +1,42 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { WorkspaceStrip } from '../components/WorkspaceStrip'; -import { registerWallHandle, resetWallHandles, type WallHandle } from '../components/wall/wall-handles'; -import { resetWorkspaceSurfaces, setWorkspaceSurfaces } from '../lib/workspace-surfaces'; -import { resetWorkspaces, setWorkspaces } from '../lib/workspace-store'; +import { registerWallHandle, stubWallHandle } from '../components/wall/wall-handles'; import { requireElement, waitForPrimedState } from './settle-terminals'; -/** A stand-in for a mounted Wall, so the strip's close flow has something to - * ask about running work without a live Workspace behind it. */ -function stubHandle(workspaceId: string): WallHandle { +/** Workspace ids by position, so `parameters.primedWorkspaces` and the story's + * membership and stub handle all name the same ones. */ +const ws = (index: number) => `story-ws-${index + 1}`; + +function primed(names: string[], activeIndex: number, membership?: Record) { return { - workspaceId, - surfaceIds: () => [], - ownsSurface: () => false, - hasTouchedSurfaces: () => true, - runningCount: () => 1, - serialize: async () => ({ version: 3, panes: [], doors: [] }), - flushPersistence: async () => {}, - focusSelected: () => {}, - // Never resolves: the story is the confirmation, not what follows it. - closeAll: () => new Promise(() => {}), - handleDorControl: () => {}, + workspaces: names.map((name, index) => ({ id: ws(index), name })), + activeId: ws(activeIndex), + membership: Object.fromEntries( + Object.entries(membership ?? {}).map(([index, ids]) => [ws(Number(index)), ids]), + ), }; } -function StripStory({ - names, - activeIndex = 0, - membership, - busyIndex, - width = 640, -}: { - names: string[]; - activeIndex?: number; - /** Member Surface ids per tab index. Their Activity is primed through - * `parameters.primedSessionState`, which the preview decorator applies two - * frames after mount — anything written here would be cleared by it. */ - membership?: Record; - busyIndex?: number; - width?: number; -}) { - // The strip reads module stores, so the scenario is written before first paint - // and torn down after — a story must not leak Workspaces into the next one. - const [ready, setReady] = useState(false); +/** The Workspace model comes from `parameters.primedWorkspaces`, which the + * preview decorator writes before first render (the strip reads the store on + * its first) and clears after. */ +function StripStory({ width = 640, busyIndex }: { width?: number; busyIndex?: number }) { + // A stand-in for a mounted Wall, so the close flow has something to ask about + // running work. `closeAll` never resolves: the story is the confirmation, not + // what follows it. useEffect(() => { - const ids = names.map((_, index) => `story-ws-${index + 1}`); - setWorkspaces({ - workspaces: names.map((name, index) => ({ id: ids[index], name })), - activeId: ids[activeIndex], - }); - resetWorkspaceSurfaces(); - resetWallHandles(); - for (const [index, surfaces] of Object.entries(membership ?? {})) { - setWorkspaceSurfaces(ids[Number(index)], surfaces); - } - if (busyIndex !== undefined) registerWallHandle(stubHandle(ids[busyIndex])); - setReady(true); - return () => { - resetWorkspaces(); - resetWorkspaceSurfaces(); - resetWallHandles(); - }; - }, [names, activeIndex, membership, busyIndex]); + if (busyIndex === undefined) return; + return registerWallHandle(stubWallHandle(ws(busyIndex), { + hasTouchedSurfaces: () => true, + runningCount: () => 1, + closeAll: () => new Promise(() => {}), + })); + }, [busyIndex]); return (
- {ready && } +
); } @@ -81,18 +52,20 @@ type Story = StoryObj; /** Two Workspaces, the second active: the active tab takes the wall's own * background and the terminal top radius, the other is transparent. */ export const Default: Story = { - args: { names: ['Workspace 1', 'Deploys'], activeIndex: 1 }, + parameters: { primedWorkspaces: primed(['Workspace 1', 'Deploys'], 1) }, }; /** Only a HIDDEN Workspace shows indicators — the visible one's panes already * say it (`docs/specs/alert.md` → the Workspace union). */ export const Indicators: Story = { - args: { - names: ['Builds', 'Agents', 'Workspace 3'], - activeIndex: 2, - membership: { 0: ['builds-a', 'builds-b'], 1: ['agents-a'], 2: ['visible-a'] }, - }, parameters: { + primedWorkspaces: primed(['Builds', 'Agents', 'Workspace 3'], 2, { + 0: ['builds-a', 'builds-b'], + 1: ['agents-a'], + 2: ['visible-a'], + }), + // Applied two frames after mount by the preview decorator, which clears + // Activity for every session-less id first. primedSessionState: { byId: { 'builds-a': { status: 'ALERT_RINGING' }, @@ -107,7 +80,7 @@ export const Indicators: Story = { }; export const Renaming: Story = { - args: { names: ['Workspace 1', 'Deploys'], activeIndex: 1 }, + parameters: { primedWorkspaces: primed(['Workspace 1', 'Deploys'], 1) }, play: async () => { const tab = await requireElement('[data-workspace-tab] button', 'workspace tab'); tab.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); @@ -118,16 +91,17 @@ export const Renaming: Story = { /** Past the point where tabs still fit: they shrink toward the floor and the * strip scrolls. No overflow arrows. */ export const Overflow: Story = { - args: { - names: ['Workspace 1', 'Deploys', 'Agents', 'Builds', 'Docs', 'Scratch'], - activeIndex: 3, - width: 420, + args: { width: 420 }, + parameters: { + primedWorkspaces: primed(['Workspace 1', 'Deploys', 'Agents', 'Builds', 'Docs', 'Scratch'], 3), }, }; -/** Closing a Workspace that holds work asks first, anchored to its own tab. */ +/** Closing a Workspace that holds work asks first; with no Window behind it the + * confirmation is viewport-centered. */ export const CloseConfirm: Story = { - args: { names: ['Workspace 1', 'Deploys'], activeIndex: 1, busyIndex: 1 }, + args: { busyIndex: 1 }, + parameters: { primedWorkspaces: primed(['Workspace 1', 'Deploys'], 1) }, play: async () => { const close = await requireElement( '[data-workspace-tab-active="true"] [data-workspace-tab-close]', diff --git a/lib/src/stories/WorkspaceWindow.stories.tsx b/lib/src/stories/WorkspaceWindow.stories.tsx index ecc3f4003..bec85f30a 100644 --- a/lib/src/stories/WorkspaceWindow.stories.tsx +++ b/lib/src/stories/WorkspaceWindow.stories.tsx @@ -1,9 +1,7 @@ -import { useEffect, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { WorkspaceStrip } from '../components/WorkspaceStrip'; import { WorkspaceWindow } from '../components/WorkspaceWindow'; import { flattenScenario, SCENARIO_LS_OUTPUT } from '../lib/platform'; -import { resetWorkspaces, setWorkspaces } from '../lib/workspace-store'; import { requireElement, settleTerminals, waitForCondition } from './settle-terminals'; const WORKSPACES = [ @@ -12,16 +10,9 @@ const WORKSPACES = [ ]; /** The Window as the standalone host composes it: the strip in the bar, one - * mounted Wall per Workspace below it. */ + * mounted Wall per Workspace below it. The Workspace model comes from + * `parameters.primedWorkspaces`, written before first render. */ function WorkspaceWindowStory() { - const [ready, setReady] = useState(false); - useEffect(() => { - setWorkspaces({ workspaces: WORKSPACES, activeId: WORKSPACES[0].id }); - setReady(true); - return () => resetWorkspaces(); - }, []); - - if (!ready) return null; return (
@@ -35,7 +26,10 @@ function WorkspaceWindowStory() { const meta: Meta = { title: 'App/WorkspaceWindow', component: WorkspaceWindowStory, - parameters: { fakePty: { scenario: flattenScenario(SCENARIO_LS_OUTPUT) } }, + parameters: { + fakePty: { scenario: flattenScenario(SCENARIO_LS_OUTPUT) }, + primedWorkspaces: { workspaces: WORKSPACES, activeId: WORKSPACES[0].id }, + }, }; export default meta; diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 908747dff..f44704152 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -9,7 +9,7 @@ "docs/specs/dor-cli.md": 4900, "docs/specs/dor-tool.md": 2100, "docs/specs/glossary.md": 2850, - "docs/specs/layout.md": 8150, + "docs/specs/layout.md": 8200, "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/notepad.md": 3750, From 47cd0371938d004a9544ea753ee65eb6bcae27d0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 21:10:01 -0700 Subject: [PATCH 10/13] Fix the Workspace close race and hidden-Wall input leaks after review The close verb could empty a Wall the store then refused to remove, and a hidden Wall could still answer window input. The Stage A review findings, each with a test: - One close at a time for the Window, with the count re-checked after the confirmation; a `closeWorkspace` that refuses anyway hands the Wall back its auto-spawn through the new `WallHandle.cancelClose`. - `closeAll` re-reads membership until nothing new turns up, refuses the Surface-creating `dor` verbs while it walks, and refuses on its exit deadline instead of reporting clean over a live Surface (`awaitWallEmpty`). - The kill confirmation, the refused-archive prompt, and a terminal's selection popup render only in the visible Workspace, so their key traps answer nothing behind a switch; the staged state survives it. - The strip's drag latch is one-shot, consumed by the click that follows a release, and the pointer capture lands on the tab rather than on React's root container. - Session persistence filters PTY traffic on Wall membership (Doors included), so a minimized Session's untouched flip is persisted. - The `dor` router types container refs before use, answers a handler that throws or rejects, and retries a bounded number of macrotasks across the gap between `createWorkspace()` and the new Wall registering. - Four hand-rolled refcounts collapse onto `createRefCount`, whose release is idempotent; membership is published only when leaves change, and the strip's union cache evicts closed Workspaces. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- docs/specs/dor-browser.md | 11 ++- docs/specs/dor-cli.md | 7 +- docs/specs/layout.md | 11 ++- docs/specs/standalone.md | 6 +- docs/specs/tiling-engine.md | 2 +- lib/src/components/SelectionPopup.test.tsx | 42 ++++++++ lib/src/components/SelectionPopup.tsx | 8 +- lib/src/components/Wall.test.tsx | 58 +++++++++++ lib/src/components/Wall.tsx | 99 ++++++++++++------- lib/src/components/WorkspaceStrip.test.tsx | 29 ++++++ lib/src/components/WorkspaceStrip.tsx | 5 + lib/src/components/WorkspaceWindow.test.tsx | 91 +++++++++++++++++ lib/src/components/wall/close-all.test.ts | 54 ++++++++++ lib/src/components/wall/close-all.ts | 43 ++++++++ .../wall/dor-control-router.test.ts | 72 ++++++++++++++ lib/src/components/wall/dor-control-router.ts | 86 ++++++++++------ lib/src/components/wall/use-alert-speech.ts | 15 +-- .../components/wall/use-dev-server-ports.ts | 34 +++---- lib/src/components/wall/use-dor-control.ts | 29 +++++- .../wall/use-session-persistence.ts | 29 ++++-- lib/src/components/wall/wall-handles.ts | 5 + .../wall/workspace-lifecycle.test.ts | 94 ++++++++++++++++++ .../components/wall/workspace-lifecycle.ts | 41 ++++++-- lib/src/components/workspace-strip-drag.ts | 25 ++++- lib/src/lib/ref-count.test.ts | 60 +++++++++++ lib/src/lib/ref-count.ts | 45 +++++++++ lib/src/lib/themes/use-dynamic-palette.ts | 17 +--- scripts/spec-word-budgets.json | 4 +- 28 files changed, 871 insertions(+), 151 deletions(-) create mode 100644 lib/src/components/wall/close-all.test.ts create mode 100644 lib/src/components/wall/close-all.ts create mode 100644 lib/src/components/wall/workspace-lifecycle.test.ts create mode 100644 lib/src/lib/ref-count.test.ts create mode 100644 lib/src/lib/ref-count.ts diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index c6de99cfe..1334c1745 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -130,10 +130,13 @@ Source of truth: `lib/src/components/wall/SurfacePaneHeader.tsx`, ## Dev-Server Chip For loopback URLs (`localhost`, `*.localhost`, `127.0.0.1`, `::1`) the header -registers interest in the port. The Wall scans terminal panes and minimized doors -via `PlatformAdapter.getOpenPorts(id)` and **shows a chip only when exactly one -terminal owns that port**; zero or two-plus leave it unsettled, so a dev server -that starts later still matches. **Match only binds that serve localhost** — +registers interest in the port. **One scan loop per Window, over every mounted +Wall's terminal panes and minimized doors** — each Wall registers its Surfaces as +a candidate source, since the wanted-port store and the resolutions are +window-wide. It reads `PlatformAdapter.getOpenPorts(id)` and **shows a chip only +when exactly one terminal owns that port**; zero or two-plus leave it unsettled, +so a dev server that starts later still matches, and a Wall arriving or leaving +re-validates what had settled. **Match only binds that serve localhost** — loopback or any-interface (`0.0.0.0`, `::`), never a specific non-loopback bind. Scanning is debounced, idle-scheduled, and polls only while a wanted port is unmatched; reload revalidates optimistically. diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 6c27ff9f0..ed8e0337d 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -289,7 +289,12 @@ Invariants: never identifies a Workspace. - **One Wall answers each request**, resolved in order: an explicit `workspace:`, else the Workspace owning the calling Surface, else the - active one; nothing mounted leaves the request unanswered. **Surface targets + active one; nothing mounted leaves the request unanswered, after a bounded + retry that covers the tick between a Workspace being created and its Wall + registering. **Every request is answered, including a container ref of the + wrong type and a handler that throws** — an unanswered one blocks its caller + to the deadline. A Workspace being closed refuses the Surface-creating verbs + (`docs/specs/layout.md` → "Workspaces"). **Surface targets resolve within the answering Workspace** — refs are Workspace-scoped — so a `dor split` from a background Workspace lands beside its caller rather than wherever the user is looking. Cross-Workspace targeting is staged with diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 2484b1648..2379d5f73 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -145,14 +145,15 @@ Each Wall renders one Workspace's Content (Lath layout) and Baseboard (doors). S - **Must mount every Workspace's Wall in one grid cell**, inactive Walls `visibility:hidden` (plus `inert`) and never `display:none` (rationale). - **Must switch by flipping `active` alone**: no re-seed, no re-parent, no unmount, and no `mountElement` / `resumeTerminal` / `restoreTerminal`, which is what makes I8 hold by construction (`WorkspaceWindow.test.tsx`). -- **A hidden Wall consumes no window input**: every listener it keeps is gated on `active`, so nothing it hears is dispatched, forwarded, or `preventDefault`ed. **Only the active Wall renders the modal hosts** (rationale); their store-backed state survives a switch. +- **A hidden Wall consumes no window input**: every listener it keeps is gated on `active`, so nothing it hears is dispatched, forwarded, or `preventDefault`ed. **Only the active Wall renders the modal hosts and the overlays that trap keys** — the kill confirmation, the refused-archive prompt, a terminal's selection popup (rationale): a staged prompt survives the switch and is answered only where the user can see it. - **Exactly one Wall answers a `dor` request**, chosen by `docs/specs/dor-cli.md` → "Handle Model". Every Wall registers a handle, a bare one under `DEFAULT_WORKSPACE_ID`, so the router always finds one. -- **Never unmount a Wall before its Surfaces are disposed** — `closeAll` waits for the kill fade to commit, bounded by the engine's exit duration, since unmounting mid-fade would leave `Orphaned` Registry entries (`docs/specs/glossary.md` → "Invariants" I4). +- **Never unmount a Wall before its Surfaces are disposed** — `closeAll` waits for the kill fade to commit, bounded by the engine's exit duration, since unmounting mid-fade would leave `Orphaned` Registry entries (`docs/specs/glossary.md` → "Invariants" I4). **The deadline refuses rather than reporting clean**, and the walk re-reads membership until nothing is left, so a Surface born behind it is closed too. +- **A closing Workspace takes no new Surfaces**: while `closeAll` walks, this Wall answers every Surface-creating `dor` verb with an error (`docs/specs/dor-cli.md` → "Handle Model"). - **Must reject duplicate Workspace IDs before mutating the model**, preserving the last-Workspace close guard (`workspace-store.test.ts`). - Each Wall keeps its own mode and selection across switches: deactivating blurs its selected pane, activating focuses it a frame later, since focus into a hidden subtree is a no-op. - A Workspace's first activation claims the GL context its hidden mount deferred ([Renderer](#renderer); rationale). -**Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter and key rule over the Window's content area, then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers the positional `workspace:` refs with it. **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path. +**Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter and key rule over the Window's content area, then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **One close runs at a time for the whole Window**, with the count re-checked after the confirmation, so two of them cannot empty two Walls between them; **a close the store then refuses hands the Wall back its auto-spawn** rather than leaving it mounted and empty. **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers the positional `workspace:` refs with it. **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path. The union projection and its indicators are owned by `docs/specs/alert.md` → Workspace union; the strip that renders them by `docs/specs/standalone.md` → AppBar. Persisted containers are owned by `docs/specs/transport.md`; `dormouse.flags.workspaces` still selects the bare `PersistedSession` versus `PersistedWindow` stored format, and **both standalone adapters still disable session persistence**, so a relaunch restores one Workspace. @@ -334,7 +335,7 @@ On cold restore, a terminal pane with a host-captured recovery invocation runs i ### Renderer -Text uses `@xterm/addon-webgl`; ImageAddon uses canvas layers (rationale). **Claim the GL context on a session's first `mountElement`, never at creation**, guarded by `TerminalEntry.webglAttempted` so each session claims at most one (rationale). **xterm's built-in DOM renderer is the fallback, never the default** — its per-cell span rebuild makes a truecolor-dense TUI an order of magnitude slower (rationale). Image rules: `docs/specs/terminal-escapes.md` → "Inline graphics". +Text uses `@xterm/addon-webgl`; ImageAddon uses canvas layers (rationale). **Claim the GL context on a Session's first mount inside a *visible* Workspace, never at creation** — one mounted in a hidden Workspace claims on that Workspace's first activation (rationale). **One claim per Session**, guarded by `TerminalEntry.webglAttempted`, since the mount path and the activation path both ask. **xterm's built-in DOM renderer is the fallback, never the default** — its per-cell span rebuild makes a truecolor-dense TUI an order of magnitude slower (rationale). Image rules: `docs/specs/terminal-escapes.md` → "Inline graphics". **Fallback to the DOM renderer must stay automatic** — two expected failure modes: @@ -343,7 +344,7 @@ Text uses `@xterm/addon-webgl`; ImageAddon uses canvas layers (rationale). **Cla **Degradation is one-way**: a demoted pane stays on the DOM renderer even after other panes close. Re-arming is unbuilt — see `## Future`. The outcome is recorded as `data-renderer="webgl"|"dom"` on the host element, including after a context loss demotes a pane. `cfg.terminal.webglRenderer` disables the whole path, and it is off under Chromatic (pinned in `lib/.storybook/preview.ts`). -Source of truth: `tryEnableWebglRenderer` and `createXtermHost` in `lib/src/lib/terminal-lifecycle.ts`. Not the SDF fork of `docs/specs/webgl-text.md`, a different addon consumed only by `canopy/`. +Source of truth: `tryEnableWebglRenderer` and `createXtermHost` in `lib/src/lib/terminal-lifecycle.ts`; the claim itself in `claimWebglRenderer` there, called from `TerminalPane` in `lib/src/components/TerminalPane.tsx` behind `WorkspaceActiveContext`. Not the SDF fork of `docs/specs/webgl-text.md`, a different addon consumed only by `canopy/`. ### Session persistence diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 475b8eace..0c5087c8a 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -255,10 +255,8 @@ then the strip scrolls, with no overflow arrows. matches that attribute on the event target alone, so a tab carrying it would drag the window instead of activating, renaming, or reordering. Its wrapper — the bar past the last tab — carries it, and is the draggable spacer. -- **Deferred to the multi-window stage:** `onDragOutsideWindow` and - `onDropOnOtherWindow` are the strip's seams for tearing a Workspace out and - dropping it on another Window; nothing passes them yet - (`docs/specs/layout.md` `## Future`, workspaces-rollout). +- `onDragOutsideWindow` / `onDropOnOtherWindow` are the strip's tear-out seams, + staged in `docs/specs/layout.md` `## Future` (workspaces-rollout). Source of truth: `WorkspaceStrip` in `lib/src/components/WorkspaceStrip.tsx`; `createWorkspaceStripDrag` in `lib/src/components/workspace-strip-drag.ts`. diff --git a/docs/specs/tiling-engine.md b/docs/specs/tiling-engine.md index c37fdb399..ac54bc407 100644 --- a/docs/specs/tiling-engine.md +++ b/docs/specs/tiling-engine.md @@ -190,7 +190,7 @@ Source of truth: `createAnimator` in `lib/src/lib/lath/animator.ts`; the animato - **Read side**: `PaneProps` — `{ id, title, params, parked? }`, supplied by LathHost straight from `leafMeta`, parked leaves included; a meta commit re-renders the leaf, so params stay live either way. - **Write side**: `PaneWriteContext` (`{ setTitle(id, t), updateParams(id, patch) }`), provided by the Wall over the store (`lath.store.setTitle` / `lath.store.updateParams`); the `wsPort`-refresh and render-swap flows route through the same seam. The value is stable per mount; the `AgentBrowserPanel` controller sink captures it once. -- **Visibility**: a mounted leaf is engine-visible unless **parked**, so `parked` is the one non-meta pane prop and absent means "not parked" — right for anything rendered outside LathHost. `useSurfaceVisibility(parked)` folds it with document visibility, so a backgrounded window and a minimized browser Surface both gate streaming while the session stays alive. +- **Visibility**: a mounted leaf is engine-visible unless **parked**, so `parked` is the one non-meta pane prop and absent means "not parked" — right for anything rendered outside LathHost. `useSurfaceVisibility(parked)` folds it with document visibility and the Wall's Workspace being the visible one (`docs/specs/layout.md` → "Workspaces"), so a backgrounded window, a hidden Workspace, and a minimized browser Surface all gate streaming while the session stays alive. - `use-pane-chrome` registers the pane's root element in `PaneElementsContext`, for the overlays to measure, and nothing else — there is no CSS spawn-animation to trigger. Source of truth: `lib/src/components/wall/pane-props.ts`; `PaneWriteContext` in `lib/src/components/wall/wall-context.tsx`. diff --git a/lib/src/components/SelectionPopup.test.tsx b/lib/src/components/SelectionPopup.test.tsx index 344f162d8..c3d3124a0 100644 --- a/lib/src/components/SelectionPopup.test.tsx +++ b/lib/src/components/SelectionPopup.test.tsx @@ -32,6 +32,7 @@ import { } from '../lib/mouse-selection'; import { getTerminalOverlayDims } from '../lib/terminal-registry'; import { SelectionPopup } from './SelectionPopup'; +import { WorkspaceActiveContext } from './wall/wall-context'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -252,3 +253,44 @@ describe('SelectionPopup: copy and flash', () => { expect(getMouseSelectionState('term-1').selection?.startRow).toBe(8); }); }); + +describe('SelectionPopup: hidden Workspace', () => { + /** The same finalized selection, rendered inside a Wall that is not the + * visible Workspace. */ + function renderHidden(): void { + act(() => { + setSelection('term-1', { + startRow: 1, + startCol: 0, + endRow: 2, + endCol: 10, + shape: 'linewise', + dragging: false, + startedInScrollback: false, + }); + }); + act(() => root.render( + + + , + )); + } + + it('renders nothing and swallows no window input, keeping the selection for the way back', () => { + renderHidden(); + expect(container.querySelector('[data-selection-popup-for="term-1"]')).toBeNull(); + + // The dismissal listeners are capture-phase window listeners: answering + // these would take an Escape or a click from the visible Workspace + // (docs/specs/layout.md -> "Workspaces"). + const escape = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + act(() => { window.dispatchEvent(escape); }); + expect(escape.defaultPrevented).toBe(false); + act(() => { window.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); }); + expect(getMouseSelectionState('term-1').selection).not.toBeNull(); + + // Visible again: the popup comes back over the same selection. + act(() => root.render()); + expect(container.querySelector('[data-selection-popup-for="term-1"]')).not.toBeNull(); + }); +}); diff --git a/lib/src/components/SelectionPopup.tsx b/lib/src/components/SelectionPopup.tsx index 6cb2d02b9..ec5bfa612 100644 --- a/lib/src/components/SelectionPopup.tsx +++ b/lib/src/components/SelectionPopup.tsx @@ -17,6 +17,7 @@ import { IS_MAC } from '../lib/platform'; import { getTerminalOverlayDims } from '../lib/terminal-registry'; import { PopupButtonRow, popupButton, Shortcut } from './design'; import { TouchUiContext } from './touch-ui-context'; +import { WorkspaceActiveContext } from './wall/wall-context'; interface Anchor { left: number; @@ -49,10 +50,15 @@ export function SelectionPopup({ terminalId }: Props) { const touchUi = useContext(TouchUiContext); const states = useSyncExternalStore(subscribeToMouseSelection, getMouseSelectionSnapshot); const renderTick = useSyncExternalStore(subscribeToRenderTick, getRenderTick); + // A hidden Workspace consumes no window input (docs/specs/layout.md → + // "Workspaces"): the dismissal listeners below are capture-phase and would + // otherwise answer an Escape or a click meant for the visible Workspace. The + // selection itself lives in the store, so it is still there on the way back. + const workspaceActive = useContext(WorkspaceActiveContext); const state = states.get(terminalId) ?? DEFAULT_MOUSE_SELECTION_STATE; const selection = state.selection; - const shouldRender = (!!selection && !selection.dragging) || !!state.copyFlash; + const shouldRender = workspaceActive && ((!!selection && !selection.dragging) || !!state.copyFlash); const [anchor, setAnchor] = useState(null); diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 0ae63b948..7d876077c 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -2043,3 +2043,61 @@ describe('Wall on the Lath engine', () => { expect(handle.ownsSurface('pane-elsewhere')).toBe(false); }); }); + +describe('Wall session persistence: ownership filtering', () => { + /** The pty-data handlers the Wall and the registry registered, invoked + * directly. Going through `FakePtyAdapter.writePty` would also move the alert + * manager, whose activity change marks the session dirty on its own — this + * isolates the ownership filter under test. */ + function capturePtyHandlers(): Array<(detail: { id: string; data: string; textData: string }) => void> { + const handlers: Array<(detail: { id: string; data: string; textData: string }) => void> = []; + const subscribe = fake.onPtyData.bind(fake); + vi.spyOn(fake, 'onPtyData').mockImplementation((handler) => { + handlers.push(handler as (detail: { id: string; data: string; textData: string }) => void); + subscribe(handler); + }); + return handlers; + } + + it('marks a minimized Session\'s pty echo dirty, and ignores a foreign Session\'s', async () => { + vi.useFakeTimers(); + try { + const ptyHandlers = capturePtyHandlers(); + const saveState = vi.spyOn(fake, 'saveState'); + const settle = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); }); + const echo = (id: string) => act(() => { + ptyHandlers.forEach((handler) => handler({ id, data: '', textData: '' })); + }); + + await act(async () => { + root.render(); + }); + await settle(0); + await act(async () => { + container.querySelector('[data-lath-leaf="pane-a"] [aria-label="Minimize"]')!.click(); + }); + // Past the debounce, so the commit's own save has landed and the tracker + // is clean again. + await settle(1_000); + expect(container.querySelector('[data-door-id="pane-a"]')).not.toBeNull(); + saveState.mockClear(); + + // The heartbeat writes only when something marked dirty. + await settle(31_000); + expect(saveState).not.toHaveBeenCalled(); + + // Another Workspace's Session, fanned to this Wall by the adapter. + await echo('pane-elsewhere'); + await settle(31_000); + expect(saveState).not.toHaveBeenCalled(); + + // The Door's own Session: its `untouched` flip rides this echo and nothing + // else reports it, so the Wall has to hear it. + await echo('pane-a'); + await settle(31_000); + expect(saveState).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 659ba4002..b01412e81 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -67,6 +67,7 @@ import { hasBrowser, hasTerminal } from 'dor/commands/types'; import { DEFAULT_WORKSPACE_ID, type PersistedSurfaceRefs, type WorkspaceId } from '../lib/session-types'; import { clearWorkspaceSurfaces, setWorkspaceSurfaces } from '../lib/workspace-surfaces'; import { workspaceRefFor } from '../lib/workspace-store'; +import { awaitWallEmpty } from './wall/close-all'; import { registerWallHandle, type WallHandle } from './wall/wall-handles'; import { installDorControlRouter } from './wall/dor-control-router'; import type { DropTarget, RestoreToken } from '../lib/lath/ops'; @@ -939,6 +940,14 @@ export function Wall({ [lath], ); + /** Whether a Surface belongs to this Wall — the membership test in the hot + * paths (a PTY chunk per Session per Wall), so it asks the store rather than + * building a projection. Stable, so a listener can close over it. */ + const ownsSurface = useCallback( + (id: string): boolean => lath.store.has(id) || doorsRef.current.some((door) => door.id === id), + [lath], + ); + /** Publish membership for the union projection: the Activity store is * window-wide, so this map is what scopes it to one Workspace. */ const publishMembership = useCallback( @@ -981,8 +990,13 @@ export function Wall({ } // The size check also catches pure removals, purging dead ids so a later // re-add of the same id fires again. - if (leavesChanged) prevLeafIdsRef.current = new Set(currentIds); - publishMembership(); + // Only a leaf change moves membership; a title, zoom, or resize commit + // would otherwise republish the same list on every keystroke. The doors + // effect below covers the other edge. + if (leavesChanged) { + prevLeafIdsRef.current = new Set(currentIds); + publishMembership(); + } if (closingWorkspaceRef.current) return; refillEmptyTree(); }); @@ -992,11 +1006,21 @@ export function Wall({ // of a doored Surface removes only the chip), so publish on that edge too. useEffect(publishMembership, [doors, publishMembership]); + /** Abandon a `closeAll`: the Workspace stays, so the Wall's "always one pane" + * rule is re-armed and an emptied tree refilled. Every path that gives up on + * a close — a refused Surface, the exit deadline, a `closeWorkspace` the + * store refuses — ends here (`docs/specs/layout.md` → "Workspaces"). */ + const cancelClose = useCallback(() => { + closingWorkspaceRef.current = false; + refillEmptyTree(); + }, [refillEmptyTree]); + // --- Session persistence --- const persistence = useSessionPersistence({ lath, doors, doorsRef, + ownsSurface, selectedIdRef, selectedTypeRef, surfaceRefsForSave, @@ -1012,40 +1036,41 @@ export function Wall({ */ const closeAll = useCallback(async (mode: CloseSurfaceMode = 'prompt'): Promise => { closingWorkspaceRef.current = true; - for (const id of memberSurfaceIds()) { - // Re-checked per iteration: an earlier closure can take a Surface with it - // (a helper's source, a replaced leaf). - if (!lath.store.has(id) && !doorsRef.current.some((door) => door.id === id)) continue; - const refusal = await closeSurfaceRef.current(id, mode); - if (refusal) { - closingWorkspaceRef.current = false; - refillEmptyTree(); - return refusal; + // Walked until nothing new turns up rather than over one snapshot: a member + // pane's `dor` request can create a Surface during the awaits, and one + // created after the walk had passed it would ride the unmount out as an + // Orphaned Session. `handleDorControl` refuses to create while this flag is + // set, so the walk is racing a shrinking set and terminates; `attempted` + // makes that true even if it did not. + const attempted = new Set(); + for (;;) { + const pending = memberSurfaceIds().filter((id) => !attempted.has(id)); + if (pending.length === 0) break; + for (const id of pending) { + attempted.add(id); + // Re-checked per iteration: an earlier closure can take a Surface with it + // (a helper's source, a replaced leaf). + if (!ownsSurface(id)) continue; + const refusal = await closeSurfaceRef.current(id, mode); + if (refusal) { + cancelClose(); + return refusal; + } } } // `killPaneImmediately` defers the tree removal by the exit animation; // unmounting the Wall before that lands would leave Orphaned Sessions // (docs/specs/glossary.md → "Invariants" I4). The commit that empties the // tree is what resolves this; the deadline only bounds a stuck fade so it - // cannot hang a quit. - const emptied = () => memberSurfaceIds().length === 0; - if (!emptied()) { - await new Promise((resolve) => { - let done = false; - const settle = () => { - if (done) return; - done = true; - clearTimeout(timer); - unsubscribe(); - resolve(); - }; - const timer = setTimeout(settle, lath.exitMs + 50); - const unsubscribe = lath.store.subscribe(() => { if (emptied()) settle(); }); - if (emptied()) settle(); - }); - } - return null; - }, [lath, memberSurfaceIds, refillEmptyTree]); + // cannot hang a quit, and it refuses rather than reporting clean. + const refusal = await awaitWallEmpty({ + members: memberSurfaceIds, + subscribe: (listener) => lath.store.subscribe(listener), + timeoutMs: lath.exitMs + 50, + }); + if (refusal) cancelClose(); + return refusal; + }, [lath, memberSurfaceIds, ownsSurface, cancelClose]); // --- Dev-server port → pane correlation (browser header connection chip) --- useDevServerPortCorrelation({ lath, doorsRef }); @@ -1498,6 +1523,7 @@ export function Wall({ createContentSurface, isClosingSurface, closeSurface, + isClosingWorkspace: useCallback(() => closingWorkspaceRef.current, []), lastAgentBrowserBinaryPathRef, workspaceRef: useCallback(() => workspaceRefFor(effectiveWorkspaceId), [effectiveWorkspaceId]), }); @@ -1517,7 +1543,7 @@ export function Wall({ // re-render never replaces a registered entry. const methods: Omit = { surfaceIds: memberSurfaceIds, - ownsSurface: (id) => lath.store.has(id) || doorsRef.current.some((door) => door.id === id), + ownsSurface, hasTouchedSurfaces: () => memberSurfaceIds().some((id) => { // A browser Surface has no "untouched" notion and always holds a page, so // it counts; a terminal counts once its Session exists and has input. @@ -1527,6 +1553,7 @@ export function Wall({ runningCount: () => countRunningSessionsIn(memberSurfaceIds()), flushPersistence: () => persistence.flush(), closeAll, + cancelClose, handleDorControl, }; const handleRef = useRef(null); @@ -1993,8 +2020,12 @@ export function Wall({ /> ) : null} - {/* Kill confirmation overlay — centered over the pane being killed */} - {confirmKill && ( + {/* Kill confirmation overlay — centered over the pane being killed. + Gated on `active` with the modal hosts below: its Escape trap is + a window listener, and a hidden Wall consumes no window input + (docs/specs/layout.md → "Workspaces"). The staged confirmation + is React state, so a switch away and back shows it again. */} + {active && confirmKill && ( { await act(async () => { window.dispatchEvent(pointer('pointerup', { clientX: 90, clientY: 12 })); }); }); + it('captures the pointer on the dragged tab, and activates on the click after the drag ends', async () => { + createWorkspace({ id: 'ws-2' }); + await render(); + const first = getWorkspacesSnapshot().workspaces[0].id; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue( + { left: 0, right: 100, width: 100, top: 0, bottom: 24, height: 24, x: 0, y: 0, toJSON: () => ({}) } as DOMRect, + ); + // Spied on the ELEMENT, not the prototype: the capture has to land on the + // tab, and React 19 hands the handler a native event whose `currentTarget` + // is the root container. + const tab = tabFor(first); + const capture = vi.fn(); + Object.defineProperty(tab, 'setPointerCapture', { configurable: true, value: capture }); + Object.defineProperty(tab, 'releasePointerCapture', { configurable: true, value: () => {} }); + + await act(async () => { tab.dispatchEvent(pointer('pointerdown', { button: 0, clientX: 50, clientY: 12 })); }); + await act(async () => { window.dispatchEvent(pointer('pointermove', { clientX: 90, clientY: 12 })); }); + expect(capture).toHaveBeenCalledTimes(1); + await act(async () => { window.dispatchEvent(pointer('pointerup', { clientX: 90, clientY: 12 })); }); + + // The click the release produces is the drag's tail and must not activate… + await act(async () => { activateButton(first).click(); }); + expect(getActiveWorkspaceId()).toBe('ws-2'); + // …but the latch is one-shot, so the next click (a keyboard activation, a + // later plain click) is a real activate again. + await act(async () => { activateButton(first).click(); }); + expect(getActiveWorkspaceId()).toBe(first); + }); + it('renders the rename editor and close flow the command-mode keys open', async () => { const first = getWorkspacesSnapshot().workspaces[0].id; await act(async () => { createWorkspace({ id: 'ws-2' }); }); diff --git a/lib/src/components/WorkspaceStrip.tsx b/lib/src/components/WorkspaceStrip.tsx index 97c8bd4cb..ad218b441 100644 --- a/lib/src/components/WorkspaceStrip.tsx +++ b/lib/src/components/WorkspaceStrip.tsx @@ -144,6 +144,11 @@ export function WorkspaceStrip({ // One union per tab, computed in the loop it is rendered in. The visible // Workspace never shows indicators, so it skips the projection entirely. const unionsRef = useRef(new Map()); + // Closed Workspaces leave the strip and must leave this cache with them, or a + // long session accumulates one entry per Workspace it ever had. + for (const id of unionsRef.current.keys()) { + if (!workspaces.some((workspace) => workspace.id === id)) unionsRef.current.delete(id); + } const unionFor = (id: WorkspaceId, active: boolean): WorkspaceUnion => { if (active) return EMPTY_WORKSPACE_UNION; const next = computeWorkspaceUnion(membership.get(id) ?? [], activity); diff --git a/lib/src/components/WorkspaceWindow.test.tsx b/lib/src/components/WorkspaceWindow.test.tsx index 81f0e14b4..d2a8edaea 100644 --- a/lib/src/components/WorkspaceWindow.test.tsx +++ b/lib/src/components/WorkspaceWindow.test.tsx @@ -8,7 +8,10 @@ import { StrictMode, act } from 'react'; import { type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SURFACE_CONTROL_METHODS } from 'dor/protocol'; import { WorkspaceWindow } from './WorkspaceWindow'; +import { closeWorkspaceWithSurfaces } from './wall/workspace-lifecycle'; +import * as terminalRegistry from '../lib/terminal-registry'; import { setPlatform } from '../lib/platform'; import { FakePtyAdapter } from '../lib/platform/fake-adapter'; import { clearAllNotepads, addPlainNote } from '../lib/notepad/notepad-store'; @@ -73,6 +76,11 @@ function wallFor(workspaceId: string): HTMLElement { return container.querySelector(`[data-workspace-wall="${workspaceId}"]`)!; } +/** Every mounted kill confirmation, whichever Wall rendered it. */ +function killConfirms(): HTMLElement[] { + return [...container.querySelectorAll('#kill-confirm-title')]; +} + function leafIdsIn(workspaceId: string): string[] { return [...wallFor(workspaceId).querySelectorAll('[data-lath-leaf]')] .map((leaf) => leaf.getAttribute('data-lath-leaf')!); @@ -197,6 +205,89 @@ describe('WorkspaceWindow', () => { expect(handle.surfaceIds()).toEqual([]); }); + it('serializes two closes started together, so the survivor keeps its Surfaces', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await render(); + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + await flush(); + + // Both close verbs run: the second is refused rather than emptying a Wall + // the store will then refuse to remove. + let refusals: Array = []; + await act(async () => { + refusals = await Promise.all([ + closeWorkspaceWithSurfaces(first), + closeWorkspaceWithSurfaces('ws-2'), + ]); + }); + await flush(); + + expect(refusals.filter((refusal) => refusal === null)).toHaveLength(1); + const survivors = getWorkspacesSnapshot().workspaces; + expect(survivors).toHaveLength(1); + expect(getWallHandle(survivors[0].id)!.surfaceIds()).toHaveLength(1); + expect(leafIdsIn(survivors[0].id)).toHaveLength(1); + }); + + it('refuses a Surface-creating dor request while its Workspace is closing', async () => { + await render(); + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + await flush(); + const handle = getWallHandle('ws-2')!; + const [paneId] = handle.surfaceIds(); + const respond = vi.fn(); + + await act(async () => { + // Dispatched INSIDE the walk: `dor split` from a member pane still routes + // here, and a Surface born behind the walk would ride the unmount out. + const closing = handle.closeAll('silent'); + handle.handleDorControl({ + requestId: 'r1', + method: SURFACE_CONTROL_METHODS.split, + surfaceId: paneId, + params: { direction: 'right' }, + respond, + }); + expect(await closing).toBeNull(); + }); + await flush(); + + expect(respond).toHaveBeenCalledWith({ ok: false, error: 'this workspace is closing' }); + expect(handle.surfaceIds()).toEqual([]); + expect(leafIdsIn('ws-2')).toEqual([]); + }); + + it('keeps a hidden Workspace out of the window keyboard: its kill confirm outlives an Escape next door', async () => { + vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(false); + const first = getWorkspacesSnapshot().workspaces[0].id; + await render(); + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + await flush(); + + const press = async (key: string) => { + await act(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + }); + await flush(); + }; + + // Stage the confirmation in ws-2 while it is the visible Workspace. + await press('x'); + expect(killConfirms()).toHaveLength(1); + expect(wallFor('ws-2').contains(killConfirms()[0])).toBe(true); + + // Hidden: the overlay is unmounted, so its Escape trap hears nothing… + await act(async () => { setActiveWorkspace(first); }); + await flush(); + expect(killConfirms()).toHaveLength(0); + await press('Escape'); + + // …and the staged confirmation is still there on the way back. + await act(async () => { setActiveWorkspace('ws-2'); }); + await flush(); + expect(killConfirms()).toHaveLength(1); + }); + it('binds the command-mode Workspace keys through the active Wall only', async () => { const first = getWorkspacesSnapshot().workspaces[0].id; await render(); diff --git a/lib/src/components/wall/close-all.test.ts b/lib/src/components/wall/close-all.test.ts new file mode 100644 index 000000000..ddbeacdec --- /dev/null +++ b/lib/src/components/wall/close-all.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import { awaitWallEmpty } from './close-all'; + +/** A Lath-store stand-in: a member list plus the commit callback the Wall + * subscribes to. */ +function watch(initial: string[]) { + let members = [...initial]; + const listeners = new Set<() => void>(); + return { + members: () => members, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + listenerCount: () => listeners.size, + commit(next: string[]) { + members = next; + listeners.forEach((listener) => listener()); + }, + }; +} + +describe('awaitWallEmpty', () => { + it('resolves clean the moment the last Surface leaves the tree', async () => { + const store = watch(['pane-a']); + const settled = awaitWallEmpty({ members: store.members, subscribe: store.subscribe, timeoutMs: 1000 }); + store.commit([]); + expect(await settled).toBeNull(); + // Nothing is left listening or pending. + expect(store.listenerCount()).toBe(0); + }); + + it('resolves clean without waiting when the Wall is already empty', async () => { + const store = watch([]); + expect(await awaitWallEmpty({ members: store.members, subscribe: store.subscribe, timeoutMs: 1000 })).toBeNull(); + expect(store.listenerCount()).toBe(0); + }); + + it('refuses on the deadline rather than reporting clean over a live Surface', async () => { + vi.useFakeTimers(); + try { + const store = watch(['pane-a', 'pane-b']); + const settled = awaitWallEmpty({ members: store.members, subscribe: store.subscribe, timeoutMs: 50 }); + // A commit that does not empty the Wall keeps the wait going. + store.commit(['pane-b']); + await vi.advanceTimersByTimeAsync(50); + // Unmounting here would leave an Orphaned Session, so the caller is told. + expect(await settled).toBe('1 surface did not finish closing'); + expect(store.listenerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/lib/src/components/wall/close-all.ts b/lib/src/components/wall/close-all.ts new file mode 100644 index 000000000..ac623511b --- /dev/null +++ b/lib/src/components/wall/close-all.ts @@ -0,0 +1,43 @@ +/** + * The tail of a Workspace `closeAll`: the wait between the last closure and the + * Wall being safe to unmount (`docs/specs/layout.md` → "Workspaces"). + * + * Split out of `Wall.tsx` because the deadline is the part that has to be + * exercised on its own — a Wall that never empties is not something a mounted + * composition can be talked into. + */ +export interface WallEmptyWatch { + /** The Wall's remaining member Surfaces. */ + members: () => string[]; + /** Called back on every Lath commit; returns its unsubscribe. */ + subscribe: (listener: () => void) => () => void; + /** How long a stuck exit animation may hold the wait. */ + timeoutMs: number; +} + +/** + * Resolve null once the Wall holds no Surfaces, or a refusal naming what is + * still open if the deadline passes first. **The deadline never reports clean**: + * unmounting a Wall over a live Surface would leave Orphaned Sessions + * (`docs/specs/glossary.md` → "Invariants" I4), so the caller keeps the + * Workspace instead. + */ +export function awaitWallEmpty({ members, subscribe, timeoutMs }: WallEmptyWatch): Promise { + if (members().length === 0) return Promise.resolve(null); + return new Promise((resolve) => { + let done = false; + const settle = () => { + if (done) return; + const remaining = members().length; + done = true; + clearTimeout(timer); + unsubscribe(); + resolve(remaining === 0 + ? null + : `${remaining} surface${remaining === 1 ? '' : 's'} did not finish closing`); + }; + const timer = setTimeout(settle, timeoutMs); + const unsubscribe = subscribe(() => { if (members().length === 0) settle(); }); + if (members().length === 0) settle(); + }); +} diff --git a/lib/src/components/wall/dor-control-router.test.ts b/lib/src/components/wall/dor-control-router.test.ts index 27bdccdf4..44579b719 100644 --- a/lib/src/components/wall/dor-control-router.test.ts +++ b/lib/src/components/wall/dor-control-router.test.ts @@ -104,6 +104,78 @@ describe('dor control routing', () => { expect(handle.handleDorControl).toHaveBeenCalledTimes(2); }); + it('answers a container target of the wrong type instead of throwing out of the listener', () => { + const handle = handleFor(getWorkspacesSnapshot().workspaces[0].id); + const release = installDorControlRouter(); + // Whatever crossed the control socket, not a validated string: a `.trim()` + // on it would throw past `respond` and leave the caller blocked. + for (const [params, error] of [ + [{ workspace: 2 }, "unknown workspace target '2'"], + [{ workspace: { ref: 'x' } }, "unknown workspace target '[object Object]'"], + [{ window: 1 }, "unknown window target '1'"], + ] as const) { + const detail = request({ params: params as never }); + window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail })); + expect(detail.respond).toHaveBeenCalledWith({ ok: false, error }); + } + expect(handle.handleDorControl).not.toHaveBeenCalled(); + release(); + }); + + it('answers a handler that throws or rejects, rather than letting the caller time out', async () => { + const workspaceId = getWorkspacesSnapshot().workspaces[0].id; + const handle = handleFor(workspaceId); + const release = installDorControlRouter(); + + handle.handleDorControl.mockImplementationOnce(() => { throw new Error('boom'); }); + const thrown = request(); + window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: thrown })); + expect(thrown.respond).toHaveBeenCalledWith({ ok: false, error: 'boom' }); + + handle.handleDorControl.mockImplementationOnce(() => Promise.reject(new Error('late boom'))); + const rejected = request(); + window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: rejected })); + await Promise.resolve(); + expect(rejected.respond).toHaveBeenCalledWith({ ok: false, error: 'late boom' }); + release(); + }); + + it('waits out the gap between createWorkspace and the new Wall registering', async () => { + vi.useFakeTimers(); + try { + const release = installDorControlRouter(); + // No Wall has registered yet — the request must not be dropped. + const detail = request(); + window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail })); + const workspaceId = createWorkspace({ id: 'ws-2' }).id; + const handle = handleFor(workspaceId); + expect(handle.handleDorControl).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(0); + expect(handle.handleDorControl).toHaveBeenCalledWith(detail); + release(); + } finally { + vi.useRealTimers(); + } + }); + + it('gives up after a bounded number of retries when nothing ever mounts', async () => { + vi.useFakeTimers(); + try { + const release = installDorControlRouter(); + const detail = request(); + window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail })); + await vi.advanceTimersByTimeAsync(10); + // The retry chain is finite: registering afterwards is too late. + const handle = handleFor(getWorkspacesSnapshot().workspaces[0].id); + await vi.advanceTimersByTimeAsync(10); + expect(handle.handleDorControl).not.toHaveBeenCalled(); + release(); + } finally { + vi.useRealTimers(); + } + }); + it('answers a bad container target instead of handing it to a Wall', () => { const handle = handleFor(getWorkspacesSnapshot().workspaces[0].id); const release = installDorControlRouter(); diff --git a/lib/src/components/wall/dor-control-router.ts b/lib/src/components/wall/dor-control-router.ts index c8ff33b8d..54f0e10ef 100644 --- a/lib/src/components/wall/dor-control-router.ts +++ b/lib/src/components/wall/dor-control-router.ts @@ -1,3 +1,4 @@ +import { createRefCount } from '../../lib/ref-count'; import { getActiveWorkspaceId, isWindowRef, workspaceIdForRef } from '../../lib/workspace-store'; import { getWallHandle, wallHandleOwning, type WallHandle } from './wall-handles'; import type { DorControlRequest } from './use-dor-control'; @@ -22,15 +23,20 @@ export type DorControlRoute = */ export function resolveDorControlRoute(detail: DorControlRequest): DorControlRoute { const params = detail.params ?? {}; - if (params.window !== undefined && !isWindowRef(params.window)) { - return { kind: 'error', message: `unknown window target '${params.window}'` }; + // Typed before use: `params` is whatever crossed the control socket, and a + // non-string ref reaching `.trim()` would throw out of the window listener, + // leaving the caller to block until its own deadline. + if (params.window !== undefined && (typeof params.window !== 'string' || !isWindowRef(params.window))) { + return { kind: 'error', message: `unknown window target '${String(params.window)}'` }; } if (params.workspace !== undefined) { - // A ref outside the strip and one whose Wall is not mounted are the same - // answer: this Window has no such Workspace to route to. - const workspaceId = workspaceIdForRef(params.workspace); - const handle = workspaceId ? getWallHandle(workspaceId) : null; - return handle ? { kind: 'handle', handle } : { kind: 'error', message: `unknown workspace target '${params.workspace}'` }; + // A ref of the wrong type, one outside the strip, and one whose Wall is not + // mounted are the same answer: this Window has no such Workspace to route to. + const ref = typeof params.workspace === 'string' ? workspaceIdForRef(params.workspace) : null; + const handle = ref ? getWallHandle(ref) : null; + return handle + ? { kind: 'handle', handle } + : { kind: 'error', message: `unknown workspace target '${String(params.workspace)}'` }; } // The caller's own Workspace: `dor split` from a background Workspace lands // beside its caller, not in whichever Workspace the user is looking at. @@ -42,37 +48,53 @@ export function resolveDorControlRoute(detail: DorControlRequest): DorControlRou return active ? { kind: 'handle', handle: active } : { kind: 'none' }; } -let installCount = 0; -let listener: ((event: Event) => void) | null = null; +/** + * How many macrotasks a request waits for a Wall to appear. A Wall registers its + * handle in a passive effect, so a request landing between `createWorkspace()` + * and that effect — `dor workspace new && dor split`, the strip's `+` under a + * scripted caller — finds nothing to route to. Retrying is what answers it + * instead of dropping it; the bound keeps a Window with no Walls at all (a + * Storybook strip) from retrying forever. + */ +const ROUTE_RETRIES = 5; + +function dispatchDorControl(detail: DorControlRequest, attempt: number): void { + const route = resolveDorControlRoute(detail); + if (route.kind === 'error') { + detail.respond({ ok: false, error: route.message }); + return; + } + if (route.kind === 'none') { + if (attempt < ROUTE_RETRIES) setTimeout(() => dispatchDorControl(detail, attempt + 1), 0); + return; + } + // Every failure the handler can raise is answered: an unanswered request + // blocks its caller until the CLI's own deadline + // (`docs/specs/dor-cli.md` → "Handle Model"). + const fail = (error: unknown) => detail.respond({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + try { + const running = route.handle.handleDorControl(detail) as unknown; + if (running instanceof Promise) void running.catch(fail); + } catch (error) { + fail(error); + } +} /** * Install the router's window listener, reference-counted so N Walls share one. * Returns its (idempotent) release. */ -export function installDorControlRouter(): () => void { - installCount += 1; - if (installCount === 1) { - listener = (event: Event) => { +export const installDorControlRouter = createRefCount({ + onFirst: () => { + const listener = (event: Event) => { const detail = (event as CustomEvent).detail; if (!detail) return; - const route = resolveDorControlRoute(detail); - if (route.kind === 'error') { - detail.respond({ ok: false, error: route.message }); - return; - } - if (route.kind === 'none') return; - route.handle.handleDorControl(detail); + dispatchDorControl(detail, 0); }; window.addEventListener('dormouse:control-request', listener); - } - let released = false; - return () => { - if (released) return; - released = true; - installCount -= 1; - if (installCount === 0 && listener) { - window.removeEventListener('dormouse:control-request', listener); - listener = null; - } - }; -} + return () => window.removeEventListener('dormouse:control-request', listener); + }, +}); diff --git a/lib/src/components/wall/use-alert-speech.ts b/lib/src/components/wall/use-alert-speech.ts index d632f094a..35b2f8aa3 100644 --- a/lib/src/components/wall/use-alert-speech.ts +++ b/lib/src/components/wall/use-alert-speech.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { startAlertSpeech } from '../../lib/alert-speech'; +import { createRefCount } from '../../lib/ref-count'; /** * Arm spoken alarms for the lifetime of the desktop shell. The settings that @@ -11,18 +12,8 @@ import { startAlertSpeech } from '../../lib/alert-speech'; * speak each ring N times and reset each other's delivery state. Reference * counted, so the first mounted Wall arms it and the last one disarms it. */ -let holders = 0; -let stop: (() => void) | null = null; +const acquire = createRefCount({ onFirst: () => startAlertSpeech() }); export function useAlertSpeech(): void { - useEffect(() => { - holders += 1; - if (holders === 1) stop = startAlertSpeech(); - return () => { - holders -= 1; - if (holders > 0) return; - stop?.(); - stop = null; - }; - }, []); + useEffect(acquire, []); } diff --git a/lib/src/components/wall/use-dev-server-ports.ts b/lib/src/components/wall/use-dev-server-ports.ts index f9d883bd3..da7efbe01 100644 --- a/lib/src/components/wall/use-dev-server-ports.ts +++ b/lib/src/components/wall/use-dev-server-ports.ts @@ -34,6 +34,7 @@ */ import { useEffect } from 'react'; import { getPlatform } from '../../lib/platform'; +import { createRefCount } from '../../lib/ref-count'; import { deriveSessionLabel } from '../../lib/session-label'; import { getWantedDevServerPorts, @@ -82,14 +83,27 @@ function cancelIdle(handle: number | undefined): void { } const sources = new Set(); -let holders = 0; let stopLoop: (() => void) | null = null; let scheduleScanNow: ((delay: number) => void) | null = null; + /** Ports already matched to a Surface. Not rescanned until a reload (clears the * whole set), the port leaves "wanted" (navigation), or the set of Walls * changes — a Wall arriving or leaving can change who owns a port. */ const settled = new Set(); +/** A Wall arriving or leaving changes who can own a port, so re-validate: + * a resolution settled without it may now be ambiguous, or newly resolvable. */ +const acquireLoop = createRefCount({ + onFirst: () => { + stopLoop = startCorrelationLoop(); + return () => { stopLoop?.(); stopLoop = null; }; + }, + onChange: () => { + settled.clear(); + scheduleScanNow?.(DEBOUNCE_MS); + }, +}); + /** id → fallback title across every mounted Wall; the first Wall to claim an id * owns it, and a Wall only ever lists its own Surfaces. */ function collectCandidates(): Map { @@ -257,25 +271,11 @@ export function useDevServerPortCorrelation({ }; sources.add(source); - holders += 1; - if (holders === 1) stopLoop = startCorrelationLoop(); - // A Wall arriving changes who can own a port, so re-validate: a resolution - // settled without it may now be ambiguous, or newly resolvable. - else { - settled.clear(); - scheduleScanNow?.(DEBOUNCE_MS); - } + const release = acquireLoop(); return () => { sources.delete(source); - holders -= 1; - if (holders > 0) { - settled.clear(); - scheduleScanNow?.(DEBOUNCE_MS); - return; - } - stopLoop?.(); - stopLoop = null; + release(); }; }, [lath, doorsRef]); } diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index ef3e4a51c..447f9ac86 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -49,8 +49,10 @@ export type DorControlParams = { session?: unknown; surface?: unknown; url?: unknown; - workspace?: string; - window?: string; + // Container refs arrive unvalidated like every other param; the router types + // them before use (`dor-control-router.ts`). + workspace?: unknown; + window?: unknown; scrollback?: unknown; wsPort?: unknown; }; @@ -262,6 +264,16 @@ function waitForTerminalState( } const RESTART_CANCELLED: ParseResult = { ok: false, message: 'restart was cancelled' }; +/** The control verbs that can add a Surface to the Wall. `resolveOpen` and + * `resolveAgentBrowser` only answer questions, and every other verb addresses a + * Surface that already exists. */ +const CREATING_CONTROL_METHODS = new Set([ + SURFACE_CONTROL_METHODS.split, + SURFACE_CONTROL_METHODS.ensure, + SURFACE_CONTROL_METHODS.iframe, + SURFACE_CONTROL_METHODS.agentBrowser, +]); + const ENSURE_CANCELLED = 'ensure was cancelled'; /** @@ -364,6 +376,7 @@ export function useDorControl({ createSplitSurface, createContentSurface, isClosingSurface, + isClosingWorkspace, closeSurface, lastAgentBrowserBinaryPathRef, workspaceRef, @@ -399,6 +412,8 @@ export function useDorControl({ }) => ParseResult<{ id: string; ref: string; status: 'created' | 'replaced' }>; /** A Wall closure in flight, independent of another caller freezing notes. */ isClosingSurface: (id: string) => boolean; + /** Whether this Wall's Workspace is being closed. */ + isClosingWorkspace: () => boolean; /** The user-visible closure path: archive the Surface's notes, then tear it * down. A string means the closure was refused, and is why; the Surface is * still here. */ @@ -592,6 +607,14 @@ export function useDorControl({ const handleDorControl = useCallback(async (detail: DorControlRequest) => { const params = detail.params ?? {}; + // A Workspace being closed takes no new Surfaces: `closeAll` walks its + // members, and one created behind the walk would ride the Wall's unmount out + // as an Orphaned Session (docs/specs/glossary.md → "Invariants" I4). + if (CREATING_CONTROL_METHODS.has(detail.method) && isClosingWorkspace()) { + detail.respond({ ok: false, error: 'this workspace is closing' }); + return; + } + // Resolve the split reference surface across listed Surfaces. A minimized // reference is valid: the Wall creates the new split as a sibling Door. const resolveSplitTarget = () => { @@ -1072,7 +1095,7 @@ export function useDorControl({ } detail.respond({ ok: false, error: `unsupported Dormouse control method '${detail.method}'` }); - }, [buildDorSurfaces, buildDorSurfaceList, closeSurface, createContentSurface, createSplitSurface, ensureAgentBrowserSurface, findSurfaceIdRunningCommand, requireBrowserSurface, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav, workspaceRef]); + }, [buildDorSurfaces, buildDorSurfaceList, closeSurface, createContentSurface, createSplitSurface, ensureAgentBrowserSurface, findSurfaceIdRunningCommand, isClosingWorkspace, requireBrowserSurface, requireListedSurface, requireTerminalSurface, resolveListedSurface, resolveVisibleSurface, surfaceRefForId, lath, nav, workspaceRef]); return { findSurfaceByParams, updateSurfaceParams, handleDorControl }; } diff --git a/lib/src/components/wall/use-session-persistence.ts b/lib/src/components/wall/use-session-persistence.ts index a5c8be9e8..9272071fc 100644 --- a/lib/src/components/wall/use-session-persistence.ts +++ b/lib/src/components/wall/use-session-persistence.ts @@ -25,6 +25,7 @@ export function useSessionPersistence({ doorsRef, selectedIdRef, selectedTypeRef, + ownsSurface, surfaceRefsForSave, workspaceId, }: { @@ -40,6 +41,11 @@ export function useSessionPersistence({ doorsRef: RefObject; selectedIdRef: RefObject; selectedTypeRef: RefObject; + /** Whether a Surface belongs to this Wall — panes AND Doors. The adapter fans + * every Session's traffic to every mounted Wall, so this is what keeps one + * Workspace from persisting on another's keystroke. Must be stable: the + * subscription effect closes over it. */ + ownsSurface: (id: string) => boolean; surfaceRefsForSave?: () => { refs: PersistedSurfaceRefs; next: number }; /** Present when this Wall belongs to a Workspace: its record then goes to the * Window collector instead of the platform slot, and is compared against its @@ -165,17 +171,19 @@ export function useSessionPersistence({ const platform = getPlatform(); const { markDirty, isDirty } = trackerRef.current; - // Both PTY triggers are ownership-filtered: the adapter fans every Session's - // traffic to every mounted Wall, so an unfiltered one would have each - // Workspace persisting on every other Workspace's keystroke. - const ownsPane = (id: string) => lath.listPanes().some((p) => p.id === id); + // Both PTY triggers are ownership-filtered over MEMBERS, panes and Doors + // alike: a minimized Session's `untouched` flip rides the pty echo of the + // keystroke, so filtering on visible panes would never persist it. const handlePtyData = (detail: { id: string }) => { - if (ownsPane(detail.id)) markDirty(); + if (ownsSurface(detail.id)) markDirty(); }; const handlePtyExit = (detail: { id: string }) => { - if (!ownsPane(detail.id)) return; + if (!ownsSurface(detail.id)) return; void flushSessionSave().catch(() => undefined); }; + // Only a bare Wall answers the host directly; a Workspace Wall's answer is + // `WorkspaceWindow`'s, which flushes every Workspace before notifying (the + // adapter completes on the first notification). const handleSessionFlushRequest = (detail: { requestId: string }) => { void flushSessionSave() .catch(() => undefined) @@ -192,9 +200,9 @@ export function useSessionPersistence({ const unsubscribeStore = lath.store.subscribe(scheduleSessionSave); // Content inputs mark dirty but never schedule — the heartbeat persists them - // (docs/specs/layout.md → "Session persistence"). Untouched flips ride - // the pty echo of the keystroke, not the pane-state store (the registry mutates - // silently). + // (docs/specs/layout.md → "Session persistence"). An untouched flip has no + // store of its own to report it (the registry mutates silently), which is + // why the pty echo above is what marks it. platform.onPtyData(handlePtyData); const unsubActivity = subscribeToActivity(markDirty); const unsubPaneState = subscribeToTerminalPaneState(markDirty); @@ -212,7 +220,7 @@ export function useSessionPersistence({ if (paths.length === 0) return; const sid = selectedTypeRef.current === 'pane' ? selectedIdRef.current : null; if (!sid) return; - if (!lath.listPanes().some((p) => p.id === sid)) return; + if (!ownsSurface(sid)) return; pasteFilePaths(sid, paths); }); @@ -235,6 +243,7 @@ export function useSessionPersistence({ }, [ lath, flushSessionSave, + ownsSurface, ownsHostFlush, persistSessionNow, scheduleSessionSave, diff --git a/lib/src/components/wall/wall-handles.ts b/lib/src/components/wall/wall-handles.ts index 487babc95..87aa395dd 100644 --- a/lib/src/components/wall/wall-handles.ts +++ b/lib/src/components/wall/wall-handles.ts @@ -23,6 +23,10 @@ export interface WallHandle { * once the Wall is empty, else the first refusal's message with the Workspace * left as it was. */ closeAll(mode?: CloseSurfaceMode): Promise; + /** Abandon a close the Wall has already emptied for: the Workspace survives, + * so its "always one pane" rule is re-armed and the tree refilled. The close + * verb calls it when the store refuses to drop the Workspace after all. */ + cancelClose(): void; handleDorControl(detail: DorControlRequest): void; } @@ -76,6 +80,7 @@ export function stubWallHandle(workspaceId: WorkspaceId, overrides: Partial 0, flushPersistence: async () => {}, closeAll: async () => null, + cancelClose: () => {}, handleDorControl: () => {}, ...overrides, }; diff --git a/lib/src/components/wall/workspace-lifecycle.test.ts b/lib/src/components/wall/workspace-lifecycle.test.ts new file mode 100644 index 000000000..3f12d63d0 --- /dev/null +++ b/lib/src/components/wall/workspace-lifecycle.test.ts @@ -0,0 +1,94 @@ +/** + * The Workspace close verb's guards (`docs/specs/layout.md` → "Workspaces"). + * The composed behavior — real Walls, real Surfaces — is in + * `WorkspaceWindow.test.tsx`; this pins what the verb refuses. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { closeWorkspaceWithSurfaces, LAST_WORKSPACE_REFUSAL, requestWorkspaceClose } from './workspace-lifecycle'; +import { registerWallHandle, resetWallHandles, stubWallHandle, type WallHandle } from './wall-handles'; +import { resetWorkspaceUi, getWorkspaceUiSnapshot } from '../../lib/workspace-ui-store'; +import { + closeWorkspace, + createWorkspace, + getWorkspacesSnapshot, + resetWorkspaces, +} from '../../lib/workspace-store'; + +function handleFor(workspaceId: string, overrides: Partial = {}): WallHandle { + const handle = stubWallHandle(workspaceId, overrides); + registerWallHandle(handle); + return handle; +} + +const ids = () => getWorkspacesSnapshot().workspaces.map((workspace) => workspace.id); + +beforeEach(() => { + resetWorkspaces(); + resetWorkspaceUi(); + resetWallHandles(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('closeWorkspaceWithSurfaces', () => { + it('refuses the last Workspace before emptying its Wall', async () => { + const [only] = ids(); + const closeAll = vi.fn(async () => null); + handleFor(only, { closeAll }); + + expect(await closeWorkspaceWithSurfaces(only)).toBe(LAST_WORKSPACE_REFUSAL); + expect(closeAll).not.toHaveBeenCalled(); + expect(ids()).toEqual([only]); + }); + + it('refuses a second close while one is in flight, so both Walls cannot empty', async () => { + const [first] = ids(); + createWorkspace({ id: 'ws-2' }); + let releaseFirst!: () => void; + const firstClosed = new Promise((resolve) => { releaseFirst = resolve; }); + handleFor(first, { closeAll: async () => { await firstClosed; return null; } }); + const secondCloseAll = vi.fn(async () => null); + handleFor('ws-2', { closeAll: secondCloseAll }); + + const firstClose = closeWorkspaceWithSurfaces(first); + expect(await closeWorkspaceWithSurfaces('ws-2')).toBe('another Workspace is closing'); + expect(secondCloseAll).not.toHaveBeenCalled(); + // The verb the strip and the keys share takes the same lock. + requestWorkspaceClose('ws-2'); + expect(getWorkspaceUiSnapshot().pendingClose).toBeNull(); + + releaseFirst(); + expect(await firstClose).toBeNull(); + expect(ids()).toEqual(['ws-2']); + }); + + it('hands the Wall back its auto-spawn when the store refuses after a clean closeAll', async () => { + const [first] = ids(); + createWorkspace({ id: 'ws-2' }); + const cancelClose = vi.fn(); + // The count drops to one WHILE this close is walking its Surfaces, so the + // store refuses to remove the Workspace the Wall has already emptied. + handleFor('ws-2', { + closeAll: async () => { closeWorkspace(first); return null; }, + cancelClose, + }); + + expect(await closeWorkspaceWithSurfaces('ws-2')).toBe(LAST_WORKSPACE_REFUSAL); + expect(cancelClose).toHaveBeenCalledTimes(1); + expect(ids()).toEqual(['ws-2']); + }); + + it('releases the lock after a refusal, so the next close still works', async () => { + const [first] = ids(); + createWorkspace({ id: 'ws-2' }); + handleFor('ws-2', { closeAll: async () => 'notepad archive failed' }); + expect(await closeWorkspaceWithSurfaces('ws-2')).toBe('notepad archive failed'); + expect(ids()).toEqual([first, 'ws-2']); + + handleFor('ws-2', { closeAll: async () => null }); + expect(await closeWorkspaceWithSurfaces('ws-2')).toBeNull(); + expect(ids()).toEqual([first]); + }); +}); diff --git a/lib/src/components/wall/workspace-lifecycle.ts b/lib/src/components/wall/workspace-lifecycle.ts index aea4b9eef..0abdcb97e 100644 --- a/lib/src/components/wall/workspace-lifecycle.ts +++ b/lib/src/components/wall/workspace-lifecycle.ts @@ -19,6 +19,16 @@ export function workspaceNeedsCloseConfirmation(id: WorkspaceId): boolean { return !!handle && (handle.hasTouchedSurfaces() || handle.runningCount() > 0); } +/** The two ways a close is turned down before it starts. Both leave every + * Surface where it was. */ +export const LAST_WORKSPACE_REFUSAL = 'the last Workspace cannot be closed'; +const CLOSE_IN_FLIGHT_REFUSAL = 'another Workspace is closing'; + +/** One close at a time, for the whole Window. Two closes overlapping would each + * see the other's Workspace in the count, empty both Walls, and leave the + * Window with a single Workspace whose Surfaces are all gone. */ +let closeInFlight = false; + /** * Close every member Surface through the closure coordinator, then drop the * Workspace itself. Resolves the first refusal's message with the Workspace left @@ -26,17 +36,33 @@ export function workspaceNeedsCloseConfirmation(id: WorkspaceId): boolean { * once it is gone. Membership is cleared by the Wall's own unmount. */ export async function closeWorkspaceWithSurfaces(id: WorkspaceId): Promise { + if (closeInFlight) return CLOSE_IN_FLIGHT_REFUSAL; + // Re-checked here, not only in `requestWorkspaceClose`: the count can drop + // while the typed confirmation is on screen, and emptying the Wall for a + // `closeWorkspace` the store then refuses would leave the Window's one + // Workspace with nothing in it. + if (getWorkspacesSnapshot().workspaces.length <= 1) return LAST_WORKSPACE_REFUSAL; + closeInFlight = true; const handle = getWallHandle(id); - if (handle) { - const refusal = await handle.closeAll('prompt'); - if (refusal) { + try { + if (handle) { + const refusal = await handle.closeAll('prompt'); + if (refusal) { + setActiveWorkspace(id); + return refusal; + } + } + if (!closeWorkspace(id)) { + // The Wall is empty and stays mounted, so hand it back its auto-spawn. + handle?.cancelClose(); setActiveWorkspace(id); - return refusal; + return LAST_WORKSPACE_REFUSAL; } + forgetWorkspaceSession(id); + return null; + } finally { + closeInFlight = false; } - forgetWorkspaceSession(id); - closeWorkspace(id); - return null; } /** @@ -45,6 +71,7 @@ export async function closeWorkspaceWithSurfaces(id: WorkspaceId): Promise active, + dragged: () => { + const tail = clickIsDragTail; + clickIsDragTail = false; + return tail; + }, dispose: () => end(false), }; } diff --git a/lib/src/lib/ref-count.test.ts b/lib/src/lib/ref-count.test.ts new file mode 100644 index 000000000..578702cc6 --- /dev/null +++ b/lib/src/lib/ref-count.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createRefCount } from './ref-count'; + +describe('createRefCount', () => { + it('arms on the first holder and disarms on the last', () => { + const disarm = vi.fn(); + const onFirst = vi.fn(() => disarm); + const acquire = createRefCount({ onFirst }); + + const a = acquire(); + const b = acquire(); + expect(onFirst).toHaveBeenCalledTimes(1); + + a(); + expect(disarm).not.toHaveBeenCalled(); + b(); + expect(disarm).toHaveBeenCalledTimes(1); + + // Armed again from zero, with a fresh disarm. + acquire(); + expect(onFirst).toHaveBeenCalledTimes(2); + }); + + it('ignores a repeated release, so a double teardown cannot strand the resource', () => { + const disarm = vi.fn(); + const acquire = createRefCount({ onFirst: () => disarm }); + + const a = acquire(); + const b = acquire(); + a(); + a(); + a(); + expect(disarm).not.toHaveBeenCalled(); + // The count is still 1, not -1: the last real holder disarms it. + b(); + expect(disarm).toHaveBeenCalledTimes(1); + }); + + it('reports a holder joining or leaving an already-armed resource, and no other edge', () => { + const counts: number[] = []; + const acquire = createRefCount({ onFirst: () => () => {}, onChange: (count) => counts.push(count) }); + + const a = acquire(); + expect(counts).toEqual([]); // arming is not a change + const b = acquire(); + const c = acquire(); + expect(counts).toEqual([2, 3]); + c(); + b(); + expect(counts).toEqual([2, 3, 2, 1]); + a(); + expect(counts).toEqual([2, 3, 2, 1]); // disarming is not a change either + }); + + it('accepts an onFirst that arms nothing to undo', () => { + const acquire = createRefCount({ onFirst: () => {} }); + const release = acquire(); + expect(() => { release(); release(); }).not.toThrow(); + }); +}); diff --git a/lib/src/lib/ref-count.ts b/lib/src/lib/ref-count.ts new file mode 100644 index 000000000..0c08021a5 --- /dev/null +++ b/lib/src/lib/ref-count.ts @@ -0,0 +1,45 @@ +/** + * One window-wide resource shared by N holders: the first holder arms it and the + * last one disarms it. Every Wall-scoped hook that has to run exactly once per + * Window (the spoken alarms, the dynamic palette, the dev-server scan loop, the + * `dor` router's window listener) takes one of these instead of hand-rolling the + * counter, so a double release cannot drive the count negative and strand the + * resource armed. + */ +export interface RefCountOptions { + /** Arm the resource. Its return value is the disarm, run when the last holder + * releases. */ + onFirst: () => (() => void) | void; + /** A holder joined or left an already-armed resource (the count changed + * without crossing the 0/1 boundary). For a resource whose answer depends on + * who is holding it — the port scan's Wall set — this is where it + * re-validates. */ + onChange?: (count: number) => void; +} + +/** Take a share of the resource. The returned release is idempotent: calling it + * twice — StrictMode's double teardown, a cleanup that also runs on unmount — + * drops one share, never two. */ +export type AcquireRefCount = () => () => void; + +export function createRefCount({ onFirst, onChange }: RefCountOptions): AcquireRefCount { + let holders = 0; + let disarm: (() => void) | void; + return () => { + holders += 1; + if (holders === 1) disarm = onFirst(); + else onChange?.(holders); + let released = false; + return () => { + if (released) return; + released = true; + holders -= 1; + if (holders > 0) { + onChange?.(holders); + return; + } + disarm?.(); + disarm = undefined; + }; + }; +} diff --git a/lib/src/lib/themes/use-dynamic-palette.ts b/lib/src/lib/themes/use-dynamic-palette.ts index 920e77f89..4cdfb7fdb 100644 --- a/lib/src/lib/themes/use-dynamic-palette.ts +++ b/lib/src/lib/themes/use-dynamic-palette.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { computeDynamicPalette } from './dynamic-palette'; +import { createRefCount } from '../ref-count'; /** * Publish the derived palette onto `document.body` and keep it in step with the @@ -11,9 +12,6 @@ import { computeDynamicPalette } from './dynamic-palette'; * variables the survivors still need. Reference counted, so the first caller * starts it and the last one removes the variables. */ -let holders = 0; -let stop: (() => void) | null = null; - function start(): () => void { const ctx = document.createElement('canvas').getContext('2d'); if (!ctx) return () => {}; @@ -47,15 +45,8 @@ function start(): () => void { }; } +const acquire = createRefCount({ onFirst: start }); + export function useDynamicPalette(): void { - useEffect(() => { - holders += 1; - if (holders === 1) stop = start(); - return () => { - holders -= 1; - if (holders > 0) return; - stop?.(); - stop = null; - }; - }, []); + useEffect(acquire, []); } diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index f44704152..4ac17439b 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -6,10 +6,10 @@ "docs/specs/auto-update.md": 1000, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4500, - "docs/specs/dor-cli.md": 4900, + "docs/specs/dor-cli.md": 4950, "docs/specs/dor-tool.md": 2100, "docs/specs/glossary.md": 2850, - "docs/specs/layout.md": 8200, + "docs/specs/layout.md": 8350, "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3750, "docs/specs/notepad.md": 3750, From cc16a225f91a42ffc4e7c414e0ab84f7f8c7b304 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 13:35:16 -0700 Subject: [PATCH 11/13] Minimize a hidden Workspace's terminals A Wall under visibility:hidden still intersects, so xterm kept rasterizing every output frame for every hidden pane and held its GL context. Route a hidden Workspace's terminals through the minimize primitives instead: TerminalPane skips mountElement while its Workspace is inactive and its cleanup runs unmountElement on deactivation, which releases the renderer (#612). Activation remounts and fits through the layout gate, so an unchanged grid sends no PTY resize (#611). The Session, PTY, buffers, and notepad pins survive on the registry entry; browser Surfaces keep their live documents as before. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PkPyEFCxiPo5UFeju5Ya9u --- docs/specs/glossary.md | 8 +-- docs/specs/layout.md | 5 +- docs/specs/layout.rationale.md | 4 +- lib/src/components/TerminalPane.test.tsx | 66 ++++++++++++++++++++++-- lib/src/components/TerminalPane.tsx | 10 +++- lib/src/components/WorkspaceWindow.tsx | 7 +-- 6 files changed, 85 insertions(+), 15 deletions(-) diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md index da2def84c..6693ce34f 100644 --- a/docs/specs/glossary.md +++ b/docs/specs/glossary.md @@ -143,7 +143,7 @@ A **Session** is the tuple of its `SessionId` plus one state per layer (I1). |---|---| | `Unregistered` | No entry in `terminal-registry` | | `Mounted` | Entry present, DOM element in the document tree | -| `Orphaned` | Entry present, element detached. Not transient — a `Doored` terminal Surface sits here as long as it stays minimized (I4) | +| `Orphaned` | Entry present, element detached. Not transient — a `Doored` terminal Surface sits here as long as it stays minimized, and so does a `Paned` one in a hidden Workspace (I4) | | `Disposed` | Entry removed, xterm disposed | ### View @@ -197,7 +197,7 @@ A user verb is an intentional action that produces a single observable change. | `rename` | Update title; layer-agnostic | | `zoom` / `unzoom` | Paned ↔ Zoomed | | `swap` | Exchange two Surfaces' layout slots; ids travel with them, so Registry entries, Processes, and titles are untouched | -| `switchWorkspace` | Set the active Workspace (`setActiveWorkspace`), revealing its Wall and hiding the outgoing one. No Surface changes state; I8 holds by construction. | +| `switchWorkspace` | Set the active Workspace (`setActiveWorkspace`), revealing its Wall and hiding the outgoing one. Terminal elements reattach; nothing resumes or restores; I8 holds by construction. | | `createWorkspace` | Add a Workspace and mount its Wall, which spawns one pane; activate by default, unless `activate: false`. | | `closeWorkspace` | `kill` each member Surface, then remove the Workspace; the last remaining Workspace cannot be closed. | | `renameWorkspace` | Update a Workspace's `name`; touches no Session | @@ -240,11 +240,11 @@ Source of truth: `focusSession` / `refitSession` in `lib/src/lib/terminal-lifecy - I1: `SessionId` is immutable for the life of a Session and stable across `resume` / `restore`. - I2: Process state is independent of Registry, View, and Link. A `Live` process may be `Doored` or `Hidden`; an `Exited` process may still be `Paned`. - I3: Activity state survives `minimize` / `reattach`. `ALERT_RINGING` fires only on a *fresh* transition, never on `mount` or `reattach`. -- I4: `Registry: Orphaned` outlives no Session state except `View: Doored` — at rest every other entry is `Mounted` or `Disposed`, so an `Orphaned` entry that is not `Doored` is a leak. +- I4: `Registry: Orphaned` outlives no Session state except `View: Doored` or a Surface in a hidden Workspace — at rest every other entry is `Mounted` or `Disposed`, so an `Orphaned` entry that is not `Doored` is a leak. - I5: `kill` is universally valid and always ends at `View: Hidden`; its per-kind effects are the [User verbs](#user-verbs) row. - I6: `rename` is universally valid including when `Process = Exited` and `View = Doored`. - I7: Every Surface sits in exactly one Pane; every Pane and its Surfaces belong to exactly one Workspace; every Workspace belongs to one Window. -- I8: **Must preserve Process and Activity during `switchWorkspace`, without firing a fresh ring** (I3). A switch mounts nothing, so no ring can fire (`docs/specs/layout.md` → Workspaces). +- I8: **Must preserve Process and Activity during `switchWorkspace`, without firing a fresh ring** (I3). A switch reattaches terminal elements but resumes and restores nothing, so no ring can fire (`docs/specs/layout.md` → Workspaces). - I9: A Workspace's union status is a pure projection of its members' Activity: no independent state, destroyed with the Workspace. - I10: **Must preserve a terminal Surface's `SessionId`** (I1). **Must transfer the `surface:N` CLI ref when replacing a browser Surface**, minting a new id in the same layout slot with its target URL. An `ab-screencast` ⇄ `ab-popout` relaunch keeps the Surface id; render-mode changes do not universally imply replacement (rationale; `docs/specs/dor-browser.md` → Display Modal And Render Swaps). diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 994120415..71269309f 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -144,7 +144,8 @@ Source of truth: `lib/src/components/Baseboard.tsx`, `lib/src/components/Door.ts Each Wall renders one Workspace's Content (Lath layout) and Baseboard (doors). Standalone mounts one Wall **per Workspace**; VS Code and the website playground mount a bare Wall with no Workspace id, which behaves exactly as a single-Workspace Window (VS Code's per-webview mapping is `docs/specs/vscode.md`). - **Must mount every Workspace's Wall in one grid cell**, inactive Walls `visibility:hidden` (plus `inert`) and never `display:none` (rationale). -- **Must switch by flipping `active` alone**: no re-seed, no re-parent, no unmount, and no `mountElement` / `resumeTerminal` / `restoreTerminal`, which is what makes I8 hold by construction (`WorkspaceWindow.test.tsx`). +- **Must switch by flipping `active` alone**: no re-seed, no re-parent, no leaf unmount, and no `resumeTerminal` / `restoreTerminal`; the only mount work is the terminal reattach below, which replays nothing, so I8 holds by construction (`WorkspaceWindow.test.tsx`). +- **A hidden Wall's terminals hold no element and no GL context**: deactivation runs `unmountElement` on every terminal pane, exactly as minimize does ([Renderer](#renderer)); activation runs `mountElement` and fits through the [Animations](#animations) gate, so an unchanged grid sends no PTY resize (`TerminalPane.test.tsx`). Browser Surfaces keep their live documents (rationale). - **A hidden Wall consumes no window input**: every listener it keeps is gated on `active`, so nothing it hears is dispatched, forwarded, or `preventDefault`ed. **Only the active Wall renders the modal hosts and the overlays that trap keys** — the kill confirmation, the refused-archive prompt, a terminal's selection popup (rationale): a staged prompt survives the switch and is answered only where the user can see it. - **Exactly one Wall answers a `dor` request**, chosen by `docs/specs/dor-cli.md` → "Handle Model". Every Wall registers a handle, a bare one under `DEFAULT_WORKSPACE_ID`, so the router always finds one. - **Never unmount a Wall before its Surfaces are disposed** — `closeAll` waits for the kill fade to commit, bounded by the engine's exit duration, since unmounting mid-fade would leave `Orphaned` Registry entries (`docs/specs/glossary.md` → "Invariants" I4). **The deadline refuses rather than reporting clean**, and the walk re-reads membership until nothing is left, so a Surface born behind it is closed too. @@ -337,7 +338,7 @@ On cold restore, a terminal pane with a host-captured recovery invocation runs i **Must use `@xterm/addon-webgl` for mounted terminals when available**, falling back to xterm's DOM renderer on unsupported WebGL, activation failure, or context-budget eviction. `cfg.terminal.webglRenderer` disables WebGL and is off under Chromatic. ImageAddon owns its separate canvas layers (`docs/specs/terminal-escapes.md` → "Inline graphics"). (rationale) - **Must acquire GPU resources at mount, never at Session creation**, and keep a successfully activated renderer when context capture or explicit loss is unavailable. (rationale) -- **Must dispose the addon on unmount/minimize, helper parking, and Session disposal**, then explicitly lose its context when captured and supported. Report addon or extension failures without aborting teardown. Minimize preserves the xterm, grid, buffers, PTY, and other addons. +- **Must dispose the addon on unmount/minimize, Workspace deactivation, helper parking, and Session disposal**, then explicitly lose its context when captured and supported. Report addon or extension failures without aborting teardown. Minimize preserves the xterm, grid, buffers, PTY, and other addons. - **Must load a fresh addon on reattachment without resizing the terminal for the renderer swap.** Terminal fitting follows "Animations". A stale mount's cleanup must not release a newer mount's renderer. - **Must attempt WebGL at most once per mount.** Failure or context loss stays on DOM until the next unmount/remount; focus and metadata changes never retry. Focus-based recovery remains under `## Future`. - **Must preserve the addon's shared atlas cache.** Compatible mounted terminals share rasterized atlas canvases; GPU texture copies remain per context. Releasing one renderer releases only its atlas ownership; the last owner releases the cache. (rationale) diff --git a/docs/specs/layout.rationale.md b/docs/specs/layout.rationale.md index 735cd1d7e..f7c0d1461 100644 --- a/docs/specs/layout.rationale.md +++ b/docs/specs/layout.rationale.md @@ -16,7 +16,9 @@ xterm.js paints only its own rendered surface, and integer row fitting leaves a ## Workspaces -**Why `visibility: hidden` in one grid cell rather than `display: none`.** A `display:none` Wall has no box, so every xterm in it would refit on the way back — including a resize that happened while it was hidden. Sharing one grid cell keeps every Wall's box identical, so a resize refits all of them once and a switch refits nothing (checked in the browser-dev harness by resizing with Workspace 2 visible and finding Workspace 1's screen already at the new size, 2026-09). +**Why `visibility: hidden` in one grid cell rather than `display: none`.** A `display:none` Wall has no box, so every xterm in it would refit on the way back — including a resize that happened while it was hidden. Sharing one grid cell keeps every Wall's box identical, so a switch's reattach fit finds an unchanged grid (checked in the browser-dev harness by resizing with Workspace 2 visible and finding Workspace 1's screen already at the new size, 2026-09). + +**Why a hidden Workspace's terminals are detached rather than merely hidden.** xterm pauses rendering only when its screen element stops intersecting (`RenderService` in `@xterm/xterm`), and a `visibility: hidden` box still intersects, so a hidden pane kept rasterizing every output frame and holding a GL context. Reusing the minimize primitives pauses it and releases the context; the box stays laid out, so the reattach fit finds the same grid and sends no PTY resize (2026-09). **Why `inert` is only defense in depth.** `visibility: hidden` already removes focusability, so the attribute exists for a future presentation that keeps the subtree visible. diff --git a/lib/src/components/TerminalPane.test.tsx b/lib/src/components/TerminalPane.test.tsx index 7407c2a16..2e4dd4112 100644 --- a/lib/src/components/TerminalPane.test.tsx +++ b/lib/src/components/TerminalPane.test.tsx @@ -8,6 +8,7 @@ import { createLathWallEngine, terminalLeafMeta, type LathWallEngine } from './w import { createLathWallStore, type LathWallStore } from './wall/lath-wall-store'; import { leaf, split, tree } from '../lib/lath/test-util'; import { PANE_HEADER_HEIGHT_PX } from './design'; +import { WorkspaceActiveContext } from './wall/wall-context'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -124,11 +125,14 @@ function settle() { for (let i = 0; i < 30; i++) frame(); act(() => vi.advanceTimersByTime(200)); } -function mount() { - act(() => root.render( root.render( { store.resizeBoundary(path, boundary, delta); }} componentsOverride={{ bodies: { terminal: ({ id }) => }, tabs: { terminal: () => null } }} - />)); + />)); +} +function mount(active = true) { + render(active); settle(); registry.resizes.length = 0; registry.fits.mockClear(); @@ -256,3 +260,59 @@ describe('terminal fitting follows settled layout, not animated geometry', () => expect(registry.resizes).toEqual([{ id: 'standalone', cols: 100, rows: 30 }]); }); }); + +describe('a hidden Workspace minimizes its terminals', () => { + it('detaches every terminal and fits nothing, even when the host resizes', () => { + mount(); + expect(registry.entries.get('a')!.container).toBeDefined(); + render(false); + settle(); + expect(registry.entries.get('a')!.container).toBeUndefined(); + expect(registry.entries.get('b')!.container).toBeUndefined(); + registry.fits.mockClear(); + hostWidth = 1200; + notifyResize(); + settle(); + expect(registry.fits).not.toHaveBeenCalled(); + expect(registry.resizes).toEqual([]); + }); + + it('reattaches on activation with no PTY resize at the same grid', () => { + mount(); + const terminal = registry.entries.get('b'); + render(false); + settle(); + render(true); + settle(); + expect(registry.entries.get('b')).toBe(terminal); + expect(registry.entries.get('b')!.container).toBeDefined(); + expect(registry.resizes).toEqual([]); + }); + + it('sends exactly one grid transition per terminal after a resize while hidden', () => { + mount(); + render(false); + settle(); + hostWidth = 1200; + notifyResize(); + settle(); + expect(registry.resizes).toEqual([]); + render(true); + settle(); + expect(registry.resizes).toEqual([ + { id: 'a', cols: 59, rows: 28 }, { id: 'b', cols: 59, rows: 28 }, + ]); + }); + + it('creates a Session mounted into a hidden Workspace without attaching it', () => { + mount(false); + expect(registry.entries.has('a')).toBe(true); + expect(registry.entries.get('a')!.container).toBeUndefined(); + expect(registry.fits).not.toHaveBeenCalled(); + render(true); + settle(); + expect(registry.entries.get('a')!.container).toBeDefined(); + // The first fit of any Session, hidden-born or not: one transition per terminal. + expect(registry.resizes.map(e => e.id).sort()).toEqual(['a', 'b']); + }); +}); diff --git a/lib/src/components/TerminalPane.tsx b/lib/src/components/TerminalPane.tsx index ab156858f..451ef186e 100644 --- a/lib/src/components/TerminalPane.tsx +++ b/lib/src/components/TerminalPane.tsx @@ -11,7 +11,7 @@ import { SelectionOverlay } from './SelectionOverlay'; import { SelectionPopup } from './SelectionPopup'; import { MouseOverrideBanner } from './wall/MouseOverrideBanner'; import { TERMINAL_BOTTOM_RADIUS_CLASS } from './design'; -import { TerminalResizeContext } from './wall/wall-context'; +import { TerminalResizeContext, WorkspaceActiveContext } from './wall/wall-context'; interface TerminalPaneProps { id: string; @@ -31,12 +31,18 @@ const REFIT_DEBOUNCE_MS = 150; export function TerminalPane({ id, isFocused = true }: TerminalPaneProps) { const containerRef = useRef(null); const resize = useContext(TerminalResizeContext); + const workspaceActive = useContext(WorkspaceActiveContext); useEffect(() => { const container = containerRef.current; if (!container) return; getOrCreateTerminal(id); + // A hidden Workspace's terminal is minimized: the Session and PTY exist, but + // the element stays detached so xterm stops rasterizing and holds no GL + // context (docs/specs/layout.md → "Workspaces"). Activation re-runs this + // effect, and the reattach fit finds the box the hidden Wall kept. + if (!workspaceActive) return; mountElement(id, container); // The one fit path, whatever wakes it: the layout coordinator when it has painted // committed geometry, a debounced container resize otherwise. Both drop a pending @@ -63,7 +69,7 @@ export function TerminalPane({ id, isFocused = true }: TerminalPaneProps) { clearTimeout(timer); unmountElement(id, container); }; - }, [id, resize]); + }, [id, resize, workspaceActive]); useEffect(() => { focusSession(id, isFocused); diff --git a/lib/src/components/WorkspaceWindow.tsx b/lib/src/components/WorkspaceWindow.tsx index 3a1ca96ac..eefa66db7 100644 --- a/lib/src/components/WorkspaceWindow.tsx +++ b/lib/src/components/WorkspaceWindow.tsx @@ -8,9 +8,10 @@ import type { WallBootProps } from './wall/wall-types'; /** * One Window's Workspaces: a mounted `` each, all in the same grid cell so - * a switch never changes a Wall's box and no xterm refits + * a switch never changes a Wall's box and the reattach fit finds the same grid * (docs/specs/layout.md → "Workspaces"). Switching flips which Wall is `active`; - * nothing re-seeds, re-parents, or unmounts. + * nothing re-seeds, re-parents, or unmounts a leaf — only a hidden Wall's + * terminal elements detach, as minimize does, so they hold no GL context. */ export function WorkspaceWindow({ baseboardNotice, @@ -57,7 +58,7 @@ export function WorkspaceWindow({ data-workspace-wall={workspace.id} data-workspace-active={isActive ? 'true' : 'false'} // `visibility: hidden` (not `display: none`) keeps the box laid out, - // so a hidden Workspace's xterms never refit. `inert` is + // so a hidden Workspace's reattached xterms find an unchanged grid. `inert` is // defense-in-depth: `visibility: hidden` already removes focusability. inert={!isActive} className={clsx( From afc1e9e592c85498337433e13d11288a331171bd Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 17:15:00 -0700 Subject: [PATCH 12/13] Fix the strip's stranded rename lease, drag target, and confirmation keys Review findings on the Workspace strip and its close verb: - `closeWorkspaceWithSurfaces` now clears `renamingId` and `pendingClose` with the Workspace. Renaming a tab and then middle-clicking it closed left the rename editor's chrome keyboard lease held forever (the input unmounts without a `blur`), which silenced command mode in every Wall. - The AppBar carries `data-tauri-drag-region` on a dedicated `min-w-8` spacer after the strip instead of the strip's `flex-1` wrapper, so the window stays draggable once tabs fill the bar. - A press inside the open rename editor no longer starts a reorder drag: `InlineEditInput` stops `mousedown` but not `pointerdown`, so a text selection reordered the Workspace and committed the half-selected draft. - A bare Shift or Meta no longer dismisses the close confirmation, matching the pane kill, where `handleDualTap` consumes both before the confirmation sees them. - Glossary I4's trailing clause now agrees with the exception it opens. Each behavioral rule is pinned in `WorkspaceStrip.test.tsx` or `workspace-lifecycle.test.ts`; layout.md's budget is ratcheted for the three added rules. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RChsJ5rMUMyfu22UZDfUus --- docs/specs/glossary.md | 2 +- docs/specs/layout.md | 2 +- docs/specs/standalone.md | 5 +- lib/src/components/WorkspaceStrip.test.tsx | 75 ++++++++++++++++++- lib/src/components/WorkspaceStrip.tsx | 7 ++ .../wall/workspace-lifecycle.test.ts | 14 +++- .../components/wall/workspace-lifecycle.ts | 5 ++ scripts/spec-word-budgets.json | 2 +- standalone/src/AppBar.tsx | 13 ++-- 9 files changed, 112 insertions(+), 13 deletions(-) diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md index 6693ce34f..12f94330f 100644 --- a/docs/specs/glossary.md +++ b/docs/specs/glossary.md @@ -240,7 +240,7 @@ Source of truth: `focusSession` / `refitSession` in `lib/src/lib/terminal-lifecy - I1: `SessionId` is immutable for the life of a Session and stable across `resume` / `restore`. - I2: Process state is independent of Registry, View, and Link. A `Live` process may be `Doored` or `Hidden`; an `Exited` process may still be `Paned`. - I3: Activity state survives `minimize` / `reattach`. `ALERT_RINGING` fires only on a *fresh* transition, never on `mount` or `reattach`. -- I4: `Registry: Orphaned` outlives no Session state except `View: Doored` or a Surface in a hidden Workspace — at rest every other entry is `Mounted` or `Disposed`, so an `Orphaned` entry that is not `Doored` is a leak. +- I4: `Registry: Orphaned` outlives no Session state except `View: Doored` or a Surface in a hidden Workspace — at rest every other entry is `Mounted` or `Disposed`, so an `Orphaned` entry that is neither is a leak. - I5: `kill` is universally valid and always ends at `View: Hidden`; its per-kind effects are the [User verbs](#user-verbs) row. - I6: `rename` is universally valid including when `Process = Exited` and `View = Doored`. - I7: Every Surface sits in exactly one Pane; every Pane and its Surfaces belong to exactly one Workspace; every Workspace belongs to one Window. diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 71269309f..b8ec755bd 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -153,7 +153,7 @@ Each Wall renders one Workspace's Content (Lath layout) and Baseboard (doors). S - **Must reject duplicate Workspace IDs before mutating the model**, preserving the last-Workspace close guard (`workspace-store.test.ts`). - Each Wall keeps its own mode and selection across switches: deactivating blurs its selected pane, activating focuses it a frame later, since focus into a hidden subtree is a no-op. -**Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter and key rule over the Window's content area, then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **One close runs at a time for the whole Window**, with the count re-checked after the confirmation, so two of them cannot empty two Walls between them; **a close the store then refuses hands the Wall back its auto-spawn** rather than leaving it mounted and empty. **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers the positional `workspace:` refs with it. **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path. +**Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter and key rule over the Window's content area (**a bare `Shift` or `Meta` is not an answer**, as for a pane kill), then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **One close runs at a time for the whole Window**, with the count re-checked after the confirmation, so two of them cannot empty two Walls between them; **a close the store then refuses hands the Wall back its auto-spawn** rather than leaving it mounted and empty. **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers the positional `workspace:` refs with it; **a press inside the open rename editor never starts a reorder**. **A close drops the rename editor and pending confirmation**, or a stale `renamingId` holds the chrome keyboard lease for the session (`WorkspaceStrip.test.tsx`). **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path. The union projection and its indicators are owned by `docs/specs/alert.md` → Workspace union; the strip that renders them by `docs/specs/standalone.md` → AppBar. Persisted containers are owned by `docs/specs/transport.md`; `dormouse.flags.workspaces` still selects the bare `PersistedSession` versus `PersistedWindow` stored format, and **both standalone adapters still disable session persistence**, so a relaunch restores one Workspace. diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 68da125e5..55d498a5f 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -253,8 +253,9 @@ then the strip scrolls, with no overflow arrows. - **Never put `data-tauri-drag-region` on a tab or anything inside one.** Tauri matches that attribute on the event target alone, so a tab carrying it would - drag the window instead of activating, renaming, or reordering. Its wrapper — - the bar past the last tab — carries it, and is the draggable spacer. + drag the window instead of activating, renaming, or reordering. **A dedicated + spacer after the strip carries it, with a minimum width**, so the window stays + draggable at every tab count and the strip scrolls into what is left. - `onDragOutsideWindow` / `onDropOnOtherWindow` are the strip's tear-out seams, staged in `docs/specs/layout.md` `## Future` (workspaces-rollout). diff --git a/lib/src/components/WorkspaceStrip.test.tsx b/lib/src/components/WorkspaceStrip.test.tsx index 25e3f211a..3b0854896 100644 --- a/lib/src/components/WorkspaceStrip.test.tsx +++ b/lib/src/components/WorkspaceStrip.test.tsx @@ -11,7 +11,7 @@ import { ensureResizeObserver } from './wall/wall-test-utils'; import { requestWorkspaceClose, requestWorkspaceRename } from './wall/workspace-lifecycle'; import { resetWorkspaceSurfaces, setWorkspaceSurfaces } from '../lib/workspace-surfaces'; import { clearTerminalActivity, setTerminalActivity } from '../lib/terminal-registry'; -import { resetWorkspaceUi } from '../lib/workspace-ui-store'; +import { getWorkspaceUiSnapshot, resetWorkspaceUi } from '../lib/workspace-ui-store'; import { createWorkspace, getActiveWorkspaceId, @@ -278,4 +278,77 @@ describe('WorkspaceStrip', () => { await act(async () => { requestWorkspaceClose('ws-2'); }); expect(closed).toHaveBeenCalled(); }); + + it('releases the rename lease when the tab being renamed is middle-clicked closed', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + stubHandle('ws-2', { closeAll: async () => null }); + await render(); + + await act(async () => { + activateButton('ws-2').dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + }); + expect(chromeKeyboardHeld()).toBe(true); + // Removing the focused input fires no `blur`, so neither submit nor cancel + // runs: only the close verb can put `renamingId` back. + await act(async () => { + tabFor('ws-2').dispatchEvent(new MouseEvent('auxclick', { button: 1, bubbles: true, cancelable: true })); + }); + await act(async () => { await Promise.resolve(); }); + expect(getWorkspacesSnapshot().workspaces.map((workspace) => workspace.id)).toEqual([first]); + expect(getWorkspaceUiSnapshot().renamingId).toBeNull(); + expect(chromeKeyboardHeld()).toBe(false); + }); + + it('never starts a reorder from a press inside the open rename editor', async () => { + createWorkspace({ id: 'ws-2' }); + createWorkspace({ id: 'ws-3' }); + await render(); + const order = () => getWorkspacesSnapshot().workspaces.map((workspace) => workspace.id); + const first = order()[0]; + const capture = vi.fn(); + Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { configurable: true, value: capture }); + Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', { configurable: true, value: () => {} }); + const boxes = new Map(order().map((id, index) => [id, { left: index * 100, width: 100 }])); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + const id = this.dataset.workspaceTab; + const box = id ? boxes.get(id) : undefined; + const left = box?.left ?? 0; + const width = box?.width ?? 300; + return { left, right: left + width, width, top: 0, bottom: 24, height: 24, x: left, y: 0, toJSON: () => ({}) } as DOMRect; + }); + + await act(async () => { requestWorkspaceRename(first); }); + const input = container.querySelector(`[data-workspace-rename-for="${first}"]`)!; + // A drag-select across the editor's text, well past the reorder threshold. + await act(async () => { input.dispatchEvent(pointer('pointerdown', { button: 0, clientX: 50, clientY: 12 })); }); + await act(async () => { window.dispatchEvent(pointer('pointermove', { clientX: 160, clientY: 12 })); }); + await act(async () => { window.dispatchEvent(pointer('pointerup', { clientX: 160, clientY: 12 })); }); + expect(order()).toEqual([first, 'ws-2', 'ws-3']); + expect(capture).not.toHaveBeenCalled(); + expect(getWorkspaceUiSnapshot().renamingId).toBe(first); + }); + + it('keeps the close confirmation up through a bare Shift or Meta, as the pane kill does', async () => { + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + stubHandle('ws-2', { hasTouchedSurfaces: () => true, closeAll: async () => null }); + await render(); + await act(async () => { + container.querySelector('[data-workspace-tab-close="ws-2"]')!.click(); + }); + expect(container.querySelector('#kill-confirm-title')).not.toBeNull(); + + for (const key of ['Shift', 'Meta']) { + await act(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); + }); + expect(container.querySelector('#kill-confirm-title')).not.toBeNull(); + } + // Any other key still answers. + await act(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + expect(container.querySelector('#kill-confirm-title')).toBeNull(); + expect(getWorkspacesSnapshot().workspaces).toHaveLength(2); + }); }); diff --git a/lib/src/components/WorkspaceStrip.tsx b/lib/src/components/WorkspaceStrip.tsx index ad218b441..3276308cc 100644 --- a/lib/src/components/WorkspaceStrip.tsx +++ b/lib/src/components/WorkspaceStrip.tsx @@ -125,6 +125,10 @@ export function WorkspaceStrip({ const onKeyDown = (event: KeyboardEvent) => { event.preventDefault(); event.stopPropagation(); + // A bare modifier is not an answer: `handleDualTap` consumes Meta and + // Shift before the pane kill confirmation sees them, so neither may + // dismiss this one either. + if (event.key === 'Shift' || event.key === 'Meta') return; setPendingWorkspaceClose(null); if (acceptsKillChar(event.key, char)) void closeWorkspaceWithSurfaces(id); }; @@ -266,6 +270,9 @@ const WorkspaceTab = memo(function WorkspaceTab({ )} style={dragging ? { opacity: 0.6 } : undefined} onPointerDown={(event) => { + // The close button has its own click, and a press inside the open rename + // editor is a text selection — neither may start a reorder drag. + if (renaming) return; if (event.target instanceof Element && event.target.closest('[data-workspace-tab-close]')) return; onPress(id, event); }} diff --git a/lib/src/components/wall/workspace-lifecycle.test.ts b/lib/src/components/wall/workspace-lifecycle.test.ts index 3f12d63d0..472116cc6 100644 --- a/lib/src/components/wall/workspace-lifecycle.test.ts +++ b/lib/src/components/wall/workspace-lifecycle.test.ts @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { closeWorkspaceWithSurfaces, LAST_WORKSPACE_REFUSAL, requestWorkspaceClose } from './workspace-lifecycle'; import { registerWallHandle, resetWallHandles, stubWallHandle, type WallHandle } from './wall-handles'; -import { resetWorkspaceUi, getWorkspaceUiSnapshot } from '../../lib/workspace-ui-store'; +import { getWorkspaceUiSnapshot, resetWorkspaceUi, setRenamingWorkspace } from '../../lib/workspace-ui-store'; import { closeWorkspace, createWorkspace, @@ -91,4 +91,16 @@ describe('closeWorkspaceWithSurfaces', () => { expect(await closeWorkspaceWithSurfaces('ws-2')).toBeNull(); expect(ids()).toEqual([first]); }); + + it('drops the strip UI state with the Workspace, so a stranded rename cannot hold the keyboard lease', async () => { + createWorkspace({ id: 'ws-2' }); + handleFor('ws-2', { closeAll: async () => null }); + // The rename editor is open on the Workspace being closed: nothing unmounts + // it through `blur`, so the verb itself has to clear it. + setRenamingWorkspace('ws-2'); + + expect(await closeWorkspaceWithSurfaces('ws-2')).toBeNull(); + expect(getWorkspaceUiSnapshot().renamingId).toBeNull(); + expect(getWorkspaceUiSnapshot().pendingClose).toBeNull(); + }); }); diff --git a/lib/src/components/wall/workspace-lifecycle.ts b/lib/src/components/wall/workspace-lifecycle.ts index 0abdcb97e..c9c660763 100644 --- a/lib/src/components/wall/workspace-lifecycle.ts +++ b/lib/src/components/wall/workspace-lifecycle.ts @@ -59,6 +59,11 @@ export async function closeWorkspaceWithSurfaces(id: WorkspaceId): Promise + the bar on Windows/Linux. The spacer after it is the drag target, with + a floor so it survives any tab count — the strip scrolls into what is + left rather than growing over it. Tauri matches `data-tauri-drag-region` + on the event target alone, so no tab or tab button may carry it — that + is what leaves a press on a tab free to activate, rename, or reorder. */} +
+
{/* Theme and shell selection live in the Settings dialog at the bottom-right of the window (docs/specs/theme.md, From 61ac0bed98645f445280284d8925bc450b8dcda0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 18:21:02 -0700 Subject: [PATCH 13/13] Preserve sibling Workspace editors and confirmations on close --- docs/specs/layout.md | 2 +- lib/src/components/wall/workspace-lifecycle.test.ts | 13 ++++++++++++- lib/src/components/wall/workspace-lifecycle.ts | 8 +++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index b8ec755bd..db58e6223 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -153,7 +153,7 @@ Each Wall renders one Workspace's Content (Lath layout) and Baseboard (doors). S - **Must reject duplicate Workspace IDs before mutating the model**, preserving the last-Workspace close guard (`workspace-store.test.ts`). - Each Wall keeps its own mode and selection across switches: deactivating blurs its selected pane, activating focuses it a frame later, since focus into a hidden subtree is a no-op. -**Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter and key rule over the Window's content area (**a bare `Shift` or `Meta` is not an answer**, as for a pane kill), then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **One close runs at a time for the whole Window**, with the count re-checked after the confirmation, so two of them cannot empty two Walls between them; **a close the store then refuses hands the Wall back its auto-spawn** rather than leaving it mounted and empty. **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers the positional `workspace:` refs with it; **a press inside the open rename editor never starts a reorder**. **A close drops the rename editor and pending confirmation**, or a stale `renamingId` holds the chrome keyboard lease for the session (`WorkspaceStrip.test.tsx`). **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path. +**Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter and key rule over the Window's content area (**a bare `Shift` or `Meta` is not an answer**, as for a pane kill), then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **One close runs at a time for the whole Window**, with the count re-checked after the confirmation, so two of them cannot empty two Walls between them; **a close the store then refuses hands the Wall back its auto-spawn** rather than leaving it mounted and empty. **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers the positional `workspace:` refs with it; **a press inside the open rename editor never starts a reorder**. **Must drop only the closing Workspace’s rename editor and pending confirmation**, or a stale `renamingId` holds the chrome keyboard lease for the session (`WorkspaceStrip.test.tsx`). **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path. The union projection and its indicators are owned by `docs/specs/alert.md` → Workspace union; the strip that renders them by `docs/specs/standalone.md` → AppBar. Persisted containers are owned by `docs/specs/transport.md`; `dormouse.flags.workspaces` still selects the bare `PersistedSession` versus `PersistedWindow` stored format, and **both standalone adapters still disable session persistence**, so a relaunch restores one Workspace. diff --git a/lib/src/components/wall/workspace-lifecycle.test.ts b/lib/src/components/wall/workspace-lifecycle.test.ts index 472116cc6..3f041c6a9 100644 --- a/lib/src/components/wall/workspace-lifecycle.test.ts +++ b/lib/src/components/wall/workspace-lifecycle.test.ts @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { closeWorkspaceWithSurfaces, LAST_WORKSPACE_REFUSAL, requestWorkspaceClose } from './workspace-lifecycle'; import { registerWallHandle, resetWallHandles, stubWallHandle, type WallHandle } from './wall-handles'; -import { getWorkspaceUiSnapshot, resetWorkspaceUi, setRenamingWorkspace } from '../../lib/workspace-ui-store'; +import { getWorkspaceUiSnapshot, resetWorkspaceUi, setPendingWorkspaceClose, setRenamingWorkspace } from '../../lib/workspace-ui-store'; import { closeWorkspace, createWorkspace, @@ -98,9 +98,20 @@ describe('closeWorkspaceWithSurfaces', () => { // The rename editor is open on the Workspace being closed: nothing unmounts // it through `blur`, so the verb itself has to clear it. setRenamingWorkspace('ws-2'); + setPendingWorkspaceClose({ id: 'ws-2', char: 'x' }); expect(await closeWorkspaceWithSurfaces('ws-2')).toBeNull(); expect(getWorkspaceUiSnapshot().renamingId).toBeNull(); expect(getWorkspaceUiSnapshot().pendingClose).toBeNull(); }); }); + +it('preserves another Workspace’s rename and close confirmation when closing a sibling', async () => { + const [first] = ids(); + createWorkspace({ id: 'ws-2' }); + setRenamingWorkspace(first); + setPendingWorkspaceClose({ id: first, char: 'x' }); + const before = getWorkspaceUiSnapshot(); + expect(await closeWorkspaceWithSurfaces('ws-2')).toBeNull(); + expect(getWorkspaceUiSnapshot()).toBe(before); +}); diff --git a/lib/src/components/wall/workspace-lifecycle.ts b/lib/src/components/wall/workspace-lifecycle.ts index c9c660763..cd4e6ae63 100644 --- a/lib/src/components/wall/workspace-lifecycle.ts +++ b/lib/src/components/wall/workspace-lifecycle.ts @@ -1,7 +1,7 @@ import { randomKillChar } from '../KillConfirm'; import { getWallHandle } from './wall-handles'; import { forgetWorkspaceSession } from '../../lib/window-session-aggregator'; -import { setPendingWorkspaceClose, setRenamingWorkspace } from '../../lib/workspace-ui-store'; +import { getWorkspaceUiSnapshot, setPendingWorkspaceClose, setRenamingWorkspace } from '../../lib/workspace-ui-store'; import { closeWorkspace, getWorkspacesSnapshot, setActiveWorkspace } from '../../lib/workspace-store'; import type { WorkspaceId } from '../../lib/session-types'; @@ -62,8 +62,10 @@ export async function closeWorkspaceWithSurfaces(id: WorkspaceId): Promise