Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
162e310
Add dor workspace move, dor list --window, and the iframe move gate
nedtwigg Sep 10, 2026
3de7748
Answer moved once the target adopts, and carry --index across windows
nedtwigg Sep 11, 2026
80c3006
Merge workspaces-fidelity into workspaces-move-verb
nedtwigg Sep 11, 2026
ba3ad16
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
0bbb3a8
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
cfa7905
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
b4160e2
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
3463ae2
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
f6beff0
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
dec7fb1
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
58767f7
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
092ce8d
Account for merged Workspace invariants in spec budgets
nedtwigg Sep 11, 2026
4021cba
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
35c12d9
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
550e379
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
237f877
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
7c00085
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
526db39
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
64ea9cf
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
8474c83
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
c55029a
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
8822e97
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
21952df
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
bcb583a
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
7c2100d
Merge branch 'workspaces-fidelity' into workspaces-move-verb
nedtwigg Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions docs/specs/dor-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@ bridge → `TauriAdapter` `CustomEvent("dormouse:control-request")` → Wall
handler, and back along the same hops.

**A request naming a Workspace or a Window is routed to the window holding
it**, whichever window the caller sits in; one naming neither goes to the
it** (`--workspace`, or `dor list --window <label>`), whichever window the
caller sits in; one naming neither goes to the
window that owns its `DORMOUSE_SURFACE_ID`, and one naming no Surface to the
focused window. A target the registry cannot place — an unknown ref, or a name
two windows use — reaches the caller's own window, which refuses it by name.
Expand Down Expand Up @@ -458,6 +459,7 @@ Wall (Handle Model), and each takes a `workspace:<n|name>` target except `new`:
| `rename <ref> <name>` | Renames the Workspace only — no Surface title (`docs/specs/layout.md` → "Workspaces"). |
| `close <ref> [--force]` | **Refuses, raising no confirmation, when the Workspace holds a touched or running Surface** unless `--force` — the caller is a command, not someone watching the Wall, exactly as `dor kill` archives silently. The last Workspace, a Workspace whose close meets another already in flight, and one whose Wall never registers (`still mounting`, after the routing retry — closing past it would leave its Sessions running with nothing holding them) refuse too. Member Surfaces close through the closure coordinator, and a refusal leaves the Workspace open and the user where they were (`docs/specs/notepad.md` → "Closure"). |
| `switch <ref>` | Activates it. |
| `move <ref> [--window <label\|new>] [--index <n>] [--dangerously-destroy-iframe-page-state]` | To another Window (`new` tears out), a strip position, or both — with both, the position is in the target's strip; the Window handling it runs the same transfer the strip's drag does (`docs/specs/standalone.md` → Transfer), with no pointer to place the tab by. **Answers `moved` only once the target Window has adopted the Workspace**; one handed back (the target closed mid-transfer, or never answered) is an error naming the reason, and the Workspace stays where it was. **Refuses a move between Windows while the Workspace holds a plain iframe Surface unless the flag is passed**, naming them: the page state is lost (`docs/specs/layout.md` → Workspaces). A host with one Window reorders only. |

**Each verb ships as one action of one command**, not a route map: the published
CLI reference renders one help page per top-level command
Expand Down Expand Up @@ -656,12 +658,10 @@ Source of truth: `buildDorSurfacesInternal` in `lib/src/components/Wall.tsx`; `d
npm) distributes the bootstrap stub, never a copy of the content. A user-level
`--global` install variant waits until a story needs it.

- **Cross-Window listing and moves.** `--workspace` already reaches a sibling
Window's Workspace ([Standalone](#standalone)); the router places a `window`
target too, but no command takes that flag yet. `dor list --all` still lists
the answering Window alone, and no verb moves a Workspace between Windows or
reorders the strip. Both read the registry
(`docs/specs/standalone.md` → "Workspace registry").
- **Cross-Window `--all`.** `dor list --window <label>` lists one other Window
and `--workspace` reaches a sibling's Workspace ([Standalone](#standalone)),
but `dor list --all` still lists the answering Window alone; a union would
read the registry (`docs/specs/standalone.md` → "Workspace registry").
- **Cross-Workspace listing in VS Code.** Each Workspace is its own webview
there, so `dor list --all` would have to aggregate at the extension host
rather than in a per-webview control handler; until it does, VS Code refuses
Expand Down
7 changes: 6 additions & 1 deletion docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,14 +158,19 @@ its own window, or dropped onto an existing one — carrying its Surfaces, its
Sessions and its notes with it, and killing nothing on the way
(`docs/specs/standalone.md` → Transfer). Leaving is not a close and arriving is
not a create: a Workspace that arrives mounts from the record it brought.
**Must confirm before a move that would destroy an iframe's page state**: a
plain iframe Surface's document cannot leave its webview, so it reopens at its
saved URL, and a Workspace holding one — Doored ones included — asks with the
kill's typed letter before it leaves; agent-browser Surfaces reconnect and ask
nothing (`iframeSurfaceIds` on the Wall handle; `workspace-drag.test.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 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. **A Workspace whose Wall has not registered is refused** (`workspace '<ref>' is still mounting`, one wording for every caller), never closed past — the Wall walks the member Surfaces, so dropping it would leave its Sessions running unheld (`docs/specs/glossary.md` → "Invariants" I4); **a gesture waits out the registration gap first**, as `dor workspace close` does, so `×` or `&` right after a create closes rather than silently doing nothing. **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 `workspace:<n>` refs with it only where they are positional (`docs/specs/dor-cli.md` → "Handle Model"); **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`: standalone stores one `PersistedWindow` per window, so a relaunch restores every Workspace ([Session persistence](#session-persistence)).

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`.

**Create, rename, close, and switch have `dor` counterparts** (`docs/specs/dor-cli.md` → "dor workspace"); reorder is strip-only, and transfer and tear-out stay drag-only until `docs/specs/dor-cli.md` → Future "Cross-Window targeting" lands. Each takes the same route as the strip and the command-mode keys: a command close raises no confirmation, refusing instead, and closes its member Surfaces silently.
**Every Workspace verb has a `dor` counterpart** (`docs/specs/dor-cli.md` → "dor workspace"), taking the same route as the strip and the command-mode keys: a command close raises no confirmation, refusing instead, and closes its member Surfaces silently.

## Modes

Expand Down
2 changes: 2 additions & 0 deletions docs/specs/layout.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ xterm.js paints only its own rendered surface, and integer row fitting leaves a

**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 a move confirms for iframes but not for agent-browser Surfaces.** An agent-browser Surface's session lives in the host process; the target window reconnects its viewer and the page is as it was. A plain iframe is a document inside the source webview, and no API carries a document between webviews — the alternative, keeping every Workspace in its own native child webview and reparenting it, was prototyped on a vendored Tauri fork and rejected for the fork (2026-09). The confirmation is the kill's typed letter rather than a button because what is lost is as gone as a killed process, and the same gesture already means that.

**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 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.
Expand Down
21 changes: 15 additions & 6 deletions dor/src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ interface ListFlags extends WorkspaceScopedFlags {
readonly ports?: boolean;
readonly view?: SurfaceView;
readonly workspaces?: boolean;
readonly window?: string;
}

const FULL_DESCRIPTION = `Lists every Surface in the current Workspace — terminals and browser Surfaces, including minimized ones (view "minimized").
Expand All @@ -65,7 +66,7 @@ Filters are ANDed. --command is an exact match against the running command repor

JSON output (--json) always includes both stable ids and refs, and each row carries has_terminal (a PTY) and has_browser (a browser renderer) — gate on those, not on kind, so a Surface that has both still matches. It adds top-level caller_surface_ref/caller_surface_id and focused_surface_ref/focused_surface_id — the calling and focused Surfaces, null when neither is in the list — plus workspace_ref, window_ref, and a host block (app, workspace, cli_js_path, node_path): the identity dump dor identify used to print.

--workspace <ref> lists another Workspace instead, in this window or another: workspace:<n> (a stable number) or workspace:<name>, which resolves only when exactly one Workspace carries that name. Both are accepted bare ("2", "build").
--workspace <ref> lists another Workspace instead, in this window or another: workspace:<n> (a stable number) or workspace:<name>, which resolves only when exactly one Workspace carries that name. Both are accepted bare ("2", "build"). --window <label> lists another window's Surfaces or Workspaces (window:main, ws-2).

--all lists every Workspace of this Window, grouped under a Workspace header — every Workspace keeps its header, including one holding nothing and one the filters emptied. Rows keep their own Workspace-scoped surface:N refs, so several groups have a surface:1, but only the active Workspace's selection carries the focus marker; each JSON row adds workspace_ref, and the payload adds a workspaces array plus caller_workspace_ref/focused_workspace_ref, because caller_surface_ref/focused_surface_ref then name a ref several groups share (the _id halves stay unique). Target a row from another Workspace by its stable id, or pass --workspace.

Expand Down Expand Up @@ -149,14 +150,21 @@ function buildListCommand(): Command['command'] {
optional: true,
withNegated: false,
},
window: {
kind: 'parsed',
parse: stringParser,
brief: "Another window to list, by its label (window:main, ws-2).",
optional: true,
placeholder: 'label',
},
};

return buildCommand<ListFlags, [], DorCommandContext>({
docs: {
brief: 'List Dormouse Surfaces.',
customUsage: [
'[--workspace ref|--all] [--kind terminal|browser] [--view paned|zoomed|minimized] [--command text] [--cwd path] [--port number] [--ports] [--json] [--id-format refs|ids|both]',
'--workspaces [--json]',
'[--workspace ref|--all] [--window label] [--kind terminal|browser] [--view paned|zoomed|minimized] [--command text] [--cwd path] [--port number] [--ports] [--json] [--id-format refs|ids|both]',
'--workspaces [--window label] [--json]',
],
fullDescription: FULL_DESCRIPTION,
},
Expand All @@ -179,7 +187,7 @@ async function runListCommand(

try {
if (flags.workspaces === true) {
const overview = await client.listWorkspaces({});
const overview = await client.listWorkspaces(flags.window === undefined ? {} : { window: flags.window });
writeStdout(context, flags.json === true
? renderWorkspacesJson(overview)
: renderWorkspacesText(overview));
Expand All @@ -190,6 +198,7 @@ async function runListCommand(
includePorts,
...(flags.all === true ? { scope: 'all' as const } : {}),
...workspaceParam(flags.workspace),
...(flags.window === undefined ? {} : { window: flags.window }),
});
const env = context.options.env ?? {};
const filtered = applyListFilters(response, flags, env);
Expand All @@ -206,7 +215,7 @@ async function runListCommand(

/** The only flags the Workspace overview takes — an allowlist, so a flag added
* to this command is refused there until it is named here. */
const WORKSPACES_FLAGS: ReadonlySet<keyof ListFlags> = new Set(['json', 'workspaces']);
const WORKSPACES_FLAGS: ReadonlySet<keyof ListFlags> = new Set(['json', 'window', 'workspaces']);

/** A flag as the user typed it, from the name stricli parsed it into. */
function flagSpelling(name: string): string {
Expand All @@ -225,7 +234,7 @@ function checkScopeFlags(flags: ListFlags): { ok: true } | { ok: false; message:
.map((name) => flagSpelling(name))
.sort();
if (others.length > 0) {
return { ok: false, message: `dor list --workspaces takes only --json, not ${others.join(', ')}` };
return { ok: false, message: `dor list --workspaces takes only --json and --window, not ${others.join(', ')}` };
}
}
return { ok: true };
Expand Down
9 changes: 9 additions & 0 deletions dor/src/commands/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ export function parsePositiveInt(input: string, flag: string, max = Number.POSIT
return value;
}

/** Flag parser for non-negative integers (`--index`, 0-based). */
export function parseNonNegativeInt(input: string, flag: string): number {
const value = Number(input);
if (input.trim() === '' || !Number.isInteger(value) || value < 0) {
throw new SyntaxError(`invalid ${flag} '${input}'`);
}
return value;
}

export function renderJson(payload: unknown): string {
return `${JSON.stringify(payload, null, 2)}\n`;
}
Expand Down
16 changes: 15 additions & 1 deletion dor/src/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,24 @@ export interface SwitchWorkspaceRequest {
window?: string;
}

/** Move a Workspace: to another Window (`toWindow`, a label or `new` for a
* torn-out one), to a strip position (`index`, 0-based), or both. */
export interface MoveWorkspaceRequest {
workspace: string;
toWindow?: string;
index?: number;
/** A move between Windows destroys every plain iframe's page state (the
* document cannot leave its webview); the host refuses such a move unless
* the caller passed this. */
dangerouslyDestroyIframePageState: boolean;
window?: string;
}

/** The answer every mutating Workspace verb gives: what it did, and the
* Workspace it did it to. `workspaceRef` is positional, so for `close` it is
* the ref the Workspace had. */
export interface WorkspaceMutationResponse {
status: 'created' | 'renamed' | 'closed' | 'active';
status: 'created' | 'renamed' | 'closed' | 'active' | 'moved';
workspaceId: string;
workspaceRef: string;
name: string;
Expand Down Expand Up @@ -397,6 +410,7 @@ export interface ControlClient {
renameWorkspace(request: RenameWorkspaceRequest): Promise<WorkspaceMutationResponse>;
closeWorkspace(request: CloseWorkspaceRequest): Promise<WorkspaceMutationResponse>;
switchWorkspace(request: SwitchWorkspaceRequest): Promise<WorkspaceMutationResponse>;
moveWorkspace(request: MoveWorkspaceRequest): Promise<WorkspaceMutationResponse>;
}

export interface AgentBrowserExecResult {
Expand Down
Loading