diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 148784449..6a13cf378 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -750,7 +750,7 @@ accTitle: Why does streaming output not save the whole workspace? accDescr: Layout changes and agent output update different stores. Only workspace metadata goes through workspace persistence. %% scope: Renderer data flow · persisted layout versus live session state subgraph Layout["Workspace changes"] - Action["Move a pane / change a tab"] -->|updates placement| Metadata["Workspace metadata"] + Action["Move a lane / switch project"] -->|updates placement| Metadata["Workspace metadata"] Metadata -->|persists via preload| Saved[("Saved workspace")] end subgraph Live["Live agent output"] @@ -1483,49 +1483,54 @@ Closing a window while the app continues can transfer its sessions to a survivin -[![How does the grid refer to its sessions?](docs/architecture/diagrams/workspace-model.svg)](docs/architecture/diagrams/workspace-model.svg) +[![How does the stage refer to its sessions?](docs/architecture/diagrams/workspace-model.svg)](docs/architecture/diagrams/workspace-model.svg) -A split contains two tiles; a leaf refers to session metadata by ID. Rearranging tiles changes placement without making the layout own a provider process. +A lane refers to session metadata by ID; a session's PROJECT is a field on its own row. Rearranging lanes changes placement without making the layout own a provider process.
Mermaid source ```text classDiagram -accTitle: How does the grid refer to its sessions? -accDescr: A split contains two tiles; a leaf refers to session metadata by ID. Rearranging tiles changes placement without making the layout own a provider process. -%% scope: Grid layout · selected type relationships, not runtime class inheritance +accTitle: How does the stage refer to its sessions? +accDescr: A lane refers to session metadata by ID; a session's project is a field on its own row. Rearranging lanes changes placement without making the layout own a provider process. +%% scope: Stage layout · selected type relationships, not runtime class inheritance direction LR - class Tab - class TileNode { - <> + class WorkspaceState + class Stage { + lanes + rows + focusedLane } - class TileLeaf { - sessionId - } - class TileSplit { - direction - ratio + class DispatchLane { + selectedSessionId } class SessionMeta { kind providerRuntime cwd + projectId + joinedAt + } + class Project { + id + title } - Tab *-- TileNode : root - TileNode <|-- TileLeaf : leaf variant - TileNode <|-- TileSplit : split variant - TileSplit "1" *-- "2" TileNode : children - TileLeaf ..> SessionMeta : references sessionId + WorkspaceState *-- Stage + WorkspaceState *-- SessionMeta : sessions by ID + WorkspaceState *-- Project : projects + Stage *-- DispatchLane : row-major + DispatchLane ..> SessionMeta : references sessionId + SessionMeta ..> Project : projectId names a project ```
-`TileNode` is a TypeScript discriminated union: a leaf references a session, while a split contains child tiles. The class notation here summarizes that data structure. Each split has exactly two children, either of which can be a leaf or another split. A split ratio is normalized to the allowed range; a tab's focused session must be an actual leaf. +A session is OWNED iff its `projectId` names a project that exists; unowned rows are dropped at the read boundary. Lane selections, pins and the active project are pointers, never ownership — a stale pointer must not keep a session alive or bring one back. A project exists while at least one session names it and is removed by the commit that takes its last session. -Grid placement, Dispatch Mode lanes, pinning, detached sessions, and buried sessions describe visibility and organization. They do not by themselves terminate a backend. Dispatch lanes are a flat ordered sequence with explicit row structure; row weights and scope are normalized separately. Empty lanes remain meaningful and are not automatically populated from the session pool. +Lane placement, pinning and pool parking describe visibility and organization. They do not by themselves terminate a backend. Lanes are a flat row-major sequence with explicit row structure; row weights and per-row project bindings are normalized separately. Empty lanes remain meaningful and are not automatically populated from the session pool, and an occupied lane is never displaced by a spawn (context-places). -Related-session selection can display a child in a physical grid leaf owned by another session. Linked terminal parentage is a one-level association with cascading close behavior. Orchestration parent/root/run metadata is a separate relationship and should not be reused as the linked-terminal tree. +Linked terminal parentage is a one-level association with cascading close behavior. Orchestration parent/root/run metadata is a separate relationship and should not be reused as the linked-terminal tree. #### 6.2.3 Recovery preserves the workspace shell @@ -3074,12 +3079,11 @@ Sources: [Vitest configuration](vitest.config.ts), [live configuration](vitest.l | Application session | Stable Agent Code identity associated with workspace metadata and, when active, a managed backend | | Session run | One backend execution attempt, distinguished from the stable application session | | Native session identity | Provider-owned conversation identity used to locate/resume native history | -| Pane / tile | A visible workspace placement; it is not itself a provider process | -| Project tab | A workspace membership boundary that can differ from another tab using the same directory | -| Dispatch Mode | Workspace presentation using explicitly ordered agent lanes and independent scope/focus | +| Stage | The one workspace layout: rows of lanes beside the agent index. Each lane shows at most one session | +| Lane | A visible slot on the stage; selecting a session into it shows that session. It is not itself a provider process | +| Pool | Every session a project owns. A session not in any lane is still in the pool, alive or hibernated | +| Project | A workspace membership boundary (`projectId` on each session) that can differ from another project using the same directory | | Hibernated session | Retained session metadata whose backend is intentionally absent until wake | -| Buried session | Hidden retained session placement; a live backend can continue running | -| Detached session | Session associated with the workspace/project but not placed in the ordinary grid | | Provider runtime flavor | The execution mechanism for a provider, such as structured OpenCode versus OpenCode terminal | | PTY | Pseudoterminal connecting the application to a native interactive process | | tmux attachment | A PTY connection to a separately managed persistent shell session | diff --git a/README.md b/README.md index 636931aa6..130f7cbc4 100644 --- a/README.md +++ b/README.md @@ -82,13 +82,13 @@ a running session can move mid-task among Claude Code, Codex, and OpenCode. - **Persistent terminals** — tmux-backed shells that survive UI reloads. - **Built-in MCP + agent control** — orchestration lets a parent create and coordinate real Agent Code children. The independently configurable Agent - Management MCP can inventory every grid, Dispatch, and buried agent in the - caller's project, expose transcript/activity evidence, read bounded outputs, - and send follow-ups. Destructive close is restricted to an explicit current + Management MCP can inventory every agent in the caller's project — on a + lane or parked in the pool — expose transcript/activity evidence, read + bounded outputs, and send follow-ups. Destructive close is restricted to an explicit current user request and refuses self-close or multi-session cascades.

- Agent Code Dispatch sidebar with orchestration MCP tool calls (send_prompt, wait_agents, read_agent, close_run) running in a live session + Agent Code agent index with orchestration MCP tool calls (send_prompt, wait_agents, read_agent, close_run) running in a live session

- **TLDR peek** — enable **TLDR MCP** for an agent, then hold **Cmd+L** to diff --git a/docs/architecture/diagrams/renderer-state.svg b/docs/architecture/diagrams/renderer-state.svg index e4af65ae5..2302baae6 100644 --- a/docs/architecture/diagrams/renderer-state.svg +++ b/docs/architecture/diagrams/renderer-state.svg @@ -1,2 +1,2 @@ -Why does streaming output not save the whole workspace?Layout changes and agent output update different stores. Only workspace metadata goes through workspace persistence.Why does streaming output not save the whole workspace?Renderer data flow · persisted layout versus live session stateWhy does streaming output not save the whole workspace?Layout changes and agent output update different stores. Only workspace metadata goes through workspace persistence.Live agent outputWorkspace changesupdates placementpersists via preloadfolds into current stateupdates that session'sviewselects which session todisplayMove a pane / change atabWorkspace metadataSaved workspaceSessionFeed eventPer-session runtimeConversation or terminalviewBlue: Agent Code · Gray / dashed border: external · Amber: checks and cautionsArrow: read its label in the arrow direction. Cylinder: stored data. Diamond: a check. +Why does streaming output not save the whole workspace?Layout changes and agent output update different stores. Only workspace metadata goes through workspace persistence.Why does streaming output not save the whole workspace?Renderer data flow · persisted layout versus live session stateWhy does streaming output not save the whole workspace?Layout changes and agent output update different stores. Only workspace metadata goes through workspace persistence.Live agent outputWorkspace changesupdates placementpersists via preloadfolds into current stateupdates that session'sviewselects which session todisplayMove a lane / switchprojectWorkspace metadataSaved workspaceSessionFeed eventPer-session runtimeConversation or terminalviewBlue: Agent Code · Gray / dashed border: external · Amber: checks and cautionsArrow: read its label in the arrow direction. Cylinder: stored data. Diamond: a check. diff --git a/docs/architecture/diagrams/workspace-model.svg b/docs/architecture/diagrams/workspace-model.svg index 152fd0e8b..0a503a864 100644 --- a/docs/architecture/diagrams/workspace-model.svg +++ b/docs/architecture/diagrams/workspace-model.svg @@ -1,2 +1,2 @@ -How does the grid refer to its sessions?A split contains two tiles; a leaf refers to session metadata by ID. Rearranging tiles changes placement without making the layout own a provider process.How does the grid refer to its sessions?Grid layout · selected type relationships, not runtime class inheritanceHow does the grid refer to its sessions?A split contains two tiles; a leaf refers to session metadata by ID. Rearranging tiles changes placement without making the layout own a provider process.rootleaf variantsplit variantchildrenreferences sessionId12Tab«union»TileNodeTileLeafsessionIdTileSplitdirectionratioSessionMetakindproviderRuntimecwdBlue: Agent Code · Gray / dashed border: external · Amber: checks and cautionsDiamond: contains. Triangle: variant. Dotted arrow: reference. 2: two children. +How does the stage refer to its sessions?A lane refers to session metadata by ID; a session's project is a field on its own row. Rearranging lanes changes placement without making the layout own a provider process.How does the stage refer to its sessions?Stage layout · selected type relationships, not runtime class inheritanceHow does the stage refer to its sessions?A lane refers to session metadata by ID; a session's project is a field on its own row. Rearranging lanes changes placement without making the layout own a provider process.sessions by IDprojectsrow-majorreferences sessionIdprojectId names aprojectWorkspaceStateStagelanesrowsfocusedLaneDispatchLaneselectedSessionIdSessionMetakindproviderRuntimecwdprojectIdjoinedAtProjectidtitleBlue: Agent Code · Gray / dashed border: external · Amber: checks and cautionsDiamond: contains. Triangle: variant. Dotted arrow: reference. 2: two children. diff --git a/docs/command-style.md b/docs/command-style.md index 837fbb615..f1539be25 100644 --- a/docs/command-style.md +++ b/docs/command-style.md @@ -5,7 +5,7 @@ This repo treats command titles as stable names, not as descriptions of the curr ## Rules 1. Use a stable noun phrase for toggles and modes. - Examples: `Reader Mode`, `Spotlight`, `Tiled Tabs`, `Dangerous Agents`, `Git Bar`. + Examples: `Reader Mode`, `Spotlight`, `Auto-follow All Visible Agents`, `Git Bar`. 2. Do not encode state changes into the title. Avoid: `Toggle`, `Enable`, `Disable`, `Enter`, `Exit`, `Turn On`, `Turn Off`. @@ -14,7 +14,7 @@ This repo treats command titles as stable names, not as descriptions of the curr Use short badges like `On`, `Off`, `Claude`, `Codex`, `Active`. 4. Use imperative verbs for one-shot actions. - Examples: `Open Settings`, `Copy Last Response`, `Normalize Layout`, `Reload Agent`. + Examples: `Open Settings`, `Copy Last Response`, `Close Focused Session`, `Reload Agent`. 5. Use `New X` for creation commands. Examples: `New Tab`, `New Agent`, `New Terminal Right`. diff --git a/docs/superpowers/plans/2026-09-17-unified-stage-layout.md b/docs/superpowers/plans/2026-09-17-unified-stage-layout.md new file mode 100644 index 000000000..ceb0a9847 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-unified-stage-layout.md @@ -0,0 +1,1006 @@ +# Unified Workspace Layout (Stage over Fleet) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan stage-by-stage. Stages in §9 are the task units. + +**Issue:** [#992](https://github.com/Juliusolsson05/agent-code/issues/992) +**Branch:** `feat/unified-stage-layout` + +> **Status:** plan. Owner decisions recorded 2026-09-17: kill nested splits +> ("never nest"), demote tabs to grouping ("just grouping"), pool-first +> sessions, per-row indexes stay, spawn is context-places; and the merge +> exists to remove new-user confusion and mode-relative naming — the owner +> uses Dispatch only. + +**Goal:** Delete the two-mode layout. The workspace becomes one thing: a +**stage** of ragged rows of lanes (today's Grid Dispatch, promoted from mode +to workspace) over a **fleet** pool of sessions (today's "detached" state, +promoted from exception to default). Project tabs stop owning tile trees and +become grouping only. One model to onboard onto, one vocabulary for +commands: no mode names anywhere a user can see. + +**Architecture:** One placement authority (stage lanes referencing pool +sessions), one focus truth (`focusedLane`), one session bucket. The binary +split tree, the detached-session bucket, the buried bucket, mode switching, +and every invariant that reconciles them are deleted rather than reconciled. + +**Tech Stack:** TypeScript, React 18, Zustand-style setState, electron-vite. +Reuses verbatim: `gridShape.ts`, `tiledDispatchSelectors.ts`, lane coherence +helpers, `renderWorkspaceLeaf`, `DispatchAgentList`, `DispatchMiniList`, +`TiledDispatchLayout`. + +--- + +## 1. Why + +### 1.1 The evidence + +- **Live workspace (2026-09-17):** 12 lanes in 2 rows (6+6), 14 detached + sessions, 3 project tabs each holding a *single* pane. Tabs are used as + project buckets, not as split trees. 9 of 17 sessions are terminals. +- **History:** Dispatch was born (2026-04-29 plan) because command-center + agents "poison the grid layout." Every evolution since — Tiled Dispatch + (#248), Grid Dispatch (#681), first-lane strip (#850), entry seeding and + mode-agnostic New Lane (#977/#978) — moved placement authority from the + tree toward lanes and made the mode boundary thinner. This plan finishes + that trajectory instead of continuing to bridge two systems. +- **Owner call:** nested splits are never used. The binary tree's only + remaining virtue is gone. + +### 1.4 Two modes are an onboarding and naming tax + +Every mode-relative name is a question a new user must answer before they can +work: "Grid or Dispatch?", "Tiled Dispatch vs Grid Dispatch?", "why is New +Lane greyed out?" (#978 was exactly this bug). The palette splits its verbs +by `surface: 'grid' | 'dispatch'`, and docs explain a toggle that — after +this plan — explains nothing. The owner's own usage (Dispatch only) shows +the second mode is not carrying its weight; new users pay for it at first +launch, and every command title pays for it forever. Removing the modes is +therefore not just a layout change: it is the fix for the confusing +onboarding path and the mode-relative command vocabulary in the same stroke. + +### 1.2 The costs of keeping both + +Two placement authorities, two focus truths, two spawn paths, two close +semantics, two command surfaces (7 `grid` + 16 `dispatch`), and the +leaf-XOR-detached invariant enforced at nine coherence call sites +(`remapTiledLanes` / `clearTiledLaneSessions` / `keepTiledLaneSessions`, +applied across id-remap ×2, kill, close ×2, bury, tab close, undo-close, +rehydrate, autosave-prune). Every layout bug class on record +(#266/#267/#271/#272 focus drift, #681 auto-fill, #690 dead panes) came from +one system trying to mirror the other. + +### 1.3 The unifying principles + +> **U1 — The fleet is the only home.** Every session (agent, terminal, +> extension view) lives in the pool, grouped by project. There is no second +> bucket. A session is visible iff some lane shows it. + +> **U2 — The stage is lanes.** The screen is ragged rows of lanes. A lane is +> *space*; only the user names occupants (#681's P2, now global law). The +> two sanctioned continuity writes are entry-seed (already shipped) and +> context-places spawn (§5.3). Neither consults the index; neither displaces. + +> **U3 — One focus truth.** `focusedLane` is the only focus scalar. +> Spotlight / Reader / Focus Mode are view modes over the focused lane. + +> **U4 — Projects are labels, not layouts.** A project is a title, a cwd +> default, a letter for `A1/B7` labels, and a filter for rows and indexes. +> It owns no placement. + +--- + +## 2. State model + +### 2.1 The shape (workspace.json v3) + +```ts +export type ProjectRef = { + /** Stable id — carries the old TabId so lanes, bindings, and labels survive. */ + id: TabId + title: string + /** Spawn cwd default. Absent => inherit from the spawning context. */ + cwd?: string +} + +export type SessionMeta = { + // ...all existing fields stay exactly as-is (title, kind, providerRuntime, + // providerSessionId, tmuxName, extensionViewId, linkedParentId, + // orchestration*, tldrIdentity, agentNameId, builtInMcp*)... + /** + * Project membership. Replaces BOTH "I am a leaf of tabs[i].root" and + * DetachedSessionRecord.projectTabId — the two ways a session used to + * know its project. Minted at spawn; carried across provider swaps. + */ + projectId: TabId +} + +export type StageState = TiledDispatchState // lanes / rows / laneWeights / focusedLane — unchanged type + +export type WorkspaceState = { + projects: ProjectRef[] + /** Spawn defaults + index highlight. The tab bar's only surviving job. */ + activeProjectId: TabId + /** The workspace. Always present — there is no mode to be out of. */ + stage: StageState + sessions: Record + pinnedSessionIds: SessionId[] + /** Lane-local "peek at worker" — unchanged semantics, key is the lane's + * parent session id (was: physical grid leaf id, same value in practice). */ + gridRelatedSelections?: Record + lastProviderSwitchBatch?: ProviderSwitchBatch | null +} +``` + +Deleted from `WorkspaceState`: `tabs` (with `root` and `focusedSessionId`), +`activeTabId` (→ `activeProjectId`), `detachedSessions`, `buried`, +`dispatchMode` (scope dies with it; per-row `projectTabIds` binding is the +only scoping mechanism), `tileTabs`. `SpotlightState` / `ReaderModeState` +lose `tabId` and keep `focusedSessionId` only. + +**As built (through stage 3b-ii) — where the code differs from the sketch +above, the code is right and §9.1 says why:** + +- `SessionMeta` also carries **`joinedAt: number`**, the order key inside a + project. The sketch had no order key at all; `detachedAt` was the only thing + ordering rows in v2 and it lived on the record being deleted. +- `projectId` and `joinedAt` are **optional on the type** and required in + practice: a row without a `projectId` naming a live project is UNOWNED and is + dropped at the next autosave. Optional is what lets the migration and the + ownership prune see, and refuse, a half-filed row instead of the compiler + pretending one cannot exist. +- **`gridRelatedSelections` is deleted**, not kept. It had no writer on screen + after stage 3a. Whether lanes get a related-agent strip at all is a stage 4 + decision, and if they do it is fed from the pool, not from this field. +- **In memory the fields are still named `tabs` / `activeTabId`** and the type + is still `Tab = { id, title }`. ON DISK they are `projects` / + `activeProjectId` — autosave writes v3 only. The in-memory rename touches + ~150 files for no behavior change and is stage 8's, so that it lands as one + mechanical commit instead of smearing through the semantic ones. +- `ProjectRef.cwd` does not exist yet. Spawn cwd still comes from the spawning + context (`projectCwd` reads the project's first session with a directory). + +### 2.2 Why `stage` is `TiledDispatchState` verbatim + +Every byte of the hard-won lane machinery — flat row-major lanes, the +`sum(rows[].length) === lanes.length` invariant, `normalizeGridShape` +repair-on-read, ragged-by-design rows, `MAX_DISPATCH_ROWS = 4`, +`MAX_DISPATCH_TILES = 10`, `MAX_DISPATCH_LANES = 16` — keeps its type, its +tests, and its normalization. The rename is cosmetic; do not fork it. + +### 2.3 The invariants (what replaces the deleted ones) + +- **Placement:** `stage.lanes[i].selectedSessionId` either names a session in + `sessions` or is undefined. That is the entire placement contract. A + session may appear in 0..N lanes (agent views mirror for free; terminals + are single-attacher — §8). +- **Focus:** `focusedLane` ∈ `[0, lanes.length)`. No per-row remembered + column, ever (#266-class). +- **Wake-before-place:** any path that writes a pooled session id into a + lane wakes it first (#690 rule, already implemented for lane selection and + entry seed — `selectTiledLaneSession`, `grid-dispatch.entry-seed`). +- **Labels stay canonical:** row indexes and mini-strips render *filtered* + views of one canonical row set; `globalIndex` never renumbers under child + caps or project filters (existing rule, now covering everything). + +--- + +## 3. What gets deleted + +| Deleted | Replacement | +|---|---| +| `TileNode` / `treeOps.ts` / `TileTree.tsx` splits, ratios, `SplitDirection` | Stage rows + `laneWeights` | +| `tabs[].root`, per-tab `focusedSessionId` | Pool + `focusedLane` | +| `DetachedSessionRecord`, `detachedSessions` | The pool (default state) | +| `BuriedPaneRecord`, bury/revive, placement hints | Pool membership (buried was proto-pool) | +| `dispatchMode` wrapper, enter/exit, switch-back banner | Stage is the workspace | +| `dispatchMode.scope` ('project'/'global') | Per-row `projectTabIds` binding | +| Tile Tabs feature (`TileTabsState`, `features/tile-tabs/`) | Rows bound to different projects | +| `normalize-layout`, `hard-normalize-layout`, `rotate-layout` commands | — | +| `nav-left/right/up/down` (`Focus Pane *`) | ⌥←/→ lane focus; row-focus commands | +| `detach-to-dispatch`, attach-to-grid flows | `Clear Lane` (occupant → pool); selecting from an index *is* placing | +| Auto-created project terminal concepts (already retired) | — | +| `buildAutoLanes`-style anything | Stays dead (#681) | + +`gridRelatedSelections` was planned to survive and did NOT (deleted in 3b-ii, +see §2.1 "As built"). `pinnedSessionIds` survives. `Close Old Agents`, `Close Idle Orchestration Agents`, bulk close, +provider switch — all fleet operations, unchanged. + +--- + +## 4. UX + +### 4.1 The screen + +``` +┌──────────────────────────────────────────────────────────────┐ +│ [agent-code ▾] [ml-pipeline ▾] [+] ← project rail │ +├──────────────┬───────────────────────────────────────────────┤ +│ SESSIONS ⊟ A▾│ [mini][ agent ] │ [mini][ agent ] │ [mini][ ] │ row 0 +│ ★1 … ├───────────────────────────────────────────────┤ +│ ▸ agent-code │ [mini][ agent ] │ [mini][ terminal] │ … │ row 1 +├──────────────┴───────────────────────────────────────────────┤ +│ status bar / composer of focused lane │ +└──────────────────────────────────────────────────────────────┘ +``` + +- **Project rail** (top): chips, not tabs. Click = set `activeProjectId` + (spawn default + index highlight + which project the palette groups + first). It never swaps the layout — there is nothing to swap. +- **Per-row indexes** stay verbatim (#681 P1): each row's own + `DispatchAgentList`, own project binding, own density, own strip per lane. +- **New Project (`⌘T`)** prompts for a cwd, exactly as New Tab did — it + created a cwd prompt anyway. + +### 4.2 Empty lanes and hints + +Unchanged from current Grid Dispatch: empty lane renders the pick hint; +killed agent leaves the lane empty; out-of-scope keeps its selection +rendering `Not in this scope`. + +### 4.3 Spawn (context-places) + +| Context | Behavior | +|---|---| +| Spawned from an **empty focused lane's** composer / New Agent in lane | New session fills that lane (continuity write — sanctioned by U2) | +| Spawned from an **occupied** lane, the palette, ⌘N, MCP, orchestration | Session lands in the pool; index badges it; nothing on screen moves | + +An occupied lane is never displaced — that would be the healer wearing a +spawn costume. Placement is one click (lane's strip / row index) or ⌘N. + +### 4.4 Close + +Closing a lane's agent: today's close-safety flow (#887), then the lane goes +empty. "Clear Lane" returns the occupant to the pool alive (same gesture as +close-and-remove, minus the kill). Closing a *project* closes its sessions +after the same confirmation the tab-close path uses today. + +### 4.5 First run and onboarding + +A brand-new workspace opens with **one row, one lane, focused** — no empty +lanes, no shape editor, no mode choice, nothing to explain. The first New +Agent fills the focused lane (context-places, §4.3). Growth is user-paced: +`New Lane` adds space when they want it, `New Row` when they outgrow a row. +The migration default (`[{ length: 2 }]` with entry seed, §6) applies only to +*imported* v2 workspaces; first-run mints `[{ length: 1 }]`. Setup and +onboarding copy never mention layout at all — there is one layout, and it is +the app. + +### 4.6 Starter card (the Neovim-style keybind hints) + +When the screen would otherwise say nothing — a fresh agent whose feed shows +only the provider welcome banner, or an empty lane — show a compact card of +the ~8 commands a new user actually needs next. This is the +which-key/starter-dashboard pattern, appearing at exactly the two moments of +maximum "now what?": + +**Context A — fresh agent (rendered agent surface, zero user turns).** +Rendered in the feed area beneath the provider's welcome text; hides itself +the moment the first prompt is sent. One card per session, in-memory only — +no persisted dismissal state. + +**Context B — empty focused lane.** Extends the existing empty-lane hint +(the unfocused lane keeps showing only `Empty lane`, per the existing rule: +never advertise a key that acts on a *different* lane). + +**The card is registry-driven, always.** Rows are command ids rendered +through the catalog (title + `getState` badge) with the *live* binding from +the keybinding map — a user who rebinds `New Lane` sees their chord, and a +default-chord change can never leave the card lying. Hardcoded chord strings +in the card are a plan failure, not a shortcut. Commands with no default +binding render title-only (the row-focus commands already set this +precedent). + +**The eight slots (Context A), by command id** — curated for v1, not +usage-ranked: + +| # | Command | Why it's here | +|---|---|---| +| 1 | Commands (palette) | the escape hatch that finds everything else | +| 2 | New Agent | the thing they just learned, one chord away | +| 3 | New Lane | growth is the first question ("can I run two?") | +| 4 | New Row | the second question ("what if I outgrow the row?") | +| 5 | Focus Lane Left/Right | the arrow grammar of the whole layout | +| 6 | Fill Lane from Index (⌘1–9) | placement, the core new gesture | +| 7 | Spotlight | "read one thing big" — high-frequency | +| 8 | Clear Lane | the gentle exit; Close Agent is one hop away | + +Context B shows the four placement-flavored slots only (6, 3, 1, plus the +index walk ⌥↑/↓). + +**Terminal lanes never get the card.** Raw PTY views are the provider's +canvas; we do not paint over another program's welcome screen. The card is +a rendered-agent-surface feature. + +**Usage-adaptive ranking is a follow-up, not v1.** If durable command +invocation telemetry exists (control-history is the candidate source), the +eight slots can be re-ranked from real usage later; a curated list that is +honest beats an adaptive list that guesses. + +--- + +## 5. Commands and keyboard + +### 5.1 Surface consolidation + +- The `grid` surface (7 commands) is deleted with the tree, and the + `dispatch` surface is **retired with the mode** — both merge into one + `workspace` surface. A command is visible because it acts on the workspace + or on a session (`session`, 36 commands, unchanged; `app` unchanged), not + because of which mode the user is in. Palette filtering and `when` gates + simplify accordingly. +- `tiled-dispatch` (the Grid Dispatch toggle) is deleted — nothing to toggle. +- `global-dispatch` / scope commands are deleted with scope. +- New: `New Project…` (app surface, cwd prompt), `Clear Lane` (workspace + surface, `getState` badges the occupant's title). +- Keep verbatim: `new-tiled-lane` (already app-surface after #978), + `remove-tiled-lane`, `close-agent-remove-lane`, `new-dispatch-row`, + `remove-dispatch-row`, `dispatch-row-project`, `dispatch-row-child-cap`, + `dispatch-focus-row-up/down`, `pin-agents`, `unpin-agent`. +- `Merge Project Tabs` (#913/#914) becomes `Merge Projects` — same flow, + renamed noun, because it now merges groups, not layouts. + +### 5.2 Keyboard registry migration (the deferred debt) + +The four inline Dispatch arrows (`useKeybinds.ts` Dispatch branch) move into +the command registry in this work — we are re-homing keyboard anyway, and +#681 §7.1 already filed it. ⌥↑/↓ index walk, ⌥←/→ lane focus within row, +arrows/K/J/H/L as registered, rebindable, visible in the shortcuts surface. +Row-focus commands keep their no-default-chord status (Option+Shift+Arrow +stays reserved for text selection — recorded lesson, do not re-litigate). + +### 5.3 Spotlight / Reader / Focus Mode + +Types lose `tabId`; commands and overlays unchanged. They operate on the +focused lane's session — which is what they always actually did. + +### 5.4 Vocabulary: the word "Dispatch" retires + +User-facing language never names a mode again: + +- **Titles** already read mode-free ("New Lane", "Row Project…", "Nested + Agents", "Remove Row") — keep them. The `Tiled Dispatch` / `Grid Dispatch` + branded commands die with the toggle. New copy says **lanes**, **rows**, + **the agent index**, **projects**, **the workspace**. +- **Command ids stay stable** (`new-tiled-lane`, `dispatch-row-project`, …) + so `Settings.commandVisibilityOverrides` and user keybindings never orphan. + Ids are not user-facing; renaming them buys nothing and breaks bindings. +- **Internal identifiers** (`DispatchAgentList`, `dispatchSelectors`, …) + rename only in the cleanup stage, only where the file is already being + touched; a bulk no-behavior rename PR is optional follow-up, not scope. +- **Docs and onboarding** (README, setup flow, palette help, shortcuts + surface) must read as if Dispatch never existed. No diagram, tooltip, or + heading may contain the words "Dispatch Mode", "Grid Dispatch", or "mode". + +--- + +## 6. Persistence and migration + +Read-time normalization in `rehydrate.ts`, same discipline as +`normalizeGridShape` — a pure function with its own tests, no schema writer: + +1. `tabs[]` → `projects[]` (id, title). `activeTabId` → `activeProjectId`. +2. Every session: `projectId` = its tab's id (leaf membership first, then + `detachedSessions[sid].projectTabId`, else `activeTabId`). +3. `detachedSessions` folded away; `buried[]` sessions enter the pool live + (they were live while buried; the pool does not change that). +4. `dispatchMode.tiled` → `stage`. Absent `tiled` (classic dispatch or pure + grid user) → default stage `[{ length: 2 }]` with lane 0 seeded via the + existing `dispatchEntrySeedSessionId` resolver (continuity on first open). +5. **Accepted loss, recorded:** a multi-pane tab's spatial arrangement is not + reconstructed — its leaves enter the pool and the user re-places them. + (Live data shows 1-pane tabs; reconstructing rows from trees would + surprise far more than it preserves.) One row of lanes is minted; nothing + auto-fills beyond the seed (#681). +6. `tileTabs`, `ratios`, `userEmptied`, bury hints: dropped on read. +7. Autosave writes v3 only. `keepTiledLaneSessions` keeps scrubbing dead + lane pointers at the ownership prune — unchanged job, now the only bucket. + +--- + +## 7. External contracts + +- **Control SDK / `observeWorkspace`:** the placements view changes shape + (no tab roots; lanes + pool). Bump the SDK catalog entry and update + `agent_management` MCP descriptions in the same PR — they must never + disagree. +- **ARCHITECTURE.md §5.4/§6.2:** rewritten in this branch (the layout + section changes shape — this is the sanctioned kind of doc change). The + workspace-model and workspace-recovery diagrams get new sources; the + recovery story ("layout restored before backends resolve") is unchanged in + substance — `stage` + `sessions` restore exactly as `tiled` + `sessions` + do today. +- **Conventions doc** (`docs/design/agent-code-conventions.md`): only if it + names tabs-as-layouts — check and fix wording. + +--- + +## 8. Risks and open questions + +- **16-lane ceiling becomes the app's ceiling.** Each lane mounts a real + `renderWorkspaceLeaf` with runtime subscriptions; `project_screen_snapshot_gc_churn` + is on record. Keep 16 for v1 of the merge; virtualized lanes are a separate + follow-up if it bites. +- **Terminal single-attach.** A terminal duplicated across lanes streams to + one view (existing, documented in `DispatchLane`). Pool-first makes + duplication *more likely to be attempted*; ship the lane hint ("open in + another lane") rather than solve multi-attach here. +- **Extension views as lane occupants** already work (`extensionViewId`); + verify the panel-mounted view in a lane before merge PR — it is in the + owner's live workspace. +- **Blast radius.** Renderer suites pinning tree behavior (treeOps, bury, + attach/detach, tile-tabs, pane-remount) get rewritten, not deleted where + they still cover live contracts (close safety, remount-on-id-swap). +- **Open:** does the project rail deserve a context menu (rename, merge, + close) on day one, or is the palette enough? Recommend palette-first; + rail context menu is a follow-up. + +--- + +## 9. Execution order + +Each stage leaves `npm run check` green and is one PR-sized review. + +1. **State + migration (no UI change).** `ProjectRef`/`SessionMeta.projectId` + /`WorkspaceState.stage`; v2→v3 read-time migration as a pure function; + golden-file tests from a real v2 workspace fixture; tree paths still + render from migrated state (compat readers). +2. **Stage promotion.** App renders the stage unconditionally; project rail + replaces the tab bar; `dispatchMode` null-object removed from render + forks; palette surfaces consolidate (`grid`/`dispatch` → `workspace`). + Grid tree rendering becomes unreachable. +3. **Tree deletion.** `TileNode`, `treeOps`, `TileTree`, bury/revive, + attach/detach, `nav-*`, normalize/rotate commands; coherence helpers keep + only their lane jobs; rewrite affected suites. +4. **Spawn/close semantics.** Context-places spawn rules (§4.3), `Clear + Lane`, project-close confirmation; close-safety tests extended to + lane-cleared-occupant. +5. **Keyboard registry migration.** Four inline arrows → registered + commands; shortcuts surface shows them; `check:keybindings` green. +6. **Starter card (§4.6).** `StarterHintCard` component; registry + live + keybinding reads; fresh-agent and empty-lane contexts; auto-hide rules. +7. **Contracts + docs.** SDK `observeWorkspace` shape, agent_management + descriptions, ARCHITECTURE §5.4/§6.2 rewrite, diagrams. +8. **Cleanup.** Dead fields, dead settings (`commandVisibilityOverrides` + entries for deleted ids are dropped with a note in the release changelog + — command ids that vanish must not silently orphan user bindings). + +--- + +### 9.1 Stage 3 execution record (amended during implementation) + +The survey before stage 3 counted 48 non-test files reading the tile tree and +about 100 touching the v2 buckets, so stage 3 was split. Each half leaves the +branch green. + +**3a — delete features nothing renders, state shape unchanged.** Tile Tabs +(feature, store slot, persisted field, `setTileTabs` threaded through eight +action hooks), Bury / Revive / Kill Buried (commands, prompt, two palette +modes, activity-modal action, `agents.bury` / `agents.restore`), the grid +attach / detach pair and `placement.list` / `placement.attach` / +`placement.detach` / `placement.inspect`, `layout.adjust`, split resize and +`Focus Pane` actions and their key handlers, the placement step of New Agent, +`geometry.ts`, `newAgentPlacement.ts`, the recursive `TileTree` component, and +the grid branches of agent-index navigation. + +Decisions made in 3a that were not in the original plan: + +- **Buried sessions fold into the pool at every read boundary** + (`foldBuriedIntoDetached`, applied by rehydrate and by window adoption). With + the revive UI gone, a record left in `buried` would be alive, owned and + unreachable. A buried session whose source project is gone re-parents to the + active project rather than being dropped, because v2 kept buried sessions + unconditionally. The `buried` field itself survives until 3b, always empty. + *(Superseded in 3b-ii: the fold is no longer a separate pass. One function, + `legacyMemberships` in `legacyWorkspaceV2.ts`, reads leaves, detached + records and buried records and returns each session's membership; the + re-parenting rule for a buried session with no surviving project is + unchanged, and it is the ONLY case that may land in the active project.)* +- **Three keyboard reservations were released** (split resize, directional + split resize, Tile Tabs resize continuation). A reservation with no owner + only fences off free chords. The macOS Option+Shift+Arrow record was kept in + `useKeybinds` as a comment, because it is the only place that fact lives. +- **Related-agent mini-tabs are currently unreachable.** Only the recursive + tree passed `showRelatedAgentTabs`; lanes pass `false`. `gridRelatedSelections` + therefore has no writer on screen. Stage 4 decides whether lanes show the + mini-tabs or the field is deleted; §2.1's "survives, lane-local" is a + proposal until then. + +**3b — invert the stored authority.** Split once more during execution, for +the same reason stage 3 was: the two halves touch different things and each +leaves the branch green. + +**3b-i — the lane grid becomes a required `WorkspaceState.stage`.** The +`dispatchMode` envelope is deleted from live state, and with it everything it +carried besides the grid: the layout-wide `scope: 'project' | 'global'`, the +classic single-selection `focusedSessionId`, and the null state that meant +"Dispatch is off". Deleted with them: `enterDispatchMode`, `exitDispatchMode`, +`setDispatchScope`, `focusDispatchSession`, `enterTiledDispatch`, +`exitTiledDispatch`; the `enter` / `exit` / `scope` actions of +`dispatch.configure`; the `dispatchModeEnabled` / `globalDispatchEnabled` +palette flags; the New Lane entry path (#978); the close-successor picker for +classic focus (#261). `dispatchMode` survives only as `LegacyDispatchMode` on +`PersistedWorkspace`, read by the migration and never written. + +Decisions made in 3b-i that were not in the original plan: + +- **Scope had to die here, not in stage 4.** Stage 2c deleted the command that + switched scope. A user whose file said `scope: 'project'` would have been + left with every other project's agents alive, owned and unlisted, with no + command to bring them back. An integration test had this pinned as + TRANSITIONAL; it now asserts the fleet is visible. +- **Boot no longer knows lanes exist.** Bootstrap used to call + `enterTiledDispatch([1] | [2])` after each boot path. Now the store starts on + `freshStage()` (one empty lane), `rehydrate` publishes + `migrateWorkspaceToStage(persisted).stage` in its FIRST commit, and + `useBootstrap` lost two parameters. No state without a stage can be rendered + or autosaved. +- **`newTab` places a new project's first agent in the focused lane, only if + that lane is empty.** This is the first piece of context-places spawn + (§4.3), pulled forward because a fresh install's single lane would + otherwise show nothing. It never displaces. The other spawn paths still + overwrite an occupied focused lane (`applyDispatchSpawnFocus`); that is the + known gap stage 4 closes, and it is commented at the function. +- **The entry seed (#977) now runs exactly once, in the migration.** Its + wake-ordering cases (#690 parity) were deleted with the action rather than + re-homed: the migration runs at boot and places nothing live. The lane's + leaf owns the wake (a terminal on mount, an agent on its first send, #691), + so no reducer there has to order a wake before a write. +- **Autosave writes the in-memory stage verbatim.** Through stage 2 the v3 + half was derived at save time from the v2 half. Derivation at the durability + boundary would now overwrite the user's lanes with a guess on every save. +- **Takeovers do not write lanes.** Switching the agent inside Spotlight or + Reader used to mirror into the classic focus (and, with Dispatch off, the + tree focus). Both fields are gone and nothing replaces them: browsing inside + a takeover is not the user naming a lane occupant (U2). Only the active + project follows. +- **Published control shapes were kept, not renamed.** `layout.read` still + returns `dispatch: { focusedSessionId, tiled }` and `app.observe` still + reports `mode: 'tiled-dispatch'`. Both are constants or derived now; the + rename belongs with the SDK schema change in stage 7. +- **A lane that names a session it cannot resolve reads "Agent no longer + available"**, not "Not in this scope". With no scope, a dead id is the only + way to get there. +- **Test fixtures.** "The user is commanding X" used to be expressed by a + tab's tree focus with `dispatchMode: null`. Its translation is a one-lane + stage showing X (`workspace/testing/stageFixtures.ts`). The recorded v2 + workspace `dispatch-global-d23.json` is lifted in code by + `workspace/testing/recordedDispatchWorkspace.ts` and is NOT re-recorded or + edited: the lift is a field move, never the migration, so + `gridPersistence.test.ts` still sees the legacy `ratios` array. + +**3b-ii — delete the v2 owners.** Done as ONE compiler-driven change, not a +dual-write step followed by a delete: a period in which a session's owner was +written in two places is exactly the kind of state a hybrid-file bug lives in, +and `tsc -b` is a better checklist of tree readers than any survey. `Tab` is +`{ id, title }`. `TileNode`, `tile-tree/treeOps.ts`, `detachedSessions`, +`buried`, `gridRelatedSelections` and the `RATIO_*` constants are deleted from +live state. The v2 shapes exist in exactly one module, +`workspace/legacyWorkspaceV2.ts`, imported by the migration and by nothing that +runs after boot. + +Ownership moved ONTO THE ROW: + +- `SessionMeta.projectId` — the project the session is filed under. +- `SessionMeta.joinedAt` — the order key inside that project. The migration + seeds it so every old list keeps its order: tile leaves get ordinals + `0, 1, 2…` in depth-first tree order, detached rows keep `detachedAt`, buried + panes keep `buriedAt`. Ordinals sort ahead of any real timestamp, which + reproduces v2's "grid leaves first, then rows by age". New sessions are + stamped `Date.now()`. + +**The ownership rule (v3):** a session is OWNED iff its `projectId` names a +project that exists. Unowned rows are dropped at autosave and at rehydrate. +Lane selections, pins and the active project are POINTERS and never ownership: +a stale pointer must not keep a session alive or bring one back. A ghost row +never falls back to the active project — that would hand a stranger's agent to +whichever project happened to be open. + +Decisions made in 3b-ii that were not in the original plan, or that REVERSE it: + +- **Boot spawns the FOCUSED lane's occupant only — not every lane occupant.** + This reverses the rule this section proposed before execution ("lane + occupants spawn"). The recorded owner workspace decided it: in v2 its 3 tile + leaves spawned while all 12 of the lanes the user actually worked in booted + parked and woke on first use. "Wake on first use" is therefore not a new + risk; it is the path the product's only heavy user already took for every + agent they touched. Spawning all lane occupants would have turned a 3-spawn + boot into a 12-spawn boot (each with its own mitmdump and MCP host) to save + one wake per lane. The focused lane is the one place a parked agent costs + the user something — it is where the first keystroke goes. Consequences + pinned in tests: a parked agent the user was commanding (the #977 entry seed) + now comes up LIVE and the tile leaf they had left behind waits, exactly + swapping the v2 roles; and a focused lane that is empty, or names a ghost, + spawns NOTHING — `{ restored: 0, expected: 0, complete: true }` is a complete + boot that unlocks autosave. +- **How a parked session wakes depends on its kind, and neither is "on + mount".** A terminal leaf wakes its shell when it mounts. An agent leaf + renders its committed transcript with no backend and wakes on its first SEND + (`TileLeaf.send → ensureSessionLive`, #691; `deliverWithWake`, #706). Four + comments written during this stage claimed lanes wake their occupant on + mount; all four were corrected. +- **Wake decisions read the RUNTIME, not a structure.** "Is it detached?" was + the v2 test for "needs a wake". With one kind of session the test is + `processStatus === 'started'` ⇒ synchronous lane write, anything else ⇒ + `ensureSessionLive` first. This closes a documented gap (a tile leaf whose + respawn failed, or whose process died, was placed un-woken and needed the + pane's Retry) and removes its mirror image (every lane agent was "detached", + so every selection paid a recover round-trip even when the agent was up). + `requiresWake` left the pure navigation reducer. Reload-all uses the same + idea in the other direction: it restarts sessions whose runtime is not + `idle`, so a parked agent stays parked (the #258 guard) and an agent woken + from a lane — which v2 skipped for not being a leaf — is restarted. +- **A project exists while at least one session names it (U4).** It is removed + by the commit that takes its last session (`workspaceWithoutSessions` in + `workspace/pool.ts`, which also empties lanes, drops pins and moves the active + project to the nearest surviving neighbour) and is never force-removed while + it holds a session, because that would orphan a running backend. +- **Every close is session-scoped.** The tab's root tile leaf was special in + v2 — closing it emptied the tree and therefore removed the project — so it + raised a three-way "Close the agent or the tab?" dialog and, on Close Agent, + PROMOTED a Dispatch row into the emptied tree. No session is special now. + Deleted: row promotion, the `agentOnly` request field, the dialog's scoped + branch, `requestRootCloseConfirmation`, and the `'agent'` answer. + `CommittedClose` is `gone | session | tab-removed`. "Everything in this + project" is the Close Tab command with its own list; it closes + deepest-linked-first (nothing has to go LAST any more to keep a tree valid), + and a partial close leaves the project holding its survivors. +- **Undo entries carry rows, not records.** `ClosedSession { sessionId, + sessionMeta }`, `ClosedTab { tab, tabIndex, sessions: [{ sessionId, meta }] }`, + `ClosedGroup`. The row is stored verbatim, so `joinedAt` rides through and a + restored session returns to its old POSITION instead of the bottom of the + list; `carryDurableMeta` carries `projectId` / `joinedAt` for the same + reason. Lineage remaps `projectId` through the restored-tabs map and + relationship pointers through the sessions map. Tab restore is best-effort + per session. Undo files a session back and deliberately does not re-aim a + lane at it. +- **Merge Project Tabs appends.** Moved sessions are re-filed under the target + with `joinedAt = max(now, lastTarget + 1) + i`, so the target's own order is + untouched and the moved block keeps its internal order. Takeovers follow the + merge into the target. +- **Window adoption takes the closed window's POOL, not its stage.** A lane + grid is one window's screen; merging two would be inventing a layout. The + payload is migrated first (`migrateWorkspaceToStage` is total over v2, v3 and + hybrid files), then its projects, rows and pins are merged. History loads + eagerly only for adopted sessions main still holds a LIVE backend snapshot + for — the honest form of what "tile leaves load, detached rows do not" had + been standing in for. +- **Autosave writes v3 ONLY**: `projects`, `activeProjectId`, `stage`, + `sessions`, pins, drafts. No `tabs` at all. Writing an empty or synthesized + `tabs` "for compatibility" would be worse than omitting it: an older build + would read a real, EMPTY workspace, boot a fresh tab over it and autosave + that, erasing the pool. With the key absent, the older build fails its shape + check and lands in `persisted-fallback` with autosave LOCKED, so a downgrade + cannot destroy a file it does not understand. This is called out in the PR. +- **The related-agent strip's STATE is deleted; its components are not.** + `gridRelatedSelections` had no writer on screen since 3a. `PaneHeader`, + `TileLeaf` and `AgentTerminalLeaf` still accept the strip props and are fed + nothing. Stage 4 decides between feeding them from the pool and deleting + them. +- **Published contracts: kept, with the smallest honest change.** + `layout.read` tabs are `{ id, title, sessionIds }`; placement kinds gain + `'project'` (one ownership placement, never `visible`) and the v2 kinds stay + in the enum, unproduced; `tabs[].focusedSessionId` is optional and absent; + the extension API's `panes.observe.leafSessionIds` is the project's sessions; + `ManagedAgent.placement` is always `'dispatch'` ("a row in the project's + index", which every session is). Narrowing the enums is stage 7. +- **Main's analytics projection reads both generations.** + `src/main/agentActivity/workspaceProjection.ts` re-states the migration's + precedence (row wins when it names a live project, else the v2 structures) + rather than importing it, because main treats the document as opaque. Its + new test found a real defect in the code it was written to cover: the + tile-tree walker's "cycle guard" was a depth cap only, and a split walks two + children, so a self-referencing node was a 2^64-call tree, not a 64-step + loop. A document read from disk is JSON and cannot hold a cycle, so + production never met it; the guard now tracks visited nodes and keeps the + depth cap for the call stack. + +What the test conversion taught, recorded because it will recur in stage 4: + +- **`as unknown as WorkspaceState` hid most of the breakage.** After the source + compiled, the first full sweep still had 48 runtime failures in 21 files, all + behind casts: fixtures with no `pinnedSessionIds`, no `stage`, or rows with + no `projectId`. Fixtures touched here use `satisfies WorkspaceState` where + they can. +- **Replacing a whole row un-files it.** `state.sessions.a = { cwd, kind }` + used to be a harmless way to change a kind. Membership is on the row now, so + it silently makes the session unowned, and the test then fails (or passes) + for a reason unrelated to its subject. Spread the row. +- **A test whose premise was the tile tree was RE-BASED, not deleted**, and + says so in a comment naming what it used to pin. Deleted outright: + `gridRelatedAgents.test.ts` and `extensionPaneOwnership.test.ts`, whose + subjects no longer exist. + +Still open after 3b-ii, deliberately: + +- `applyDispatchSpawnFocus` overwrites an occupied focused lane on every spawn + path except `newTab`. `controlPlacement.renderer.test.tsx` pins today's + behavior with a comment saying it is not endorsed. Stage 4 (§4.3). +- The `'grid'` binding context and `activeBindingContexts({ dispatchMode: + true })` survive. Stage 5. +- The dead setting `defaultWorkspaceMode`. Stage 8. +- `collectLegacyLeaves` (renderer) is recursive with no depth cap. Its input is + always `JSON.parse` output, so it terminates; a hand-edited file nesting + thousands of splits could still overflow the stack at boot. Not fixed here — + it needs an iterative walk and a decision about what a truncated tree means + for the migration — but noted so it is not rediscovered as a surprise. + + +--- + +### 9.2 Stage 4 execution record (spawn/close semantics) + +Executed as designed in §4.3/§4.4, with the deviations and reasons: + +- **Never-displace is decided at COMMIT time, inside the updater.** + `applyDispatchSpawnFocus` takes the whole state (it needs `sessions` to know + occupancy) and fills the target lane only when that lane is EMPTY — where + "empty" includes a lane whose `selectedSessionId` names a gone session (the + stale pointer is dropped by the same write). Refused placement returns the + stage BY REFERENCE, and that identity is the fill/refuse signal the callers + read (`pooled = stage === prev.stage`) — decided against the same `prev` the + placement read, which is what makes a lane freed during the awaited spawn + fillable and one filled since not. A refused spawn also does not move the + FOCUS cursor: "nothing on screen moves" is half the rule. +- **`createLinkedAgent` lost its lane capture entirely.** The capture aimed + the child at the focused lane WHEN it showed the parent; under never-displace + a lane showing the parent is occupied by definition, so both branches of the + capture were dead. Linked and orchestration children are pool-only, and + orchestration children — many from one prompt — are the purest case for the + badge below. +- **The "index badges it" half is `SessionRuntime.pooledSpawnAt`.** A spawn + that pools marks the runtime (`markPooledSpawn`, one wrapper so the + "guard the row exists" dance is written once); the index row renders a + `new` chip from it; placing the session into ANY lane retires it inside + `setTiledLaneSession` — the one write every placement gesture (index click, + lane strip, ⌘N, the ⌥ walk) funnels through. WHY the runtime and not + workspace state: the badge is presentation, not truth — autosave must not + write it, undo must not restore it, and a per-row `useShallow` selector + re-renders one row instead of the whole index. In-memory only, so it never + survives a restart, which is the right lifetime for "you have not looked at + this yet". It is retired by placement, never by time — an expiring badge + teaches the user to distrust it. `selectCreated:false` callers badge too: + they asked for no view change, and until they place the returned ID the + badge is the honest state of that row. +- **Clear Lane shipped as its own action + command (`clear-focused-lane`).** + The action (`clearTiledLane`) empties the lane without ending anything and + without an undo entry — the undo stack is for CLOSES; undoing a clear is + selecting the session back into the lane it never left. The command's + `getState` badges the occupant through the shared `sessionDisplayTitle` + resolver (a bare "Clear Lane" makes the user check which lane is focused; + the badge is that check). Admission requires a LIVE occupant: a lane naming + a gone session is as empty as the user is concerned. +- **⌥⌫ forced the runtime half of the macOS text-editing reservation.** + Clear Lane ships on Option+Backspace (the plan's card) and macOS owns that + chord as delete-word in every text field. The static reservation table + already claimed OS ownership for that chord family without enforcing it — + its own header admitted the gap ("does not stop the inline dispatch grammar + from consuming Alt+Shift+Arrow in a composer"). `MACOS_TEXT_EDITING_CHORDS` + now exists once, feeds the reservation entry, and is enforced at routing: + `routedCommandForEvent` refuses ANY binding on those chords while a text + field owns the target, making the table true. The chord pairing is recorded + in APPROVED_OVERLAPS (owners: clear-focused-lane + macOS text selection) + with the yield as the precedence rule. Bare Option+Arrow stays unreserved + on purpose: dispatch navigation from a focused composer is the intended + workflow and is documented there. +- **The split-command family stopped lying.** `splitFocused` lost its inert + direction argument; `openExtensionViewInPane` lost its `direction` too. + Titles dropped the grid directions ("Split Pane Right" → "New Claude", + "New Terminal Right" → "New Terminal", "New Codex Right" → "New Codex"), + and every description now states the context-places outcome (fills an empty + focused lane, else pools with a new badge). **Ids and chords are frozen** + (§5.4): ⌥D/⌥⇧D/⌥T/⌥⇧T/⌥C/⌥⇧C keep firing what they always fired. The + "-horizontal" twins are palette-hidden (`pickerVisibility: 'advanced'`, + honest "(legacy id)" titles) but stay runnable and rebindable — deleting + them would orphan bindings, which is stage 8's ledger, not this stage's. +- **The related-agent strip is deleted, not fed.** Stage 3 left the + presentational half alive (PaneHeader chips, TileLeaf/AgentTerminalLeaf + props) fed by nothing. Feeding it from the pool would have built a second + session selector inside a lane — against U2, which says a lane shows one + occupant the user names — duplicating what every per-row index already does + with more space (children nest under their parents there). Deleted: + `gridRelatedAgents.ts` (types), the prop chain through TileLeaf / + AgentTerminalLeaf / PaneHeader, the phone's `relatedAgentTabs={[]}` call + shape, and the #858 identity chrome (`ownerSessionId`, the `parent` + button) whose input state no longer exists. What survives of its test + suites is the part that was never about chips: PaneHeader's phone-stub + safety case. +- **Control API wording follows behavior.** `agents.create`'s + `selectCreated` description and the control guide's layout paragraph now + state context-places honestly (fills only an empty lane; pool + badge + otherwise; `selectCreated:false` preserves everything, place the returned + ID with lane-select). Full description rewrites remain stage 7. + +What the test conversion taught this time: + +- A badge test that crosses TWO hooks has no natural single-suite home; + `contextPlacesSpawn.renderer.test.tsx` holds the lifecycle (mark on pool, + not on fill; linked always pools) and says in comments which half lives in + which other suite. +- The catalog baseline is the first test that moves when a command is ADDED + (115 now); its arithmetic comment is the ledger, and the "growing the + catalog means raising the subtrahend" rule kept the plan-count test honest. +- `focusModeKeyboardOwnership.renderer.test.tsx` was the natural home for the + ⌥⌫ yield cases: it is the suite that already reasons about who owns a + keystroke, and both halves (routes on the bare stage; yields in a + composer) are ownership claims. + + +--- + +### 9.3 Stage 5 execution record (keyboard registry migration) + +Executed as designed in §5.2, plus the 'grid' context deletion that was filed +under "still open" after 3b-ii: + +- **The four inline arrows are commands.** `dispatch-select-previous-agent` / + `dispatch-select-next-agent` (⌥↑/⌥↓, aliases ⌥K/⌥J) walk the focused lane's + selection through its row's index; `dispatch-focus-lane-left` / -right + (⌥←/⌥→, aliases ⌥H/⌥L) move lane focus within the row, stopping at the + edges. The movers live in `workspace/dispatch/laneKeyboard.ts` — one home + for the grammar — and selection writes through `selectTiledLaneSession`, + never the raw lane writer, so a hibernated agent wakes before it is placed + (#690). `useKeybinds`' inline `alt && !cmd` branch is deleted; the commands + route through the binding table like everything else, so they are + rebindable, visible in the shortcuts surface, and participate in collision + checking. +- **The migration fixed Alt+Shift+Arrow by accident of correctness.** The + inline branch tested `alt && !cmd` and never checked shift, so ⌥⇧↓ ran the + index walk while the user was selecting text by word — the exact failure + the reservation table's header admitted it could not prevent ("does not + stop the inline dispatch grammar from consuming Alt+Shift+Arrow in a + composer today"). The binding grammar is exact-match, so the shifted chords + match nothing and stay native. Pinned by a keyboard test. +- **The 'Dispatch row and lane selection' reservation became commands.** The + reservation existed because an unregistered handler owned eight chords; the + commands own them in the defaults table now, and keeping the reservation + would have reported each chord as doubly owned by its own command. The + entry is replaced by a ledger note, same pattern as the deleted resize + reservations. +- **'grid' is gone as a binding context**, with the `dispatchMode` flag on + `activeBindingContexts` — the stage is the workspace, so the layout context + is simply live whenever the global editor does not own the target. + DISJOINT_CONTEXT_PAIRS keeps only `['dispatch', 'editor']` (#697's gate and + its test unchanged in substance). The shortcuts surface's context label for + `dispatch` reads "Workspace only" — no user-facing copy may name Dispatch + as a mode (§5.4). +- The ⌘1–9 / two-digit row grammar stays INLINE deliberately: it is a + contextual interaction with continuation state (a pending digit and a + timer), not a command — exactly the class the reservation header describes. + + +--- + +### 9.4 Stage 6 execution record (starter card) + +Executed as designed in §4.6, with one honest divergence from the ASCII +walkthrough recorded below: + +- **`StarterHintCard`** (`features/workspace/ui/StarterHintCard.tsx`) renders + both contexts from one registry-driven component. Every slot is a COMMAND + ID resolved through the catalog for its title and through + `resolveEffectiveKeybindings` — the same resolution the router performs — + for its chord, so a rebound command shows the USER's chord (pinned by + test). The one non-command slot (Fill Lane from Index) resolves through + `reservedInteractionBindings`, a new accessor over the reservation table: + the digit grammar owns chords without being a command, and the reservation + table is the registry of exactly that. Unbound commands render title-only. +- **Context A** mounts in TileLeaf above the feed: visible when + `starterCardVisibleForAgent(meta, entries)` — an agent-kind session whose + committed entries hold no user turn. Derived, never stored: the first + prompt lands as an entry and the card vanishes by itself; a restored + session replays history and never sees one; there is no dismissal state to + persist. Terminal views never mount it (AgentTerminalLeaf has no card, + structurally), and the predicate itself refuses terminal/extension kinds + so a future caller cannot reintroduce it. +- **Context B** extends the focused empty lane's hint in TiledDispatchLayout, + under the same three conditions the hint has (focused, empty, the row + offers agents) — the card advertises keys that act on `focusedLane`, and + an unfocused or agentless lane would promise gestures that do nothing + there. Four placement-flavored slots: Fill Lane, New Lane, Commands, and + the ⌥↑/⌥↓ index walk. +- **⌘N now binds New Agent…** — the platform convention for "new thing", + unclaimed by any command, reservation, or Electron role (New Window is + ⌘⇧N). The card's second slot pointed at a command with no chord, and a + card that says "New Agent" with no key teaches nothing. +- **Divergence from the ASCII walkthrough, recorded on purpose.** The + walkthrough's card showed ⌘K Commands, ⌥L New Lane, ⌥R New Row, ⇧⌘S + Spotlight. The shipped card shows the LIVE registry: ⌘⇧P Commands, + New Lane/New Row title-only (they ship no default), ⌥S Spotlight — and + ⌥L is Focus Lane Right, carrying years of inline-grammar muscle memory the + walkthrough sketch overwrote by accident. Registry truth beats the sketch; + that is what "registry-driven, always" means, and the sketch was + illustrative in a way the plan's own §4.6 table already was not. + + +--- + +### 9.5 Stage 7 execution record (contracts + docs) + +- **The placement enum narrowed** to `['project', 'dispatch', 'reader', + 'spotlight']`. The v2 ownership kinds ('grid', 'related', 'detached', + 'buried') had been held one release after nothing produced them; stage 7 + removes them per the hold's own comment. An old client parsing new + observations is unaffected (it never sees the removed values); a new + client still switching on them fails at compile time, which is the point. +- **`mode` is `z.literal('tiled-dispatch')`, deprecated.** The field stays + one release so observations still parse for clients reading it, described + as deprecated in the schema itself; it leaves with the next schema + version because "which layout" is no longer a question the app can ask. +- **`ManagedAgentPlacement` narrowed to `'dispatch'`** — same one-release + hold, same compile-time-failure rationale. +- **ARCHITECTURE §6.2.2 rewritten** around the stage/pool model: the + workspace-model diagram now shows lanes referencing sessions by ID and + sessions naming projects by field; the prose states the v3 ownership rule + (owned iff `projectId` names a live project; lanes/pins/active-project are + pointers, never ownership), project lifetime, and context-places. §5.4's + renderer-state diagram label updated ("Move a lane / switch project"). + Both SVGs regenerated with the pinned tooling. + ENVIRONMENT NOTE, not a regression: `render-architecture-diagrams.mjs + --check` fails on 16 UNTOUCHED diagrams in this environment — the + committed SVGs were rendered with a different Chrome and this one's font + metrics differ. The two diagrams this stage changed render and verify. +- **The control guide's layout entry retitled** ("The workspace: lanes, rows + and the agent index") and rewritten: no mode names, buried/detached + vocabulary replaced with parked/hibernated, the related-children paragraph + now describes index nesting, and the context-places paragraph from stage 4 + is the spine. `dispatch.configure`'s title says "stage rows and lanes". +- **README** no longer says "grid, Dispatch, and buried agent"; the + orchestration screenshot alt says "agent index". + + +--- + +### 9.6 Stage 8 execution record (cleanup) + +- **`defaultWorkspaceMode` is deleted end to end**: the Settings row, the + Settings type + default, the persistence coercion (a stale persisted value + is now dropped on read rather than validated), the useBootstrap/useWorkspace + params that had been deliberately unread since 3b-i, and their tests. The + "fresh-install" metadata scope test went with its only subject. +- **The user-facing "Dispatch" audit** swept command titles/descriptions: + "Dispatch row" → session, "the Dispatch list" → the index, mode-conditioned + sentences ("In **Dispatch**, …") replaced with the unconditional behavior + (pool + new badge), and Merge Project Tabs' notes no longer describe buried + panes becoming Dispatch agents. Command IDS still carry `dispatch-` prefixes + by design (§5.4: ids are not user-facing; renaming them orphans bindings). +- **The retired-ids release note lives in the PR body**: the repo has no + CHANGELOG file, and the ledger in catalog.test.ts already says "release + notes must say so" — the PR description is this release's vehicle and + carries the full retired-id list with what happened to each chord. +- **Internal renames were opportunistic only** (laneKeyboard.ts, + reservedInteractionBindings, the placement-schema comments); the bulk + `tabs`→projects / DispatchAgentList renames remain an optional follow-up + PR, as the plan allows. + +FINAL VERIFICATION (this sweep): `npx tsc -b` clean; full vitest sweep +524 files / 3741 tests, zero failures; `check:keybindings` OK (44 binding +sets, 13 reserved, 8 approved overlaps); `test:contract` satisfied; the +worktree-live-fixture, conversation-fixture and live-resume-probe checks +pass. + +## 10. Testing strategy + +Per `docs/testing/standard.md` — suffix picks the tier, each test protects +one contract: + +- **Unit (`migrateWorkspaceShape.test.ts`):** v2→v3 golden files (grid-heavy + workspace, dispatch workspace, mixed, corrupt); the accepted-loss rule + (multi-pane tab leaves all pooled, none placed); seed-on-first-open. +- **Unit (`workspaceShape.test.ts`):** placement contract (lane ids resolve + or are undefined), single-focus truth, wake-before-place at every writer. +- **Renderer:** spawn fills empty focused lane and never displaces an + occupant; Clear Lane returns occupant to pool alive; killed agent leaves + lane empty (existing #681 tests carry over); project rail sets + activeProjectId without touching lanes; Spotlight/Reader open on the + focused lane's session; **first-run workspace is one row × one lane, + focused, with no mode-gated command visible in the palette**; no + user-facing string in palette, shortcuts surface, or onboarding contains + "Dispatch" (assert with a string audit over the command catalog — same + shape as the title-noun checks `command-style.md` already prescribes). +- **Renderer (starter card):** fresh agent with zero user turns shows the + card; sending the first prompt hides it (no persisted flag); an empty + *unfocused* lane never shows keyed hints; a rebound command renders the + user's chord, not the default (the anti-drift contract); a terminal lane + never shows the card. +- **Migration fixtures** recorded from a real v2 workspace (the owner's, + redacted) rather than imagined cases. diff --git a/scripts/extract-work-context-fixtures.mts b/scripts/extract-work-context-fixtures.mts index 7765723fc..c6bb5469a 100644 --- a/scripts/extract-work-context-fixtures.mts +++ b/scripts/extract-work-context-fixtures.mts @@ -26,7 +26,8 @@ import { } from '../src/renderer/src/rendering/replay/redact.js' import { buildVisibleDispatchRows } from '../src/renderer/src/workspace/dispatch/dispatchSelectors.js' import { paneLabelForSession } from '../src/renderer/src/workspace/tile-tree/paneLabels.js' -import type { WorkspaceState } from '../src/renderer/src/workspace/types.js' +import type { PersistedWorkspace } from '../src/renderer/src/workspace/persistence.js' +import { liveWorkspaceFromPersisted } from '../src/renderer/src/workspace/workspaceShape.js' type JsonRecord = Record @@ -782,10 +783,20 @@ type DispatchObservation = { targetPlacement: string } -function replayDispatchState(state: WorkspaceState): { - state: WorkspaceState +// WHY the recording is a PERSISTED workspace and the selectors get a LIFTED one +// (#992): the fixture is evidence of what the app wrote, so it stays in the +// shape it was written in (v2: tile trees, a detached bucket, a dispatchMode +// envelope). The selectors take live state, which has none of that, so the +// replay runs them on exactly what the app would build from this file at boot. +// `state` in the result is still the persisted recording — that is what gets +// written to disk — and `targetPlacement` is read off the recording too, +// because "grid" vs "detached" is a fact about the v2 file, not about live +// state, where every session is just a pool row. +function replayDispatchState(persisted: PersistedWorkspace): { + state: PersistedWorkspace observed: DispatchObservation } { + const state = liveWorkspaceFromPersisted(persisted) const rows = buildVisibleDispatchRows(state) const target = rows.find(row => row.label === 'D23') if (!target) throw new Error('recorded workspace no longer contains D23') @@ -797,7 +808,7 @@ function replayDispatchState(state: WorkspaceState): { paneLabelForSession(state, row.tabId, row.sessionId) !== row.label )) return { - state, + state: persisted, observed: { tabCount: state.tabs.length, visibleRowCount: rows.length, @@ -805,23 +816,18 @@ function replayDispatchState(state: WorkspaceState): { targetSessionId: target.sessionId, targetVisibleLabel: target.label, targetLocalLabel: local, - targetPlacement: target.placement, + targetPlacement: persisted.detachedSessions?.[target.sessionId] ? 'detached' : 'grid', }, } } function reducedDispatchState(raw: JsonRecord): { - state: WorkspaceState + state: PersistedWorkspace observed: DispatchObservation } { const persisted = asRecord(raw.workspace) if (!persisted) throw new Error('workspace.json has no workspace object') - const sourceState = { - ...persisted, - pinnedSessionIds: Array.isArray(persisted.pinnedSessionIds) - ? persisted.pinnedSessionIds - : [], - } as unknown as WorkspaceState + const sourceState = liveWorkspaceFromPersisted(persisted as unknown as PersistedWorkspace) const sourceRows = buildVisibleDispatchRows(sourceState) const sourceTarget = sourceRows.find(row => row.label === 'D23') if (!sourceTarget) throw new Error('recorded workspace no longer contains D23') @@ -961,7 +967,7 @@ function reducedDispatchState(raw: JsonRecord): { .map(mapSessionId) .filter((value): value is string => Boolean(value)) : [], - } as unknown as WorkspaceState + } as unknown as PersistedWorkspace const targetSessionId = mapSessionId(sourceTarget.sessionId) const replayed = replayDispatchState(state) @@ -981,7 +987,7 @@ function reducedDispatchState(raw: JsonRecord): { } async function recordedDispatchState(): Promise<{ - state: WorkspaceState + state: PersistedWorkspace observed: DispatchObservation }> { // WHY the default source becomes the checked-in reduced recording after the @@ -999,7 +1005,7 @@ async function recordedDispatchState(): Promise<{ const existing = asRecord(JSON.parse(existingText)) const state = asRecord(existing?.state) if (!state) throw new Error('existing Dispatch fixture has no state object') - return replayDispatchState(state as unknown as WorkspaceState) + return replayDispatchState(state as unknown as PersistedWorkspace) } } diff --git a/src/control-sdk/catalog/workspace.ts b/src/control-sdk/catalog/workspace.ts index b2e2c82a2..379ba34f0 100644 --- a/src/control-sdk/catalog/workspace.ts +++ b/src/control-sdk/catalog/workspace.ts @@ -2,16 +2,35 @@ import { z } from 'zod' // Portable observation contracts; feature adapters translate their live state // into these records. The SDK never imports or owns the workspace store. +// Placement kinds (#992). A session has exactly ONE ownership placement — +// 'project': it belongs to the project `tabId` — plus one view placement per +// place it is on screen: 'dispatch' (a lane, by index), 'reader', 'spotlight'. +// +// The v2 ownership kinds ('grid', 'related', 'detached', 'buried') were held +// in this enum for one release after nothing produced them (#992 stage 3b); +// stage 7 removes them. An OLD client parsing NEW observations is unaffected +// (it simply never sees the removed values); a NEW client that still switches +// on them fails at compile time, which is the point of narrowing. export const placementSchema = z.object({ - kind: z.enum(['grid', 'related', 'dispatch', 'detached', 'buried', 'reader', 'spotlight']), + kind: z.enum(['project', 'dispatch', 'reader', 'spotlight']), tabId: z.string().optional(), lane: z.number().optional(), gridOwnerSessionId: z.string().optional(), visible: z.boolean(), }) export const workspaceObservationSchema = z.object({ observedAt: z.number(), focusedSessionId: z.string().nullable(), ui: z.object({ commandPickerOpen: z.boolean(), settingsOpen: z.boolean(), inputOwnedBySurface: z.boolean() }), restoreStatus: z.string(), activeTabId: z.string(), - mode: z.enum(['grid', 'tiled-tabs', 'dispatch', 'tiled-dispatch']), - tabs: z.array(z.object({ id: z.string(), title: z.string(), focusedSessionId: z.string(), sessionIds: z.array(z.string()) })), + // The layout-mode field's whole vocabulary died with the two-mode layout + // (#992): 'grid' and 'dispatch' described shapes that no longer save, and + // 'tiled-tabs' went earlier. The FIELD stays as a literal for one release + // so observations still parse for clients reading it; it is deprecated and + // leaves with the next schema version — "which layout" is no longer a + // question the app can ask. + mode: z.literal('tiled-dispatch').describe('Deprecated: one layout since #992. Always \'tiled-dispatch\'.'), + // `focusedSessionId` was each tab's tile-tree focus. A project has no focus + // of its own (#992): the one focus is the top-level `focusedSessionId`, the + // focused lane's agent. Optional and never produced, for the same one-release + // reason as the placement kinds above. + tabs: z.array(z.object({ id: z.string(), title: z.string(), focusedSessionId: z.string().optional(), sessionIds: z.array(z.string()) })), sessions: z.array(z.object({ sessionId: z.string(), title: z.string(), displayLabel: z.string().nullable().default(null).describe('Current window-local visible coordinate; can change with layout. Never use as a stable ID.'), displayedTitle: z.string().default('').describe('The current UI title, including prompt fallback where shown.'), diff --git a/src/main/agentActivity/workspaceProjection.test.ts b/src/main/agentActivity/workspaceProjection.test.ts new file mode 100644 index 000000000..b57977355 --- /dev/null +++ b/src/main/agentActivity/workspaceProjection.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' + +import type { PersistedWindow } from '@main/storage/workspaceFile.js' +import { projectWorkspace } from './workspaceProjection.js' + +// Main reads the renderer's workspace document to answer "which project does +// this session belong to" for Agent Analytics (#964). The unified layout (#992) +// changed how the document SAYS that: a v3 file has no tile tree and no +// detached/buried buckets at all — membership is `sessions[id].projectId`, and +// projects are listed under `projects`, not `tabs`. +// +// WHY this file exists separately from AgentActivityRecorder.test.ts, which +// keeps its v2 fixture on purpose: the recorder suite proves attribution still +// works for a window that has not saved since the upgrade. Nothing proved it +// works for a window that HAS — and the failure is silent. A projection that +// only understood v2 would not throw on a v3 document; it would find no `tabs`, +// attribute every session to `tabId: null`, and Agent Analytics would quietly +// file a whole fleet's working time under "Unknown project" from the first +// autosave onward. + +function windowOf(workspace: unknown): PersistedWindow { + return { windowId: 'window-1', workspace } as PersistedWindow +} + +describe('projectWorkspace — v3 documents (#992)', () => { + it('attributes every session through its own row, parked or on screen', () => { + const projection = projectWorkspace([windowOf({ + projects: [{ id: 'p-app', title: 'app' }, { id: 'p-svc', title: 'service' }], + activeProjectId: 'p-app', + // The lane shows one of three sessions. Placement must not depend on it: + // a parked agent still belongs to its project and still accrues time. + stage: { lanes: [{ selectedSessionId: 'shown' }], rows: [{ length: 1 }], focusedLane: 0 }, + sessions: { + shown: { cwd: '/x/app', kind: 'codex', projectId: 'p-app', joinedAt: 0, title: 'Reviewer' }, + parked: { cwd: '/x/app', kind: 'claude', projectId: 'p-app', joinedAt: 1, agentNameId: 'name-7' }, + worker: { cwd: '/x/svc', kind: 'claude', projectId: 'p-svc', joinedAt: 0, orchestrationParentId: 'shown' }, + }, + pinnedSessionIds: [], + })]) + + // Exact match on purpose: this pins the whole projected shape. It + // includes `tldrIdentity` and `pinned`, which main's remote read model + // (30424f13) added while #992 was in flight. They merged cleanly into the + // projection, but this exact expectation predated them. + expect(projection.sessions.get('shown')).toEqual({ + sessionId: 'shown', kind: 'codex', cwd: '/x/app', title: 'Reviewer', + agentNameId: null, tldrIdentity: null, pinned: false, orchestration: false, tabId: 'p-app', tabTitle: 'app', + }) + expect(projection.sessions.get('parked')).toMatchObject({ tabId: 'p-app', tabTitle: 'app', agentNameId: 'name-7' }) + expect(projection.sessions.get('worker')).toMatchObject({ tabId: 'p-svc', tabTitle: 'service', orchestration: true }) + expect([...projection.openTabTitles].sort()).toEqual(['app', 'service']) + }) + + it('leaves a row whose project does not exist unattributed instead of guessing', () => { + // Mirrors the renderer's ownership rule: a ghost never falls back to the + // active project. Filing a stranger's agent under whichever project + // happened to be open would put its hours on the wrong line of a report + // people read to decide where time went. + const projection = projectWorkspace([windowOf({ + projects: [{ id: 'p-app', title: 'app' }], + activeProjectId: 'p-app', + sessions: { + ghost: { cwd: '/gone', kind: 'claude', projectId: 'p-deleted', joinedAt: 0 }, + unfiled: { cwd: '/x', kind: 'claude' }, + }, + })]) + + expect(projection.sessions.get('ghost')).toMatchObject({ tabId: null, tabTitle: null }) + expect(projection.sessions.get('unfiled')).toMatchObject({ tabId: null, tabTitle: null }) + }) +}) + +describe('projectWorkspace — hybrid documents', () => { + it('lets the row win over a v2 structure that disagrees', () => { + // The intermediate builds of #992 wrote BOTH shapes. Where they disagree + // the row is newer by construction (it is written by every v3 action; the + // v2 structures were only carried along), which is the same precedence + // migrateWorkspaceToStage applies. Main re-states the rule rather than + // importing it, so this is the pin that keeps the two from drifting. + const projection = projectWorkspace([windowOf({ + projects: [{ id: 'p-new', title: 'new home' }, { id: 'tab-old', title: 'old home' }], + tabs: [{ id: 'tab-old', title: 'old home', focusedSessionId: 'moved', root: { type: 'leaf', sessionId: 'moved' } }], + sessions: { moved: { cwd: '/x', kind: 'claude', projectId: 'p-new', joinedAt: 3 } }, + })]) + + expect(projection.sessions.get('moved')).toMatchObject({ tabId: 'p-new', tabTitle: 'new home' }) + }) + + it('falls back to the v2 structure when the row names a project that is gone', () => { + const projection = projectWorkspace([windowOf({ + tabs: [{ id: 'tab-old', title: 'old home', focusedSessionId: 'kept', root: { type: 'leaf', sessionId: 'kept' } }], + sessions: { kept: { cwd: '/x', kind: 'claude', projectId: 'p-deleted', joinedAt: 3 } }, + })]) + + expect(projection.sessions.get('kept')).toMatchObject({ tabId: 'tab-old', tabTitle: 'old home' }) + }) +}) + +describe('projectWorkspace — v2 documents still on disk', () => { + it('reads tile leaves, detached rows and buried panes, in that precedence', () => { + const projection = projectWorkspace([windowOf({ + tabs: [{ + id: 'tab-a', title: 'app', focusedSessionId: 'leaf', + root: { type: 'split', direction: 'vertical', ratio: 0.5, a: { type: 'leaf', sessionId: 'leaf' }, b: { type: 'leaf', sessionId: 'both' } }, + }, { id: 'tab-b', title: 'service', focusedSessionId: 'x', root: { type: 'leaf', sessionId: 'x' } }], + detachedSessions: { + row: { sessionId: 'row', projectTabId: 'tab-b' }, + // Also a leaf of tab-a: the leaf wins, as it does in the renderer. + both: { sessionId: 'both', projectTabId: 'tab-b' }, + }, + buried: [{ sessionId: 'hidden', sourceTabId: 'tab-a' }], + sessions: { + leaf: { cwd: '/a', kind: 'claude' }, both: { cwd: '/a', kind: 'claude' }, x: { cwd: '/b', kind: 'claude' }, + row: { cwd: '/b', kind: 'codex' }, hidden: { cwd: '/a', kind: 'claude' }, + }, + })]) + + expect(Object.fromEntries([...projection.sessions].map(([id, placement]) => [id, placement.tabId]))).toEqual({ + leaf: 'tab-a', both: 'tab-a', x: 'tab-b', row: 'tab-b', hidden: 'tab-a', + }) + }) + + it('degrades a malformed document to nothing rather than throwing', () => { + // Main treats the document as opaque: a future or corrupt shape must cost + // attribution, never the analytics recorder (which runs in the main + // process, where a throw here is an app-level crash path). + expect(() => projectWorkspace([ + windowOf(null), windowOf('nope'), windowOf({ sessions: [] }), + windowOf({ projects: 'x', tabs: 7, detachedSessions: [], buried: {}, sessions: { a: null, b: 3 } }), + ])).not.toThrow() + const cyclic: Record = { type: 'split' } + cyclic.a = cyclic + cyclic.b = cyclic + expect(() => projectWorkspace([windowOf({ + tabs: [{ id: 't', title: 'loop', root: cyclic }], sessions: { a: { cwd: '/x' } }, + })])).not.toThrow() + }) +}) diff --git a/src/main/agentActivity/workspaceProjection.ts b/src/main/agentActivity/workspaceProjection.ts index 0f7b550bb..80c5727f8 100644 --- a/src/main/agentActivity/workspaceProjection.ts +++ b/src/main/agentActivity/workspaceProjection.ts @@ -9,9 +9,21 @@ import type { PersistedWindow } from '@main/storage/workspaceFile.js' // The shape is the renderer's PersistedWorkspace, opaque to main, so it is read // defensively: a malformed or future field degrades to "unknown", never a throw. // -// Membership rules mirror the renderer's resolveTabSessions: a grid leaf belongs -// to the tab whose tile tree holds it; a Dispatch row belongs to its -// `projectTabId`; a buried pane still runs and belongs to its `sourceTabId`. +// Membership rules mirror the renderer's, for BOTH file generations, because +// main reads whatever is on disk and a window that has not saved since an +// upgrade still holds a v2 document: +// +// v3 (#992) — a session belongs to the project its own row names +// (`sessions[id].projectId`), and projects are listed under `projects`. +// v2 — a grid leaf belongs to the tab whose tile tree holds it; a Dispatch +// row belongs to its `projectTabId`; a buried pane still runs and belongs +// to its `sourceTabId`. Tabs are listed under `tabs`. +// +// The row's own `projectId` wins when both are present (the intermediate +// builds wrote both), matching migrateWorkspaceToStage. This is a deliberately +// small, defensive RE-STATEMENT of that precedence rather than an import of +// it: main treats the renderer's document as opaque and must not throw on a +// shape it does not recognize. export type SessionPlacement = { sessionId: string @@ -50,17 +62,38 @@ function str(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null } -function collectLeaves(node: unknown, out: string[], depth = 0): void { - // Depth guard: a corrupt self-referencing document must not recurse forever. - if (!isRecord(node) || depth > 64) return +function collectLeaves( + node: unknown, + out: string[], + seen: WeakSet = new WeakSet(), + depth = 0, +): void { + // Two guards, because they stop different things. + // + // `seen` stops a node from being walked twice. This used to be a depth cap + // ALONE, commented as the guard against "a corrupt self-referencing + // document" — and it was not one. A split walks TWO children, so a node + // whose `a` and `b` both point back at itself is not a 64-step loop, it is a + // 2^64-call tree: the cap bounded the depth of every path and did nothing + // about how many paths there were. workspaceProjection.test.ts found this by + // hanging. (A document read from disk is JSON and cannot hold a cycle, so + // production never met it; the guard claimed to cover the in-memory case and + // silently did not, which is worse than not claiming it.) + // + // `depth` is kept for what a depth cap IS good for: the call stack. A + // hand-edited file can nest splits arbitrarily deep with no cycle at all, + // and this runs in the main process, where a RangeError is an app crash. + // Real trees were never deeper than the number of panes in a tab. + if (!isRecord(node) || depth > 64 || seen.has(node)) return + seen.add(node) if (node.type === 'leaf') { const id = str(node.sessionId) if (id) out.push(id) return } if (node.type === 'split') { - collectLeaves(node.a, out, depth + 1) - collectLeaves(node.b, out, depth + 1) + collectLeaves(node.a, out, seen, depth + 1) + collectLeaves(node.b, out, seen, depth + 1) } } @@ -74,6 +107,15 @@ export function projectWorkspace(windows: readonly PersistedWindow[]): Workspace const tabTitleById = new Map() const tabBySession = new Map() + // v3 projects first: id + title, no tree. + for (const project of Array.isArray(workspace.projects) ? workspace.projects : []) { + if (!isRecord(project)) continue + const projectId = str(project.id) + if (!projectId) continue + const title = str(project.title) ?? '' + tabTitleById.set(projectId, title) + if (title) openTabTitles.add(title) + } for (const tab of Array.isArray(workspace.tabs) ? workspace.tabs : []) { if (!isRecord(tab)) continue const tabId = str(tab.id) @@ -111,7 +153,10 @@ export function projectWorkspace(windows: readonly PersistedWindow[]): Workspace for (const [sessionId, meta] of Object.entries(workspace.sessions)) { if (!isRecord(meta)) continue - const tabId = tabBySession.get(sessionId) ?? null + const stamped = str(meta.projectId) + const tabId = (stamped && tabTitleById.has(stamped) ? stamped : null) + ?? tabBySession.get(sessionId) + ?? null sessions.set(sessionId, { sessionId, // Missing kind is legacy Claude, the renderer's DEFAULT_PROVIDER. diff --git a/src/main/control/controlHost.system.test.ts b/src/main/control/controlHost.system.test.ts index f489c0428..86ecd39fc 100644 --- a/src/main/control/controlHost.system.test.ts +++ b/src/main/control/controlHost.system.test.ts @@ -47,11 +47,19 @@ it('routes real renderer observations across two windows and survives reload wit import { useAppStore } from '${resolve(root, 'src/renderer/src/app-state/store.ts')}' const id = location.hash.slice(1) useAppStore.setState({ workspaceState: { - tabs: [{id, title: id, root: {type: 'leaf', sessionId: id + '-agent'}, focusedSessionId: id + '-agent'}], - activeTabId: id, dispatchMode: null, sessions: { [id + '-agent']: {cwd: '/control-trial/' + id, kind: 'codex'} }, - detachedSessions: {}, buried: [], pinnedSessionIds: [] + tabs: [{id, title: id}], + // One lane showing this window's agent: the command target is the + // focused lane's occupant since #992 (it used to fall back to the + // tab's tree focus with Dispatch off). Inline rather than imported — + // this string is a generated renderer entry, not a test module. + stage: { lanes: [{ selectedSessionId: id + '-agent' }], rows: [{ length: 1 }], focusedLane: 0 }, + // Filed under this window's one project. Ownership is the row's own + // \`projectId\` since #992; a row naming no project is unowned, so no + // index lists it and the control reads below would not see it. + activeTabId: id, sessions: { [id + '-agent']: {cwd: '/control-trial/' + id, kind: 'codex', projectId: id, joinedAt: 0} }, + pinnedSessionIds: [] }}) - window.addAmbiguousAgent = () => useAppStore.getState().setWorkspaceState(state => ({ ...state, sessions: { ...state.sessions, 'right-agent': {cwd: '/ambiguous', kind: 'codex'} } })) + window.addAmbiguousAgent = () => useAppStore.getState().setWorkspaceState(state => ({ ...state, sessions: { ...state.sessions, 'right-agent': {cwd: '/ambiguous', kind: 'codex', projectId: id, joinedAt: 1} } })) window.changeTrialBinding = () => useAppStore.getState().setSettings({ commandKeybindingOverrides: {'new-tab': ['Cmd+Alt+T']} }) registerRendererHost([ ...workspaceControlCapabilities(() => ({restoreStatus: 'fresh'})), diff --git a/src/main/control/globalCapabilities.ts b/src/main/control/globalCapabilities.ts index 250af25ef..0a0ad33dd 100644 --- a/src/main/control/globalCapabilities.ts +++ b/src/main/control/globalCapabilities.ts @@ -19,7 +19,7 @@ export function globalControlCapabilities(observe: ObserveWindows) { }), defineCapability({ id: 'agents.search', title: 'Search agents across windows', execution: 'main', effect: 'read', - description: 'Find existing agents and terminals across every window/project, including related, detached and buried agents and terminals. Labels are window-local and may be ambiguous globally; all matching candidates are returned. Spoken agent names are application-wide and never recycled, but the same agent can still be observed by several windows. Results carry stable ownership for direct navigation. Incomplete windows are reported, never silently dropped.', + description: 'Find existing agents and terminals across every window/project, including the ones that are not in a lane. Labels are window-local and may be ambiguous globally; all matching candidates are returned. Spoken agent names are application-wide and never recycled, but the same agent can still be observed by several windows. Results carry stable ownership for direct navigation. Incomplete windows are reported, never silently dropped.', // WHY the two free-text fields carry a length bound and `label` does not: // `label` is already pinned by a regex, but `name` and `query` are compared // — normalized, lowercased, substring-scanned — against every session of @@ -36,7 +36,7 @@ export function globalControlCapabilities(observe: ObserveWindows) { // 'terminal' for a shell — filtering `provider: 'terminal'` before // this fix always matched zero rows because zod rejected the input // value outright, silently making "find just my shells" impossible. - provider: z.enum(['claude', 'codex', 'opencode', 'grok', 'terminal']).optional().describe('Restrict to one provider, or `terminal` for shells.'), placement: z.enum(['grid', 'related', 'dispatch', 'detached', 'buried', 'reader', 'spotlight']).optional().describe('Restrict to agents with this placement; mirrored placements still identify the same agent.'), ...pageInput }).strict(), + provider: z.enum(['claude', 'codex', 'opencode', 'grok', 'terminal']).optional().describe('Restrict to one provider, or `terminal` for shells.'), placement: z.enum(['project', 'dispatch', 'reader', 'spotlight', 'grid', 'related', 'detached', 'buried']).optional().describe('Restrict to agents with this placement: `dispatch` (shown in a lane), `reader`, `spotlight`, or `project` (every agent has one). The v2 kinds grid/related/detached/buried match nothing. Mirrored placements still identify the same agent.'), ...pageInput }).strict(), output: pageSchema(match).extend({ unavailableWindows: z.array(z.object({ windowId: z.string(), error: z.string() })) }), handler: async (input, context) => { const windows = (await observe(context)).filter(window => !input.windowId || window.windowId === input.windowId) diff --git a/src/main/storage/workspaceFile.ts b/src/main/storage/workspaceFile.ts index d65011613..bdff1a72f 100644 --- a/src/main/storage/workspaceFile.ts +++ b/src/main/storage/workspaceFile.ts @@ -54,7 +54,25 @@ export type WorkspaceFile = { windows: PersistedWindow[] } -export const WORKSPACE_FILE_VERSION = 2 +/** + * The version this build WRITES. + * + * WHY 3 (#992 review, blocker): the unified stage (v3 document) stores a + * different workspace payload, while the envelope around it is unchanged. + * Leaving this at 2 told every older build, including the released + * v0.0.2-beta.1, that a v3 file was an ordinary writable v2 file. A + * downgrade plus a two-window close then let the old build's window handoff + * adopt the stage document, drop every session it could not read, and save + * over the pool. Older builds refuse any version but 2 as `unreadable` and + * run read-only (see ParsedWorkspaceFile), so writing 3 turns that data loss + * into the designed "newer file, do not touch" outcome. + */ +export const WORKSPACE_FILE_VERSION = 3 + +/** Versions this build READS. 2 is every file written before the unified + * stage. Its payload migrates in the renderer (migrateWorkspaceToStage), and + * the envelope itself is unchanged. */ +const READABLE_WORKSPACE_FILE_VERSIONS: readonly unknown[] = [2, 3] /** * Restoring usable windows and proving a complete resource inventory are @@ -71,7 +89,16 @@ export type WorkspaceDecodeCompleteness = | { kind: 'partial'; invalidWindowsContainer: boolean; discardedWindows: number } export type ParsedWorkspaceFile = - | { kind: 'ok'; file: WorkspaceFile; migratedFromV1: boolean; completeness: WorkspaceDecodeCompleteness } + | { + kind: 'ok' + file: WorkspaceFile + migratedFromV1: boolean + completeness: WorkspaceDecodeCompleteness + /** The version found on disk (1 for the unversioned single-window file). + * WorkspaceFileStore backs the original bytes up once before the first + * write that upgrades an older file. */ + sourceVersion: number + } /** * The file exists but this build cannot represent it — a NEWER version * written by a future build. @@ -161,6 +188,7 @@ export function parseWorkspaceFile( return { kind: 'ok', migratedFromV1: true, + sourceVersion: 1, completeness: { kind: 'complete' }, file: { version: WORKSPACE_FILE_VERSION, @@ -175,10 +203,10 @@ export function parseWorkspaceFile( } } - if (parsed.version !== WORKSPACE_FILE_VERSION) { + if (!READABLE_WORKSPACE_FILE_VERSIONS.includes(parsed.version)) { return { kind: 'unreadable', - reason: `workspace.json is version ${String(parsed.version)}; this build understands ${WORKSPACE_FILE_VERSION}`, + reason: `workspace.json is version ${String(parsed.version)}; this build understands ${READABLE_WORKSPACE_FILE_VERSIONS.join(' and ')}`, } } @@ -207,6 +235,7 @@ export function parseWorkspaceFile( return { kind: 'ok', migratedFromV1: false, + sourceVersion: parsed.version as number, file: { version: WORKSPACE_FILE_VERSION, windows }, completeness: invalidWindowsContainer || discardedWindows > 0 ? { kind: 'partial', invalidWindowsContainer, discardedWindows } diff --git a/src/main/storage/workspaceFileStore.ts b/src/main/storage/workspaceFileStore.ts index 2e056f6f5..bfc3774f9 100644 --- a/src/main/storage/workspaceFileStore.ts +++ b/src/main/storage/workspaceFileStore.ts @@ -11,6 +11,7 @@ import { serializeWorkspaceFile, withoutWindow, withWindowSlice, + WORKSPACE_FILE_VERSION, } from '@main/storage/workspaceFile.js' import type { PersistedWindow, @@ -53,6 +54,18 @@ export class WorkspaceFileStore { */ private readOnlyReason: string | null = null + /** + * The exact bytes of an older-version file this store loaded, held until + * the first write upgrades it. + * + * WHY a one-time backup (#992 review): writing version 3 is one-way. Older + * builds refuse the result by design, and the renderer's v2→v3 migration + * drops v2-only layout (tile trees, ghost records). The only way back to a + * pre-stage build's workspace is the untouched original, so it is written + * once, beside the file, before it is replaced, and never again. + */ + private preUpgradeOriginal: { text: string, fromVersion: number } | null = null + // WHY the whole save transaction is queued, not just writeFile: unique temp // names prevent scratch-path ENOENT races, but they do not order the final // renames. An older renderer save can be delayed, rename after a newer save, @@ -117,6 +130,7 @@ export class WorkspaceFileStore { return } this.file = parsed.file + if (parsed.sourceVersion < WORKSPACE_FILE_VERSION) this.preUpgradeOriginal = { text, fromVersion: parsed.sourceVersion } if (parsed.migratedFromV1) { // eslint-disable-next-line no-console console.info('[workspace] migrated single-window workspace.json to the window format') @@ -258,6 +272,30 @@ export class WorkspaceFileStore { const serializeStartedAt = performance.now() const json = serializeWorkspaceFile(next) mainOperations.observe('persistence.serialize', performance.now() - serializeStartedAt) + if (this.preUpgradeOriginal) { + const backup = `${STATE_FILE}.pre-v${WORKSPACE_FILE_VERSION}-${Date.now()}.bak` + try { + // `wx`: never overwrite an existing backup, even a same-millisecond one. + await writeFile(backup, this.preUpgradeOriginal.text, { encoding: 'utf8', flag: 'wx' }) + // eslint-disable-next-line no-console + console.info(`[workspace] kept the v${this.preUpgradeOriginal.fromVersion} original at ${backup} before upgrading`) + this.preUpgradeOriginal = null + } catch (error) { + // A failed backup must not block saving. That would cost the + // user's current session to protect a copy. It retries on the next + // save and is warned about here, so it is not silently skipped. + // + // The partial file goes (#1013 verification review): a full disk + // fails the write AFTER `wx` created it, and each retry uses a new + // timestamp. The earliest "backup", the one a user would take as + // the original, was a 0-byte file. EEXIST is the exception: `wx` + // refused a file that already existed, which is someone's real + // backup and must stay. + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') await unlink(backup).catch(() => undefined) + // eslint-disable-next-line no-console + console.warn('[workspace] could not write the pre-upgrade backup; will retry on next save', error) + } + } const finishWrite = mainOperations.begin('persistence.write') try { await writeFile(tmp, json, 'utf8') diff --git a/src/main/storage/workspaceFileVersion.system.test.ts b/src/main/storage/workspaceFileVersion.system.test.ts new file mode 100644 index 000000000..f8044a50b --- /dev/null +++ b/src/main/storage/workspaceFileVersion.system.test.ts @@ -0,0 +1,119 @@ +import { copyFile, mkdir, readdir, readFile, rm } from 'node:fs/promises' +import { resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// #1013 review A, BLOCKER. The v3 stage document kept the OUTER file version +// at 2. Every older build, including the released v0.0.2-beta.1, therefore +// saw a v3 file as an ordinary writable v2 file. A downgrade followed by a +// two-window close then ran the old build's window handoff, whose autosave +// dropped every session it could not read, and the whole pool was deleted +// from disk (reproduced in review against the beta's code). +// +// The fix is the version gate those builds already have. Their decoder +// refuses any version other than 2 as `unreadable` and runs READ-ONLY. That +// behaviour exists exactly for a newer file (workspaceFile.ts, "a NEWER +// version written by a future build"). Writing 3 hands them that refusal. +// +// Inputs are REAL: the live v2 workspace.json the app persisted on +// 2026-09-19, sanitized (testing/fixtures/workspace-v2/README.md). The store +// runs against the real filesystem in a temp directory. Only the state path +// is redirected, the same seam the existing store tests use. + +const h = vi.hoisted(() => ({ + dir: `${process.env.TMPDIR ?? '/tmp'}/agent-code-ws-version-${process.pid}-${Date.now()}`, + failBackups: 0, +})) +vi.mock('@main/storage/paths.js', () => ({ STATE_DIR: h.dir, STATE_FILE: `${h.dir}/workspace.json` })) +// Only the BACKUP write can be made to fail, the way a full disk fails it: +// the file is created (`wx` succeeded), a prefix lands, then ENOSPC. The +// verification review reproduced exactly this on a real 1 MB disk image. +vi.mock('fs/promises', async () => { + const real = await vi.importActual('fs/promises') + return { + ...real, + writeFile: async (path: string, data: string, options: unknown) => { + if (h.failBackups > 0 && String(path).endsWith('.bak')) { + h.failBackups -= 1 + await real.writeFile(path, String(data).slice(0, 10), { flag: 'wx' }) + throw Object.assign(new Error('ENOSPC: no space left on device, write'), { code: 'ENOSPC' }) + } + return real.writeFile(path, data as string, options as never) + }, + } +}) + +const { WORKSPACE_FILE_VERSION, parseWorkspaceFile, serializeWorkspaceFile } = await import('@main/storage/workspaceFile.js') +const { WorkspaceFileStore } = await import('@main/storage/workspaceFileStore.js') + +const fixture = resolve(__dirname, '../../../testing/fixtures/workspace-v2/2026-09-19-live-workspace.sanitized.json') +let id = 0 +const mint = () => `minted-${++id}` + +beforeEach(async () => { h.failBackups = 0; await mkdir(h.dir, { recursive: true }) }) +afterEach(async () => { await rm(h.dir, { recursive: true, force: true }) }) + +describe('workspace file version (#1013 downgrade safety)', () => { + it('writes version 3, so every build that only understands 2 refuses the file and goes read-only', async () => { + const parsed = parseWorkspaceFile(await readFile(fixture, 'utf8'), mint) + expect(parsed.kind).toBe('ok') + if (parsed.kind !== 'ok') return + const written = JSON.parse(serializeWorkspaceFile(parsed.file)) as { version: unknown } + expect(WORKSPACE_FILE_VERSION).toBe(3) + // The released beta's gate is `parsed.version !== 2 → unreadable` + // (workspaceFile.ts on v0.0.2-beta.1). Anything this build writes must + // trip it. + expect(written.version).not.toBe(2) + }) + + it('still reads a real v2 file with every window and session intact', async () => { + const raw = JSON.parse(await readFile(fixture, 'utf8')) as { version: number, windows: { workspace: { sessions: Record } }[] } + expect(raw.version).toBe(2) + const parsed = parseWorkspaceFile(JSON.stringify(raw), mint) + expect(parsed).toMatchObject({ kind: 'ok', completeness: { kind: 'complete' } }) + if (parsed.kind !== 'ok') return + expect(parsed.file.windows).toHaveLength(raw.windows.length) + expect(Object.keys((parsed.file.windows[0]!.workspace as { sessions: object }).sessions)) + .toEqual(Object.keys(raw.windows[0]!.workspace.sessions)) + }) + + it('refuses a version it does not know as unreadable instead of guessing', () => { + expect(parseWorkspaceFile(JSON.stringify({ version: 4, windows: [] }), mint)).toMatchObject({ kind: 'unreadable' }) + }) + + it('keeps the original v2 bytes in a one-time backup before its first v3 write', async () => { + await copyFile(fixture, `${h.dir}/workspace.json`) + const original = await readFile(fixture, 'utf8') + const store = await WorkspaceFileStore.open() + const [window] = store.windows() + // The renderer's real save shape: a `{ workspace }` payload plus the + // window's geometry, exactly what workspace:save sends. + const geometry = { bounds: window!.bounds, displayId: window!.displayId, fullScreen: window!.fullScreen } + await store.saveSlice(window!.windowId, JSON.stringify({ workspace: window!.workspace }), geometry) + await store.saveSlice(window!.windowId, JSON.stringify({ workspace: window!.workspace }), geometry) + const backups = (await readdir(h.dir)).filter(name => name.startsWith('workspace.json.pre-v3-') && name.endsWith('.bak')) + // Exactly one: the conversion is one-way for older builds, so the only + // way back is the untouched original, taken once and never overwritten. + expect(backups).toHaveLength(1) + expect(await readFile(`${h.dir}/${backups[0]}`, 'utf8')).toBe(original) + expect((JSON.parse(await readFile(`${h.dir}/workspace.json`, 'utf8')) as { version: number }).version).toBe(3) + }) + + it('a backup that fails mid-write leaves no truncated file named like a backup', async () => { + // #1013 verification review: a failed attempt left a 0-byte + // `workspace.json.pre-v3-.bak`, and each retry used a new timestamp. + // The EARLIEST backup, the one a user would take as "the original", was + // then the empty one. + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + await copyFile(fixture, `${h.dir}/workspace.json`) + const original = await readFile(fixture, 'utf8') + const store = await WorkspaceFileStore.open() + const [window] = store.windows() + const geometry = { bounds: window!.bounds, displayId: window!.displayId, fullScreen: window!.fullScreen } + h.failBackups = 1 + await store.saveSlice(window!.windowId, JSON.stringify({ workspace: window!.workspace }), geometry) + await store.saveSlice(window!.windowId, JSON.stringify({ workspace: window!.workspace }), geometry) + const backups = (await readdir(h.dir)).filter(name => name.startsWith('workspace.json.pre-v3-') && name.endsWith('.bak')) + expect(backups).toHaveLength(1) + expect(await readFile(`${h.dir}/${backups[0]}`, 'utf8')).toBe(original) + }) +}) diff --git a/src/mcp/runtime/createBuiltInMcpServer.ts b/src/mcp/runtime/createBuiltInMcpServer.ts index 71739f946..535eb1155 100644 --- a/src/mcp/runtime/createBuiltInMcpServer.ts +++ b/src/mcp/runtime/createBuiltInMcpServer.ts @@ -43,14 +43,14 @@ export const AGENT_MANAGEMENT_MCP_INSTRUCTIONS = `Agent Management controls Agen * Instructions for a session whose user enabled Root Agent Code Management. * * WHY the caller's own session ID is spelled out: the `ac_*` catalog can - * close, bury, detach, reload and provider-switch ANY session, and the model + * close, reload and provider-switch ANY session, and the model * only knows itself as "this conversation". Naming the ID is the one fact * that lets it keep its own pane out of a reorganization. The authorization * language mirrors Agent Management's, with a wider allowed surface (placement * and focus) because reorganizing the workspace is the feature's purpose. */ export function rootManagementInstructions(sessionId: string): string { - return `Root Agent Code Management is enabled for this agent by an explicit user action confirmed in a dialog; it is off for every other agent. The ac_* tools are the application-wide operator control surface: every window, project tab, agent, terminal and layout in Agent Code, not only the caller's project. Start with ac_app_describe, then ac_app_observe or ac_app_windows for identities; use stable session and tab IDs, never pane labels. Your own Agent Code session ID is ${sessionId}: never close, bury, detach, reload, rewind or switch the provider of that session. Prefer reads, make the smallest layout change that satisfies the user's current request, and re-read the layout revision after every mutation. Never close, kill, bury, restore, switch providers for, or prompt another agent unless the user's current request names that agent or that outcome; a request to organize, tidy or focus the workspace authorizes placement, focus, pin and title changes only. The app's own confirmation dialogs still apply, and a declined dialog is a refusal, not a reason to retry. When you finish, say exactly what you changed and where.` + return `Root Agent Code Management is enabled for this agent by an explicit user action confirmed in a dialog; it is off for every other agent. The ac_* tools are the application-wide operator control surface: every window, project tab, agent, terminal and layout in Agent Code, not only the caller's project. Start with ac_app_describe, then ac_app_observe or ac_app_windows for identities; use stable session and tab IDs, never pane labels. Your own Agent Code session ID is ${sessionId}: never close, reload, rewind or switch the provider of that session. Prefer reads, make the smallest layout change that satisfies the user's current request, and re-read the layout revision after every mutation. Never close, kill, switch providers for, or prompt another agent unless the user's current request names that agent or that outcome; a request to organize, tidy or focus the workspace authorizes placement, focus, pin and title changes only. The app's own confirmation dialogs still apply, and a declined dialog is a refusal, not a reason to retry. When you finish, say exactly what you changed and where.` } export function createBuiltInMcpServer( @@ -321,7 +321,7 @@ function registerAgentManagementTools( { title: 'List Project Agents', description: - 'Lists every Agent Code agent in the caller\'s exact project tab, including visible panes, detached Dispatch agents, and buried agents. Returns transcript paths/availability, backend and activity state, last activity, idle duration, conditions, and relationships. This read-only audit does not wake agents.', + 'Lists every Agent Code agent in the caller\'s exact project, including the ones that are not in a lane. Returns transcript paths/availability, backend and activity state, last activity, idle duration, conditions, and relationships. This read-only audit does not wake agents.', inputSchema: {}, annotations: { readOnlyHint: true, diff --git a/src/mcp/shared/agentManagementTypes.ts b/src/mcp/shared/agentManagementTypes.ts index edf5d36da..058056889 100644 --- a/src/mcp/shared/agentManagementTypes.ts +++ b/src/mcp/shared/agentManagementTypes.ts @@ -1,7 +1,12 @@ import type { AgentProviderKind } from '@shared/types/providerKind.js' import type { PromptDeliveryResult } from '@shared/types/providerConfig.js' -export type ManagedAgentPlacement = 'grid' | 'dispatch' | 'buried' +// One value since the unified layout (#992): every managed agent is a row in +// its project's agent index. The union kept 'grid' and 'buried' for one +// release after nothing produced them (they named v2 owner structures that +// no longer exist) and narrows here — stage 7 — so a caller still switching +// on the removed values fails to compile instead of silently never matching. +export type ManagedAgentPlacement = 'dispatch' export type ManagedAgentBackendState = | 'live' diff --git a/src/renderer/src/app-state/settings/persistence.test.ts b/src/renderer/src/app-state/settings/persistence.test.ts index abd9158fe..08fbc92e3 100644 --- a/src/renderer/src/app-state/settings/persistence.test.ts +++ b/src/renderer/src/app-state/settings/persistence.test.ts @@ -152,15 +152,12 @@ describe('coerceSettings retired keys', () => { }) }) -describe('coerceSettings default workspace mode (#973)', () => { - it('opens a fresh install in Dispatch', () => { - expect(coerceSettings({}).defaultWorkspaceMode).toBe('dispatch') - }) - - it('keeps an explicit Grid preference', () => { - expect(coerceSettings({ defaultWorkspaceMode: 'grid' }).defaultWorkspaceMode).toBe('grid') - }) -}) +// 'coerceSettings default workspace mode (#973)' lived here until the +// unified layout (#992 stage 8) deleted the setting: it chose between grid +// and Dispatch for a fresh install, and there is one layout now. A stale +// persisted `defaultWorkspaceMode` is dropped on read — which the generic +// "drops %s instead of carrying it forever" cases above now cover by listing +// the key among the dropped ones if it is ever reintroduced. describe('coerceSettings public-release defaults (#973)', () => { // Each pair: an absent key resolves to the new default; an explicit value diff --git a/src/renderer/src/app-state/settings/persistence.ts b/src/renderer/src/app-state/settings/persistence.ts index 96f7c9292..b0c9cd592 100644 --- a/src/renderer/src/app-state/settings/persistence.ts +++ b/src/renderer/src/app-state/settings/persistence.ts @@ -6,7 +6,6 @@ import { FONT_FAMILIES, isBuiltInThemeMode, USAGE_HEADER_LEVELS, - WORKSPACE_MODES, } from '@renderer/app-state/settings/types' import { V4_CUSTOM_MIGRATION_MARKER, @@ -144,12 +143,8 @@ export function coerceSettings(value: unknown): Settings { ) ? (parsed.usageHeaderLevel as UsageHeaderLevel) : DEFAULT_SETTINGS.usageHeaderLevel, - // WHY membership check via WORKSPACE_MODES rather than a literal - // === 'dispatch': keeps the source of truth in one array so adding - // a new mode label later (if ever) only requires editing types.ts. - defaultWorkspaceMode: WORKSPACE_MODES.some(m => m.id === parsed.defaultWorkspaceMode) - ? (parsed.defaultWorkspaceMode as Settings['defaultWorkspaceMode']) - : DEFAULT_SETTINGS.defaultWorkspaceMode, + // `defaultWorkspaceMode` is dropped on read (#992): a stale persisted + // value names a mode that no longer exists and selects nothing. // Agent view mode is a product contract, not a loose string. A typo in // localStorage must fall back to the compatible custom-rendered Agent mode // rather than accidentally booting every pane into raw terminal mode. @@ -333,12 +328,36 @@ function resolvePersistedMode( * canonical fields) are untouched, so no value migration is needed — only the * now-meaningless per-command preference entries go. */ -const RETIRED_BUILT_IN_COMMAND_IDS: ReadonlySet = new Set([ +export const RETIRED_BUILT_IN_COMMAND_IDS: ReadonlySet = new Set([ 'toggle-status-mode', 'toggle-worktree-badges', 'usage.toggle-header', 'usage.cycle-header-level', 'dangerous-agents', + // Retired by the unified stage (#992). WHY this matters more than tidiness + // (#1013 review B, MAJOR): useKeybinds' binding index puts a user's + // customized entries AHEAD of every default and gives an id it cannot + // resolve the `global` context. A saved `nav-left: ['Alt+H', 'Alt+Left']` + // override therefore won ⌥H/⌥← over the new lane commands. The router + // called preventDefault, the gateway answered `unknown`, and the chord did + // nothing. Settings has no row for a retired id, so the user had no way to + // find the override except "Reset all bindings". + 'dispatch-mode', + 'global-dispatch', + 'normalize-layout', + 'hard-normalize-layout', + 'rotate-layout', + 'nav-left', + 'nav-right', + 'nav-up', + 'nav-down', + 'tiled-tabs', + 'bury-pane', + 'revive-pane', + 'kill-buried-pane', + 'attach-detached-to-grid', + 'attach-all-detached-for-tab', + 'detach-to-dispatch', ]) /** diff --git a/src/renderer/src/app-state/settings/types.ts b/src/renderer/src/app-state/settings/types.ts index 70fd82219..d9dc7d1f1 100644 --- a/src/renderer/src/app-state/settings/types.ts +++ b/src/renderer/src/app-state/settings/types.ts @@ -120,17 +120,11 @@ export const ACCENTS: AccentMeta[] = [ // of the two should we start in". Keeping this as a flat string union // keeps localStorage payload stable, makes coerceSettings trivial, and // avoids leaking workspace-internal shape into a global setting. -export type WorkspaceModeId = 'grid' | 'dispatch' - -export type WorkspaceModeMeta = { - id: WorkspaceModeId - label: string -} - -export const WORKSPACE_MODES: WorkspaceModeMeta[] = [ - { id: 'grid', label: 'Grid' }, - { id: 'dispatch', label: 'Dispatch' }, -] +// 'WorkspaceModeId' / 'WorkspaceModeMeta' / 'WORKSPACE_MODES' were deleted +// with the unified layout (#992 stage 8): they named the two layout modes a +// fresh install could choose between, and there is one layout now. The +// persisted `defaultWorkspaceMode` value is ignored on read and dropped from +// coercion; a stale localStorage key simply stops mattering. export type AgentViewMode = 'agent' | 'terminal' | 'hybrid' @@ -385,7 +379,6 @@ export type Settings = { * This intentional narrowness matches the "new workspaces only" * semantic the user asked for: the setting seeds initial state and * then gets out of the way. */ - defaultWorkspaceMode: WorkspaceModeId /** App-wide default surface for provider panes that support both surfaces. * * WHY this is global settings instead of per-session metadata: @@ -706,7 +699,6 @@ export const DEFAULT_SETTINGS: Settings = { // the app all day; a public fresh install should open there (#973). The // setting still only seeds a workspace that has no workspace.json yet — // existing workspaces keep their last-used mode. - defaultWorkspaceMode: 'dispatch', agentNamesEnabled: false, agentViewMode: 'agent', // The owner's day-to-day set, shipped as the default (#973). TLDR and Goal diff --git a/src/renderer/src/app-state/store.performance.test.ts b/src/renderer/src/app-state/store.performance.test.ts index 312602a23..d5d1995df 100644 --- a/src/renderer/src/app-state/store.performance.test.ts +++ b/src/renderer/src/app-state/store.performance.test.ts @@ -218,15 +218,6 @@ const workspaceCases: WorkspaceUpdateCase[] = [ change: state => state.setWorkspaceReaderMode({ tabId: 'tab', focusedSessionId: 'session' }), value: state => state.workspaceReaderMode, }, - { - name: 'tileTabs', - directNoop: state => state.setWorkspaceTileTabs(state.workspaceTileTabs), - updaterNoop: state => state.setWorkspaceTileTabs(previous => previous), - change: state => state.setWorkspaceTileTabs({ - tabIds: ['tab-a', 'tab-b'], focusedTabId: 'tab-a', direction: 'horizontal', ratios: [0.5, 0.5], - }), - value: state => state.workspaceTileTabs, - }, ] describe('workspace setter notification isolation', () => { diff --git a/src/renderer/src/app-state/store.test.ts b/src/renderer/src/app-state/store.test.ts index 0c3b7d264..3777975ed 100644 --- a/src/renderer/src/app-state/store.test.ts +++ b/src/renderer/src/app-state/store.test.ts @@ -133,7 +133,7 @@ describe('palette sub-mode', () => { it('resets to the command list on close, so reopening never resumes a sub-flow', async () => { const { useAppStore } = await import('@renderer/app-state/store') - useAppStore.getState().setPaletteMode('buried') + useAppStore.getState().setPaletteMode('prompt-template') useAppStore.getState().closeCommandPalette() expect(useAppStore.getState().paletteMode).toBe('commands') diff --git a/src/renderer/src/app-state/types.ts b/src/renderer/src/app-state/types.ts index a81b0f54f..aab5cd332 100644 --- a/src/renderer/src/app-state/types.ts +++ b/src/renderer/src/app-state/types.ts @@ -2,7 +2,6 @@ import type { PerformancePanelRequest } from './uiShell/types' import type { PaletteMode } from '@renderer/features/command-palette/paletteMode' import type { Settings } from '@renderer/app-state/settings/types' import type { - DispatchAttachIntent, PendingCommandInvocation, UiShellState, } from '@renderer/app-state/uiShell/types' @@ -14,7 +13,6 @@ import type { ExtensionFailure } from '@renderer/apps/types' import type { ReaderModeState, SpotlightState, - TileTabsState, } from '@renderer/workspace/types' import type { ColorFlagId } from '@renderer/app-state/settings/dispatchColorFlags' @@ -47,8 +45,6 @@ export type UiShellSlice = UiShellState & { openPathPicker: (defaultValue?: string) => void closePathPicker: () => void setPathPickerDefault: (value: string) => void - openTileTabsModal: (initialSelectedIds: TabId[]) => void - closeTileTabsModal: () => void openReorderTabs: () => void closeReorderTabs: () => void openMergeProjectTabs: () => void @@ -59,8 +55,6 @@ export type UiShellSlice = UiShellState & { closeSettingsPage: () => void openAgentTitlePrompt: (sessionId: SessionId) => void closeAgentTitlePrompt: () => void - openBuryPrompt: (sessionId: SessionId) => void - closeBuryPrompt: () => void openRootManagementPrompt: (sessionId: SessionId) => void closeRootManagementPrompt: () => void openDebugBundleNotePrompt: (payload: { @@ -93,8 +87,6 @@ export type UiShellSlice = UiShellState & { openDispatchRowProjectPicker: (rowIndex: number) => void closeDispatchRowProjectPicker: () => void closeTiledDispatchPrompt: () => void - openDispatchAttach: (intent: DispatchAttachIntent) => void - closeDispatchAttach: () => void openLinkedAgent: (sessionId: SessionId) => void closeLinkedAgent: () => void toggleGitBar: () => void @@ -157,7 +149,6 @@ export type WorkspaceSlice = { workspaceRuntimes: Record workspaceSpotlight: SpotlightState | null workspaceReaderMode: ReaderModeState | null - workspaceTileTabs: TileTabsState | null /** Allocated spoken names keyed by SessionMeta.agentNameId. * * WHY this is store state and not a ref or a React context: three unrelated @@ -181,9 +172,6 @@ export type WorkspaceSlice = { setWorkspaceReaderMode: ( next: ReaderModeState | null | ((prev: ReaderModeState | null) => ReaderModeState | null), ) => void - setWorkspaceTileTabs: ( - next: TileTabsState | null | ((prev: TileTabsState | null) => TileTabsState | null), - ) => void setWorkspaceAgentNames: ( next: Record | ((prev: Record) => Record), diff --git a/src/renderer/src/app-state/uiShell/slice.ts b/src/renderer/src/app-state/uiShell/slice.ts index 7f2920738..33fbbcb3e 100644 --- a/src/renderer/src/app-state/uiShell/slice.ts +++ b/src/renderer/src/app-state/uiShell/slice.ts @@ -19,14 +19,11 @@ export const createUiShellSlice: StateCreator< paletteMode: DEFAULT_PALETTE_MODE, pathPickerOpen: false, pathPickerDefault: '', - tileTabsModalOpen: false, - tileTabsInitialSelectedIds: [], reorderTabsOpen: false, mergeProjectTabsOpen: false, pinAgentsOpen: false, settingsPageOpen: false, agentTitlePromptSessionId: null, - buryPromptSessionId: null, rootManagementPromptSessionId: null, debugBundleNotePrompt: null, recordingNotePrompt: null, @@ -37,7 +34,6 @@ export const createUiShellSlice: StateCreator< newAgentInOpen: false, tiledDispatchPromptOpen: false, dispatchRowProjectPickerRow: null, - dispatchAttachIntent: null, linkedAgentParentId: null, gitBarOpen: false, worktreesBarOpen: false, @@ -125,14 +121,6 @@ export const createUiShellSlice: StateCreator< setPathPickerDefault: value => set({ pathPickerDefault: value }, false, 'uiShell/setPathPickerDefault'), - openTileTabsModal: initialSelectedIds => - set({ - tileTabsModalOpen: true, - tileTabsInitialSelectedIds: initialSelectedIds, - }, false, 'uiShell/openTileTabsModal'), - closeTileTabsModal: () => - set({ tileTabsModalOpen: false }, false, 'uiShell/closeTileTabsModal'), - openReorderTabs: () => set({ reorderTabsOpen: true }, false, 'uiShell/openReorderTabs'), closeReorderTabs: () => @@ -157,11 +145,6 @@ export const createUiShellSlice: StateCreator< closeAgentTitlePrompt: () => set({ agentTitlePromptSessionId: null }, false, 'uiShell/closeAgentTitlePrompt'), - openBuryPrompt: sessionId => - set({ buryPromptSessionId: sessionId }, false, 'uiShell/openBuryPrompt'), - closeBuryPrompt: () => - set({ buryPromptSessionId: null }, false, 'uiShell/closeBuryPrompt'), - openRootManagementPrompt: sessionId => set({ rootManagementPromptSessionId: sessionId }, false, 'uiShell/openRootManagementPrompt'), closeRootManagementPrompt: () => @@ -223,11 +206,6 @@ export const createUiShellSlice: StateCreator< closeDispatchRowProjectPicker: () => set({ dispatchRowProjectPickerRow: null }, false, 'uiShell/closeDispatchRowProjectPicker'), - openDispatchAttach: intent => - set({ dispatchAttachIntent: intent }, false, 'uiShell/openDispatchAttach'), - closeDispatchAttach: () => - set({ dispatchAttachIntent: null }, false, 'uiShell/closeDispatchAttach'), - openLinkedAgent: sessionId => set({ linkedAgentParentId: sessionId }, false, 'uiShell/openLinkedAgent'), closeLinkedAgent: () => diff --git a/src/renderer/src/app-state/uiShell/types.ts b/src/renderer/src/app-state/uiShell/types.ts index b3f6ea22b..a02ffb89c 100644 --- a/src/renderer/src/app-state/uiShell/types.ts +++ b/src/renderer/src/app-state/uiShell/types.ts @@ -3,10 +3,6 @@ import type { TabId, SessionId } from '@renderer/workspace/types' import type { ExtensionListEntry } from '@shared/types/extensions' import type { ExtensionFailure } from '@renderer/apps/types' -export type DispatchAttachIntent = { - sessionId: SessionId - targetTabId: TabId -} /** * A command waiting to be dispatched through the shared execution gateway. @@ -63,8 +59,6 @@ export type UiShellState = { pendingCommandInvocation: PendingCommandInvocation | null pathPickerOpen: boolean pathPickerDefault: string - tileTabsModalOpen: boolean - tileTabsInitialSelectedIds: TabId[] /** When true, the Reorder Tabs modal is open. * * WHY this lives in uiShell instead of WorkspaceState: the modal is @@ -97,10 +91,9 @@ export type UiShellState = { * not whichever lane happens to be focused after the prompt appears. */ agentTitlePromptSessionId: SessionId | null - buryPromptSessionId: SessionId | null /** * Session awaiting the Root Agent Code Management confirmation (#906), or - * null. Stored like the bury and title prompts: the grant must land on the + * null. Stored like the title prompt: the grant must land on the * agent the command was invoked for, not whichever Dispatch lane is focused * by the time the user finishes reading the warning. */ @@ -122,28 +115,6 @@ export type UiShellState = { viewPromptsSessionId: SessionId | null tldrHistorySessionId: SessionId | null newAgentPlacementOpen: boolean - /** - * Non-null when the placement overlay is open in "attach detached - * session to grid" mode. The overlay reads this to skip the kind - * picker (the session already exists, we don't spawn a new one), which - * detached sessionId to insert, and which tab owns the placement target. - * - * WHY a separate field instead of overloading newAgentPlacementOpen: - * the two flows commit through different actions - * (commitNewAgentPlacement spawns a new session; - * attachDetachedToGrid moves an existing one), and conflating them - * forces every overlay code path to disambiguate at the bottom of - * the call stack instead of at the top. - * - * WHY the target tab is part of the intent: - * Tiled Dispatch lane selection does not mutate activeTabId. Deferring tab - * lookup until overlay render or reducer commit would make "attach the - * focused lane's row" depend on whichever tab happened to be active before - * the user entered global Tiled Dispatch. The visible row already carries - * the correct tab id, so the command captures it once and every later step - * treats it as the source of truth. - */ - dispatchAttachIntent: DispatchAttachIntent | null /** * Non-null when the placement overlay is open in "Linked Agent" * mode. The value is the PARENT session id — the agent that was @@ -168,11 +139,12 @@ export type UiShellState = { * WHY the target is captured up front rather than resolved at commit time: * exactly the reason `dispatchAttachIntent` documents above. Tiled Dispatch * lane selection does not mutate `activeTabId`, and - * `resolveDispatchSpawnTarget`'s tiled branch reads the focused LANE, never - * `dispatchMode.focusedSessionId`. So the tempting cheap version — focus the - * project, then open the normal flow — works in classic Dispatch and - * silently spawns into whatever project lane 0 happens to show in Tiled - * Dispatch. The visible header already knows its own tab; capture it once. + * `resolveDispatchSpawnTarget` reads the focused LANE, never the active + * project. So the tempting cheap version — activate the project, then open + * the normal flow — silently spawns into whatever project the focused lane + * happens to show. (It did work in classic Dispatch, which had a single + * focus the header click could move; #992 removed that layout.) The visible + * header already knows its own tab; capture it once. * * NOTE this does NOT make clicking "+" selection-neutral: the spawn still * sets `activeTabId` to the target project unconditionally, so the active diff --git a/src/renderer/src/app-state/workspace/slice.ts b/src/renderer/src/app-state/workspace/slice.ts index 8755c2f16..1404707ef 100644 --- a/src/renderer/src/app-state/workspace/slice.ts +++ b/src/renderer/src/app-state/workspace/slice.ts @@ -2,11 +2,11 @@ import type { StateCreator } from 'zustand' import type { AppStore, WorkspaceSlice } from '@renderer/app-state/types' import type { WorkspaceState } from '@renderer/workspace/types' +import { freshStage } from '@renderer/workspace/dispatch/gridShape' import type { SessionRuntime } from '@renderer/session-runtime/state' import type { ReaderModeState, SpotlightState, - TileTabsState, } from '@renderer/workspace/types' function applyUpdater(prev: T, next: T | ((prev: T) => T)): T { @@ -18,11 +18,10 @@ function applyUpdater(prev: T, next: T | ((prev: T) => T)): T { const initialWorkspaceState: WorkspaceState = { tabs: [], activeTabId: '', - gridRelatedSelections: {}, - dispatchMode: null, + stage: freshStage(), + // The pool. `detachedSessions`, `buried` and `gridRelatedSelections` sat + // beside it until #992; see WorkspaceState for where each went. sessions: {}, - detachedSessions: {}, - buried: [], // Fresh workspace has no pins. The array is the source of truth // for order: index 0 is the topmost pin in the Pinned section. pinnedSessionIds: [], @@ -38,7 +37,6 @@ export const createWorkspaceSlice: StateCreator< workspaceRuntimes: {}, workspaceSpotlight: null, workspaceReaderMode: null, - workspaceTileTabs: null, workspaceAgentNames: {}, setWorkspaceState: next => @@ -69,12 +67,6 @@ export const createWorkspaceSlice: StateCreator< return Object.is(workspaceReaderMode, state.workspaceReaderMode) ? state : { workspaceReaderMode } }, false, 'workspace/setWorkspaceReaderMode'), - setWorkspaceTileTabs: next => - set(state => { - const workspaceTileTabs = applyUpdater(state.workspaceTileTabs, next) - return Object.is(workspaceTileTabs, state.workspaceTileTabs) ? state : { workspaceTileTabs } - }, false, 'workspace/setWorkspaceTileTabs'), - setWorkspaceAgentNames: next => set(state => { const workspaceAgentNames = applyUpdater>(state.workspaceAgentNames, next) diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index aa594b8cc..d2ca70d66 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -57,7 +57,6 @@ export default function App() { // everything else is consumed by the shell pieces / surfaces directly. const dangerousAgentsEnabled = useAppStore(state => state.settings.dangerousAgentsEnabled) const useProxyStreaming = useAppStore(state => state.settings.useProxyStreaming) - const defaultWorkspaceMode = useAppStore(state => state.settings.defaultWorkspaceMode) const defaultBuiltInMcpDomains = useAppStore( state => state.settings.defaultBuiltInMcpDomains, ) @@ -88,7 +87,6 @@ export default function App() { const workspace = useWorkspace( dangerousAgentsEnabled, useProxyStreaming, - defaultWorkspaceMode, defaultBuiltInMcpDomains, ) useRenderedLeaseHygiene(workspace) diff --git a/src/renderer/src/app/controlGuide.ts b/src/renderer/src/app/controlGuide.ts index 9bd15bba5..0405327a9 100644 --- a/src/renderer/src/app/controlGuide.ts +++ b/src/renderer/src/app/controlGuide.ts @@ -30,14 +30,18 @@ Give the agent a concrete task, constraints and the desired completion evidence. For several tasks, create separate agents, title them by responsibility, and arrange or pin them. Read their progress before issuing dependent work. When one needs attention, reveal that existing session rather than creating another agent with a similar name.`, }, { - id: 'layouts', title: 'Grid, tiled tabs and Dispatch', - markdown: `Grid uses split panes within each project tab. Splitting, resizing, normalizing and rotating change the layout. Tiled Tabs shows several project tabs at once; each retains its own pane layout and focus. Focusing a project tab is different from focusing an agent inside it. + // Retitled with #992: "Grid, tiled tabs and Dispatch" named three surfaces + // that no longer exist. There is one layout and its vocabulary is lanes, + // rows, the agent index and projects (§5.4 of the plan: no guide, diagram + // or heading may name a mode). + id: 'layouts', title: 'The workspace: lanes, rows and the agent index', + markdown: `The workspace is a stage: ragged rows of lanes over a pool of sessions grouped by project. There is one layout; nothing is entered or toggled. -Dispatch separates the agent inventory from fixed grid placement. A detached session belongs to a project but does not occupy a grid leaf. Classic Dispatch shows the selected agent. Tiled Dispatch provides multiple rows and lanes: the same agent may legitimately be selected in more than one lane. Those are mirrored views of one session, not independent agents. Clicking a row’s shared index places that agent in its focused lane, or its first lane if focus is in another row; agents.show instead reuses an existing view. Agent creation selects the captured focused lane by default. To preserve all current assignments, pass selectCreated:false to agents.create, agents.resume or agents.duplicate, then read layout.read and use dispatch.configure with lane-select and the exact returned session ID. +The workspace is a stage: ragged rows of lanes over a pool of sessions grouped by project. A session not shown in a lane is parked, not gone — it stays in its project’s index. The same agent may legitimately be selected in more than one lane; those are mirrored views of one session, not independent agents. Clicking a row’s index places that agent in that row’s focused lane; agents.show instead reuses an existing view. Agent creation fills the lane focused at creation ONLY when it is empty (context-places): an occupied lane is never displaced, so creations usually land in the pool and the index marks them new until placed. To keep every lane assignment untouched, pass selectCreated:false to agents.create, agents.resume or agents.duplicate, then read layout.read and use dispatch.configure with lane-select and the exact returned session ID. -Related linked/orchestration children can be displayed inside a parent's grid pane without becoming new grid leaves. A navigation request should normally reuse an existing view of the target. Opening it in a specifically chosen lane is a different intent and can deliberately create another view. Cross-project navigation can change Dispatch scope when needed to keep selected work reachable. +Linked and orchestration children nest under their parent in every index that lists them. A navigation request should normally reuse an existing view of the target; opening it in a specifically chosen lane is a different intent and deliberately creates another view. -Buried sessions are hidden from normal placement and have a separate restore route. Detached, buried, off-screen, hibernated and closed are different states. Search the inventory and inspect placement before deciding to restore, wake or recreate anything.`, +Parked, off-screen, hibernated and closed are different states. A parked agent with no backend wakes on selection or first send; reading its history does not wake it. Search the inventory and inspect placement before deciding to restore, wake or recreate anything.`, }, { id: 'agent-lifecycle', title: 'Agent identity, runtime and lifecycle', @@ -109,7 +113,7 @@ The operation history records retained MCP requests, arguments, steps, results a 4. Read conversation depth incrementally for both agents. Expand to activity or full detail only when a question needs it. 5. Find the session needing attention and show its existing view. Use the UI for a provider-specific dialog or visual inspection when appropriate. 6. Observe again after computer use. Inspect diffs/results and ask for any missing verification before treating the work as finished. -7. Retrieve operation history if any outcome is uncertain. Keep, detach, bury or close sessions according to the user's requested cleanup, respecting close impact. +7. Retrieve operation history if any outcome is uncertain. Keep or close sessions according to the user's requested cleanup, respecting close impact. A session that should stay alive but off screen needs no action: select another session into its lane and it remains in the pool. For deeper instruction, request a section from this tool or page through full mode. The feature reference explains individual workflows; the command and interaction catalogs provide the exact names, descriptions and current shortcuts for this build.`, }, diff --git a/src/renderer/src/app/shell/MainSurface.tsx b/src/renderer/src/app/shell/MainSurface.tsx index 9d53a17fe..2e3ad7f34 100644 --- a/src/renderer/src/app/shell/MainSurface.tsx +++ b/src/renderer/src/app/shell/MainSurface.tsx @@ -4,9 +4,7 @@ import { SettingsPage } from '@renderer/features/settings/ui/SettingsPage' import { ReaderView } from '@renderer/features/reader/ui/ReaderView' import { SpotlightView } from '@renderer/features/spotlight/ui/SpotlightView' import { GlobalEditorShell } from '@renderer/features/global-editor/ui/GlobalEditorShell' -import { TileTabsView } from '@renderer/features/tile-tabs/ui/TileTabsView' import { DispatchLayout } from '@renderer/workspace/dispatch/DispatchLayout' -import { TileTree } from '@renderer/workspace/tile-tree/TileTree' import { NewAgentPlacementOverlay } from '@renderer/features/workspace/ui/NewAgentPlacementOverlay' import { usePlacementOverlay } from '@renderer/features/workspace/surfaces/usePlacementOverlay' import { RetainedWorkspaceSurface } from './RetainedWorkspaceSurface' @@ -112,14 +110,16 @@ export function MainSurface({ onNewTabRequest }: { onNewTabRequest: () => void } ) : null}