-
-
diff --git a/src/renderer/src/app/shell/TerminalDimensionOwnership.renderer.test.tsx b/src/renderer/src/app/shell/TerminalDimensionOwnership.renderer.test.tsx
index d9b7b4318..5b5074133 100644
--- a/src/renderer/src/app/shell/TerminalDimensionOwnership.renderer.test.tsx
+++ b/src/renderer/src/app/shell/TerminalDimensionOwnership.renderer.test.tsx
@@ -49,13 +49,53 @@ vi.mock('@renderer/features/global-editor/ui/GlobalEditorShell', () => ({
GlobalEditorShell: ({ children }: { children: ReactNode }) => <>{children}>,
}))
-vi.mock('@renderer/features/tile-tabs/ui/TileTabsView', () => ({
- TileTabsView: () => null,
-}))
-
-vi.mock('@renderer/workspace/dispatch/DispatchLayout', () => ({
- DispatchLayout: () => null,
-}))
+// Unified layout (#992): MainSurface renders the lane stage — there is no
+// tile-tree branch to mount panes anymore. The retention contracts this
+// suite protects (takeovers HIDE the workspace, never unmount it; dimension
+// ownership follows the actually-mounted terminal) are unchanged; what
+// changed is the harness: sessions must be PLACED IN LANES for their panes
+// to mount, exactly like the real app now works.
+vi.mock('@renderer/workspace/dispatch/TiledDispatchLayout', async () => {
+ const { useEffect } = await import('react')
+ // The REAL ownership wrapper, not a fake: this suite's entire subject is
+ // the dimension-claim handshake (register on visible, release on hidden),
+ // so the stand-in lane must register exactly like a real pane terminal.
+ const { MountedAgentTerminalOwner } = await import(
+ '@renderer/workspace/terminal/AgentTerminalOwnership'
+ )
+ return {
+ // A lane-shaped stand-in that mounts each lane's pane through the same
+ // ownership wrapper the real layout uses. The real TiledDispatchLayout's
+ // own behavior is covered by gridDispatchLayout.renderer.test.tsx; this
+ // suite is about RetainedWorkspaceSurface + ownership across takeovers,
+ // so the lane grid itself stays a thin mount point here.
+ TiledDispatchLayout: ({ workspace }: { workspace: { state: { stage: { lanes: Array<{ selectedSessionId?: string }> } } } }) => {
+ const lanes = workspace.state.stage.lanes
+ return (
+ <>
+ {lanes.map((lane, index) =>
+ lane.selectedSessionId ? (
+
+ ) : null,
+ )}
+ >
+ )
+ },
+ }
+ function LanePane({ sessionId }: { sessionId: string }) {
+ useEffect(() => {
+ harness.paneMounts[sessionId] = (harness.paneMounts[sessionId] ?? 0) + 1
+ return () => {
+ harness.paneUnmounts[sessionId] = (harness.paneUnmounts[sessionId] ?? 0) + 1
+ }
+ }, [sessionId])
+ return (
+
+
+
+ )
+ }
+})
vi.mock('@renderer/features/workspace/ui/NewAgentPlacementOverlay', () => ({
NewAgentPlacementOverlay: () => null,
@@ -90,11 +130,7 @@ describe('terminal dimension ownership across main-surface takeovers', () => {
harness.paneMounts = {}
harness.paneUnmounts = {}
const runtime = emptyRuntime()
- const activeTab = {
- id: 'tab-1',
- focusedSessionId: 'session-1',
- root: { type: 'leaf', sessionId: 'session-1' },
- }
+ const activeTab = { id: 'tab-1', title: 'Project' }
harness.appState = {
workspaceRuntimes: {},
debugPanelOpen: true,
@@ -124,15 +160,20 @@ describe('terminal dimension ownership across main-surface takeovers', () => {
'session-1': {
kind: 'claude',
agentViewModeOverride: 'terminal',
+ projectId: 'tab-1',
+ joinedAt: 0,
},
},
- detachedSessions: {},
- gridRelatedSelections: {},
- dispatchMode: null,
+ pinnedSessionIds: [],
+ // The stage placing session-1 — the unified workspace's one mount
+ // path. One row, one occupied lane, focused.
+ stage: {
+ lanes: [{ selectedSessionId: 'session-1' }],
+ rows: [{ length: 1 }],
+ focusedLane: 0,
+ },
},
activeTab,
- dispatchMode: null,
- tileTabs: null,
readerMode: null,
spotlight: null,
getRuntime: () => runtime,
@@ -215,31 +256,31 @@ describe('terminal dimension ownership across main-surface takeovers', () => {
})
it('guards the debug target by the terminal Spotlight actually mounted', async () => {
- const splitTab = {
- ...(harness.workspace.activeTab as Record
),
- root: {
- type: 'split',
- direction: 'vertical',
- ratio: 0.5,
- a: { type: 'leaf', sessionId: 'session-1' },
- b: { type: 'leaf', sessionId: 'session-2' },
- },
+ // Unified layout: BOTH panes are lane occupants, and both are ordinary
+ // pool rows of tab-1. Spotlight mounts its own leaf for session-2 on top
+ // of the retained (hidden) stage, which still holds both lanes.
+ const tiled = {
+ lanes: [{ selectedSessionId: 'session-1' }, { selectedSessionId: 'session-2' }],
+ rows: [{ length: 2 }],
+ focusedLane: 0,
}
harness.workspace = {
...harness.workspace,
- activeTab: splitTab,
spotlight: { tabId: 'tab-1', focusedSessionId: 'session-2' },
setSpotlightSession: vi.fn(),
+ stage: tiled,
state: {
...(harness.workspace.state as Record),
- tabs: [splitTab],
sessions: {
...((harness.workspace.state as { sessions: Record }).sessions),
'session-2': {
kind: 'codex',
agentViewModeOverride: 'terminal',
+ projectId: 'tab-1',
+ joinedAt: 1,
},
},
+ stage: tiled,
},
}
diff --git a/src/renderer/src/app/surfaces/registry.tsx b/src/renderer/src/app/surfaces/registry.tsx
index 5cd9727fc..8206c94f1 100644
--- a/src/renderer/src/app/surfaces/registry.tsx
+++ b/src/renderer/src/app/surfaces/registry.tsx
@@ -13,10 +13,8 @@ import { RemotePanelSurface } from '@renderer/features/remote/surfaces/RemotePan
import { DebugSurfaces } from '@renderer/features/debug/surfaces/DebugSurfaces'
import { CommandPaletteSurface } from '@renderer/features/command-palette/surfaces/CommandPaletteSurface'
import { PathPickerSurface } from '@renderer/features/path-picker/surfaces/PathPickerSurface'
-import { TileTabsModalSurface } from '@renderer/features/workspace/surfaces/TileTabsModalSurface'
import { ReorderTabsSurface } from '@renderer/features/workspace/surfaces/ReorderTabsSurface'
import { PinAgentsSurface } from '@renderer/features/dispatch-pin/surfaces/PinAgentsSurface'
-import { BuryPanePromptSurface } from '@renderer/features/workspace/surfaces/BuryPanePromptSurface'
import { RootManagementConfirmSurface } from '@renderer/features/workspace/surfaces/RootManagementConfirmSurface'
import { MergeProjectTabsSurface } from '@renderer/features/workspace/surfaces/MergeProjectTabsSurface'
import { CloseConfirmationSurface } from '@renderer/features/workspace/surfaces/CloseConfirmationSurface'
@@ -74,10 +72,8 @@ export const modalSurfaces: SurfaceEntry[] = [
{ id: 'dispatch-row-project', Component: DispatchRowProjectSurface },
{ id: 'caffeinate-toast', Component: CaffeinateToastSurface },
{ id: 'keyboard-shortcuts', Component: KeyboardShortcutsSurface },
- { id: 'tile-tabs', Component: TileTabsModalSurface },
{ id: 'reorder-tabs', Component: ReorderTabsSurface },
{ id: 'pin-agents', Component: PinAgentsSurface },
- { id: 'bury-pane', Component: BuryPanePromptSurface },
{ id: 'close-confirmation', Component: CloseConfirmationSurface },
{ id: 'debug-bundle-note', Component: DebugBundleNoteSurface },
{ id: 'recording-note', Component: RecordingNoteSurface },
diff --git a/src/renderer/src/apps/api/createAppHostApi.ts b/src/renderer/src/apps/api/createAppHostApi.ts
index fcc926227..816649e9f 100644
--- a/src/renderer/src/apps/api/createAppHostApi.ts
+++ b/src/renderer/src/apps/api/createAppHostApi.ts
@@ -2,7 +2,7 @@ import { useAppStore } from '@renderer/app-state/hooks'
// This API needs the tree traversal, not the workspace hook's compatibility
// barrel. That barrel imports the complete provider/editor UI and makes a
// standalone extension host initialize unrelated application modules.
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
+import { resolveTabSessions } from '@renderer/workspace/queries'
import type { AgentCodeApiV1, JsonValue } from '@renderer/apps/api/types'
import { CUSTOM_APPEARANCE_CSS_VARS } from '@renderer/app-state/settings/customAppearance'
@@ -150,12 +150,28 @@ export function createAppHostApi(deps: AppHostApiDeps): AgentCodeApiV1 {
},
panes: {
- observe: async () =>
- useAppStore.getState().workspaceState.tabs.map(tab => ({
+ // `leafSessionIds` is a PUBLISHED extension-SDK field name and keeps its
+ // spelling, but there are no leaves (#992): it is every session filed
+ // under the project, in index order. For the workspaces extensions were
+ // written against — one-pane tabs with their agents parked in Dispatch —
+ // this now reports the agents an extension author would have expected
+ // and the old value (the tab's lone tile leaf) did not. Renamed with the
+ // rest of the SDK surface in stage 7 of the plan.
+ observe: async () => {
+ const state = useAppStore.getState().workspaceState
+ return state.tabs.map(tab => ({
tabId: tab.id,
- leafSessionIds: [...collectLeaves(tab.root)],
- })),
- subscribe: listener => useAppStore.subscribe(s => s.workspaceState.tabs, () => listener()),
+ leafSessionIds: resolveTabSessions(state, tab.id),
+ }))
+ },
+ // Membership lives on the session rows now, so a session joining or
+ // leaving a project changes `sessions` without touching `tabs`. Listening
+ // to `tabs` alone (correct while a tab owned a tree) would miss it.
+ subscribe: listener => {
+ const offTabs = useAppStore.subscribe(s => s.workspaceState.tabs, () => listener())
+ const offSessions = useAppStore.subscribe(s => s.workspaceState.sessions, () => listener())
+ return () => { offTabs(); offSessions() }
+ },
},
}
}
diff --git a/src/renderer/src/apps/host/derive.ts b/src/renderer/src/apps/host/derive.ts
index 64f78cdb4..76063c39a 100644
--- a/src/renderer/src/apps/host/derive.ts
+++ b/src/renderer/src/apps/host/derive.ts
@@ -58,11 +58,11 @@ export function deriveAppDefinitions(installed: ExtensionListEntry[]): AppDefini
export function deriveExtensionCommands(
installed: ExtensionListEntry[],
openApp: (appId: string) => void,
- // Opens a contributed view as a PANE (a tile leaf) instead of a modal. A view
+ // Opens a contributed view as a PANE (a stage session) instead of a modal. A view
// whose manifest `mount` is 'panel' routes here; 'modal' routes to openApp. Made
// optional with a no-op default so the Settings call sites that only LIST commands
// stay a 3-arg call — the routing still resolves there, it just never fires.
- openInPane: (viewId: string) => void = () => {},
+ openInPane: (viewId: string, options?: { reveal?: boolean }) => void = () => {},
): CommandDef[] {
const seen = new Set()
const commands: CommandDef[] = []
@@ -151,7 +151,17 @@ export function deriveExtensionCommands(
// extension's action command opened it as a floating modal — directly
// contradicting its own manifest — because deriveAppDefinitions builds
// an AppDefinition for every view regardless of declared mount.
- if (viewMountById.get(onlyView) === 'panel') openInPane(onlyView)
+ //
+ // `reveal` (#1013 parity review, MAJOR): the queued command
+ // flushes only when a frame MOUNTS. Under the unified stage a
+ // plain pane open waits in the pool when the focused lane is
+ // occupied, so no frame mounted. The command never ran, each
+ // retry pooled another copy of the view and queued it again,
+ // and placing any copy later fired them all at once. On main
+ // the view split in beside the focus and was visible at once.
+ // Reveal restores that: the view (an existing one if there is
+ // one) takes the focused lane.
+ if (viewMountById.get(onlyView) === 'panel') openInPane(onlyView, { reveal: true })
else openApp(onlyView)
}
}
diff --git a/src/renderer/src/apps/host/frameRegistry.ts b/src/renderer/src/apps/host/frameRegistry.ts
index 799183643..42aa8aedf 100644
--- a/src/renderer/src/apps/host/frameRegistry.ts
+++ b/src/renderer/src/apps/host/frameRegistry.ts
@@ -22,8 +22,9 @@ type FrameDispatch = (commandId: string) => void
// ── A STACK PER EXTENSION, NOT A SINGLE ENTRY ──
// This was a `Map` whose comment asserted "there is at most
// one visible frame per extension". The pane path broke that assumption the moment
-// it landed: openExtensionViewInPane always splits a NEW leaf, and a pane and a
-// modal of the same extension are explicitly designed to coexist. So a second frame
+// it landed: openExtensionViewInPane opened a NEW pane on every call (it still
+// does, except for a legacy command's reveal), and a pane and a modal of the same
+// extension are explicitly designed to coexist. So a second frame
// silently overwrote the first's dispatcher — and because clearFrameDispatch only
// removes an entry still pointing at the disposing frame, closing the NEWER frame
// deleted the entry outright while an older live frame was still on screen. The
diff --git a/src/renderer/src/apps/host/legacyColdCommand.renderer.test.tsx b/src/renderer/src/apps/host/legacyColdCommand.renderer.test.tsx
new file mode 100644
index 000000000..895a206ef
--- /dev/null
+++ b/src/renderer/src/apps/host/legacyColdCommand.renderer.test.tsx
@@ -0,0 +1,102 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { act, cleanup, renderHook } from '@testing-library/react'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { useAppStore } from '@renderer/app-state/hooks'
+import { emptyRuntime } from '@renderer/session-runtime/state'
+import type { CommandContext } from '@renderer/features/command-palette/types'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
+import { useWorkspace } from '@renderer/workspace/hook'
+import type { ExtensionListEntry, ExtensionManifest } from '@shared/types/extensions'
+import { deriveExtensionCommands } from './derive'
+import { clearFrameDispatch, discardPendingCommands, setFrameDispatch } from './frameRegistry'
+
+// #1013 parity review, MAJOR: a cold command from an API v1 panel extension
+// must run even when the focused lane is occupied.
+//
+// A v1 extension executes only inside its live frame. A cold "timer.start"
+// therefore queues itself and opens the view, and the queue flushes when that
+// frame mounts. Under the unified stage a plain pane open waits in the POOL
+// when the focused lane is occupied, so no frame mounted: the command never
+// ran, and every retry pooled another copy of the view.
+//
+// The manifest is the real Timer 0.3.1 manifest, its last API v1 release
+// (Juliusolsson05/agent-code-timer at 74de8c4c), with a panel view. The
+// command is derived and run by the real host code against the real
+// workspace hook; only process/IPC ingress is suppressed, as in the
+// orchestration runtime test.
+vi.mock('@renderer/workspace/hook/ipc/useIpcSubscriptions', () => ({ useIpcSubscriptions: () => undefined }))
+vi.mock('@renderer/workspace/hook/ipc/useWorkspaceAdoption', () => ({ useWorkspaceAdoption: () => undefined }))
+vi.mock('@renderer/workspace/hook/persistence/useBootstrap', () => ({ useBootstrap: () => undefined }))
+vi.mock('@renderer/features/sessionFeed/SessionFeedContext', () => ({ useSessionFeed: () => ({}) }))
+
+const manifest = JSON.parse(readFileSync(
+ resolve(__dirname, '../../../../../testing/fixtures/extensions/timer-0.3.1.agent-code.extension.json'), 'utf8',
+)) as ExtensionManifest
+const timer: ExtensionListEntry = {
+ manifest, origin: 'github', repo: 'Juliusolsson05/agent-code-timer', ref: 'v0.3.1',
+ sha256: 'a'.repeat(64), installedAt: 1, present: true,
+} as ExtensionListEntry
+
+const originalStore = useAppStore.getState()
+const originalApi = Object.getOwnPropertyDescriptor(window, 'api')
+
+beforeEach(() => {
+ useAppStore.setState({
+ workspaceState: {
+ ...originalStore.workspaceState,
+ activeTabId: 'project', stage: oneLaneStage('agent'), pinnedSessionIds: [],
+ tabs: [{ id: 'project', title: 'Project' }],
+ sessions: { agent: { kind: 'claude', cwd: '/repo', projectId: 'project', joinedAt: 0 } },
+ },
+ workspaceRuntimes: { agent: emptyRuntime() },
+ })
+ Object.defineProperty(window, 'api', { configurable: true, value: {
+ onOrchestrationRequest: () => () => undefined,
+ onAgentManagementRequest: () => () => undefined,
+ ghostRead: async () => [],
+ reportSessionLifecycle: vi.fn(),
+ appendFeedDebugLog: async () => undefined,
+ } })
+})
+afterEach(() => {
+ cleanup()
+ discardPendingCommands('timer')
+ useAppStore.setState(originalStore, true)
+ if (originalApi) Object.defineProperty(window, 'api', originalApi)
+ else Reflect.deleteProperty(window, 'api')
+})
+
+const timerViews = () => Object.entries(useAppStore.getState().workspaceState.sessions)
+ .filter(([, meta]) => meta.kind === 'extension-view' && meta.extensionViewId === 'timer.main')
+ .map(([id]) => id)
+
+it('a cold legacy command puts its view on screen, reuses it on retry, and runs once the frame is up', () => {
+ const hook = renderHook(() => useWorkspace())
+ const openApp = vi.fn()
+ const run = () => {
+ const commands = deriveExtensionCommands([timer], openApp, hook.result.current.openExtensionViewInPane)
+ const start = commands.find(command => command.id === 'timer.start')!
+ act(() => { void start.run?.({ ui: { closePalette: vi.fn() } } as unknown as CommandContext) })
+ }
+
+ run()
+ const [view] = timerViews()
+ expect(view).toBeDefined()
+ const { stage } = useAppStore.getState().workspaceState
+ // On screen, so its frame mounts and the queued command can flush.
+ expect(stage.lanes[stage.focusedLane]?.selectedSessionId).toBe(view)
+ // The agent it replaced in the lane is still there, in the pool.
+ expect(useAppStore.getState().workspaceState.sessions.agent).toBeDefined()
+ expect(openApp).not.toHaveBeenCalled()
+
+ // Pressing again before the frame is ready opens no second copy.
+ run()
+ expect(timerViews()).toEqual([view])
+
+ // The frame signals ready: the queued command runs in it.
+ const dispatch = vi.fn()
+ setFrameDispatch('timer', dispatch)
+ expect(dispatch).toHaveBeenCalledWith('timer.start')
+ clearFrameDispatch('timer', dispatch)
+})
diff --git a/src/renderer/src/apps/host/testing/electronHarness.tsx b/src/renderer/src/apps/host/testing/electronHarness.tsx
index 05e97089d..3053c2f7b 100644
--- a/src/renderer/src/apps/host/testing/electronHarness.tsx
+++ b/src/renderer/src/apps/host/testing/electronHarness.tsx
@@ -7,6 +7,7 @@ import { useAppStore } from '@renderer/app-state/hooks'
import { createAppHostApi } from '@renderer/apps/api/createAppHostApi'
import { viewComponentFor } from '@renderer/apps/host/viewBridge'
import { ThemePickerRow } from '@renderer/features/settings/ui/ThemePickerRow'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
let root: Root | undefined
const messages: Array> = []
@@ -22,16 +23,14 @@ function KeyboardFixture({ render }: { render: (focus: () => void, focused: bool
const [selected, setSelected] = useState('previous')
selectedPane = selected
resetSelection = () => setSelected('previous')
- const tab = { id: 'fixture-tab', title: 'Fixture', focusedSessionId: selected,
- root: { type: 'split', direction: 'horizontal', ratio: 0.5,
- a: { type: 'leaf', sessionId: 'previous' }, b: { type: 'leaf', sessionId: 'extension' } } }
+ const tab = { id: 'fixture-tab', title: 'Fixture' }
// Only workspace data is fixture-owned. Use the real global keyboard router
// and store invocation queue, so native forwarding cannot pass by calling a
// test-only command handler that skips focus/context/override behavior.
const workspace = { state: { activeTabId: tab.id, tabs: [tab],
- sessions: { previous: { kind: 'terminal' }, extension: { kind: 'extension-view' } },
- detachedSessions: {}, buried: [], pinnedSessionIds: [], gridRelatedSelections: {}, dispatchMode: null },
- activeTab: tab, dispatchMode: null, readerMode: null, spotlight: null, tileTabs: null, runtimes: {},
+ sessions: { previous: { kind: 'terminal', projectId: tab.id, joinedAt: 0 }, extension: { kind: 'extension-view', projectId: tab.id, joinedAt: 1 } },
+ pinnedSessionIds: [], stage: oneLaneStage(selected) },
+ activeTab: tab, stage: oneLaneStage(selected), readerMode: null, spotlight: null, runtimes: {},
} as unknown as Workspace
useKeybinds(workspace)
return render(() => setSelected('extension'), selected === 'extension')
diff --git a/src/renderer/src/control/featureReference.ts b/src/renderer/src/control/featureReference.ts
index 392a0141c..22368ae13 100644
--- a/src/renderer/src/control/featureReference.ts
+++ b/src/renderer/src/control/featureReference.ts
@@ -24,7 +24,6 @@ import { controlReference as reference22 } from '@renderer/features/session-prev
import { controlReference as reference23 } from '@renderer/features/settings/controlReference'
import { controlReference as reference24 } from '@renderer/features/setup/controlReference'
import { controlReference as reference25 } from '@renderer/features/spotlight/controlReference'
-import { controlReference as reference27 } from '@renderer/features/tile-tabs/controlReference'
import { controlReference as reference28 } from '@renderer/features/usage/controlReference'
import { controlReference as reference29 } from '@renderer/features/voice-dictation/controlReference'
import { controlReference as reference30 } from '@renderer/features/workflows/controlReference'
@@ -63,7 +62,6 @@ export const featureReferences = [
...reference23,
...reference24,
...reference25,
- ...reference27,
...reference28,
...reference29,
...reference30,
@@ -87,7 +85,6 @@ export const referenceOwnership = {
"caffeinate": "caffeinate",
"session-preview": "session-preview",
"voice-dictation": "dictation",
- "tile-tabs": "tiled-tabs",
"path-picker": "path-picker",
"conversations": "conversations",
"global-editor": "global-editor",
diff --git a/src/renderer/src/features/agent-status/model/agentStatusModel.renderer.test.ts b/src/renderer/src/features/agent-status/model/agentStatusModel.renderer.test.ts
index 0e91397e8..02d829f59 100644
--- a/src/renderer/src/features/agent-status/model/agentStatusModel.renderer.test.ts
+++ b/src/renderer/src/features/agent-status/model/agentStatusModel.renderer.test.ts
@@ -3,13 +3,14 @@ import { expect, it } from 'vitest'
import { emptyRuntime } from '@renderer/session-runtime/state'
import type { WorkspaceState } from '@renderer/workspace/types'
import { buildAgentStatusModel } from './agentStatusModel'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
it('describes a terminal with the session facts that apply to it (#865)', () => {
const state = {
- tabs: [{ id: 'tab', title: 'project', root: { type: 'leaf', sessionId: 'shell' }, focusedSessionId: 'shell' }],
- activeTabId: 'tab', dispatchMode: null, gridRelatedSelections: {},
- sessions: { shell: { cwd: '/work/api', kind: 'terminal', title: 'dev server' } },
- detachedSessions: {}, buried: [], pinnedSessionIds: ['shell'],
+ tabs: [{ id: 'tab', title: 'project' }],
+ activeTabId: 'tab', stage: oneLaneStage('shell'),
+ sessions: { shell: { cwd: '/work/api', kind: 'terminal', title: 'dev server', projectId: 'tab', joinedAt: 0 } },
+ pinnedSessionIds: ['shell'],
} as unknown as WorkspaceState
const runtime = { ...emptyRuntime(), sessionStatus: 'running' as const, activityStatus: 'npm' }
expect(buildAgentStatusModel(state, runtime, 'shell')).toMatchObject({
diff --git a/src/renderer/src/features/agent-status/model/agentStatusModel.ts b/src/renderer/src/features/agent-status/model/agentStatusModel.ts
index bb5a750b0..e2b9a90a0 100644
--- a/src/renderer/src/features/agent-status/model/agentStatusModel.ts
+++ b/src/renderer/src/features/agent-status/model/agentStatusModel.ts
@@ -5,7 +5,6 @@ import {
isPinned,
} from '@renderer/workspace/dispatch/dispatchSelectors'
import { commandTargetSessionIdForState } from '@renderer/workspace/hook/selectors/commandTargetSessionId'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
import { sessionDisplayTitle } from '@renderer/workspace/sessionDisplayTitle'
import type {
SessionId,
@@ -39,8 +38,20 @@ export type AgentStatusModel = {
transcriptError: string | null
}
placement: {
- bucket: 'grid' | 'detached-dispatch' | 'pinned-dispatch' | 'unknown'
- physical: 'grid' | 'detached' | 'unknown'
+ /**
+ * Where the agent is, in stage terms (#992):
+ * 'pinned' — in the Pinned section of every index;
+ * 'pool' — an ordinary row of its project's index;
+ * 'unknown' — no row lists it (its project is gone, or it is mid-spawn).
+ * Until the unified layout this was 'grid' | 'detached-dispatch' |
+ * 'pinned-dispatch' with a separate `physical: 'grid' | 'detached'` — which
+ * of v2's owner structures held the session. There is one owner now, so
+ * that axis is gone and the useful one took its place: `lanes`.
+ */
+ bucket: 'pool' | 'pinned' | 'unknown'
+ /** Flat, row-major indices of every lane showing this agent. Empty means
+ * parked: alive in the pool, on no lane. */
+ lanes: number[]
dispatchLabel: string | null
tabId: TabId | null
tabTitle: string | null
@@ -131,69 +142,34 @@ function derivePlacement(
const pinned = isPinned(state, sessionId)
const row = buildVisibleDispatchRows(state).find(item => item.sessionId === sessionId) ?? null
const commandTargetId = commandTargetSessionIdForState(state)
- const activeTabId = state.activeTabId
+ const lanes = state.stage.lanes.flatMap((lane, index) =>
+ lane.selectedSessionId === sessionId ? [index] : [])
+ // The index row is the whole answer: it already carries the project the
+ // session is filed under, and a session with no row is one nothing can show.
+ //
+ // Until #992 two fallbacks followed — a scan of every tab's tile tree, and a
+ // scan of the detached bucket — for sessions the row selector did not list,
+ // and the result distinguished 'grid' from 'detached' placement. Both scans
+ // read structures that no longer exist, and every owned session now has a
+ // row, so they could only ever have found nothing.
if (row) {
- const physical = row.placement === 'detached' ? 'detached' : 'grid'
return {
- bucket: pinned
- ? 'pinned-dispatch'
- : physical === 'detached'
- ? 'detached-dispatch'
- : 'grid',
- physical,
+ bucket: pinned ? 'pinned' : 'pool',
+ lanes,
dispatchLabel: row.label,
tabId: row.tabId,
tabTitle: row.tabTitle,
tabIndex: row.tabIndex,
- activeTab: row.tabId === activeTabId,
- focused: commandTargetId === sessionId,
- pinned,
- }
- }
-
- const gridOwner = findGridOwner(state, sessionId)
- if (gridOwner) {
- return {
- bucket: pinned ? 'pinned-dispatch' : 'grid',
- physical: 'grid',
- dispatchLabel: null,
- tabId: gridOwner.id,
- tabTitle: gridOwner.title,
- tabIndex: gridOwner.index,
- activeTab: gridOwner.id === activeTabId,
- focused: commandTargetId === sessionId,
- pinned,
- }
- }
-
- // WHY this fallback is intentionally narrow:
- // `Show Agent Status` is command-target driven, so normal callers should
- // arrive here only for a visible grid or Dispatch row. We still surface
- // detached ownership when the model is used in tests or future inspectors,
- // but we do not walk `state.buried` or invent hidden-session UI in v1. Buried
- // panes are not command targets; showing them here would quietly broaden this
- // feature into a workspace debugger instead of the compact focused-agent
- // status view requested in #209.
- const detached = Object.values(state.detachedSessions)
- .find(entry => entry.sessionId === sessionId) ?? null
- if (detached) {
- return {
- bucket: pinned ? 'pinned-dispatch' : 'detached-dispatch',
- physical: 'detached',
- dispatchLabel: null,
- tabId: detached.projectTabId,
- tabTitle: detached.projectTabTitle,
- tabIndex: detached.projectTabIndex,
- activeTab: detached.projectTabId === activeTabId,
+ activeTab: row.tabId === state.activeTabId,
focused: commandTargetId === sessionId,
pinned,
}
}
return {
- bucket: pinned ? 'pinned-dispatch' : 'unknown',
- physical: 'unknown',
+ bucket: pinned ? 'pinned' : 'unknown',
+ lanes,
dispatchLabel: null,
tabId: null,
tabTitle: null,
@@ -204,27 +180,6 @@ function derivePlacement(
}
}
-function findGridOwner(
- state: WorkspaceState,
- sessionId: SessionId,
-): { id: TabId; title: string; index: number } | null {
- // WHY this scans tile leaves directly instead of `resolveTabSessions`:
- // status placement needs to distinguish physical grid placement from
- // detached Dispatch ownership. `resolveTabSessions` deliberately returns the
- // union of both buckets for membership questions, which would erase the
- // difference this panel exists to explain. Keeping this scan private to the
- // status model, after trying the Dispatch row selector first, prevents each
- // UI surface from re-learning the grid-vs-detached split independently.
- for (let index = 0; index < state.tabs.length; index += 1) {
- const tab = state.tabs[index]
- if (!tab) continue
- if (collectLeaves(tab.root).includes(sessionId)) {
- return { id: tab.id, title: tab.title, index }
- }
- }
- return null
-}
-
function normalizeOptionalString(value: string | null | undefined): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
diff --git a/src/renderer/src/features/agent-status/model/formatAgentStatus.ts b/src/renderer/src/features/agent-status/model/formatAgentStatus.ts
index ae1d719af..be5cf6de2 100644
--- a/src/renderer/src/features/agent-status/model/formatAgentStatus.ts
+++ b/src/renderer/src/features/agent-status/model/formatAgentStatus.ts
@@ -16,14 +16,16 @@ export function formatProviderSession(model: AgentStatusModel): string {
}
export function formatPlacement(model: AgentStatusModel): string {
- const base =
- model.placement.bucket === 'pinned-dispatch'
- ? `Pinned Dispatch · ${formatPhysicalPlacement(model.placement.physical)}`
- : model.placement.bucket === 'detached-dispatch'
- ? 'Detached Dispatch'
- : model.placement.bucket === 'grid'
- ? 'Grid'
- : 'unknown'
+ // "Where is this agent?" in the words the stage uses: which lane(s) show it,
+ // or that it is parked. (Until #992 this read 'Grid', 'Detached Dispatch' or
+ // 'Pinned Dispatch · grid|detached' — which v2 owner structure held it.)
+ if (model.placement.bucket === 'unknown') return 'unknown'
+ const lanes = model.placement.lanes
+ const where = lanes.length === 0
+ ? 'Parked'
+ // 1-based: lanes are counted the way the user counts them on screen.
+ : `Lane ${lanes.map(index => index + 1).join(', ')}`
+ const base = model.placement.bucket === 'pinned' ? `Pinned · ${where}` : where
return model.placement.dispatchLabel
? `${base} · ${model.placement.dispatchLabel}`
: base
@@ -136,12 +138,6 @@ function formatOwnerTab(model: AgentStatusModel): string {
return `${model.placement.tabTitle} · ${index}`
}
-function formatPhysicalPlacement(value: AgentStatusModel['placement']['physical']): string {
- if (value === 'detached') return 'detached'
- if (value === 'grid') return 'grid'
- return 'unknown'
-}
-
function statusTone(value: string): AgentStatusField['tone'] {
if (value === 'running') return 'warn'
if (value === 'exited') return 'bad'
diff --git a/src/renderer/src/features/command-keybindings/defaults.ts b/src/renderer/src/features/command-keybindings/defaults.ts
index 48b155f29..5e2371a8c 100644
--- a/src/renderer/src/features/command-keybindings/defaults.ts
+++ b/src/renderer/src/features/command-keybindings/defaults.ts
@@ -20,9 +20,10 @@ import type { Keybinding } from '@renderer/features/command-keybindings/normaliz
export type BindingContext =
/** Fires anywhere the workspace router runs. */
| 'global'
- /** Only while the tile grid owns the layout. */
- | 'grid'
- /** Only while Dispatch owns the layout. */
+ /** Only while the stage (lanes and rows) owns the layout. With the tile
+ * grid gone (#992) this is the only layout context — the 'grid' value it
+ * replaced died with the tree, and with it the grid/dispatch disjointness
+ * that existed only to separate the two layouts. */
| 'dispatch'
/** Only while Global Editor chrome owns focus. */
| 'editor'
@@ -42,9 +43,10 @@ export type BindingContext =
* relationships explicitly instead of inheriting a permissive default.
*/
const DISJOINT_CONTEXT_PAIRS: ReadonlyArray = [
- ['grid', 'dispatch'],
- // `editor` is disjoint from both LAYOUT contexts as of #697:
- // activeBindingContexts drops grid/dispatch entirely while the GLOBAL EDITOR
+ // (['grid', 'dispatch'] died with the tile grid — #992. There is one
+ // layout, so the only disjointness left is between it and the editor.)
+ // `editor` is disjoint from the layout context as of #697:
+ // activeBindingContexts drops 'dispatch' entirely while the GLOBAL EDITOR
// owns the target, so a chord can never be matched by a layout binding and an
// editor binding for the same keystroke.
//
@@ -76,7 +78,6 @@ const DISJOINT_CONTEXT_PAIRS: ReadonlyArray {
- it('treats grid and dispatch as mutually exclusive', () => {
- // Two states of one layout switch. ⌥K focusing a grid pane and ⌥K moving
- // the Dispatch selection is one gesture with two meanings, not a conflict.
- expect(contextsOverlap('grid', 'dispatch')).toBe(false)
+ // ('grid' died with the tile grid — #992. It and its dispatch-disjointness
+ // were two states of one layout switch; with one layout there is nothing
+ // left to be mutually exclusive WITH, which is why the pair list now holds
+ // only dispatch/editor.)
+ it('treats dispatch and editor as mutually exclusive', () => {
+ expect(contextsOverlap('dispatch', 'editor')).toBe(false)
})
it('treats every other pair as potentially simultaneous', () => {
@@ -23,13 +25,13 @@ describe('context overlap matrix', () => {
// overlays the workspace, the feed sits inside a pane, and global is by
// definition everywhere.
expect(contextsOverlap('global', 'editor')).toBe(true)
- expect(contextsOverlap('global', 'grid')).toBe(true)
+ expect(contextsOverlap('global', 'dispatch')).toBe(true)
expect(contextsOverlap('editor', 'feed')).toBe(true)
- expect(contextsOverlap('feed', 'grid')).toBe(true)
+ expect(contextsOverlap('feed', 'dispatch')).toBe(true)
})
it('treats a context as overlapping itself', () => {
- expect(contextsOverlap('grid', 'grid')).toBe(true)
+ expect(contextsOverlap('dispatch', 'dispatch')).toBe(true)
})
})
@@ -53,10 +55,15 @@ describe('shipped defaults', () => {
// These ran before this change but were never advertised. Dropping them
// would be a silent regression for anyone with the muscle memory; declaring
// them is what lets Settings finally show and unbind them.
+ //
+ // The nav-* aliases (Alt+H/J/K/L + Alt+Arrows) are deliberately ABSENT
+ // since #992: the commands died with the tile tree, and the gestures now
+ // belong to the lane stage — handled inline in useKeybinds until stage 5
+ // registers them as rebindable commands.
const byId = new Map(defaults.map(d => [d.commandId, d]))
expect(byId.get('close-pane')?.bindings).toContain('Alt+W')
- expect(byId.get('nav-left')?.bindings).toContain('Alt+Left')
- expect(byId.get('nav-up')?.bindings).toContain('Alt+Up')
+ expect(byId.get('nav-left')).toBeUndefined()
+ expect(byId.get('nav-up')).toBeUndefined()
})
it('fills the Global Editor metadata gap', () => {
@@ -107,11 +114,11 @@ describe('findBindingCollisions', () => {
})
it('allows the same chord in mutually exclusive contexts', () => {
- // The grid/dispatch case, which is legal by the overlap matrix.
+ // The dispatch/editor case, which is legal by the overlap matrix.
const collisions = findBindingCollisions({
commandDefaults: [
- { commandId: 'grid-thing', bindings: ['Alt+K'], context: 'grid' },
{ commandId: 'dispatch-thing', bindings: ['Alt+K'], context: 'dispatch' },
+ { commandId: 'editor-thing', bindings: ['Alt+K'], context: 'editor' },
],
reserved: [],
})
diff --git a/src/renderer/src/features/command-keybindings/reservations.ts b/src/renderer/src/features/command-keybindings/reservations.ts
index ba1175096..68f9c98a0 100644
--- a/src/renderer/src/features/command-keybindings/reservations.ts
+++ b/src/renderer/src/features/command-keybindings/reservations.ts
@@ -36,6 +36,44 @@ export type ReservedInteraction = {
* watch — a chord that is really taken but absent from this table would be
* offered to the user as free, and the resulting conflict would be silent.
*/
+/**
+ * The macOS chords the OS owns in EVERY editable text field — the runtime half
+ * of the "macOS text selection" reservation below.
+ *
+ * WHY a second export when the reservation entry lists the same chords: the
+ * static table stops a chord being OFFERED as free; nothing enforced it at
+ * runtime, so a dispatch-context binding could still steal delete-word in the
+ * composer while the table claimed macOS owned it (the header of that entry
+ * admitted exactly this gap for Alt+Shift+Arrow). useKeybinds imports this set
+ * and refuses to route any of these chords to a command while a text field
+ * owns the target — making the table's claim true rather than aspirational.
+ *
+ * Alt+Backspace (delete word backwards) is the founding member that forced
+ * the runtime half to exist: Clear Lane ships on it (#992 §4.4), and it is
+ * the single most load-bearing editing chord Option owns in a composer.
+ */
+const MACOS_TEXT_EDITING_CHORDS: readonly Keybinding[] = [
+ 'Alt+Backspace',
+ 'Alt+Shift+Left', 'Alt+Shift+Right', 'Alt+Shift+Up', 'Alt+Shift+Down',
+ 'Cmd+Shift+Up', 'Cmd+Shift+Down',
+]
+
+/**
+ * The chords a named reserved interaction owns (its entry in
+ * RESERVED_INTERACTIONS, bindings only). Exists for surfaces that need to
+ * SHOW a reserved chord — the starter card's ⌘1–9 fill-grammar row (#992
+ * §4.6) — without hand-copying chords into a second table that would drift
+ * the first time the reservation changed.
+ */
+export function reservedInteractionBindings(owner: string): readonly Keybinding[] {
+ return RESERVED_INTERACTIONS.find(entry => entry.owner === owner)?.bindings ?? []
+}
+
+/** Runtime lookup companion of MACOS_TEXT_EDITING_CHORDS. */
+export function isMacosTextEditingChord(binding: Keybinding): boolean {
+ return (MACOS_TEXT_EDITING_CHORDS as readonly string[]).includes(binding)
+}
+
export const RESERVED_INTERACTIONS: readonly ReservedInteraction[] = [
{
// Indexed tab activation, and Dispatch's two-digit row grammar which
@@ -66,6 +104,10 @@ export const RESERVED_INTERACTIONS: readonly ReservedInteraction[] = [
// Context is `global` deliberately: the OS owns these wherever text is
// editable, so they can never be safely claimed by a layout context either.
//
+ // Alt+Backspace is in this set and yet Clear Lane binds it: see
+ // APPROVED_OVERLAPS below — the router yields the chord to the text field
+ // first, so exactly one owner is live for a given focus.
+ //
// NOT listed here: bare Option+Arrow (word movement). Dispatch genuinely
// claims Alt+Arrow for lane movement, and the router yields it back inside
// the GLOBAL EDITOR specifically (`if (alt && !cmd) return`) — not in the
@@ -77,22 +119,13 @@ export const RESERVED_INTERACTIONS: readonly ReservedInteraction[] = [
// from being OFFERED as free. It does not stop the inline dispatch grammar
// in useKeybinds from consuming Alt+Shift+Arrow in a composer today, which
// it does because that block tests `alt && !cmd` with no shift check.
- bindings: [
- 'Alt+Shift+Left', 'Alt+Shift+Right', 'Alt+Shift+Up', 'Alt+Shift+Down',
- // Select to document start/end. Monaco has its own cursorTopSelect /
- // cursorBottomSelect for these, but that is Monaco COPYING the OS
- // convention — macOS owns them in every text field, so they belong here
- // and not in the Monaco entry.
- //
- // WHY that distinction became load-bearing: `editor` is now disjoint from
- // `grid`/`dispatch` (#697). A chord filed only under `editor` is
- // therefore reported FREE for a dispatch binding — correct for chords
- // Monaco alone owns, wrong for chords the OS owns everywhere. Filed under
- // `editor` these would have been offered as free the moment the
- // disjointness landed, and select-to-document-start would have died in
- // the composer whenever Dispatch was live.
- 'Cmd+Shift+Up', 'Cmd+Shift+Down',
- ],
+ //
+ // WHY Cmd+Shift+Up/Down are in THIS entry and not Monaco's: they are the
+ // OS's select-to-document-start/end in every text field (#697 made
+ // `editor` disjoint from the layout contexts, so filing them under
+ // `editor` would have offered them as free to a dispatch binding and
+ // killed them in the composer the moment Dispatch went live).
+ bindings: [...MACOS_TEXT_EDITING_CHORDS],
context: 'global',
owner: 'macOS text selection',
},
@@ -122,26 +155,20 @@ export const RESERVED_INTERACTIONS: readonly ReservedInteraction[] = [
context: 'editor',
owner: 'Monaco multi-cursor / column select',
},
- {
- // Dispatch row/lane movement. Mutually exclusive with the grid navigation
- // COMMANDS that share these chords — that disjointness is exactly what the
- // overlap matrix encodes, and why this is legal rather than a conflict.
- bindings: ['Alt+Up', 'Alt+Down', 'Alt+Left', 'Alt+Right', 'Alt+J', 'Alt+K', 'Alt+H', 'Alt+L'],
- context: 'dispatch',
- owner: 'Dispatch row and lane selection',
- },
- {
- bindings: ['Alt+=', 'Alt+-'],
- context: 'global',
- owner: 'Split resize',
- },
- {
- // Fn+Option+Arrow arrives as Option + Home/End/PageUp/PageDown, because
- // macOS translates Fn before the event reaches the app.
- bindings: ['Alt+Home', 'Alt+End', 'Alt+PageUp', 'Alt+PageDown'],
- context: 'global',
- owner: 'Directional split resize',
- },
+ // 'Dispatch row and lane selection' (Alt+arrows + Alt+H/J/K/L, dispatch
+ // context) was reserved here until #992 stage 5: the gestures were an
+ // unregistered inline branch in useKeybinds, so a reservation was the only
+ // way to stop a user binding something the app would swallow. They are
+ // COMMANDS now — dispatch-select-previous/next-agent and
+ // dispatch-focus-lane-left/right — which own the chords in the defaults
+ // table and participate in collision checking like every other command.
+ // Keeping the reservation would have reported each chord as doubly owned by
+ // its own command.
+ // 'Split resize' (Alt+= / Alt+-) and 'Directional split resize'
+ // (Alt+Home/End/PageUp/PageDown, i.e. Fn+Option+Arrow) were reserved here
+ // until the tile tree died (#992). A reservation exists to stop a user
+ // binding a chord the app will swallow; nothing swallows these any more, so
+ // keeping the entries would have fenced off six free chords for no owner.
{
bindings: ['Escape'],
context: 'global',
@@ -188,16 +215,9 @@ export const RESERVED_INTERACTIONS: readonly ReservedInteraction[] = [
context: 'global',
owner: 'Native editing commands',
},
- {
- // Tiled-tab resize CONTINUATION. After Cmd+N focuses a tiled tab, arrows
- // held under Cmd resize it (useKeybinds' pendingTiledResizeIndex). Stateful
- // and therefore easy to miss when transcribing owners: the chord only does
- // anything in the window between Cmd+N and releasing Cmd, but during that
- // window it beats anything else bound to the same keys.
- bindings: ['Cmd+Left', 'Cmd+Right', 'Cmd+Up', 'Cmd+Down'],
- context: 'global',
- owner: 'Tiled tab resize (after numbered selection)',
- },
+ // The Tile Tabs resize continuation (Cmd+Arrow after a numbered selection)
+ // was reserved here until #992 deleted Tile Tabs. Cmd+Arrow is caret motion
+ // in every text field again, with nothing of ours competing for it.
{
// The agent pane IS a terminal, and these go to the process, not to us.
//
@@ -316,6 +336,22 @@ const APPROVED_OVERLAPS: ReadonlyArray<{
+ 'that is not text-editing, while editor tab navigation requires focus '
+ 'inside editor chrome. The two preconditions cannot hold at once.',
},
+ {
+ binding: 'Alt+Backspace',
+ owners: ['clear-focused-lane', 'macOS text selection'],
+ // Clear Lane ships on ⌥⌫ (#992 §4.4) and macOS owns ⌥⌫ as delete-word in
+ // every text field. Not a conflict for the same reason the editor pairs
+ // above are not: useKeybinds refuses to route ANY entry of
+ // MACOS_TEXT_EDITING_CHORDS while a text field owns the target, so the
+ // composer keeps delete-word and the lane keeps its clear — exactly one
+ // owner live per focus. (A user who rebinds Clear Lane off ⌥⌫ gets
+ // delete-word everywhere; a user who rebinds something ELSE onto ⌥⌫ gets
+ // the same yield, because the rule is about the chord, not the command.)
+ reason:
+ 'useKeybinds yields OS-owned text-editing chords while a text field '
+ + 'owns the target (isMacosTextEditingChord), so the composer keeps '
+ + 'native delete-word and Clear Lane fires only outside text editing.',
+ },
{
binding: 'Cmd+Shift+R',
owners: ['resume-session', 'Native application menu'],
diff --git a/src/renderer/src/features/command-keybindings/resolve.test.ts b/src/renderer/src/features/command-keybindings/resolve.test.ts
index 86a5b9ad3..cf303bc03 100644
--- a/src/renderer/src/features/command-keybindings/resolve.test.ts
+++ b/src/renderer/src/features/command-keybindings/resolve.test.ts
@@ -12,7 +12,7 @@ import type { CommandBindingDefault } from '@renderer/features/command-keybindin
const DEFAULTS: CommandBindingDefault[] = [
{ commandId: 'a', bindings: ['Cmd+A'], context: 'global' },
- { commandId: 'b', bindings: ['Cmd+B', 'Alt+B'], context: 'grid' },
+ { commandId: 'b', bindings: ['Cmd+B', 'Alt+B'], context: 'dispatch' },
]
describe('sparse override semantics', () => {
diff --git a/src/renderer/src/features/command-keybindings/routerWiring.test.ts b/src/renderer/src/features/command-keybindings/routerWiring.test.ts
index afa4b6d1b..7f3e58a9f 100644
--- a/src/renderer/src/features/command-keybindings/routerWiring.test.ts
+++ b/src/renderer/src/features/command-keybindings/routerWiring.test.ts
@@ -45,16 +45,18 @@ describe('context filtering', () => {
const contextOf = (id: string) => defaults.find(e => e.commandId === id)?.context
- it('keeps navigation commands in the grid context', () => {
- for (const id of ['nav-left', 'nav-right', 'nav-up', 'nav-down']) {
- expect(contextOf(id)).toBe('grid')
- }
- })
-
- it('does not let a grid binding match while Dispatch is live', () => {
- // 'grid' and 'dispatch' are the one disjoint pair, which is exactly what
- // lets Alt+K mean two different things without being a conflict.
- expect(contextsOverlap('grid', 'dispatch')).toBe(false)
+ // DELETED (#992): 'keeps navigation commands in the grid context' — the
+ // nav-* commands died with the tile tree, and the 'grid' context followed in
+ // stage 5 when the lane-stage gestures (⌥ arrows / HJKL) became registered
+ // 'dispatch' commands (dispatch-select-previous/next-agent,
+ // dispatch-focus-lane-left/right).
+
+ it('keeps the lane grammar in the layout context and the editor disjoint', () => {
+ // The one remaining disjoint pair: it is what lets a chord be a layout
+ // gesture here and a Monaco command inside the editor.
+ expect(contextsOverlap('dispatch', 'editor')).toBe(false)
+ expect(contextOf('dispatch-select-previous-agent')).toBe('dispatch')
+ expect(contextOf('dispatch-focus-lane-left')).toBe('dispatch')
})
it('scopes the feed binding so it cannot steal from a composer', () => {
diff --git a/src/renderer/src/features/command-keybindings/routing.test.ts b/src/renderer/src/features/command-keybindings/routing.test.ts
index 77e82e246..d9a8df7e4 100644
--- a/src/renderer/src/features/command-keybindings/routing.test.ts
+++ b/src/renderer/src/features/command-keybindings/routing.test.ts
@@ -48,8 +48,6 @@ describe('routed command bindings', () => {
const effective = resolveEffectiveKeybindings({})
expect(effective.find(e => e.commandId === 'close-pane')?.bindings)
.toEqual(['Cmd+W', 'Alt+W'])
- expect(effective.find(e => e.commandId === 'nav-left')?.bindings)
- .toEqual(['Alt+H', 'Alt+Left'])
})
it('gives the command palette its own rebindable chord', () => {
diff --git a/src/renderer/src/features/command-palette/catalog.test.ts b/src/renderer/src/features/command-palette/catalog.test.ts
index df99a232c..9d35b54d0 100644
--- a/src/renderer/src/features/command-palette/catalog.test.ts
+++ b/src/renderer/src/features/command-palette/catalog.test.ts
@@ -4,6 +4,7 @@ import { builtInCommandCatalog, findCatalogDefects } from '@renderer/features/co
import { NATIVE_MENU_COMMAND_IDS } from '@shared/commands/nativeMenuCommandIds'
import { AGENT_PROVIDER_KINDS, DEFAULT_PROVIDER } from '@shared/types/providerKind'
import type { CommandDef } from '@renderer/features/command-palette/types'
+import { RETIRED_BUILT_IN_COMMAND_IDS } from '@renderer/app-state/settings/persistence'
// ---------------------------------------------------------------------------
// Phase 0 of the command-governance plan (docs/superpowers/plans/
@@ -19,12 +20,11 @@ import type { CommandDef } from '@renderer/features/command-palette/types'
// Merge Project Tabs (#913), 123 with View TLDR History (#917), 125 with
// Goal preview and Goal MCP (#936), 126 with Auto-follow All Working Agents (#938), 128 with
// the performance report/trace commands (#944), 129 with Close Idle Orchestration
-// Agents (#960), 130 with Open Agent Analytics (#964), 132 with Goal Loop (#1001), and
-// 134 with the two generated Grok split commands (#844). That last step is the
-// one that reached main unpinned: #844 never touched this file because the
-// commands are GENERATED from AGENT_PROVIDER_KINDS, not written as literal ids,
-// so its author had no diff here to review and main went red on merge. The
-// generated-splits invariant further down is what names the cause.
+// Agents (#960), 130 with Open Agent Analytics (#964), 132 with Goal Loop (#1001), 134
+// with the two generated Grok split commands (#844), then the unified layout (#992):
+// 16 retirements took it to 118, Clear Lane (stage 4) to 119 and the lane keyboard
+// grammar (stage 5) to 123. (#992 was written against 130 and read 119 at the end;
+// merging main added Goal Loop's two commands and the two generated Grok splits.)
// Keeping ONE snapshot that moved — rather
// than a "baseline" file and an "after" file — is what makes the plan's
// headline count an assertion anyone can check against running code instead of
@@ -60,13 +60,9 @@ const BASELINE_COMMAND_IDS: readonly string[] = [
'split-vertical',
'split-horizontal',
'close-pane',
- 'bury-pane',
'linked-agent',
- 'attach-detached-to-grid',
'pin-agents',
'unpin-agent',
- 'attach-all-detached-for-tab',
- 'detach-to-dispatch',
'terminal-horizontal',
'terminal-vertical',
'codex-vertical',
@@ -75,13 +71,7 @@ const BASELINE_COMMAND_IDS: readonly string[] = [
'opencode-horizontal',
'grok-vertical',
'grok-horizontal',
- 'nav-left',
- 'nav-right',
- 'nav-up',
- 'nav-down',
'undo-close',
- 'revive-pane',
- 'kill-buried-pane',
'toggle-tail',
'toggle-tail-all',
'toggle-tail-working',
@@ -91,11 +81,14 @@ const BASELINE_COMMAND_IDS: readonly string[] = [
'undo-clear-composer',
'send-composer',
// layoutCommands (performance report/trace are ordinary app commands)
- 'dispatch-mode',
- 'global-dispatch',
'tiled-dispatch',
'new-tiled-lane',
'remove-tiled-lane',
+ 'clear-focused-lane',
+ 'dispatch-select-previous-agent',
+ 'dispatch-select-next-agent',
+ 'dispatch-focus-lane-left',
+ 'dispatch-focus-lane-right',
'close-agent-remove-lane',
'new-dispatch-row',
'remove-dispatch-row',
@@ -103,9 +96,6 @@ const BASELINE_COMMAND_IDS: readonly string[] = [
'dispatch-row-child-cap',
'dispatch-focus-row-up',
'dispatch-focus-row-down',
- 'normalize-layout',
- 'hard-normalize-layout',
- 'rotate-layout',
'toggle-performance-panel',
'save-performance-report',
'record-performance-trace',
@@ -172,7 +162,6 @@ const BASELINE_COMMAND_IDS: readonly string[] = [
'goal-loop-preview',
'goal-loop-stop',
'toggle-reader-mode',
- 'tiled-tabs',
// settingsCommands (4, was 5: worktree-badges + dangerous-agents retired,
// open-keyboard-shortcuts added)
'open-settings',
@@ -203,6 +192,28 @@ const BASELINE_COMMAND_IDS: readonly string[] = [
* canonical settings fields are untouched, so no value migration is needed —
* only the now-meaningless per-command preference entries are pruned. */
const RETIRED_COMMAND_IDS: readonly string[] = [
+ // Unified layout retirements (#992): the mode toggle, mode scope, and the
+ // tree-only layout/navigation commands. Their bindings (⌘⇧M, ⌘⇧G, ⌥H/J/K/L
+ // + ⌥Arrows) are released; release notes must say so.
+ 'dispatch-mode',
+ 'global-dispatch',
+ 'normalize-layout',
+ 'hard-normalize-layout',
+ 'rotate-layout',
+ 'nav-left',
+ 'nav-right',
+ 'nav-up',
+ 'nav-down',
+ // Stage 3a (#992): Tile Tabs, the bury archive and the grid attach/detach
+ // pair. No default chords were bound to any of them except none — see
+ // defaults.ts — so only palette rows and visibility overrides are affected.
+ 'tiled-tabs',
+ 'bury-pane',
+ 'revive-pane',
+ 'kill-buried-pane',
+ 'attach-detached-to-grid',
+ 'attach-all-detached-for-tab',
+ 'detach-to-dispatch',
'toggle-status-mode',
'toggle-worktree-badges',
'usage.toggle-header',
@@ -217,21 +228,17 @@ const RETIRED_COMMAND_IDS: readonly string[] = [
const NAVIGATION_COMMAND_GROUP: readonly string[] = [
'next-tab',
'prev-tab',
- 'nav-left',
- 'nav-right',
- 'nav-up',
- 'nav-down',
]
const ids = (): string[] => builtInCommandCatalog.map(c => c.id)
describe('built-in command catalog — baseline characterization', () => {
- it('contains exactly the 134 governed commands in registration order', () => {
+ it('contains exactly the 123 governed commands in registration order', () => {
// Order matters: this is the palette's empty-query browse order.
expect(ids()).toEqual([...BASELINE_COMMAND_IDS])
})
- it('has exactly 134 commands', () => {
+ it('has exactly 123 commands', () => {
// Stated separately from the order assertion because this number is the
// thing that moves, and a bare count failure is a clearer signal than a
// 99-line array diff.
@@ -248,13 +255,16 @@ describe('built-in command catalog — baseline characterization', () => {
// TLDR History (#917) → 125 with Goal preview and Goal MCP (#936) → 126
// with Auto-follow All Working Agents (#938) → 128 with the two ordinary
// performance report/trace commands (#944) → 129 with Close Idle
- // Orchestration Agents (#960) → 130 with Open Agent Analytics (#964) →
- // 132 with Goal Loop preview and stop (#1001) → 134 with the two generated
- // Grok split commands (#844).
+ // Orchestration Agents (#960) → 130 with Open Agent Analytics (#964) → 121 with
+ // the unified layout (#992): −dispatch-mode, −global-dispatch, −nav×4,
+ // −normalize×3 → 114 with stage 3a: −tiled-tabs, −bury/revive/kill-buried,
+ // −attach×2, −detach → 115 with Clear Lane (#992 stage 4) → 119 with the
+ // lane keyboard grammar (#992 stage 5) → 123 once main's Goal Loop preview
+ // and stop (#1001) and the two generated Grok splits (#844) merged in.
// Each step of that arithmetic was a deliberate edit to this line, which is the entire point of pinning it. (The two test
// titles above had drifted to "115" while this line said 116; they now
// track it again.)
- expect(builtInCommandCatalog).toHaveLength(134)
+ expect(builtInCommandCatalog).toHaveLength(123)
})
it('reports no structural defects', () => {
@@ -288,12 +298,13 @@ describe('generated per-provider split commands', () => {
})
it('accounts for the difference between literal and total command count', () => {
- // 134 total - 6 generated = 128 literal `id:` fields across the command
+ // 123 total - 6 generated = 117 literal `id:` fields across the command
// modules. At the original baseline this read 102 - 4 = 98; it moved down by
// the five retirements, then back up by the nine additions, Grid Dispatch's
// six row commands, New Window, and the later additions recorded in the
- // count test above (through Open Agent Analytics, #964).
- expect(builtInCommandCatalog.length - nonDefaultProviders.length * 2).toBe(128)
+ // count test above (through the lane keyboard grammar, #992 stage 5, and
+ // Goal Loop, #1001). Grok (#844) grew only the GENERATED term, 4 → 6.
+ expect(builtInCommandCatalog.length - nonDefaultProviders.length * 2).toBe(117)
})
it('emits both directions for every non-default provider', () => {
@@ -378,6 +389,14 @@ describe('governance targets', () => {
}
})
+ it('prunes every retired id from persisted settings', () => {
+ // The #992 retirements were recorded here and nowhere else, so saved
+ // overrides for them were never pruned and kept swallowing the chords the
+ // lane commands now own (#1013 review B). Retiring an id means listing it
+ // in BOTH places; this keeps the two lists equal.
+ expect([...RETIRED_BUILT_IN_COMMAND_IDS].sort()).toEqual([...RETIRED_COMMAND_IDS].sort())
+ })
+
it('contains the one approved addition', () => {
// Cmd+Shift+P was hard-coded in useKeybinds and named no command at all,
// which is exactly why it could not be rebound or collision-checked.
@@ -392,8 +411,9 @@ describe('governance targets', () => {
})
it('lands on the arithmetic the plan predicted', () => {
- // 102 baseline - 5 retirements + 37 additions = 134, checked against the
- // real catalog rather than trusted as prose.
+ // 102 baseline - 21 retirements + 42 additions = 123, checked against the
+ // real catalog rather than trusted as prose. (5 governance retirements +
+ // 16 unified-layout retirements, all recorded in RETIRED_COMMAND_IDS.)
//
// The subtracted term is the count of APPROVED ADDITIONS and the expected
// value is the pre-governance baseline — so growing the catalog means
@@ -418,13 +438,12 @@ describe('governance targets', () => {
// `view-tldr-history` (#917), `goal-preview` and `enable-goal-mcp` (#936),
// `toggle-tail-working` (#938), `save-performance-report` and
// `record-performance-trace` (#944), `close-idle-orchestration-agents` (#960),
- // `agent-analytics.open` (#964), `goal-loop-preview` and
- // `goal-loop-stop` (#1001), and `grok-vertical` and `grok-horizontal`
- // (#844 — generated, not literal: they appeared the moment grok joined
- // AGENT_PROVIDER_KINDS, which is why they count as additions here exactly
- // like the codex/opencode splits already inside the 102 baseline).
- expect(builtInCommandCatalog.length + RETIRED_COMMAND_IDS.length - 37).toBe(102)
- expect(builtInCommandCatalog).toHaveLength(134)
+ // `agent-analytics.open` (#964), `goal-loop-preview` and `goal-loop-stop`
+ // (#1001), `grok-vertical` and `grok-horizontal` (#844, generated from
+ // AGENT_PROVIDER_KINDS), `clear-focused-lane` (#992 stage 4), and the four
+ // lane-grammar commands (#992 stage 5).
+ expect(builtInCommandCatalog.length + RETIRED_COMMAND_IDS.length - 42).toBe(102)
+ expect(builtInCommandCatalog).toHaveLength(123)
})
})
diff --git a/src/renderer/src/features/command-palette/catalog.ts b/src/renderer/src/features/command-palette/catalog.ts
index 9930efc46..65c5b29c8 100644
--- a/src/renderer/src/features/command-palette/catalog.ts
+++ b/src/renderer/src/features/command-palette/catalog.ts
@@ -8,7 +8,6 @@ import { tabCommands } from '@renderer/features/workspace/commands/tabCommands'
import { windowCommands } from '@renderer/features/workspace/commands/windowCommands'
import { settingsCommands } from '@renderer/features/settings/commands/settingsCommands'
import { spotlightCommands } from '@renderer/features/spotlight/commands/spotlightCommands'
-import { tileTabsCommands } from '@renderer/features/tile-tabs/commands/tileTabsCommands'
import { readerCommands } from '@renderer/features/reader/commands/readerCommands'
import { copyAssistantCommands } from '@renderer/features/copy-assistant/commands/copyAssistantCommands'
import { copyCodeBlockCommands } from '@renderer/features/copy-code-block/commands/copyCodeBlockCommands'
@@ -79,7 +78,6 @@ export const builtInCommandCatalog: readonly CommandDef[] = Object.freeze([
...tldrCommands,
...goalLoopCommands,
...readerCommands,
- ...tileTabsCommands,
...settingsCommands,
...copyAssistantCommands,
...copyCodeBlockCommands,
@@ -158,5 +156,8 @@ export function findCatalogDefects(commands: readonly CommandDef[]): string[] {
// the catalog is the boundary a future extension-contributed or
// provider-generated command crosses, and those are built from strings that
// TypeScript cannot check at the point of construction.
-const VALID_SURFACES = new Set(['app', 'grid', 'dispatch', 'session', 'editor', 'debug'])
+// Unified layout (#992): grid/dispatch merged into 'workspace'. Kept in
+// sync with CommandSurface by hand — this runtime set exists precisely
+// because generated provider commands escape the compile-time union check.
+const VALID_SURFACES = new Set(['app', 'workspace', 'session', 'editor', 'debug'])
const VALID_TIERS = new Set(['default', 'advanced', 'experimental', 'debug'])
diff --git a/src/renderer/src/features/command-palette/executeCommand.test.ts b/src/renderer/src/features/command-palette/executeCommand.test.ts
index 3f304bc0a..60881eb29 100644
--- a/src/renderer/src/features/command-palette/executeCommand.test.ts
+++ b/src/renderer/src/features/command-palette/executeCommand.test.ts
@@ -102,26 +102,11 @@ describe('admission cannot be bypassed by source', () => {
expect(uiCalls).toEqual([])
})
- it('refuses a grid command while Dispatch Mode owns the layout', async () => {
- // The #228 class: a grid-only command is a silent no-op in Dispatch, and
- // the explicit outcome is what replaces the silence.
- //
- // `nav-left`, not `split-vertical`. This case used the latter until the
- // create commands were found to work in BOTH modes — `splitFocused` spawns
- // a detached agent in Dispatch — and their `surface: 'grid'` was refusing a
- // mode their own action implements. `nav-left` is genuinely grid-only:
- // Dispatch focus is `dispatchMode.focusedSessionId` while grid navigation
- // walks the tile tree, so asking the grid for a neighbour of a detached
- // session really does nothing.
- const ctx = makeContext({ flags: { dispatchModeEnabled: true } })
- const outcome = await dispatchCommand({ id: 'nav-left', source: 'keybinding', ctx })
- expect(outcome.status).toBe('unavailable')
- })
-
- it('admits that same grid command outside Dispatch Mode', async () => {
- const ctx = makeContext({ flags: { dispatchModeEnabled: false } })
- expect(canDispatchCommand('nav-left', ctx)).toBe(true)
- })
+ // DELETED (#992): 'refuses a grid command while Dispatch Mode owns the
+ // layout' + its inverse — the #228 mode gate died with the modes. nav-left
+ // itself died with the tile tree; no surviving command is mode-gated, so
+ // the admission seam is exercised by the unknown-id and when-guard cases
+ // below and the availability tests in resolveInvocation.test.ts.
it('distinguishes an unknown id from an unavailable one', async () => {
// A menu or caller bug and a contextual refusal are different problems and
@@ -337,17 +322,14 @@ describe('create commands in Dispatch Mode', () => {
'codex-horizontal',
]
- it('admits every create command while Dispatch owns the layout', () => {
- const ctx = makeContext({ flags: { dispatchModeEnabled: true } })
- for (const id of CREATE_IDS) {
- expect(canDispatchCommand(id, ctx), `${id} refused in Dispatch`).toBe(true)
- }
- })
-
- it('still admits them in the grid', () => {
- const ctx = makeContext({ flags: { dispatchModeEnabled: false } })
+ // Two cases lived here ("while Dispatch owns the layout" / "in the grid"),
+ // toggling a `dispatchModeEnabled` flag. There is one layout (#992) and the
+ // flag is gone, so there is one case. The property it pins is unchanged and
+ // still worth pinning: no create command is ever surface-gated away.
+ it('admits every create command on the stage', () => {
+ const ctx = makeContext()
for (const id of CREATE_IDS) {
- expect(canDispatchCommand(id, ctx), `${id} refused in the grid`).toBe(true)
+ expect(canDispatchCommand(id, ctx), `${id} refused`).toBe(true)
}
})
})
diff --git a/src/renderer/src/features/command-palette/keybindingBaseline.test.ts b/src/renderer/src/features/command-palette/keybindingBaseline.test.ts
index c4430fc5e..ef8a55e8c 100644
--- a/src/renderer/src/features/command-palette/keybindingBaseline.test.ts
+++ b/src/renderer/src/features/command-palette/keybindingBaseline.test.ts
@@ -134,14 +134,14 @@ const BINDING_BASELINE: readonly BindingBaseline[] = [
declared: '⌥T',
effective: ['⌥T'],
owner: 'useKeybinds',
- note: 'code === "KeyT" && !shift → splitFocused("vertical", "terminal").',
+ note: 'code === "KeyT" && !shift → the terminal-horizontal command (New Terminal).',
},
{
commandId: 'terminal-vertical',
declared: '⌥⇧T',
effective: ['⌥⇧T'],
owner: 'useKeybinds',
- note: 'code === "KeyT" && shift → splitFocused("horizontal", "terminal").',
+ note: 'code === "KeyT" && shift → the terminal-vertical command (legacy duplicate id).',
},
{
commandId: 'codex-vertical',
@@ -161,35 +161,11 @@ const BINDING_BASELINE: readonly BindingBaseline[] = [
note: 'Same provider loop, shift branch.',
},
- // --- Navigation: FOUR UNDECLARED ARROW ALIASES. -------------------------
- {
- commandId: 'nav-left',
- declared: '⌥H',
- effective: ['⌥H', '⌥←'],
- owner: 'useKeybinds',
- note: 'DRIFT (undeclared alias): `code === "KeyH" || k === "ArrowLeft"`.',
- },
- {
- commandId: 'nav-right',
- declared: '⌥L',
- effective: ['⌥L', '⌥→'],
- owner: 'useKeybinds',
- note: 'DRIFT (undeclared alias): `code === "KeyL" || k === "ArrowRight"`.',
- },
- {
- commandId: 'nav-up',
- declared: '⌥K',
- effective: ['⌥K', '⌥↑'],
- owner: 'useKeybinds',
- note: 'DRIFT (undeclared alias): `code === "KeyK" || k === "ArrowUp"`.',
- },
- {
- commandId: 'nav-down',
- declared: '⌥J',
- effective: ['⌥J', '⌥↓'],
- owner: 'useKeybinds',
- note: 'DRIFT (undeclared alias): `code === "KeyJ" || k === "ArrowDown"`.',
- },
+ // --- Navigation: DELETED with the tile tree (#992). ---------------------
+ // nav-left/right/up/down entries removed. Their ⌥H/J/K/L + ⌥Arrow gestures
+ // still run inline in useKeybinds (lane focus / index walk) and are
+ // recorded as unregistered reservations until stage 5 migrates them into
+ // the registry; this baseline then regains declared entries for them.
// --- Feed ---------------------------------------------------------------
{
@@ -338,17 +314,13 @@ const DISPLAY_TO_CANONICAL: Record = {
// kept because it is the evidence for what the defaults had to preserve, and
// the assertions still hold over the table itself.
describe('recorded authority drift (pre-migration history)', () => {
- it('lists exactly the six commands whose real chords exceed their metadata', () => {
+ it('lists exactly the commands whose real chords exceed their metadata', () => {
// Undeclared aliases: the palette under-reports what the keyboard does.
const underReported = BINDING_BASELINE.filter(
e => e.declared !== null && e.effective.length > 1,
)
expect(underReported.map(e => e.commandId).sort()).toEqual([
'close-pane',
- 'nav-down',
- 'nav-left',
- 'nav-right',
- 'nav-up',
])
})
@@ -382,11 +354,13 @@ describe('recorded authority drift (pre-migration history)', () => {
const effectiveChords = new Set(BINDING_BASELINE.flatMap(e => e.effective))
for (const chord of UNOWNED_COMMAND_CHORDS) effectiveChords.add(chord.chord)
- // Seven chords do real work that no palette row advertises: the Global
+ // Three chords do real work that no palette row advertises: the Global
// Editor toggle (declared nowhere), the palette itself (owned by no
- // command), the undisclosed ⌥W close, and the four arrow aliases.
+ // command), and the undisclosed ⌥W close. (The four ⌥arrow aliases left
+ // this list with the nav commands in #992; their gestures remain live in
+ // useKeybinds as lane navigation, unregistered until stage 5.)
const undisclosed = [...effectiveChords].filter(c => !declaredChords.has(c)).sort()
- expect(undisclosed).toEqual(['⌘⇧E', '⌘⇧P', '⌥W', '⌥←', '⌥↑', '⌥→', '⌥↓'])
+ expect(undisclosed).toEqual(['⌘⇧E', '⌘⇧P', '⌥W'])
})
})
diff --git a/src/renderer/src/features/command-palette/paletteMode.ts b/src/renderer/src/features/command-palette/paletteMode.ts
index 1131e3d7b..972fd83f1 100644
--- a/src/renderer/src/features/command-palette/paletteMode.ts
+++ b/src/renderer/src/features/command-palette/paletteMode.ts
@@ -22,10 +22,12 @@
// way. That is the whole argument; there is no ordering subtlety to get wrong.
// ---------------------------------------------------------------------------
+// 'buried' and 'kill-buried' were modes here until #992 deleted bury/revive:
+// in the pool-first workspace a hidden-but-alive agent is simply an unplaced
+// pool row, reachable from every index, so the picker had nothing to list.
+// (The history above still names "the buried pair" — accurately, as history.)
export type PaletteMode =
| 'commands'
- | 'buried'
- | 'kill-buried'
| 'prompt-template'
| 'manage-prompt-template'
| 'fill-prompt-template'
diff --git a/src/renderer/src/features/command-palette/registry.ts b/src/renderer/src/features/command-palette/registry.ts
index a29a246de..f545a7340 100644
--- a/src/renderer/src/features/command-palette/registry.ts
+++ b/src/renderer/src/features/command-palette/registry.ts
@@ -27,40 +27,30 @@ import type {
const commandDefs: readonly CommandDef[] = builtInCommandCatalog
/**
- * Mode gate applied BEFORE each command's own `when`.
+ * Surface availability applied BEFORE each command's own `when`.
*
- * This is the one place the surface→mode policy lives. `grid` commands
- * are meaningless or silent no-ops while Dispatch Mode owns the layout
- * (they target `tab.root` grid focus); `dispatch` commands have nothing
- * to act on outside Dispatch. Everything else — `app`, `session`,
- * `editor`, `debug` — is mode-independent and reaches its own `when`.
+ * Unified layout (#992): the old mode gate — `grid` hidden in Dispatch,
+ * `dispatch` hidden in the grid — died with the modes. Every surviving
+ * surface is available everywhere; a command's applicability now comes
+ * from its own `when` (does a target exist? is the overlay open?).
*
- * Putting the gate here, not in 13 separate `when` closures, is the
- * point of issue #228: a command's module no longer has to remember to
- * re-implement "...and hide me in the wrong mode." It declares a
- * surface; the registry enforces it uniformly.
- *
- * WHY an exhaustive switch instead of the two ifs plus `return true` this
- * replaces: the fallthrough silently classified any UNKNOWN surface as
- * "available everywhere". That is the permissive direction — a new surface
- * added to the union but forgotten here would not fail the build, it would
- * quietly show its commands in every mode, which is precisely the #228 bug
- * class the surface field was introduced to kill. With `assertNever`, adding
- * a surface without deciding its mode policy is a compile error.
+ * The switch REMAINS exhaustive with assertNever on purpose: adding a
+ * surface without deciding its availability policy must stay a compile
+ * error. The permissive fallthrough this replaced silently showed
+ * unknown-surface commands everywhere — exactly the #228 bug class the
+ * field was introduced to kill.
*/
-function surfaceAvailable(surface: CommandSurface, ctx: CommandContext): boolean {
+function surfaceAvailable(surface: CommandSurface, _ctx: CommandContext): boolean {
switch (surface) {
- case 'grid':
- return !ctx.flags.dispatchModeEnabled
- case 'dispatch':
- return ctx.flags.dispatchModeEnabled
case 'app':
+ case 'workspace':
case 'session':
case 'editor':
case 'debug':
- // Mode-independent by design. `editor` and `debug` carry their own
- // `when` guards for overlay-open / feature-enabled checks; listing them
- // explicitly rather than defaulting keeps that a stated decision.
+ // Availability is the command's own `when` from here; the surface is
+ // a category, not a gate. `editor` and `debug` carry overlay-open /
+ // feature-enabled guards; workspace/session commands guard on target
+ // existence.
return true
default:
return assertNever(surface)
diff --git a/src/renderer/src/features/command-palette/resolveInvocation.test.ts b/src/renderer/src/features/command-palette/resolveInvocation.test.ts
index 183e47eea..e17970c5f 100644
--- a/src/renderer/src/features/command-palette/resolveInvocation.test.ts
+++ b/src/renderer/src/features/command-palette/resolveInvocation.test.ts
@@ -16,16 +16,21 @@ const base: CommandDef = {
}
describe('resolveCommandAvailability', () => {
- it('hides a mode-irrelevant command without explaining', () => {
- // Product decision: a grid-spatial command in Dispatch points at a layout
- // the user cannot see. Explaining that on every mode switch is noise.
- const command: CommandDef = { ...base, surface: 'grid' }
- const ctx = makeTestCommandContext({ flags: { dispatchModeEnabled: true } })
- expect(resolveCommandAvailability(command, ctx)).toEqual({
- available: false,
- reason: 'Not applicable in this layout',
- presentation: 'hide',
+ it('lets every surviving surface reach its own when — no mode gate (#992)', () => {
+ // Unified layout: `grid`/`dispatch` surfaces died with the modes. A
+ // workspace command's availability is its own `when` (target exists?),
+ // never "which layout is active" — the old mode-hide test that lived
+ // here pinned behavior the merge deleted.
+ const command: CommandDef = { ...base, surface: 'workspace' }
+ expect(resolveCommandAvailability(command, makeTestCommandContext())).toEqual({
+ available: true,
})
+ expect(
+ resolveCommandAvailability(
+ { ...command, when: () => false },
+ makeTestCommandContext(),
+ ),
+ ).toEqual({ available: false, reason: 'Not available right now', presentation: 'hide' })
})
it('lets a command upgrade a silent hide into an explained disable', () => {
@@ -58,16 +63,4 @@ describe('resolveCommandAvailability', () => {
it('reports availability when nothing objects', () => {
expect(resolveCommandAvailability(base, makeTestCommandContext())).toEqual({ available: true })
})
-
- it('checks the surface before consulting an explicit reason', () => {
- // Order is the product decision: a command must not be able to force
- // itself visible in a layout where its concept does not exist.
- const command: CommandDef = {
- ...base,
- surface: 'grid',
- unavailableReason: () => ({ reason: 'should not win', presentation: 'disable' }),
- }
- const ctx = makeTestCommandContext({ flags: { dispatchModeEnabled: true } })
- expect(resolveCommandAvailability(command, ctx)).toMatchObject({ presentation: 'hide' })
- })
})
diff --git a/src/renderer/src/features/command-palette/surfaceOwnership.ts b/src/renderer/src/features/command-palette/surfaceOwnership.ts
index a834913f5..f4f43529e 100644
--- a/src/renderer/src/features/command-palette/surfaceOwnership.ts
+++ b/src/renderer/src/features/command-palette/surfaceOwnership.ts
@@ -114,8 +114,6 @@ export type SurfaceOwningCommandId = keyof typeof SURFACE_OWNER_FLAGS
* decides whether it gets the chance.
*/
export const PALETTE_MODE_COMMANDS = {
- 'revive-pane': 'buried',
- 'kill-buried-pane': 'kill-buried',
'prompt-template': 'prompt-template',
'manage-prompt-templates': 'manage-prompt-template',
'save-composer-as-prompt-template': 'save-prompt-template',
diff --git a/src/renderer/src/features/command-palette/taxonomy.test.ts b/src/renderer/src/features/command-palette/taxonomy.test.ts
index 4826ca12b..4ffe40d3f 100644
--- a/src/renderer/src/features/command-palette/taxonomy.test.ts
+++ b/src/renderer/src/features/command-palette/taxonomy.test.ts
@@ -16,13 +16,11 @@ import { coerceSettings } from '@renderer/app-state/settings/persistence'
// having been declared in a document.
// ---------------------------------------------------------------------------
+// Unified layout (#992): nav-left/right/up/down died with the tile tree;
+// the group keeps tab navigation.
const NAVIGATION_GROUP_IDS = [
'next-tab',
'prev-tab',
- 'nav-left',
- 'nav-right',
- 'nav-up',
- 'nav-down',
] as const
const byId = (id: string) => {
@@ -111,14 +109,14 @@ describe('tier classification', () => {
it('marks niche supported operations advanced rather than hiding them entirely', () => {
// `advanced` is not `debug`: these are supported operations a power user
// wants, just not ones that should crowd a fuzzy search.
- for (const id of ['remove-cybersecurity-block', 'normalize-layout', 'bury-pane', 'soft-reload-agent', 'switch-agents-provider']) {
+ for (const id of ['remove-cybersecurity-block', 'soft-reload-agent', 'switch-agents-provider']) {
expect(byId(id).pickerVisibility).toBe('advanced')
}
})
})
describe('Navigation Commands group', () => {
- it('has exactly the six recorded members', () => {
+ it('has exactly the two recorded members', () => {
const members = builtInCommandCatalog
.filter(c => c.commandGroup === 'navigation')
.map(c => c.id)
@@ -135,7 +133,6 @@ describe('Navigation Commands group', () => {
'jump-latest-message',
'toggle-spotlight',
'toggle-reader-mode',
- 'tiled-tabs',
'reorder-tabs',
]) {
expect(byId(id).category).toBe('navigate')
@@ -143,17 +140,16 @@ describe('Navigation Commands group', () => {
}
})
- it('removes all six from the picker when the group is off', () => {
+ it('removes both members from the picker when the group is off', () => {
const ctx = makeTestCommandContext({ flags: { navigationCommandsEnabled: false } })
const visible = new Set(buildCommandRegistry(ctx).map(c => c.id))
for (const id of NAVIGATION_GROUP_IDS) expect(visible.has(id)).toBe(false)
})
it('restores them when the group is on', () => {
- // nav-* are grid-surface, so Dispatch must be off for them to be
- // applicable at all — otherwise this would pass for the wrong reason.
+ // #992: no mode flags anymore — group membership is the only variable.
const ctx = makeTestCommandContext({
- flags: { navigationCommandsEnabled: true, dispatchModeEnabled: false },
+ flags: { navigationCommandsEnabled: true },
})
const visible = new Set(buildCommandRegistry(ctx).map(c => c.id))
for (const id of NAVIGATION_GROUP_IDS) expect(visible.has(id)).toBe(true)
@@ -163,8 +159,8 @@ describe('Navigation Commands group', () => {
// Documented precedence. If an override could pull one member back while
// the family is off, Settings would show six switches that appear able to
// contradict their own parent.
- const hidden = isVisibleInPicker(byId('nav-left'), {
- overrides: { 'nav-left': true },
+ const hidden = isVisibleInPicker(byId('next-tab'), {
+ overrides: { 'next-tab': true },
showHiddenCommands: false,
navigationCommandsEnabled: false,
})
@@ -172,8 +168,8 @@ describe('Navigation Commands group', () => {
})
it('yields to a per-command override once the group is on', () => {
- const shown = isVisibleInPicker(byId('nav-left'), {
- overrides: { 'nav-left': false },
+ const shown = isVisibleInPicker(byId('next-tab'), {
+ overrides: { 'next-tab': false },
showHiddenCommands: false,
navigationCommandsEnabled: true,
})
diff --git a/src/renderer/src/features/command-palette/testing/commandContextHarness.ts b/src/renderer/src/features/command-palette/testing/commandContextHarness.ts
index 740ed9dff..9d8564577 100644
--- a/src/renderer/src/features/command-palette/testing/commandContextHarness.ts
+++ b/src/renderer/src/features/command-palette/testing/commandContextHarness.ts
@@ -1,4 +1,5 @@
import type { CommandContext } from '@renderer/features/command-palette/types'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
// ---------------------------------------------------------------------------
// Narrow test harness for `CommandContext`.
@@ -40,7 +41,7 @@ export type CommandContextHarnessOptions = {
* Build a context that behaves like an empty workspace unless told otherwise.
*
* Defaults are chosen so admission is DECIDED BY THE TEST rather than by
- * ambient fixture state: no tabs, no dispatch mode, agent view mode, no
+ * ambient fixture state: no tabs, a one-lane stage, agent view mode, no
* visibility overrides. A test that wants a gate to fail must say so.
*/
export function makeTestCommandContext(
@@ -59,31 +60,30 @@ export function makeTestCommandContext(
},
})
+ const projectId = options.activeTabId ?? 'tab-1'
+ // Filed under the one project: a row naming no project is UNOWNED (#992), so
+ // no index lists it and the lane below would point at nothing.
const sessions = options.focusedSessionId
- ? { [options.focusedSessionId]: { kind: 'claude', cwd: '/repo' } }
+ ? { [options.focusedSessionId]: { kind: 'claude', cwd: '/repo', projectId, joinedAt: 0 } }
: {}
- // A single tab owning the focused session is the minimum shape that makes
- // `commandTargetSessionIdForState` return it: that selector reads the active
- // tab's focusedSessionId when Dispatch is off. Anything less and every
- // session-targeted test would silently resolve to "no target" and pass for
- // the wrong reason.
- const tabs = options.focusedSessionId
- ? [{
- id: options.activeTabId ?? 'tab-1',
- focusedSessionId: options.focusedSessionId,
- root: { type: 'leaf', sessionId: options.focusedSessionId },
- }]
- : []
+ // A single tab owning the focused session, shown in a one-lane stage, is the
+ // minimum shape that makes `commandTargetSessionIdForState` return it: the
+ // command target is the focused lane's occupant and nothing else (#992, U3),
+ // and a lane only resolves a session its project's index lists. Anything
+ // less and every session-targeted test would silently resolve to "no
+ // target" and pass for the wrong reason.
+ //
+ // (Until #992 the tab's `focusedSessionId` alone was enough, because the
+ // selector fell back to tree focus whenever Dispatch was off.)
+ const tabs = options.focusedSessionId ? [{ id: projectId, title: 'Project' }] : []
const workspace = {
state: {
tabs,
activeTabId: options.activeTabId ?? (tabs.length > 0 ? tabs[0].id : null),
sessions,
- dispatchMode: null,
- detachedSessions: {},
- buried: [],
+ stage: oneLaneStage(options.focusedSessionId),
pinnedSessionIds: [],
relatedAgents: {},
},
@@ -122,8 +122,6 @@ export function makeTestCommandContext(
focusedCwd: options.focusedCwd ?? null,
fileTreeVisible: false,
editorFullscreen: false,
- dispatchModeEnabled: false,
- globalDispatchEnabled: false,
agentViewMode: 'agent',
commandVisibilityOverrides: {},
// ON by default here, opposite to the product default. The harness serves
diff --git a/src/renderer/src/features/command-palette/types.ts b/src/renderer/src/features/command-palette/types.ts
index 082bc1398..5427a7e2f 100644
--- a/src/renderer/src/features/command-palette/types.ts
+++ b/src/renderer/src/features/command-palette/types.ts
@@ -2,7 +2,6 @@ import type { PaletteMode } from '@renderer/features/command-palette/paletteMode
import type { Workspace } from '@renderer/workspace/workspaceStore'
import type { AgentViewMode, UsageHeaderLevel } from '@renderer/app-state/settings/types'
import type { RenderedViewPolicy } from '@renderer/workspace/agentDisplayMode'
-import type { DispatchAttachIntent } from '@renderer/app-state/uiShell/types'
/**
* What a command's badge MEANS, not how it looks.
@@ -121,41 +120,43 @@ export type CommandRisk =
*
* WHY this exists: the command registry used to be one flat list where
* every command decided its own availability through ad-hoc `when`
- * guards (or didn't guard at all). That worked while Agent Code was
- * essentially a pane grid, but it broke down once Dispatch Mode became
- * a first-class layout. Grid-spatial commands — `Split Pane Right`,
- * `New Terminal Below`, `Focus Pane Left` — kept showing in the palette
- * while Dispatch was active, where "right"/"below"/"left" point at a
- * grid the user can't see. Worse, `Focus Pane *` and the layout
- * commands (`Normalize Layout`, `Rotate Layout`) were *silent no-ops*
- * in Dispatch: they mutate `tab.root` grid focus, which Dispatch does
- * not use. See issue #228.
+ * guards (or didn't guard at all) — grid-spatial commands showed in the
+ * palette as silent no-ops where their gestures pointed at nothing the
+ * user could see (issue #228). `surface` makes the classification
+ * explicit and machine-readable so the palette can hide commands that
+ * don't apply, and so a future native menu (#148) can build itself from
+ * the same model instead of re-deriving intent from title text.
*
- * `surface` makes that classification explicit and machine-readable so
- * the palette can hide commands that don't apply to the current mode,
- * and so a future native menu (#148) can build itself from the same
- * model instead of re-deriving intent from title text.
+ * History (#992): this union used to be mode-split — `grid` hidden in
+ * Dispatch, `dispatch` hidden in the grid — because there were two
+ * layouts. The unified layout deleted the modes and merged both into
+ * `workspace`; see the union below for what each member means now.
+ */
+/**
+ * Which part of the app a command acts on.
+ *
+ * Unified layout (#992): the mode-split surfaces `grid` and `dispatch` are
+ * GONE — there is one workspace, so a command is either workspace-shaped or
+ * it isn't. A command's visibility now comes from its own `when` (does a
+ * target exist? is the overlay open?), never from "which mode am I in".
+ * That was the whole point of killing the modes: onboarding no longer has
+ * to explain a toggle before a command can be found.
*
- * - `app` — always meaningful, mode-independent. New Tab,
- * Settings, Resume Session, Dispatch Mode toggle.
- * - `grid` — operates on the tile grid; hidden while Dispatch
- * Mode is active. Pane splits, directional pane
- * focus, layout normalize/rotate.
- * - `dispatch` — only meaningful inside Dispatch Mode; hidden in the
- * grid. Pin/unpin agents, attach detached session,
- * Global Dispatch scope.
- * - `session` — acts on the current command-target session and
- * works in BOTH modes (the target resolver is already
- * Dispatch-aware — see commandTargetSessionId). Reload
- * Agent, Tail, Copy Last Response, Reader Mode.
- * - `editor` — Global Editor overlay. Orthogonal to grid/Dispatch
- * (the overlay wraps either), so NOT mode-gated; the
- * surface is a category, and editor commands keep
- * their own `when` for overlay-open checks.
- * - `debug` — developer/diagnostic tooling. Mode-independent;
- * grouped separately so it can be demoted or hidden.
+ * - `app` — whole-application actions: settings, palette, resume,
+ * project rail, stage shape.
+ * - `workspace` — acts on the lane stage / the pool: lane and row
+ * structure, project bindings, pins, placement.
+ * - `session` — acts on the current command-target session (the
+ * focused lane's occupant): Reload Agent, Tail, Copy
+ * Last Response, Reader Mode.
+ * - `editor` — Global Editor overlay. Orthogonal to the stage (the
+ * overlay wraps it), so NOT workspace-gated; the surface
+ * is a category, and editor commands keep their own
+ * `when` for overlay-open checks.
+ * - `debug` — developer/diagnostic tooling. Workspace-independent;
+ * grouped separately so it can be demoted or hidden.
*/
-export type CommandSurface = 'app' | 'grid' | 'dispatch' | 'session' | 'editor' | 'debug'
+export type CommandSurface = 'app' | 'workspace' | 'session' | 'editor' | 'debug'
/**
* How visible a command should be in the command PICKER specifically.
@@ -195,7 +196,6 @@ export type CommandContext = {
workspace: Workspace
ui: {
openNewTabPicker: () => void
- openTileTabs: () => void
openReorderTabs: () => void
/** Open the Merge Project Tabs modal (#913); the modal performs the merge. */
openMergeProjectTabs: () => void
@@ -258,17 +258,18 @@ export type CommandContext = {
* global-editor store (not uiShell) because it's editor-scoped
* state, not workspace chrome. */
toggleFileTreeVisible: () => void
- enterDispatchMode: () => Promise | void
- enterGlobalDispatch: () => Promise | void
- exitDispatchMode: () => void
- /** Open the Tiled Dispatch tile-count prompt overlay. The overlay
- * applies the chosen count via workspace.enterTiledDispatch. */
+ // enterDispatchMode / enterGlobalDispatch / exitDispatchMode lived here
+ // until #992: they backed the `dispatch-mode` and `global-dispatch`
+ // commands, which turned the lane grid on and off and switched a
+ // layout-wide project/global scope. The stage always exists and has no
+ // scope, so there is nothing for a command to enter, leave or widen.
+ /** Open the stage shape editor. The overlay applies the chosen rows via
+ * workspace.setDispatchGridShape. */
openTiledDispatchPrompt: () => void
/** Open the placement overlay in "attach detached session to grid"
* mode for the given sessionId. The session must exist in
* workspace.state.detachedSessions; the command's `when` guard is
* responsible for that check. */
- openDispatchAttach: (intent: DispatchAttachIntent) => void
/** Open the shared placement overlay in "Linked Agent" mode. The
* session id is the parent agent; the overlay only asks for
* Claude/Codex and then delegates to workspace.createLinkedAgent. */
@@ -282,8 +283,6 @@ export type CommandContext = {
* modal itself, not the store. */
openPinAgents: () => void
setAggressiveDebugPersistence: (enabled: boolean) => void
- enterBuriedMode: () => void
- enterKillBuriedMode: () => void
enterPromptTemplateMode: () => void
enterManagePromptTemplateMode: () => void
enterSavePromptTemplateMode: () => void
@@ -403,8 +402,9 @@ export type CommandContext = {
/** Whether the Global Editor is in fullscreen (workspace hidden).
* Same scoping as fileTreeVisible — global-editor store owns it. */
editorFullscreen: boolean
- dispatchModeEnabled: boolean
- globalDispatchEnabled: boolean
+ // `dispatchModeEnabled` and `globalDispatchEnabled` were flags here until
+ // #992. No command read them once the mode commands were deleted; a flag
+ // that is always `true` invites a `when` that looks meaningful and is not.
/** App-wide agent pane surface policy from Settings. The command registry
* uses it to decide whether render-dependent commands are applicable.
* Threading it through flags keeps command modules declarative: commands
diff --git a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx
index d36e0f48a..a2fc47c83 100644
--- a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx
+++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx
@@ -80,7 +80,6 @@ import { promptTemplateTargetSessionId } from '@renderer/features/prompt-templat
import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/commandTargetSessionId'
import { deriveExtensionCommands, deriveExtensionKeybindings } from '@renderer/apps/host/derive'
import { resolveAgentPaneLabel } from '@renderer/workspace/tile-tree/paneLabels'
-import { sessionDisplayTitle } from '@renderer/workspace/sessionDisplayTitle'
import { useWorkspaceContext } from '@renderer/workspace/WorkspaceContext'
import type { PaletteMode } from '@renderer/features/command-palette/paletteMode'
import { commandOwnsOpenSurface } from '@renderer/features/command-palette/surfaceOwnership'
@@ -101,14 +100,6 @@ import type { AiWorkspaceSummary } from '@mcp/shared/aiWorkspaceTypes'
// under feature-owned folders, so adding a feature command no longer
// requires editing the palette implementation itself.
-type BuriedPaneInfo = {
- id: string
- label: string
- description: string
- note?: string
- buriedAt: number
-}
-
type PromptTemplateFillState = {
sessionId: string
template: PromptTemplate
@@ -260,12 +251,6 @@ function OpenCommandPalette({
const setSettings = useAppStore(state => state.setSettings)
const { onNewTabRequest } = usePathPickerRequests()
- const openTileTabsModal = useAppStore(state => state.openTileTabsModal)
- const onTileTabsRequest = useCallback(() => {
- openTileTabsModal(
- workspace.tileTabs?.tabIds ?? (workspace.activeTab ? [workspace.activeTab.id] : []),
- )
- }, [openTileTabsModal, workspace.activeTab, workspace.tileTabs])
const onReorderTabsRequest = useAppStore(state => state.openReorderTabs)
const openMergeProjectTabs = useAppStore(state => state.openMergeProjectTabs)
const onSettingsRequest = useAppStore(state => state.openSettingsPage)
@@ -316,7 +301,6 @@ function OpenCommandPalette({
const closeGlobalEditorAction = useAppStore(state => state.closeGlobalEditor)
const toggleGlobalEditor = useAppStore(state => state.toggleGlobalEditor)
const openTiledDispatchPrompt = useAppStore(state => state.openTiledDispatchPrompt)
- const openDispatchAttach = useAppStore(state => state.openDispatchAttach)
const openLinkedAgent = useAppStore(state => state.openLinkedAgent)
const openNewAgentIn = useAppStore(state => state.openNewAgentIn)
const openPinAgents = useAppStore(state => state.openPinAgents)
@@ -331,13 +315,6 @@ function OpenCommandPalette({
const toggleFileTreeVisible = useGlobalEditorStore(state => state.toggleFileTreeVisible)
const editorFullscreen = useGlobalEditorStore(state => state.editorFullscreen)
- const enterDispatchMode = workspace.enterDispatchMode
- const exitDispatchMode = workspace.exitDispatchMode
- const enterGlobalDispatch = useCallback(
- () =>
- workspace.setDispatchScope(workspace.dispatchMode?.scope === 'global' ? 'project' : 'global'),
- [workspace],
- )
const setAggressiveDebugPersistence = useCallback(
(enabled: boolean) => setSettings({ aggressiveDebugPersistence: enabled }),
[setSettings],
@@ -384,8 +361,6 @@ function OpenCommandPalette({
const globalEditorOpen = useAppStore(state => state.globalEditorOpen)
const caffeinateActive = caffeinateStatus?.active === true
const caffeinateSupported = caffeinateStatus?.supported !== false
- const dispatchModeEnabled = workspace.dispatchMode !== null
- const globalDispatchEnabled = workspace.dispatchMode?.scope === 'global'
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
@@ -428,50 +403,8 @@ function OpenCommandPalette({
const focusedCwd = focusedMeta?.cwd ?? null
const focusedProvider = focusedMeta?.kind ?? DEFAULT_PROVIDER
const customPromptTemplates = settings.savedPromptTemplates
- // Buried panes are scoped to the ACTIVE TAB. The natural temptation
- // is to show every buried pane in the workspace ("they're paused
- // work, the user might want any of them") but that mixes contexts:
- // a buried Codex agent from project A appears alongside a buried
- // Claude agent from project B with no surface-level indication
- // they're cross-project. Scoping by sourceTabId matches the rest of
- // the workspace's per-tab discipline and prevents revive-into-wrong-
- // tab footguns (revive places the pane back into the tab the user
- // is currently in, not the tab it was buried from).
- //
- // Buried panes from other tabs are not lost — switching to that tab
- // surfaces them in its palette.
- const activeTabId = workspace.state.activeTabId
- const buried = useMemo(
- () =>
- [...workspace.state.buried]
- .filter(entry => entry.sourceTabId === activeTabId)
- .sort((a, b) => b.buriedAt - a.buriedAt)
- .map(entry => {
- const kind = entry.sessionMeta.kind ?? DEFAULT_PROVIDER
- const cwd = entry.sessionMeta.cwd
- return {
- id: entry.id,
- // Same title rule as every other list (#865), kind as context.
- label: `${sessionDisplayTitle(entry.sessionMeta)} · ${kind}`,
- description: `${entry.sourceTabTitle} · ${cwd}`,
- note: entry.note,
- buriedAt: entry.buriedAt,
- }
- }),
- [activeTabId, workspace.state.buried],
- )
-
- const enterBuriedMode = useCallback(() => {
- setMode('buried')
- setQuery('')
- setSelectedIndex(0)
- }, [])
-
- const enterKillBuriedMode = useCallback(() => {
- setMode('kill-buried')
- setQuery('')
- setSelectedIndex(0)
- }, [])
+ // The buried / kill-buried picker modes lived here until #992: an unplaced
+ // pool session is already "hidden but alive", and every index lists it.
const enterPromptTemplateMode = useCallback(() => {
setMode('prompt-template')
@@ -578,7 +511,6 @@ function OpenCommandPalette({
workspace,
ui: {
openNewTabPicker: onNewTabRequest,
- openTileTabs: onTileTabsRequest,
openReorderTabs: onReorderTabsRequest,
openMergeProjectTabs,
openSettings: onSettingsRequest,
@@ -633,17 +565,11 @@ function OpenCommandPalette({
closeGlobalEditor: closeGlobalEditorAction,
toggleGlobalEditor,
toggleFileTreeVisible,
- enterDispatchMode,
- enterGlobalDispatch,
- exitDispatchMode,
openTiledDispatchPrompt,
- openDispatchAttach,
openLinkedAgent,
openNewAgentIn,
openPinAgents,
setAggressiveDebugPersistence,
- enterBuriedMode,
- enterKillBuriedMode,
enterPromptTemplateMode,
enterManagePromptTemplateMode,
enterSavePromptTemplateMode,
@@ -699,8 +625,6 @@ function OpenCommandPalette({
focusedCwd,
fileTreeVisible,
editorFullscreen,
- dispatchModeEnabled,
- globalDispatchEnabled,
agentViewMode,
commandVisibilityOverrides,
navigationCommandsEnabled,
@@ -711,7 +635,6 @@ function OpenCommandPalette({
[
workspace,
onNewTabRequest,
- onTileTabsRequest,
onReorderTabsRequest,
openMergeProjectTabs,
onSettingsRequest,
@@ -760,17 +683,11 @@ function OpenCommandPalette({
closeGlobalEditorAction,
toggleGlobalEditor,
toggleFileTreeVisible,
- enterDispatchMode,
- enterGlobalDispatch,
- exitDispatchMode,
openTiledDispatchPrompt,
- openDispatchAttach,
openLinkedAgent,
openNewAgentIn,
openPinAgents,
setAggressiveDebugPersistence,
- enterBuriedMode,
- enterKillBuriedMode,
enterPromptTemplateMode,
enterManagePromptTemplateMode,
enterSavePromptTemplateMode,
@@ -818,8 +735,6 @@ function OpenCommandPalette({
focusedCwd,
fileTreeVisible,
editorFullscreen,
- dispatchModeEnabled,
- globalDispatchEnabled,
agentViewMode,
commandVisibilityOverrides,
navigationCommandsEnabled,
@@ -871,25 +786,6 @@ function OpenCommandPalette({
// the user is typing the name of is `primary`, short supporting text is
// `secondary`, and long prose is `body` — which `rankEntries` matches
// by literal substring only, never by subsequence.
- const filteredBuried = useMemo(
- () =>
- rankEntries(buried, queryText, item => [
- // `note` is the ONLY human-authored, row-distinguishing field
- // here, so it is the primary one despite not being the row's
- // headline. `label` is generated (`${kind} · ${cwdBase}`) and is
- // byte-identical for every pane buried from the same repo — as
- // primary it made tier 4 a mass tie that the note could never
- // break, and let an unrelated repo's provider name outrank a note
- // that literally started with the query.
- primary(item.note),
- secondary(item.label),
- // `${sourceTabTitle} · ${cwd}` — contains an absolute path, so as
- // a secondary field every buried pane matched "users",
- // "development", and every other path segment at tier 3.
- body(item.description),
- ]),
- [buried, queryText],
- )
const filteredPromptTemplates = useMemo(
() =>
rankEntries(promptTemplates, queryText, template => [
@@ -1004,23 +900,17 @@ function OpenCommandPalette({
? resolveAgentPaneLabel(
workspace.state,
directAgentQuery.label,
- workspace.tileTabs,
)
: null,
- [directAgentQuery, workspace.state, workspace.tileTabs],
+ [directAgentQuery, workspace.state],
)
- // WHY the syntax intent is normalized against the visible surface before we
- // build the row: `A2!` can only mean "Here" when a Tiled Dispatch lane is on
- // screen. Persisted state can contain a hidden Dispatch layout underneath
- // Tiled Tabs, and grid/classic Dispatch deliberately retain ordinary
- // coordinate navigation. Passing the raw bang there would make row zero
- // promise "Open Here" while Enter actually switches to an existing pane.
- const directAgentIntent =
- directAgentQuery?.intent === 'open-in-focused-tiled-dispatch-lane' &&
- !workspace.tileTabs &&
- workspace.state.dispatchMode?.tiled
- ? directAgentQuery.intent
- : 'reuse-existing-view'
+ // The bang intent (`A2!` = "open it HERE, in the focused lane") passes
+ // straight through. It used to be normalized to 'reuse-existing-view' unless
+ // a Tiled Dispatch lane was on screen, because the grid and classic Dispatch
+ // had no lane for "Here" to mean and row zero would have promised "Open
+ // Here" while Enter switched to an existing pane. A lane is always on screen
+ // now (#992), so "Here" always has a referent.
+ const directAgentIntent = directAgentQuery?.intent ?? 'reuse-existing-view'
const directAgentCommand = useMemo(
() =>
directAgentTarget && directAgentQuery
@@ -1093,15 +983,13 @@ function OpenCommandPalette({
}, [commandSortMode, commandStarred, paletteRows, queryText])
const filteredLength =
- mode === 'buried' || mode === 'kill-buried'
- ? filteredBuried.length
- : mode === 'prompt-template'
- ? filteredPromptTemplates.length
- : mode === 'ai-workspace-open' || mode === 'ai-workspace-clear'
- ? filteredAiWorkspaces.length
- : mode === 'commands'
- ? paletteRows.length
- : 0
+ mode === 'prompt-template'
+ ? filteredPromptTemplates.length
+ : mode === 'ai-workspace-open' || mode === 'ai-workspace-clear'
+ ? filteredAiWorkspaces.length
+ : mode === 'commands'
+ ? paletteRows.length
+ : 0
const selectedPaletteRow = useMemo(() => {
if (mode !== 'commands') return null
@@ -1254,25 +1142,6 @@ function OpenCommandPalette({
}
}, [commandContext, onClose, onMenuCommandHandled, pendingMenuCommand, showToast])
- const executeBuried = useCallback(
- (item: BuriedPaneInfo) => {
- onClose()
- void workspace.reviveBuried(item.id)
- },
- [onClose, workspace],
- )
-
- const executeKillBuried = useCallback(
- (item: BuriedPaneInfo) => {
- const remainingCount = filteredBuried.filter(candidate => candidate.id !== item.id).length
- void workspace.killBuried(item.id).then(() => {
- if (remainingCount === 0) onClose()
- else setSelectedIndex(i => Math.max(0, Math.min(i, remainingCount - 1)))
- })
- },
- [filteredBuried, onClose, workspace],
- )
-
const executePromptTemplate = useCallback(
async (template: PromptTemplate, originSelectedIndex = selectedIndex) => {
const sessionId = promptTemplateSessionId
@@ -1576,12 +1445,6 @@ function OpenCommandPalette({
} else if (mode === 'ai-workspace-clear') {
const workspace = filteredAiWorkspaces[selectedIndex]
if (workspace) void clearAiWorkspace(workspace)
- } else if (mode === 'buried') {
- const item = filteredBuried[selectedIndex]
- if (item) executeBuried(item)
- } else if (mode === 'kill-buried') {
- const item = filteredBuried[selectedIndex]
- if (item) executeKillBuried(item)
} else if (mode === 'prompt-template') {
const template = filteredPromptTemplates[selectedIndex]
if (template) void executePromptTemplate(template)
@@ -1596,14 +1459,11 @@ function OpenCommandPalette({
mode,
aiWorkspacePending,
filteredLength,
- filteredBuried,
paletteRows,
filteredAiWorkspaces,
filteredPromptTemplates,
selectedIndex,
- executeBuried,
executeCommand,
- executeKillBuried,
executePromptTemplate,
createAiWorkspace,
clearAiWorkspace,
@@ -1709,16 +1569,6 @@ function OpenCommandPalette({
: 'Search application commands and related session workflows.'}
- {mode === 'buried' && (
-
- revive ›
-
- )}
- {mode === 'kill-buried' && (
-
- kill buried ›
-
- )}
{mode === 'prompt-template' && (
template ›
@@ -1777,9 +1627,7 @@ function OpenCommandPalette({
? 'Workspace name…'
: mode === 'ai-workspace-open' || mode === 'ai-workspace-clear'
? 'Search AI Workspaces…'
- : mode === 'buried' || mode === 'kill-buried'
- ? 'Search buried panes…'
- : mode === 'prompt-template'
+ : mode === 'prompt-template'
? 'Search prompt templates…'
: 'Type a command…'
}
@@ -1826,7 +1674,7 @@ function OpenCommandPalette({
)}
{/* Commands mode only. The other ten modes render short, intrinsically
- ordered lists (session recency, buried-at time, [...custom,
+ ordered lists (session recency, [...custom,
...builtin]) where a sort control would be chrome without a
purpose — the command list is the only one long enough to be hard
to scan. */}
@@ -2171,65 +2019,6 @@ function OpenCommandPalette({
)}
- {mode === 'buried' &&
- (filteredBuried.length === 0 ? (
- No buried panes
- ) : (
- filteredBuried.map((item, i) => (
- setSelectedIndex(i)}
- onClick={() => executeBuried(item)}
- >
-
{item.label}
- {item.note && (
-
{item.note}
- )}
-
{item.description}
-
- ))
- ))}
-
- {mode === 'kill-buried' &&
- (filteredBuried.length === 0 ? (
- No buried panes
- ) : (
- filteredBuried.map((item, i) => (
- setSelectedIndex(i)}
- onClick={() => executeKillBuried(item)}
- >
-
{item.label}
- {item.note && (
-
{item.note}
- )}
-
{item.description}
-
- ))
- ))}
{mode === 'prompt-template' &&
(filteredPromptTemplates.length === 0 ? (
diff --git a/src/renderer/src/features/conversations/ui/ConversationsPicker.renderer.test.tsx b/src/renderer/src/features/conversations/ui/ConversationsPicker.renderer.test.tsx
index 77acc5a69..b8ae3f948 100644
--- a/src/renderer/src/features/conversations/ui/ConversationsPicker.renderer.test.tsx
+++ b/src/renderer/src/features/conversations/ui/ConversationsPicker.renderer.test.tsx
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import type { Conversation, ConversationListResponse } from '@shared/conversations/types'
import type { Workspace } from '@renderer/workspace/workspaceStore'
import { ConversationsPicker } from './ConversationsPicker'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
const originalApi = Object.getOwnPropertyDescriptor(window, 'api')
afterEach(() => {
@@ -40,7 +41,7 @@ type WorkspaceMock = Workspace & { replaceSession: Mock; newTab: Mock }
function workspace(over: Record = {}): WorkspaceMock {
return {
activeTab: { id: 't', focusedSessionId: 's' },
- state: { tabs: [{ id: 't', focusedSessionId: 's' }], activeTabId: 't', dispatchMode: false, sessions: { s: { cwd: '/fixture/repo', kind: 'claude' } } },
+ state: { tabs: [{ id: 't', title: 'fixture' }], activeTabId: 't', stage: oneLaneStage('s'), pinnedSessionIds: [], sessions: { s: { cwd: '/fixture/repo', kind: 'claude', projectId: 't', joinedAt: 0 } } },
replaceSession: vi.fn(async () => 's2'),
newTab: vi.fn(async () => undefined),
...over,
@@ -114,7 +115,7 @@ describe('ConversationsPicker', () => {
it('asks for a pane when none is commanded, and lists everywhere without one', async () => {
const list = install()
- const ws = workspace({ state: { tabs: [{ id: 't', focusedSessionId: 's' }], activeTabId: 't', dispatchMode: false, sessions: {} } })
+ const ws = workspace({ state: { tabs: [{ id: 't', title: 'fixture' }], activeTabId: 't', stage: oneLaneStage('s'), pinnedSessionIds: [], sessions: {} } })
render( )
expect(await screen.findByText(/focus a pane to list its repository/i)).toBeInTheDocument()
expect(list).not.toHaveBeenCalled()
@@ -125,7 +126,7 @@ describe('ConversationsPicker', () => {
it('opens a new tab when no pane can be replaced', async () => {
install()
- const ws = workspace({ activeTab: null, state: { tabs: [{ id: 't', focusedSessionId: 's' }], activeTabId: 't', dispatchMode: false, sessions: { s: { cwd: '/fixture/repo', kind: 'claude' } } } })
+ const ws = workspace({ activeTab: null, state: { tabs: [{ id: 't', title: 'fixture' }], activeTabId: 't', stage: oneLaneStage('s'), pinnedSessionIds: [], sessions: { s: { cwd: '/fixture/repo', kind: 'claude', projectId: 't', joinedAt: 0 } } } })
render( )
fireEvent.click(await screen.findByText('Project context bootstrapping'))
await waitFor(() => expect(ws.newTab).toHaveBeenCalledWith('/fixture/repo', 'ededdea8-06bf-4474-b945-b3a8f8ce0fe1', 'claude'))
diff --git a/src/renderer/src/features/dispatch-pin/surfaces/PinAgentsSurface.tsx b/src/renderer/src/features/dispatch-pin/surfaces/PinAgentsSurface.tsx
index 1cf6b39fc..1f053f67f 100644
--- a/src/renderer/src/features/dispatch-pin/surfaces/PinAgentsSurface.tsx
+++ b/src/renderer/src/features/dispatch-pin/surfaces/PinAgentsSurface.tsx
@@ -89,7 +89,6 @@ export function PinAgentsSurface() {
return result
}, [
- state.detachedSessions,
state.pinnedSessionIds,
state.sessions,
state.tabs,
diff --git a/src/renderer/src/features/feed/controlRead/control.ts b/src/renderer/src/features/feed/controlRead/control.ts
index ded2567c4..b68bf087f 100644
--- a/src/renderer/src/features/feed/controlRead/control.ts
+++ b/src/renderer/src/features/feed/controlRead/control.ts
@@ -51,7 +51,7 @@ export function createAgentReadControl() {
}
const current = (sessionId: string) => {
const { workspaceState: state, workspaceRuntimes } = useAppStore.getState()
- const meta = state.sessions[sessionId] ?? state.buried.find(record => record.sessionId === sessionId)?.sessionMeta
+ const meta = state.sessions[sessionId]
if (!meta || !isAgentProviderKind(meta.kind ?? DEFAULT_PROVIDER)) throw new ControlError('unavailable', 'Agent no longer exists in this window')
const provider = (meta.kind ?? DEFAULT_PROVIDER) as AgentProviderKind
return { meta, provider, runtime: workspaceRuntimes[sessionId] ?? emptyRuntime() }
diff --git a/src/renderer/src/features/key-vault/ui/KeyVaultModal.renderer.test.tsx b/src/renderer/src/features/key-vault/ui/KeyVaultModal.renderer.test.tsx
index 09180a7ab..984df0399 100644
--- a/src/renderer/src/features/key-vault/ui/KeyVaultModal.renderer.test.tsx
+++ b/src/renderer/src/features/key-vault/ui/KeyVaultModal.renderer.test.tsx
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { WorkspaceProvider } from '@renderer/workspace/WorkspaceContext'
import type { Workspace } from '@renderer/workspace/workspaceStore'
import { KeyVaultModal } from './KeyVaultModal'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
// The app, not a modal, owns workspace boot/subscriptions. This catches an
// accidental call to useWorkspace() while exercising the real context seam.
@@ -29,7 +30,7 @@ beforeEach(() => {
})
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
function open() {
- render(
+ render(
)
}
diff --git a/src/renderer/src/features/path-picker/surfaces/PathPickerSurface.tsx b/src/renderer/src/features/path-picker/surfaces/PathPickerSurface.tsx
index 89efc99b1..396b809ad 100644
--- a/src/renderer/src/features/path-picker/surfaces/PathPickerSurface.tsx
+++ b/src/renderer/src/features/path-picker/surfaces/PathPickerSurface.tsx
@@ -5,6 +5,7 @@ import { useAppStore } from '@renderer/app-state/hooks'
import { useWorkspaceContext } from '@renderer/workspace/WorkspaceContext'
import { findTabsHoldingDirectory, resolveTabSessions } from '@renderer/workspace/queries'
import { tabIndexLabel } from '@renderer/workspace/tile-tree/paneLabelFormat'
+import { commandTargetSessionIdForState } from '@renderer/workspace/hook/selectors/commandTargetSessionId'
// Registry wrapper (#494): owns the store + workspace wiring App.tsx
// used to inline for the new-tab / resume path picker. Always mounted
@@ -38,14 +39,20 @@ export function PathPickerSurface() {
// inserted session across ALL tabs — once Dispatch Mode landed,
// that's frequently a background detached agent in a different
// project, and the user opens the new-tab picker pre-filled with
- // a directory they aren't standing in. Prefer (a) the active
- // tab's focused session, (b) the first session resolved for the
- // active tab by the canonical resolver. Falls through to
+ // a directory they aren't standing in. Prefer (a) the commanded
+ // session when it is in the active project, (b) the first session
+ // resolved for the active project by the canonical resolver. Falls through to
// window.api.defaultCwd() when the active tab has no sessions.
const activeTabId = workspace.activeTab?.id
let fallbackCwd: string | undefined
if (activeTabId) {
- const focusedId = workspace.activeTab?.focusedSessionId ?? null
+ // The agent under the cursor when it belongs to the active project,
+ // else that project's first session. (The first half was the active
+ // tab's tile-tree focus until #992.)
+ const commanded = commandTargetSessionIdForState(workspace.state)
+ const focusedId = commanded && workspace.state.sessions[commanded]?.projectId === activeTabId
+ ? commanded
+ : null
const candidateId = focusedId ?? resolveTabSessions(workspace.state, activeTabId)[0] ?? null
if (candidateId) {
fallbackCwd = workspace.state.sessions[candidateId]?.cwd
diff --git a/src/renderer/src/features/performance-monitor/PerformanceMonitor.renderer.test.tsx b/src/renderer/src/features/performance-monitor/PerformanceMonitor.renderer.test.tsx
index 8d9bcb1ab..bc98eb345 100644
--- a/src/renderer/src/features/performance-monitor/PerformanceMonitor.renderer.test.tsx
+++ b/src/renderer/src/features/performance-monitor/PerformanceMonitor.renderer.test.tsx
@@ -1,4 +1,5 @@
import { act, fireEvent, render, renderHook, screen, waitFor, within } from '@testing-library/react'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
import type { ReactElement } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { MonitorSnapshot } from '@shared/performance/monitorSnapshot.js'
@@ -22,13 +23,17 @@ const usage: MonitorAgentUsage = {
sessions: [{ sessionId: 'a1', kind: 'agent', provider: 'claude', processCount: 3, memoryBytes: 2 * GB, cpuPercent: 12, complete: true, history: [[1000, 1.2 * GB, 10], [2000, 2 * GB, 12]] }],
}
const focusAgentBySessionId = vi.fn(async () => true)
+// The v3 stage shape (#992): the session belongs to its project through its
+// own projectId row, and the stage is always present. The earlier v2 literal
+// (a tab `root`, dispatchMode, tileTabs) was cast through `unknown`, so the
+// type checker could not flag it when the model changed; the missing label
+// was the only symptom.
const workspace = {
state: {
- tabs: [{ id: 'tab-a', title: 'alpha', root: { type: 'leaf', sessionId: 'a1' }, focusedSessionId: 'a1' }],
- activeTabId: 'tab-a', gridRelatedSelections: {}, dispatchMode: null, detachedSessions: {}, buried: [], pinnedSessionIds: [],
- sessions: { a1: { cwd: '/work/alpha', kind: 'claude', title: 'Leaky agent' } },
+ tabs: [{ id: 'tab-a', title: 'alpha' }],
+ activeTabId: 'tab-a', stage: oneLaneStage('a1'), pinnedSessionIds: [],
+ sessions: { a1: { cwd: '/work/alpha', kind: 'claude', title: 'Leaky agent', projectId: 'tab-a', joinedAt: 0 } },
},
- tileTabs: null,
focusAgentBySessionId,
} as unknown as Workspace
// The monitor reads agent labels from the app's workspace context, exactly as
diff --git a/src/renderer/src/features/performance-monitor/agentIdentity.renderer.test.ts b/src/renderer/src/features/performance-monitor/agentIdentity.renderer.test.ts
index 4d258f02d..22eea48d4 100644
--- a/src/renderer/src/features/performance-monitor/agentIdentity.renderer.test.ts
+++ b/src/renderer/src/features/performance-monitor/agentIdentity.renderer.test.ts
@@ -1,30 +1,30 @@
import { describe, expect, it } from 'vitest'
import { resolveAgentPaneLabel } from '@renderer/workspace/tile-tree/paneLabels'
-import type { TileNode, WorkspaceState } from '@renderer/workspace/types'
+import type { WorkspaceState } from '@renderer/workspace/types'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
import { buildAgentIdentityIndex } from './agentIdentity'
-const leaf = (sessionId: string): TileNode => ({ type: 'leaf', sessionId })
-
+// The v3 stage shape (#992): projects are tabs, a session belongs to a
+// project through its own projectId row, and the stage is always present.
+// This mirrors paneLabels.test.ts's fixture on purpose. The monitor's contract
+// is "the label shown IS the label that navigates", so both suites must
+// describe the same workspace.
function makeState(): WorkspaceState {
return {
tabs: [
- { id: 'tab-a', title: 'alpha', root: { type: 'split', direction: 'vertical', ratio: 0.5, a: leaf('a1'), b: leaf('a2') }, focusedSessionId: 'a1' },
- { id: 'tab-b', title: 'beta', root: leaf('b1'), focusedSessionId: 'b1' },
+ { id: 'tab-a', title: 'alpha' },
+ { id: 'tab-b', title: 'beta' },
],
activeTabId: 'tab-a',
- gridRelatedSelections: {},
- dispatchMode: null,
+ stage: oneLaneStage('agent-a'),
sessions: {
- a1: { cwd: '/work/alpha/one', kind: 'claude', title: 'Leaky agent' },
- a2: { cwd: '/work/alpha/two', kind: 'codex' },
- a3: { cwd: '/work/alpha/three', kind: 'claude' },
- b1: { cwd: '/work/beta/one', kind: 'codex' },
- orphan: { cwd: '/work/elsewhere', kind: 'claude' },
- },
- detachedSessions: {
- a3: { sessionId: 'a3', surface: 'dispatch', projectTabId: 'tab-a', projectTabTitle: 'alpha', projectTabIndex: 0, detachedAt: 10 },
+ terminal: { cwd: '/work/alpha', kind: 'terminal', projectId: 'tab-a', joinedAt: 0 },
+ 'agent-a': { cwd: '/work/alpha', kind: 'codex', title: 'Leaky agent', projectId: 'tab-a', joinedAt: 1 },
+ 'agent-b': { cwd: '/work/beta', kind: 'claude', projectId: 'tab-b', joinedAt: 0 },
+ pooled: { cwd: '/work/alpha/background', kind: 'opencode', projectId: 'tab-a', joinedAt: 10 },
+ // A row whose project no longer exists: nothing places it in this window.
+ orphan: { cwd: '/work/elsewhere', kind: 'claude', projectId: 'gone', joinedAt: 0 },
},
- buried: [],
pinnedSessionIds: [],
}
}
@@ -32,11 +32,9 @@ function makeState(): WorkspaceState {
describe('monitor agent identities', () => {
it('labels every placed session exactly as the workspace resolves that label', () => {
const state = makeState()
- const index = buildAgentIdentityIndex(state, null)
- expect(index.get('a1')).toEqual({ sessionId: 'a1', label: 'A1', title: 'Leaky agent', tabTitle: 'alpha' })
- // Detached agents keep a coordinate after the grid leaves of their tab.
- expect(index.get('a3')?.label).toBe('A3')
- expect(index.get('b1')?.label).toBe('B1')
+ const index = buildAgentIdentityIndex(state)
+ expect(index.get('agent-a')).toMatchObject({ sessionId: 'agent-a', title: 'Leaky agent', tabTitle: 'alpha' })
+ for (const id of ['terminal', 'agent-a', 'agent-b', 'pooled']) expect(index.get(id)?.label).toBeTruthy()
// The monitor must never show a label that navigates somewhere else.
for (const identity of index.values()) {
if (identity.label) expect(resolveAgentPaneLabel(state, identity.label)?.sessionId).toBe(identity.sessionId)
@@ -44,6 +42,6 @@ describe('monitor agent identities', () => {
})
it('names a session this window does not place without inventing a label', () => {
- expect(buildAgentIdentityIndex(makeState(), null).get('orphan')).toEqual({ sessionId: 'orphan', label: null, title: 'elsewhere', tabTitle: null })
+ expect(buildAgentIdentityIndex(makeState()).get('orphan')).toEqual({ sessionId: 'orphan', label: null, title: 'elsewhere', tabTitle: null })
})
})
diff --git a/src/renderer/src/features/performance-monitor/agentIdentity.ts b/src/renderer/src/features/performance-monitor/agentIdentity.ts
index ae8d88ce0..f194d0059 100644
--- a/src/renderer/src/features/performance-monitor/agentIdentity.ts
+++ b/src/renderer/src/features/performance-monitor/agentIdentity.ts
@@ -1,9 +1,7 @@
import { useMemo } from 'react'
import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchSelectors'
-import { resolveTabSessions } from '@renderer/workspace/queries'
import { sessionDisplayTitle } from '@renderer/workspace/sessionDisplayTitle'
-import { tabIndexLabel } from '@renderer/workspace/tile-tree/paneLabelFormat'
-import type { SessionId, TileTabsState, WorkspaceState } from '@renderer/workspace/types'
+import type { SessionId, WorkspaceState } from '@renderer/workspace/types'
import { useWorkspaceLayoutContext } from '@renderer/workspace/WorkspaceContext'
export type AgentIdentity = {
@@ -19,21 +17,26 @@ export type AgentIdentity = {
* sessionId → the name and label a person recognizes.
*
* WHY this mirrors resolveAgentPaneLabel's precedence instead of calling
- * paneLabelForSession per row: when Dispatch is the visible surface (and Tiled
- * Tabs is not), its globally numbered rows are the labels on screen, and they
- * differ from tab-local pane positions. Showing a pane-local "A3" for an agent
- * the user sees as "D7" would send them to the wrong agent. Built once per
- * workspace layout change as a map, because the monitor looks up every process
- * row on every poll.
+ * paneLabelForSession per row: the agent index's globally numbered rows are
+ * the labels on screen, and they can differ from tab-local positions. Showing
+ * a pane-local "A3" for an agent the user sees as "D7" would send them to the
+ * wrong agent. Built once per workspace layout change as a map, because the
+ * monitor looks up every process row on every poll.
+ *
+ * Unified stage (#992): the index is ALWAYS on screen now, and Tile Tabs is
+ * gone, so the index rows always win. That is the same rule
+ * resolveAgentPaneLabel applies. The old "only when Dispatch is on and Tiled
+ * Tabs is off" gate and the tileTabs parameter went with the modes they
+ * described.
*/
-export function buildAgentIdentityIndex(state: WorkspaceState, tileTabs: TileTabsState | null): Map {
+export function buildAgentIdentityIndex(state: WorkspaceState): Map {
const index = new Map()
const place = (sessionId: SessionId, label: string, tabTitle: string) => {
const meta = state.sessions[sessionId]
if (!meta || index.has(sessionId)) return
index.set(sessionId, { sessionId, label, title: sessionDisplayTitle(meta), tabTitle })
}
- if (state.dispatchMode && !tileTabs) {
+ {
// CAVEAT: pinned dispatch rows carry labels like '★1', which the
// workspace's label-to-session resolver deliberately cannot parse — pins
// have no pane coordinate to resolve to. That is why every navigation
@@ -42,9 +45,11 @@ export function buildAgentIdentityIndex(state: WorkspaceState, tileTabs: TileTab
// added, pins must be special-cased there.
for (const row of buildVisibleDispatchRows(state)) place(row.sessionId, row.label, row.tabTitle)
}
- state.tabs.forEach((tab, tabIndex) => {
- resolveTabSessions(state, tab.id).forEach((sessionId, paneIndex) => place(sessionId, `${tabIndexLabel(tabIndex)}${paneIndex + 1}`, tab.title))
- })
+ // A per-project "A1, A2…" pass sat here: it labelled sessions the index did
+ // not list. The index lists every session of a live project now
+ // (dispatchSelectors), so that pass never labelled anything, and if it ever
+ // had, its project-local labels could collide with the index's. Sessions
+ // outside a live project get no label below, which is the honest answer.
for (const [sessionId, meta] of Object.entries(state.sessions)) {
if (!index.has(sessionId)) index.set(sessionId, { sessionId, label: null, title: sessionDisplayTitle(meta), tabTitle: null })
}
@@ -57,6 +62,6 @@ export function buildAgentIdentityIndex(state: WorkspaceState, tileTabs: TileTab
* table that polls on its own schedule. */
export function useAgentIdentities() {
const workspace = useWorkspaceLayoutContext()
- const identities = useMemo(() => buildAgentIdentityIndex(workspace.state, workspace.tileTabs), [workspace.state, workspace.tileTabs])
+ const identities = useMemo(() => buildAgentIdentityIndex(workspace.state), [workspace.state])
return { identities, focusAgent: workspace.focusAgentBySessionId }
}
diff --git a/src/renderer/src/features/prompt-templates/control.renderer.test.tsx b/src/renderer/src/features/prompt-templates/control.renderer.test.tsx
index 7033432a4..27347b510 100644
--- a/src/renderer/src/features/prompt-templates/control.renderer.test.tsx
+++ b/src/renderer/src/features/prompt-templates/control.renderer.test.tsx
@@ -11,9 +11,9 @@ afterEach(() => { cleanup(); useAppStore.setState(initial, true); window.api = o
it('inserts dynamic project context into the named agent without following focus and refuses an edit during collection', async () => {
const sessionId = 'target'
useAppStore.setState({ workspaceState: { ...initial.workspaceState, activeTabId: 'other-project',
- tabs: [{ id: 'target-project', title: 'Target project', root: { type: 'leaf', sessionId }, focusedSessionId: sessionId },
- { id: 'other-project', title: 'Other project', root: { type: 'leaf', sessionId: 'other' }, focusedSessionId: 'other' }],
- sessions: { target: { kind: 'claude', cwd: '/target', providerSessionId: 'native-target' }, other: { kind: 'codex', cwd: '/other', providerSessionId: 'native-other' } }, detachedSessions: {}, buried: [],
+ tabs: [{ id: 'target-project', title: 'Target project' },
+ { id: 'other-project', title: 'Other project' }],
+ sessions: { target: { kind: 'claude', cwd: '/target', providerSessionId: 'native-target', projectId: 'target-project', joinedAt: 0 }, other: { kind: 'codex', cwd: '/other', providerSessionId: 'native-other', projectId: 'other-project', joinedAt: 0 } },
}, workspaceRuntimes: { target: emptyRuntime(), other: { ...emptyRuntime(), draftInput: 'Other human draft' } } })
const mounted = renderHook(() => {
const setRuntimes = useAppStore.getState().setWorkspaceRuntimes
diff --git a/src/renderer/src/features/prompt-templates/targetSession.test.ts b/src/renderer/src/features/prompt-templates/targetSession.test.ts
index cb9f49cfb..fe1c768dd 100644
--- a/src/renderer/src/features/prompt-templates/targetSession.test.ts
+++ b/src/renderer/src/features/prompt-templates/targetSession.test.ts
@@ -5,20 +5,17 @@ import {
promptTemplateTargetSessionIdForState,
} from '@renderer/features/prompt-templates/targetSession'
import type { WorkspaceState } from '@renderer/workspace/types'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
function stateWithFocusedSession(kind: 'claude' | 'terminal' | 'extension-view'): WorkspaceState {
return {
tabs: [{
id: 'tab-1',
title: 'Project',
- root: { type: 'leaf', sessionId: 'session-1' },
- focusedSessionId: 'session-1',
}],
activeTabId: 'tab-1',
- dispatchMode: null,
- sessions: { 'session-1': { cwd: '/project', kind } },
- detachedSessions: {},
- buried: [],
+ stage: oneLaneStage('session-1'),
+ sessions: { 'session-1': { cwd: '/project', kind, projectId: 'tab-1', joinedAt: 0 } },
pinnedSessionIds: [],
}
}
@@ -46,13 +43,9 @@ describe('promptTemplateTargetSessionIdForState', () => {
.toBeNull()
})
- it('rejects an empty Tiled Dispatch lane instead of falling back to hidden focus', () => {
+ it('rejects an empty focused lane instead of falling back to another lane s agent', () => {
const state = stateWithFocusedSession('claude')
- state.dispatchMode = {
- scope: 'project',
- focusedSessionId: 'session-1',
- tiled: { focusedLane: 1, lanes: [{ selectedSessionId: 'session-1' }, {}] },
- }
+ state.stage = { focusedLane: 1, lanes: [{ selectedSessionId: 'session-1' }, {}] }
expect(promptTemplateTargetSessionIdForState(state)).toBeNull()
})
diff --git a/src/renderer/src/features/reader/ui/ReaderView.renderer.test.tsx b/src/renderer/src/features/reader/ui/ReaderView.renderer.test.tsx
index 3097c4713..f680f36c6 100644
--- a/src/renderer/src/features/reader/ui/ReaderView.renderer.test.tsx
+++ b/src/renderer/src/features/reader/ui/ReaderView.renderer.test.tsx
@@ -9,6 +9,7 @@ import type { Entry } from '@shared/types/transcript'
import type { AgentProviderKind } from '@shared/types/providerKind'
import type { Workspace } from '@renderer/workspace/workspaceStore'
import { ReaderView } from './ReaderView'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
function assistantEntry(uuid: string, text: string): Entry {
return {
@@ -152,27 +153,19 @@ function makeReaderWorkspace(runtime: SessionRuntime = {
assistantEntry('newer-message', 'Newer answer'),
],
}, kind: AgentProviderKind = 'claude'): Workspace {
- const tab = {
- id: 'tab-1',
- title: 'Project',
- focusedSessionId: 'session-1',
- root: { type: 'leaf' as const, sessionId: 'session-1' },
- }
+ const tab = { id: 'tab-1', title: 'Project' }
return {
state: {
activeTabId: tab.id,
tabs: [tab],
sessions: {
- 'session-1': { cwd: '/project', title: 'Agent', kind },
+ 'session-1': { cwd: '/project', title: 'Agent', kind, projectId: 'tab-1', joinedAt: 0 },
},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
- gridRelatedSelections: {},
- dispatchMode: null,
+ stage: oneLaneStage('session-1'),
},
activeTab: tab,
- dispatchMode: null,
+ stage: oneLaneStage('session-1'),
readerMode: { tabId: tab.id, focusedSessionId: 'session-1' },
getRuntime: () => runtime,
setReaderModeSession: vi.fn(),
diff --git a/src/renderer/src/features/reader/ui/ReaderView.tsx b/src/renderer/src/features/reader/ui/ReaderView.tsx
index ba8c640e4..123a6278c 100644
--- a/src/renderer/src/features/reader/ui/ReaderView.tsx
+++ b/src/renderer/src/features/reader/ui/ReaderView.tsx
@@ -20,7 +20,6 @@ import {
nextReaderSelection,
sameReaderList,
} from '@renderer/features/reader/model/readerSelection'
-import { resolveTabSessions } from '@renderer/workspace/queries'
import { useSessionRuntime } from '@renderer/workspace/useSessionRuntime'
import { dispatchSessionIdsForTab } from '@renderer/workspace/dispatch/dispatchSelectors'
import type { SessionId, Workspace } from '@renderer/workspace/workspaceStore'
@@ -105,9 +104,9 @@ export function ReaderView({ workspace }: Props) {
const tab = workspace.state.tabs.find(item => item.id === reader.tabId)
if (!tab) return null
- const sessionIds = (workspace.dispatchMode
- ? dispatchSessionIdsForTab(workspace.state, tab.id)
- : resolveTabSessions(workspace.state, tab.id))
+ // (A `resolveTabSessions` branch covered "Dispatch is off" until #992; the
+ // index is always the membership model now.)
+ const sessionIds = dispatchSessionIdsForTab(workspace.state, tab.id)
// WHY Reader filters terminal sessions even though Dispatch can render
// them: Reader is a transcript surface. Terminal sessions render raw PTY
// scrollback through xterm.js and do not have assistant messages to
diff --git a/src/renderer/src/features/settings/lib/naming.test.ts b/src/renderer/src/features/settings/lib/naming.test.ts
index 9552964de..60b433930 100644
--- a/src/renderer/src/features/settings/lib/naming.test.ts
+++ b/src/renderer/src/features/settings/lib/naming.test.ts
@@ -21,10 +21,10 @@ describe('command naming corrections', () => {
['toggle-tail-all', 'Auto-follow All Visible Agents'],
['toggle-tail-working', 'Auto-follow All Working Agents'],
['close-pane', 'Close Focused Session'],
- ['bury-pane', 'Bury Session'],
- ['revive-pane', 'Revive Buried Session…'],
- ['kill-buried-pane', 'Kill Buried Session…'],
- ['global-dispatch', 'Dispatch Scope'],
+ // bury-pane, revive-pane, kill-buried-pane and global-dispatch had rows
+ // here. #992 deleted the commands (burial folded into the pool; the
+ // layout-wide scope died with the two-mode layout), so their renames have
+ // nothing left to assert. Their ids live in RETIRED_COMMAND_IDS.
['toggle-session-recording', 'Session Recording'],
['set-agent-view-mode', 'Agent View for This Session…'],
['dispatch.color-flag.set', 'Set Color Flag…'],
@@ -38,8 +38,7 @@ describe('command naming corrections', () => {
// Renaming an id would silently discard a user's settings.
const ids = new Set(builtInCommandCatalog.map(c => c.id))
for (const id of [
- 'toggle-tail', 'toggle-tail-all', 'close-pane', 'bury-pane',
- 'revive-pane', 'kill-buried-pane', 'global-dispatch',
+ 'toggle-tail', 'toggle-tail-all', 'close-pane',
'toggle-session-recording', 'set-agent-view-mode', 'dispatch.color-flag.set',
]) {
expect(ids.has(id)).toBe(true)
@@ -52,7 +51,6 @@ describe('command naming corrections', () => {
expect(keywordsOf('toggle-tail')).toContain('tail')
expect(keywordsOf('toggle-tail-all')).toContain('tail')
expect(keywordsOf('close-pane')).toContain('pane')
- expect(keywordsOf('global-dispatch')).toContain('global dispatch')
})
it('uses a typographic ellipsis, never three periods', () => {
@@ -63,9 +61,10 @@ describe('command naming corrections', () => {
})
it('stops using Pane for things that outlive their pane', () => {
- // Bury/Revive/Kill act on SESSIONS. The live object persists without a
- // pane, so "Pane" named the wrong noun.
- for (const id of ['bury-pane', 'revive-pane', 'kill-buried-pane', 'close-pane']) {
+ // Close acts on a SESSION. The live object persists without a pane, so
+ // "Pane" named the wrong noun. (Bury/Revive/Kill Buried were checked here
+ // too until #992 deleted them.)
+ for (const id of ['close-pane']) {
expect(titleOf(id)).not.toMatch(/\bPane\b/)
}
})
@@ -103,10 +102,9 @@ describe('settings metadata', () => {
expect(cli && settingMetadata(cli).storage).toBe('setup')
})
- it('marks the fresh-install-only scope', () => {
- const row = getSettingsRegistry().find(r => r.id === 'default-workspace-mode')
- expect(row && settingMetadata(row).scope).toBe('fresh-install')
- })
+ // 'marks the fresh-install-only scope' pinned default-workspace-mode until
+ // #992 stage 8 deleted the setting (one layout, nothing to choose). No
+ // other setting claims the scope, so the case went with its subject.
it('marks both managed personal-skill surfaces as app-wide and new-session', () => {
const conventions = getSettingsRegistry().find(row => row.id === 'agent-code-conventions')
diff --git a/src/renderer/src/features/settings/lib/settingsRegistry.ts b/src/renderer/src/features/settings/lib/settingsRegistry.ts
index 8d6cb56f4..5d934a304 100644
--- a/src/renderer/src/features/settings/lib/settingsRegistry.ts
+++ b/src/renderer/src/features/settings/lib/settingsRegistry.ts
@@ -3,7 +3,6 @@ import {
AGENT_VIEW_MODES,
CORNER_STYLES,
FONT_FAMILIES,
- WORKSPACE_MODES,
} from '@renderer/app-state/settings/types'
import type {
AccentId,
@@ -11,7 +10,6 @@ import type {
CornerStyleId,
FontFamilyId,
Settings,
- WorkspaceModeId,
} from '@renderer/app-state/settings/types'
import type { Workspace } from '@renderer/workspace/workspaceStore'
import { SETTING_CATEGORIES } from '@renderer/features/settings/lib/settingsCategories'
@@ -340,15 +338,6 @@ const FONT_FAMILY_OPTIONS: ChoiceOption[] = FONT_FAMILIES.map(font
description: font.description,
}))
-const WORKSPACE_MODE_OPTIONS: ChoiceOption[] = WORKSPACE_MODES.map(mode => ({
- value: mode.id,
- label: mode.label,
- description:
- mode.id === 'dispatch'
- ? 'Open with the dispatch sidebar + main pane.'
- : 'Open with the classic tiled grid.',
-}))
-
const AGENT_VIEW_MODE_OPTIONS: ChoiceOption[] = AGENT_VIEW_MODES.map(mode => ({
value: mode.id,
label: mode.label,
@@ -555,31 +544,10 @@ export function getSettingsRegistry(
onSelect: (ctx, value) => ctx.onChange({ cornerStyle: value as CornerStyleId }),
},
},
- {
- // WHY this entry's copy is so explicit about "first launch":
- // existing users will flip it expecting an immediate effect, and
- // the setting deliberately doesn't behave that way. The friction
- // of a confused user reporting "the setting doesn't work" is
- // worse than verbose UI text. If this ever proves too narrow we
- // can add a "Reset workspace to default mode" action later.
- id: 'default-workspace-mode',
- category: 'workspace',
- title: 'Default Workspace Mode',
- description:
- 'Mode the app opens in on first launch. Existing workspaces keep their last-used mode — flipping this later only affects a fresh install.',
- keywords: ['default', 'mode', 'dispatch', 'grid', 'startup', 'launch', 'workspace'],
- // Only affects a fresh install — existing workspaces keep their last-used
- // mode, which the description says but the row could not show.
- metadata: { scope: 'fresh-install', apply: 'new-session', storage: 'settings' },
- control: {
- type: 'select',
- getValue: settings => settings.defaultWorkspaceMode,
- options: WORKSPACE_MODE_OPTIONS,
- columns: 2,
- onSelect: (ctx, value) =>
- ctx.onChange({ defaultWorkspaceMode: value as WorkspaceModeId }),
- },
- },
+ // 'default-workspace-mode' (Default Workspace Mode) was a Settings row
+ // until #992 stage 8: it chose between grid and Dispatch for a fresh
+ // install, and there is one layout now. Its persisted value is ignored
+ // on read; a stale localStorage key selects nothing.
{
// WHY this setting belongs in Workspace rather than Commands:
// terminal/agent/hybrid is the pane surface contract that commands must
diff --git a/src/renderer/src/features/settings/ui/KeyboardShortcutsModal.tsx b/src/renderer/src/features/settings/ui/KeyboardShortcutsModal.tsx
index 2acdca35a..7cc74ff81 100644
--- a/src/renderer/src/features/settings/ui/KeyboardShortcutsModal.tsx
+++ b/src/renderer/src/features/settings/ui/KeyboardShortcutsModal.tsx
@@ -92,8 +92,10 @@ const CATEGORY_RANK: Record = {
*/
const CONTEXT_LABELS: Record = {
global: null,
- grid: 'Grid only',
- dispatch: 'Dispatch only',
+ // 'Workspace only': the layout context is the stage now — 'grid' and its
+ // label died with the tile grid (#992), and user-facing copy may not name
+ // "Dispatch" as a mode (§5.4).
+ dispatch: 'Workspace only',
editor: 'Editor only',
feed: 'Feed only',
}
diff --git a/src/renderer/src/features/spotlight/ui/SpotlightView.tsx b/src/renderer/src/features/spotlight/ui/SpotlightView.tsx
index 41070336e..f42c3aaea 100644
--- a/src/renderer/src/features/spotlight/ui/SpotlightView.tsx
+++ b/src/renderer/src/features/spotlight/ui/SpotlightView.tsx
@@ -1,6 +1,5 @@
import { renderWorkspaceLeaf } from '@renderer/workspace/tile-tree/TileTree'
import type { AgentViewMode } from '@renderer/app-state/settings/types'
-import { resolveTabSessions } from '@renderer/workspace/queries'
import { dispatchSessionIdsForTab } from '@renderer/workspace/dispatch/dispatchSelectors'
import type { Workspace } from '@renderer/workspace/workspaceStore'
@@ -19,15 +18,15 @@ export function SpotlightView({ workspace, agentViewMode, showStatusMode, showWo
const tab = workspace.state.tabs.find(item => item.id === spotlight.tabId)
if (!tab) return null
- // Dispatch mode uses the visible-row selector rather than the raw project
- // groups. Pinned rows render in their own Dispatch section, but focus
- // takeovers must still let the user read/watch the pinned agent that command
- // targeting selected. The non-Dispatch path uses the canonical resolver so
- // Spotlight covers detached agents owned by this tab whenever Dispatch mode
- // is off.
- const sessionIds = workspace.dispatchMode
- ? dispatchSessionIdsForTab(workspace.state, tab.id)
- : resolveTabSessions(workspace.state, tab.id)
+ // The pill list is the index's visible rows, not the raw project members.
+ // Pinned rows render in their own index section, but a focus takeover must
+ // still let the user read and watch the pinned agent that command targeting
+ // selected.
+ //
+ // (A `resolveTabSessions` branch covered "Dispatch is off" until #992; the
+ // index is always the membership model now. usePaneFocusSanity's validator
+ // in hook/invalidation/effects.ts must list exactly this set.)
+ const sessionIds = dispatchSessionIdsForTab(workspace.state, tab.id)
if (sessionIds.length === 0) return null
const focusedSessionId = sessionIds.includes(spotlight.focusedSessionId)
diff --git a/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts b/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts
deleted file mode 100644
index 75f89f964..000000000
--- a/src/renderer/src/features/tile-tabs/commands/tileTabsCommands.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import type { CommandDef } from '@renderer/features/command-palette/types'
-import { toggle } from '@renderer/features/command-palette/commandState'
-
-export const tileTabsCommands: CommandDef[] = [
- {
- id: 'tiled-tabs',
- category: 'navigate',
- // `app`: Tiled Tabs is a top-level layout mode. Entering it clears
- // dispatchMode (they are mutually exclusive), so the command must
- // stay reachable from inside Dispatch to switch away from it.
- surface: 'app',
- title: 'Tiled Tabs',
- description: '**What it does:** Opens a modal to choose tabs for a **tiled tab view**.\n\n**Use when:** You want multiple tabs visible at once.\n\n**Notes:** If tiled tabs are already open, this command closes the tiled view.',
- getState: ({ workspace }) => toggle(Boolean(workspace.tileTabs)),
- run: ({ workspace, ui }) => {
- if (workspace.tileTabs) {
- workspace.closeTileTabs()
- return
- }
- ui.openTileTabs()
- },
- },
-]
diff --git a/src/renderer/src/features/tile-tabs/controlReference.ts b/src/renderer/src/features/tile-tabs/controlReference.ts
deleted file mode 100644
index d7af6b79e..000000000
--- a/src/renderer/src/features/tile-tabs/controlReference.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { FeatureReference } from '@control-sdk'
-
-// Keep purpose, UI routes and limitations beside this feature. The assembled
-// control reference adds current commands/bindings instead of copying them.
-export const controlReference = [
- {
- "id": "tiled-tabs",
- "title": "Several project tabs side by side",
- "purpose": "Display multiple project contexts in one workspace.",
- "ui": "Tiled Tabs configuration and layout.",
- "prerequisites": "Open project tabs.",
- "workflow": [
- "Select tabs to display",
- "arrange their sizes",
- "focus the intended tab and pane."
- ],
- "outcome": "Several project tabs are visible without merging their session ownership.",
- "cautions": "Tab focus and session focus are separate. Replacing a displayed tab should preserve the other views.",
- "commandIds": []
- }
-] satisfies FeatureReference[]
diff --git a/src/renderer/src/features/tile-tabs/ui/TileTabsModal.tsx b/src/renderer/src/features/tile-tabs/ui/TileTabsModal.tsx
deleted file mode 100644
index 6e8a448a6..000000000
--- a/src/renderer/src/features/tile-tabs/ui/TileTabsModal.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from 'react'
-
-import { Button } from '@renderer/components/ui/button'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@renderer/components/ui/dialog'
-import type { TabId } from '@renderer/workspace/types'
-
-type TileTabOption = {
- id: TabId
- title: string
-}
-
-type Props = {
- open: boolean
- tabs: TileTabOption[]
- initialSelectedIds: TabId[]
- onCancel: () => void
- onConfirm: (tabIds: TabId[]) => void
-}
-
-export function TileTabsModal({
- open,
- tabs,
- initialSelectedIds,
- onCancel,
- onConfirm,
-}: Props) {
- const [selected, setSelected] = useState(initialSelectedIds)
- const wasOpenRef = useRef(false)
-
- useEffect(() => {
- if (open && !wasOpenRef.current) {
- setSelected(initialSelectedIds)
- }
- wasOpenRef.current = open
- }, [open, initialSelectedIds])
-
- useEffect(() => {
- if (!open) return
- const validIds = new Set(tabs.map(tab => tab.id))
- setSelected(prev => {
- const next = prev.filter(id => validIds.has(id))
- return next.length === prev.length ? prev : next
- })
- }, [open, tabs])
-
- const selectedSet = useMemo(() => new Set(selected), [selected])
-
- return (
- {
- if (!nextOpen) onCancel()
- }}
- >
-
-
- Tiled Tabs
- Select two or more tabs to show side by side.
-
-
-
- {tabs.map(tab => {
- const checked = selectedSet.has(tab.id)
- return (
-
- {
- setSelected(prev =>
- prev.includes(tab.id)
- ? prev.filter(id => id !== tab.id)
- : [...prev, tab.id],
- )
- }}
- />
- {tab.title}
-
- )
- })}
-
-
-
-
- Cancel
-
- onConfirm(selected)}
- disabled={selected.length < 2}
- >
- Open Tiled Tabs
-
-
-
-
- )
-}
diff --git a/src/renderer/src/features/tile-tabs/ui/TileTabsView.tsx b/src/renderer/src/features/tile-tabs/ui/TileTabsView.tsx
deleted file mode 100644
index 2e9bb7ff6..000000000
--- a/src/renderer/src/features/tile-tabs/ui/TileTabsView.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import { Fragment, useEffect, useRef } from 'react'
-
-import type { AgentViewMode } from '@renderer/app-state/settings/types'
-import { TileTree } from '@renderer/workspace/tile-tree/TileTree'
-import type { Workspace } from '@renderer/workspace/workspaceStore'
-
-type Props = {
- workspace: Workspace
- agentViewMode: AgentViewMode
- // Threaded from settings like every other workspace surface (#856). Tiled
- // Tabs renders ordinary TileTrees, so it must honor the same toggles.
- showStatusMode: boolean
- showWorktreeBadges: boolean
-}
-
-export function TileTabsView({ workspace, agentViewMode, showStatusMode, showWorktreeBadges }: Props) {
- const containerRef = useRef(null)
- const dragRef = useRef<{
- dividerIndex: number
- pointerId: number
- } | null>(null)
-
- const tileTabs = workspace.tileTabs
- if (!tileTabs) return null
-
- const tabs = tileTabs.tabIds
- .map(id => workspace.state.tabs.find(t => t.id === id) ?? null)
- .filter((tab): tab is NonNullable => tab !== null)
-
- if (tabs.length < 2) return null
-
- const isVertical = tileTabs.direction === 'vertical'
-
- useEffect(() => {
- const onPointerMove = (e: PointerEvent) => {
- const drag = dragRef.current
- const container = containerRef.current
- if (!drag || !container) return
-
- const rect = container.getBoundingClientRect()
- const total = isVertical ? rect.width : rect.height
- if (total <= 0) return
-
- const deltaPx = isVertical ? e.movementX : e.movementY
- const deltaRatio = deltaPx / total
- if (Math.abs(deltaRatio) < 0.0001) return
- workspace.resizeTiledTabByIndex(drag.dividerIndex, deltaRatio)
- }
-
- const onPointerUp = (e: PointerEvent) => {
- const drag = dragRef.current
- if (!drag || drag.pointerId !== e.pointerId) return
- dragRef.current = null
- }
-
- window.addEventListener('pointermove', onPointerMove)
- window.addEventListener('pointerup', onPointerUp)
- window.addEventListener('pointercancel', onPointerUp)
- return () => {
- window.removeEventListener('pointermove', onPointerMove)
- window.removeEventListener('pointerup', onPointerUp)
- window.removeEventListener('pointercancel', onPointerUp)
- }
- }, [isVertical, workspace])
-
- return (
-
- {tabs.map((tab, index) => {
- const focused = tab.id === tileTabs.focusedTabId
- const ratio = tileTabs.ratios[index] ?? 1 / tabs.length
- return (
-
-
-
0
- ? isVertical
- ? 'border-l border-border'
- : 'border-t border-border'
- : ''
- }`}
- onMouseDownCapture={() => workspace.focusTiledTab(tab.id)}
- >
-
-
-
- {index + 1}
-
-
-
- {tab.title}
-
-
- Tiled Tab
-
-
-
-
- {Math.round(ratio * 100)}%
-
-
-
-
-
-
- {!focused && (
-
- )}
-
- {index < tabs.length - 1 && (
- {
- e.preventDefault()
- dragRef.current = {
- dividerIndex: index,
- pointerId: e.pointerId,
- }
- }}
- />
- )}
-
- )
- })}
-
- )
-}
diff --git a/src/renderer/src/features/tldr/paneWiring.renderer.test.tsx b/src/renderer/src/features/tldr/paneWiring.renderer.test.tsx
index 0ccac4771..c15c82fae 100644
--- a/src/renderer/src/features/tldr/paneWiring.renderer.test.tsx
+++ b/src/renderer/src/features/tldr/paneWiring.renderer.test.tsx
@@ -1,7 +1,7 @@
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { emptyRuntime } from '@renderer/session-runtime/state'
-import { TileTree, renderWorkspaceLeaf } from '@renderer/workspace/tile-tree/TileTree'
+import { renderWorkspaceLeaf } from '@renderer/workspace/tile-tree/TileTree'
import { AgentTerminalOwnershipProvider } from '@renderer/workspace/terminal/AgentTerminalOwnership'
import type { Workspace } from '@renderer/workspace/workspaceStore'
import { dismissTldr, toggleTldr } from './viewState'
@@ -17,24 +17,33 @@ const originalApi = window.api
afterEach(() => { cleanup(); dismissTldr(); window.api = originalApi })
describe('TLDR placement in the actual workspace leaf', () => {
- it.each(['agent', 'terminal'] as const)('uses the selected child’s identity and enablement in %s view', async mode => {
- const node = { type: 'split', direction: 'vertical', ratio: 0.5, a: { type: 'leaf', sessionId: 'parent' }, b: { type: 'leaf', sessionId: 'shell' } } as const
+ it.each(['agent', 'terminal'] as const)('uses the rendered session’s own identity and enablement in %s view', async mode => {
const workspace = {
state: {
- activeTabId: 'project', tabs: [{ id: 'project', title: 'Project', root: node, focusedSessionId: 'parent' }],
+ activeTabId: 'project', tabs: [{ id: 'project', title: 'Project' }],
sessions: {
- parent: { cwd: '/project', kind: 'claude', tldrIdentity: 'parent-summary', builtInMcpDomains: [] },
- child: { cwd: '/project/child', kind: 'codex', linkedParentId: 'parent', tldrIdentity: 'child-summary', builtInMcpDomains: ['tldr'] },
- shell: { cwd: '/project', kind: 'terminal' },
+ parent: { cwd: '/project', kind: 'claude', tldrIdentity: 'parent-summary', builtInMcpDomains: [], projectId: 'project', joinedAt: 0 },
+ child: { cwd: '/project/child', kind: 'codex', linkedParentId: 'parent', tldrIdentity: 'child-summary', builtInMcpDomains: ['tldr'], projectId: 'project', joinedAt: 1 },
+ shell: { cwd: '/project', kind: 'terminal', projectId: 'project', joinedAt: 2 },
},
- detachedSessions: { child: { sessionId: 'child', surface: 'dispatch', projectTabId: 'project', detachedAt: 1 } },
- gridRelatedSelections: { parent: 'child' }, buried: [], pinnedSessionIds: [],
+ pinnedSessionIds: [],
},
getRuntime: (id: string) => ({ ...emptyRuntime(), lastJsonlEntryAt: Date.parse(id === 'child' ? '2026-09-10T01:00:00.000Z' : '2026-09-09T01:00:00.000Z') }),
} as unknown as Workspace
const readTldrs = vi.fn(async (ids: string[]) => Object.fromEntries(ids.map(id => [id, { text: `Saved ${id}.`, revision: 1, updatedAt: '2026-09-11T00:00:00.000Z' }])))
window.api = { ...originalApi, readTldrs, onTldrChanged: () => () => {} }
- const view = render( )
+ // A lane shows the session it is asked to show — here the linked child,
+ // selected into a lane beside a shell — and the TLDR pane must take ITS
+ // identity and enablement, never its parent's.
+ //
+ // Until #992 this rendered the PARENT with related-agent tabs on and a
+ // stored selection pointing at the child, so the leaf swapped the child
+ // into the parent's tile. That swap is gone (see TileTree.tsx); the wiring
+ // it exercised — leaf -> TldrPane(identity, enabled) — is the same.
+ const view = render(
+ {renderWorkspaceLeaf('child', 'child', workspace, 'project', mode, true, true)}
+ {renderWorkspaceLeaf('shell', 'child', workspace, 'project', mode, true, true)}
+ )
act(toggleTldr)
await screen.findByText('Saved child-summary.')
expect(screen.getAllByRole('note')).toHaveLength(1)
@@ -44,8 +53,7 @@ describe('TLDR placement in the actual workspace leaf', () => {
expect(screen.getByText(mode === 'agent' ? 'Feed child' : 'Agent terminal child')).toBeTruthy()
expect(screen.getByText('Shell terminal')).toBeTruthy()
- // Dispatch and Spotlight call this shared entry directly. They must use
- // their explicit session, without following a grid parent's selection.
+ // The parent, rendered as itself, shows its own (disabled) TLDR state.
view.rerender({renderWorkspaceLeaf('parent', 'parent', workspace, 'project', mode, true, true)} )
expect(screen.getByText('TLDR is off')).toBeTruthy()
expect(screen.getByLabelText(/^Last active /).getAttribute('datetime')).toBe('2026-09-09T01:00:00.000Z')
diff --git a/src/renderer/src/features/tldr/sessionContinuity.renderer.test.tsx b/src/renderer/src/features/tldr/sessionContinuity.renderer.test.tsx
index aec4d26b9..19376ff19 100644
--- a/src/renderer/src/features/tldr/sessionContinuity.renderer.test.tsx
+++ b/src/renderer/src/features/tldr/sessionContinuity.renderer.test.tsx
@@ -10,6 +10,7 @@ import type { SessionSpawnOptions } from '@preload/api/types'
import type { TldrRecord } from '@shared/types/tldr'
import { TldrPane } from './TldrOverlay'
import { dismissTldr, toggleTldr } from './viewState'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
vi.mock('@renderer/workspace/hook/actions/initialHistory', () => ({ loadInitialHistoryForSession: vi.fn(async () => undefined) }))
const originalApi = window.api
@@ -19,10 +20,10 @@ describe('TLDR identity through real session actions', () => {
it.each(['rewind', 'remove cyber block'] as const)('restores the saved summary after undoing %s', async operation => {
vi.useFakeTimers()
const state = {
- tabs: [{ id: 'project', title: 'Project', root: { type: 'leaf', sessionId: 'source' }, focusedSessionId: 'source' }],
+ tabs: [{ id: 'project', title: 'Project' }],
activeTabId: 'project', sessions: {
- source: { cwd: '/project', kind: 'codex', providerSessionId: 'native-source', tldrIdentity: 'summary-source', builtInMcpDomains: ['tldr'] },
- }, detachedSessions: {}, buried: [], pinnedSessionIds: [], dispatchMode: null,
+ source: { cwd: '/project', kind: 'codex', providerSessionId: 'native-source', tldrIdentity: 'summary-source', builtInMcpDomains: ['tldr'], projectId: 'project', joinedAt: 0 },
+ }, pinnedSessionIds: [], stage: oneLaneStage('source'),
} as WorkspaceState
const refs = makeRefs(state)
const writer = stateWriter(state, refs)
@@ -82,10 +83,10 @@ describe('TLDR identity through real session actions', () => {
// mint and refuse to donate it exactly like a TLDR agent.
const domains = 'domains' in scenario && scenario.domains ? [...scenario.domains] : ['tldr' as const]
const state = {
- tabs: [{ id: 'project', title: 'Project', root: { type: 'leaf', sessionId: 'source' }, focusedSessionId: 'source' }],
+ tabs: [{ id: 'project', title: 'Project' }],
activeTabId: 'project', sessions: {
- source: { cwd: '/project', kind: 'claude', providerSessionId: 'native-source', tldrIdentity: 'summary-source', builtInMcpDomains: domains },
- }, detachedSessions: {}, buried: [], pinnedSessionIds: [], dispatchMode: null,
+ source: { cwd: '/project', kind: 'claude', providerSessionId: 'native-source', tldrIdentity: 'summary-source', builtInMcpDomains: domains, projectId: 'project', joinedAt: 0 },
+ }, pinnedSessionIds: [], stage: oneLaneStage('source'),
} as WorkspaceState
const refs = makeRefs(state)
const writer = stateWriter(state, refs)
@@ -107,7 +108,11 @@ describe('TLDR identity through real session actions', () => {
expect(spawnedIdentity).toEqual(expect.any(String))
expect(spawnedIdentity === 'summary-source').toBe(scenario.carry)
expect(writer.getState().sessions.successor?.tldrIdentity).toBe(spawnedIdentity)
- expect(writer.getState().tabs[0]?.focusedSessionId).toBe('successor')
+ // The successor takes over the row: same project, same place in its index,
+ // and the lane that showed the source now shows it. (Tree era: the tab's
+ // focus followed.)
+ expect(writer.getState().sessions.successor).toMatchObject({ projectId: 'project', joinedAt: 0 })
+ expect(writer.getState().stage.lanes[0]?.selectedSessionId).toBe('successor')
// A duplicate uses spawn with a cloned transcript. Neither the source's
// metadata nor the last replacement may donate its completion statement.
diff --git a/src/renderer/src/features/tldr/tldr.renderer.test.tsx b/src/renderer/src/features/tldr/tldr.renderer.test.tsx
index da95145f2..414667302 100644
--- a/src/renderer/src/features/tldr/tldr.renderer.test.tsx
+++ b/src/renderer/src/features/tldr/tldr.renderer.test.tsx
@@ -7,6 +7,7 @@ import { useKeybinds } from '@renderer/workspace/tile-tree/useKeybinds'
import { TldrPane } from './TldrOverlay'
import { dismissTldr, toggleTldr, useTldrView } from './viewState'
import type { TldrRecord, TldrUpdate } from '@shared/types/tldr'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
const harness = vi.hoisted(() => ({ appState: {} as Record }))
vi.mock('@renderer/app-state/hooks', () => ({
@@ -32,10 +33,10 @@ const api = {
function workspace(): Workspace {
const runtime = emptyRuntime()
- const tab = { id: 'tab', title: 'Project', focusedSessionId: 'a', root: { type: 'leaf', sessionId: 'a' } }
+ const tab = { id: 'tab', title: 'Project' }
return {
- state: { activeTabId: 'tab', tabs: [tab], sessions: { a: { kind: 'claude', cwd: '/project' } }, detachedSessions: {}, buried: [], pinnedSessionIds: [] },
- activeTab: tab, dispatchMode: null, tileTabs: null, spotlight: null, readerMode: null,
+ state: { activeTabId: 'tab', tabs: [tab], sessions: { a: { kind: 'claude', cwd: '/project', projectId: 'tab', joinedAt: 0 } }, pinnedSessionIds: [], stage: oneLaneStage('a') },
+ activeTab: tab, stage: oneLaneStage('a'), spotlight: null, readerMode: null,
runtimes: { a: runtime }, getRuntime: () => runtime,
} as unknown as Workspace
}
diff --git a/src/renderer/src/features/tldr/tldrHistory.renderer.test.tsx b/src/renderer/src/features/tldr/tldrHistory.renderer.test.tsx
index ebffe507e..845f7ab19 100644
--- a/src/renderer/src/features/tldr/tldrHistory.renderer.test.tsx
+++ b/src/renderer/src/features/tldr/tldrHistory.renderer.test.tsx
@@ -8,6 +8,7 @@ import { tldrCommands } from './commands'
import { mergeHistory, TldrHistoryModal } from './TldrHistoryModal'
import { TldrPane } from './TldrOverlay'
import { dismissTldr, toggleTldr } from './viewState'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
const originalApi = window.api
afterEach(() => { cleanup(); dismissTldr(); window.api = originalApi })
@@ -124,7 +125,7 @@ describe('TLDR history', () => {
const command = tldrCommands.find(candidate => candidate.id === 'view-tldr-history')!
const ui = { closePalette: vi.fn(), openTldrHistory: vi.fn() }
const workspace = (kind: string) => ({
- state: { activeTabId: 'tab', tabs: [{ id: 'tab', focusedSessionId: 'pane', root: { type: 'leaf', sessionId: 'pane' } }], sessions: { pane: { cwd: '/project', kind } }, dispatchMode: null, detachedSessions: {} },
+ state: { activeTabId: 'tab', tabs: [{ id: 'tab' }], sessions: { pane: { cwd: '/project', kind, projectId: 'tab', joinedAt: 0 } }, stage: oneLaneStage('pane'), pinnedSessionIds: [], },
}) as unknown as Workspace
expect(command.when?.({ workspace: workspace('terminal'), ui } as unknown as CommandContext)).toBe(false)
const context = { workspace: workspace('codex'), ui } as unknown as CommandContext
diff --git a/src/renderer/src/features/usage-limit/usageLimit.renderer.test.tsx b/src/renderer/src/features/usage-limit/usageLimit.renderer.test.tsx
index c813df739..e1dc5b284 100644
--- a/src/renderer/src/features/usage-limit/usageLimit.renderer.test.tsx
+++ b/src/renderer/src/features/usage-limit/usageLimit.renderer.test.tsx
@@ -15,13 +15,17 @@ import { codexUsageLimitNotice } from '@providers/codex/renderer/adapters/usageL
import { claudeUsageLimitNotice } from '@providers/claude/renderer/adapters/usageLimitNotice'
import { useUsageLimitActions } from './useUsageLimitActions'
import fixture from '../../../../../testing/fixtures/provider-usage-limits/cases.json'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
function host(kind: 'claude' | 'codex' = 'codex') {
let runtime = { ...emptyRuntime(), sessionRunId: 'run-a' }
const pane = 'notice-pane'
- const tab = { id: 't', focusedSessionId: 'different-pane', root: { type: 'leaf', sessionId: pane } }
+ const tab = { id: 't', title: 'Project' }
const workspace = {
- state: { detachedSessions: {}, sessions: { [pane]: { id: pane, kind, cwd: '/synthetic', providerSessionId: fixture.claude.sessionId } }, tabs: [tab] },
+ // The pool fields and stage are here because Reader lists sessions
+ // through the index now (#992): the index is always on, so a Workspace
+ // mock must be a whole workspace, not just the fields the grid path read.
+ state: { activeTabId: 't', pinnedSessionIds: [], stage: oneLaneStage(pane), sessions: { [pane]: { id: pane, kind, cwd: '/synthetic', providerSessionId: fixture.claude.sessionId, projectId: 't', joinedAt: 0 } }, tabs: [tab] },
readerMode: { tabId: 't', focusedSessionId: pane },
getRuntime: () => runtime,
get runtimes() { return { [pane]: runtime } },
diff --git a/src/renderer/src/features/workspace/commands/agentTitleCommands.test.ts b/src/renderer/src/features/workspace/commands/agentTitleCommands.test.ts
index f51a79555..556c17d0f 100644
--- a/src/renderer/src/features/workspace/commands/agentTitleCommands.test.ts
+++ b/src/renderer/src/features/workspace/commands/agentTitleCommands.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { CommandContext } from '@renderer/features/command-palette/types'
import { agentTitleCommands } from '@renderer/features/workspace/commands/agentTitleCommands'
import type { WorkspaceState } from '@renderer/workspace/types'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
const command = agentTitleCommands[0]
if (!command) throw new Error('Set Agent Title command is missing')
@@ -22,24 +23,23 @@ function context(state: WorkspaceState) {
function baseState(): WorkspaceState {
return {
tabs: [
- { id: 'tab-a', title: 'A', root: { type: 'leaf', sessionId: 'a' }, focusedSessionId: 'a' },
- { id: 'tab-b', title: 'B', root: { type: 'leaf', sessionId: 'b' }, focusedSessionId: 'b' },
+ { id: 'tab-a', title: 'A' },
+ { id: 'tab-b', title: 'B' },
],
activeTabId: 'tab-a',
- gridRelatedSelections: {},
- dispatchMode: null,
+ // The user is commanding `a`: one lane showing it. (This used to be said
+ // by tab-a's tree focus alone, with Dispatch off; #992.)
+ stage: oneLaneStage('a'),
sessions: {
- a: { cwd: '/work/a', kind: 'claude' },
- b: { cwd: '/work/b', kind: 'codex' },
+ a: { cwd: '/work/a', kind: 'claude', projectId: 'tab-a', joinedAt: 0 },
+ b: { cwd: '/work/b', kind: 'codex', projectId: 'tab-b', joinedAt: 0 },
},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
}
}
describe('Set Title command targeting', () => {
- it('captures the focused Grid agent', () => {
+ it('captures the agent in the focused lane', () => {
const harness = context(baseState())
expect(command.when?.(harness.value)).toBe(true)
@@ -47,24 +47,21 @@ describe('Set Title command targeting', () => {
expect(harness.openAgentTitlePrompt).toHaveBeenCalledWith('a')
})
- it('captures the selected classic Dispatch agent instead of stale Grid focus', () => {
+ it('follows the lane to another project instead of the stale active project', () => {
+ // activeTabId is still tab-a; the lane shows b. The lane wins (U3).
const state = baseState()
- state.dispatchMode = { scope: 'global', focusedSessionId: 'b' }
+ state.stage = oneLaneStage('b')
const harness = context(state)
command.run(harness.value)
expect(harness.openAgentTitlePrompt).toHaveBeenCalledWith('b')
})
- it('captures the focused Tiled Dispatch lane instead of stale Grid focus', () => {
+ it('captures the FOCUSED lane when several lanes show agents', () => {
const state = baseState()
- state.dispatchMode = {
- scope: 'global',
- focusedSessionId: 'a',
- tiled: {
- focusedLane: 1,
- lanes: [{ selectedSessionId: 'a' }, { selectedSessionId: 'b' }],
- },
+ state.stage = {
+ focusedLane: 1,
+ lanes: [{ selectedSessionId: 'a' }, { selectedSessionId: 'b' }],
}
const harness = context(state)
@@ -74,7 +71,10 @@ describe('Set Title command targeting', () => {
it('offers titles for a plain terminal target too (#865)', () => {
const state = baseState()
- state.sessions.a = { cwd: '/work/a', kind: 'terminal' }
+ // Spread, not replaced: the row carries its own membership (#992), so a
+ // bare `{ cwd, kind }` here would un-file it from its project and the lane
+ // would resolve no target — failing for a reason unrelated to terminals.
+ state.sessions.a = { ...state.sessions.a!, kind: 'terminal' }
const harness = context(state)
expect(command.when?.(harness.value)).toBe(true)
diff --git a/src/renderer/src/features/workspace/commands/closeAgentRemoveLane.renderer.test.tsx b/src/renderer/src/features/workspace/commands/closeAgentRemoveLane.renderer.test.tsx
index 81ad2ee2f..e33fa1683 100644
--- a/src/renderer/src/features/workspace/commands/closeAgentRemoveLane.renderer.test.tsx
+++ b/src/renderer/src/features/workspace/commands/closeAgentRemoveLane.renderer.test.tsx
@@ -7,14 +7,23 @@ import { CloseConfirmationDialog } from '@renderer/features/workspace/ui/CloseCo
import { __resetCloseConfirmationForTests } from '@renderer/workspace/closeConfirmationBroker'
import { useDispatchActions } from '@renderer/workspace/hook/actions/dispatch'
import { mountPaneActions } from '@renderer/workspace/hook/actions/testing/paneActionsHarness'
+import { emptyRuntime } from '@renderer/session-runtime/state'
import type { WorkspaceState } from '@renderer/workspace/types'
import type { Workspace } from '@renderer/workspace/workspaceStore'
// "Close Agent and Remove Lane" removes the focused lane only when closeSession
-// resolves true. Since #886, `true` for a project's root carries a second
-// meaning: the root closed AND a Dispatch row was promoted into the grid. These
-// pin that the promotion never leaks into the layout mutation — the survivor
-// keeps its own lane — and that declining leaves the layout untouched.
+// resolves true. These pin the two halves of that: a close that HAPPENS takes
+// exactly one lane and leaves the other lane's agent where it is, and a close
+// the user DECLINES leaves the layout untouched.
+//
+// Re-based with #992. The suite was written for a project's ROOT tile leaf,
+// whose close raised a three-way dialog (Close Agent / Close Tab) and, on
+// Close Agent, PROMOTED a Dispatch row into the emptied tree — the risk being
+// that the promotion leaked into the lane mutation. There is no root and no
+// promotion: closing the first session of a project is an ordinary
+// session-scoped close. An IDLE session therefore closes with no dialog at all
+// (the cheap case Undo Close covers), so the decline case models the one
+// remaining reason a single close asks — the agent is mid-turn.
//
// Real pieces throughout: the palette command, the pane close action, the
// dispatch lane action and the confirmation dialog. Only the ownership-checked
@@ -37,28 +46,29 @@ afterEach(() => {
function tiledProject(): WorkspaceState {
return {
- tabs: [{ id: 'project', title: 'Project', root: { type: 'leaf', sessionId: 'root' }, focusedSessionId: 'root' }],
+ tabs: [{ id: 'project', title: 'Project' }],
activeTabId: 'project',
sessions: {
- root: { cwd: '/project', kind: 'claude', title: 'Root' },
- worker: { cwd: '/project', kind: 'codex', title: 'Worker' },
- },
- detachedSessions: {
- worker: { sessionId: 'worker', surface: 'dispatch', projectTabId: 'project', projectTabTitle: 'Project', projectTabIndex: 0, detachedAt: 1 },
- },
- // The root in lane 1 (focused), the detached worker in lane 2.
- dispatchMode: {
- scope: 'project',
- tiled: { lanes: [{ selectedSessionId: 'root' }, { selectedSessionId: 'worker' }], focusedLane: 0 },
+ root: { cwd: '/project', kind: 'claude', title: 'Root', projectId: 'project', joinedAt: 0 },
+ worker: { cwd: '/project', kind: 'codex', title: 'Worker', projectId: 'project', joinedAt: 1 },
},
- gridRelatedSelections: {}, buried: [], pinnedSessionIds: [],
+ // `root` in the focused lane, `worker` in the other.
+ stage: { lanes: [{ selectedSessionId: 'root' }, { selectedSessionId: 'worker' }], focusedLane: 0 },
+ pinnedSessionIds: [],
}
}
-function mount() {
+function mount(options: { rootWorking?: boolean } = {}) {
const harness = mountPaneActions(tiledProject())
+ if (options.rootWorking) {
+ // `processActive` is one of the facts closeConfirmation's liveness rule
+ // reads; it is what turns a silent close into "Close a working session?".
+ harness.refs.latestRuntimesRef.current = {
+ root: { ...emptyRuntime(), processActive: true },
+ }
+ }
const dispatch = renderHook(() => useDispatchActions(
- harness.getState(), harness.setState, vi.fn(), vi.fn(), harness.refs, vi.fn(), vi.fn(),
+ harness.setState, vi.fn(), harness.refs, vi.fn(), vi.fn(),
))
render( )
const workspace = {
@@ -70,7 +80,7 @@ function mount() {
}
const laneSessions = (state: WorkspaceState) =>
- state.dispatchMode?.tiled?.lanes.map(lane => lane.selectedSessionId)
+ state.stage.lanes.map(lane => lane.selectedSessionId)
async function runAndAnswer(context: CommandContext, button: string) {
let running!: void | Promise
@@ -81,19 +91,30 @@ async function runAndAnswer(context: CommandContext, button: string) {
})
}
-describe('Close Agent and Remove Lane on a project root (#886 review m8)', () => {
- it('Close Agent removes the root lane and leaves the other lane on the promoted worker', async () => {
+describe('Close Agent and Remove Lane (#886 review m8)', () => {
+ it('closes an idle agent without asking, removes its lane, and leaves the other lane alone', async () => {
const { harness, context } = mount()
expect(command.when?.(context)).toBe(true)
- await runAndAnswer(context, 'Close Agent')
+ await act(async () => { await command!.run(context) })
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(killOwnedSession.mock.calls.map(([owner]) => owner.sessionId)).toEqual(['root'])
expect(laneSessions(harness.getState())).toEqual(['worker'])
- expect(harness.getState().tabs[0].root).toEqual({ type: 'leaf', sessionId: 'worker' })
+ // The project is untouched: it still holds the worker, which was always a
+ // full member of it. (Tree era: the worker was PROMOTED into the emptied
+ // tile tree here, and this asserted the promotion did not move its lane.)
+ expect(harness.getState().tabs.map(tab => tab.id)).toEqual(['project'])
expect(harness.getState().sessions.worker).toBeDefined()
})
+ it('asks before closing a WORKING agent, and Close removes its lane', async () => {
+ const { harness, context } = mount({ rootWorking: true })
+ await runAndAnswer(context, 'Close')
+ expect(killOwnedSession.mock.calls.map(([owner]) => owner.sessionId)).toEqual(['root'])
+ expect(laneSessions(harness.getState())).toEqual(['worker'])
+ })
+
it('Cancel leaves both lanes and both sessions', async () => {
- const { harness, context } = mount()
+ const { harness, context } = mount({ rootWorking: true })
await runAndAnswer(context, 'Cancel')
expect(killOwnedSession).not.toHaveBeenCalled()
expect(laneSessions(harness.getState())).toEqual(['root', 'worker'])
diff --git a/src/renderer/src/features/workspace/commands/closeTabCommand.renderer.test.tsx b/src/renderer/src/features/workspace/commands/closeTabCommand.renderer.test.tsx
index 0475c9dc2..c68e6897d 100644
--- a/src/renderer/src/features/workspace/commands/closeTabCommand.renderer.test.tsx
+++ b/src/renderer/src/features/workspace/commands/closeTabCommand.renderer.test.tsx
@@ -7,9 +7,10 @@ import { CloseConfirmationDialog } from '@renderer/features/workspace/ui/CloseCo
import { __resetCloseConfirmationForTests } from '@renderer/workspace/closeConfirmationBroker'
import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchSelectors'
import { mountPaneActions, mountUndoCloseAction } from '@renderer/workspace/hook/actions/testing/paneActionsHarness'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
+import { resolveTabSessions } from '@renderer/workspace/queries'
import type { WorkspaceState } from '@renderer/workspace/types'
import type { Workspace } from '@renderer/workspace/workspaceStore'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
// The Close Tab COMMAND (⌘⇧W, tab bar ×, palette), driven end to end: palette
// entry, the pane close executor, the confirmation dialog and Undo Close. Only
@@ -39,15 +40,32 @@ afterEach(() => {
else Reflect.deleteProperty(window, 'api')
})
+/**
+ * The invariants a close must never break, in the pool-first shape (#992).
+ *
+ * This used to check the TREE: every leaf has metadata, the tab's focus is one
+ * of its leaves, every detached row names a live tab. Those were three ways of
+ * saying one thing — nothing on screen points at something that is gone — and
+ * with ownership on the row it is said in three different places:
+ * - every row names a project that exists (else autosave drops it as unowned,
+ * which after a PARTIAL close would silently delete a survivor);
+ * - every project still lists at least one session (a project exists only
+ * while something names it — an empty one is a phantom tab);
+ * - every lane and pin resolves (a pointer at a closed session is the
+ * "selected-but-unresolvable lane" bug).
+ */
function expectValidWorkspace(state: WorkspaceState): void {
+ const projectIds = new Set(state.tabs.map(tab => tab.id))
+ for (const [id, meta] of Object.entries(state.sessions)) {
+ expect(projectIds.has(meta.projectId ?? ''), `session ${id} names a live project`).toBe(true)
+ }
for (const tab of state.tabs) {
- const leaves = collectLeaves(tab.root)
- for (const leaf of leaves) expect(state.sessions[leaf], `tab ${tab.id} leaf ${leaf}`).toBeDefined()
- expect(leaves).toContain(tab.focusedSessionId)
+ expect(resolveTabSessions(state, tab.id).length, `project ${tab.id} lists a session`).toBeGreaterThan(0)
}
- for (const record of Object.values(state.detachedSessions)) {
- expect(state.tabs.some(tab => tab.id === record.projectTabId), `row ${record.sessionId} has a project`).toBe(true)
+ for (const lane of state.stage.lanes) {
+ if (lane.selectedSessionId !== undefined) expect(state.sessions[lane.selectedSessionId]).toBeDefined()
}
+ for (const pinned of state.pinnedSessionIds) expect(state.sessions[pinned]).toBeDefined()
}
function mountCommand(state: WorkspaceState) {
@@ -77,44 +95,42 @@ describe('Close Tab command runs the approved close operation (#886 review round
it('closes a linked child attached in another project, captures it, and undo restores it under the restored parent', async () => {
const state: WorkspaceState = {
tabs: [
- { id: 'a', title: 'A', root: { type: 'leaf', sessionId: 'parent' }, focusedSessionId: 'parent' },
- { id: 'b', title: 'B', focusedSessionId: 'anchor', root: {
- type: 'split', direction: 'vertical', ratio: 0.5,
- a: { type: 'leaf', sessionId: 'anchor' }, b: { type: 'leaf', sessionId: 'child' },
- } },
+ { id: 'a', title: 'A' },
+ { id: 'b', title: 'B' },
],
activeTabId: 'a',
sessions: {
- parent: { cwd: '/a', kind: 'claude', title: 'Parent' },
- worker: { cwd: '/a', kind: 'codex' },
- // Attached beside a pane of project B; attachment keeps linkedParentId.
- child: { cwd: '/a', kind: 'codex', linkedParentId: 'parent' },
- anchor: { cwd: '/b', kind: 'claude' },
- },
- detachedSessions: {
- worker: { sessionId: 'worker', surface: 'dispatch', projectTabId: 'a', projectTabTitle: 'A', projectTabIndex: 0, detachedAt: 1 },
+ parent: { cwd: '/a', kind: 'claude', title: 'Parent', projectId: 'a', joinedAt: 0 },
+ worker: { cwd: '/a', kind: 'codex', projectId: 'a', joinedAt: 1 },
+ // Filed under project B while still linked to a parent in A: a linked
+ // child follows its PARENT's close, whatever project lists it.
+ child: { cwd: '/a', kind: 'codex', linkedParentId: 'parent', projectId: 'b', joinedAt: 1 },
+ anchor: { cwd: '/b', kind: 'claude', projectId: 'b', joinedAt: 0 },
},
- dispatchMode: null, gridRelatedSelections: {}, buried: [], pinnedSessionIds: [],
+ stage: oneLaneStage('parent'), pinnedSessionIds: [],
}
const { harness, context } = mountCommand(state)
await runAndConfirm(context, 'Close 3')
- // Exactly the listed set, the linked child before its parent, the tab's
- // grid leaf last.
- expect(killed()).toEqual(['child', 'worker', 'parent'])
- expect(harness.getState().tabs).toEqual([expect.objectContaining({
- id: 'b', root: { type: 'leaf', sessionId: 'anchor' }, focusedSessionId: 'anchor',
- })])
+ // Exactly the listed set, the linked child before its parent. (The order
+ // used to end on the tab's grid leaf, because a tile tree could not be left
+ // empty mid-operation. Nothing needs to go last now; only depth orders.)
+ expect(killed()).toEqual(['child', 'parent', 'worker'])
+ expect(harness.getState().tabs).toEqual([{ id: 'b', title: 'B' }])
+ expect(harness.getState().activeTabId).toBe('b')
expect(Object.keys(harness.getState().sessions)).toEqual(['anchor'])
+ // The lane that showed the closed parent is EMPTY, not refilled (#681).
+ expect(harness.getState().stage.lanes).toEqual([{}])
expectValidWorkspace(harness.getState())
expect(harness.showToast).toHaveBeenLastCalledWith('Closed “A” — ⌘⇧T Undo Close; repeat for earlier closes')
- // One undo unit for one decision: the child's pane in B, then project A.
+ // One undo unit for one decision: the child's row in B, then project A
+ // with both of its sessions in index order.
expect(harness.refs.undoStackRef.current.length).toBe(1)
expect(harness.refs.undoStackRef.current.peek()).toMatchObject({
type: 'group',
entries: [
- { type: 'pane', sessionId: 'child', tabId: 'b', siblingLeafId: 'anchor' },
- { type: 'tab', tab: { id: 'a', root: { type: 'leaf', sessionId: 'parent' } }, detachedEntries: [{ sessionId: 'worker' }] },
+ { type: 'session', sessionId: 'child', sessionMeta: { projectId: 'b', joinedAt: 1 } },
+ { type: 'tab', tab: { id: 'a', title: 'A' }, tabIndex: 0, sessions: [{ sessionId: 'parent' }, { sessionId: 'worker' }] },
],
})
@@ -128,47 +144,67 @@ describe('Close Tab command runs the approved close operation (#886 review round
await act(async () => { await undo.actions.undoClose() })
const restored = undo.getState()
const restoredA = restored.tabs.find(tab => tab.title === 'A')
- expect(restoredA?.root).toEqual({ type: 'leaf', sessionId: 'parent-2' })
- expect(restored.detachedSessions['worker-2']).toMatchObject({ projectTabId: restoredA?.id, detachedAt: 1 })
+ expect(restoredA).toBeDefined()
+ expect(resolveTabSessions(restored, restoredA!.id)).toEqual(['parent-2', 'worker-2'])
+ expect(restored.sessions['worker-2']).toMatchObject({ projectId: restoredA!.id, joinedAt: 1 })
expect(restored.sessions['child-2']?.linkedParentId).toBe('parent-2')
- expect(collectLeaves(restored.tabs.find(tab => tab.id === 'b')!.root).sort()).toEqual(['anchor', 'child-2'])
+ // The child returns to project B, where it was listed — not to the
+ // restored A, even though that is where its parent lives.
+ expect(resolveTabSessions(restored, 'b')).toEqual(['anchor', 'child-2'])
expect(harness.refs.undoStackRef.current.length).toBe(0)
expectValidWorkspace(restored)
undo.mounted.unmount()
harness.mounted.unmount()
})
- it('leaves no phantom tab or undo entry when one member\'s kill rejects, and roots the tab on the survivor', async () => {
+ it('closes the project of the agent in the focused lane, not the one highlighted in the header', async () => {
+ // #1013 parity review. The active project is only a label (U4): lane
+ // focus and index selection never move it. ⌘⇧W used to close the
+ // HIGHLIGHTED project while the user worked in another project's lane,
+ // and a single idle session closes with no dialog.
+ const state: WorkspaceState = {
+ tabs: [{ id: 'highlighted', title: 'Highlighted' }, { id: 'working', title: 'Working' }],
+ activeTabId: 'highlighted',
+ sessions: {
+ header: { cwd: '/h', kind: 'claude', projectId: 'highlighted', joinedAt: 0 },
+ lane: { cwd: '/w', kind: 'claude', projectId: 'working', joinedAt: 0 },
+ },
+ stage: oneLaneStage('lane'), pinnedSessionIds: [],
+ }
+ const { harness, context } = mountCommand(state)
+ await act(async () => { await command!.run(context) })
+ expect(killed()).toEqual(['lane'])
+ expect(harness.getState().tabs).toEqual([{ id: 'highlighted', title: 'Highlighted' }])
+ expectValidWorkspace(harness.getState())
+ })
+
+ it('leaves no phantom tab or undo entry when one member\'s kill rejects, and the project keeps its survivor', async () => {
killOwnedSession.mockImplementation(async owner => {
if (owner.sessionId === 'row') throw new Error('backend refused')
return true
})
- const split = {
- type: 'split' as const, direction: 'vertical' as const, ratio: 0.4,
- a: { type: 'leaf' as const, sessionId: 'grid' }, b: { type: 'leaf' as const, sessionId: 'term' },
- }
const state: WorkspaceState = {
- tabs: [{ id: 'a', title: 'A', root: split, focusedSessionId: 'grid' }],
+ tabs: [{ id: 'a', title: 'A' }],
activeTabId: 'a',
sessions: {
- grid: { cwd: '/a', kind: 'claude' },
- term: { cwd: '/a', kind: 'terminal', tmuxName: 'agent-code-term' },
- row: { cwd: '/a', kind: 'codex', title: 'Row' },
- },
- detachedSessions: {
- row: { sessionId: 'row', surface: 'dispatch', projectTabId: 'a', projectTabTitle: 'A', projectTabIndex: 0, detachedAt: 4 },
+ grid: { cwd: '/a', kind: 'claude', projectId: 'a', joinedAt: 0 },
+ term: { cwd: '/a', kind: 'terminal', tmuxName: 'agent-code-term', projectId: 'a', joinedAt: 1 },
+ row: { cwd: '/a', kind: 'codex', title: 'Row', projectId: 'a', joinedAt: 4 },
},
- dispatchMode: { scope: 'project' }, gridRelatedSelections: {}, buried: [], pinnedSessionIds: [],
+ stage: { lanes: [{}], rows: [{ length: 1 }], focusedLane: 0 }, pinnedSessionIds: [],
}
const { harness, context } = mountCommand(state)
await runAndConfirm(context, 'Close 3')
- // Sequential, each revalidated: the row's kill rejected and it stays; both
- // grid panes closed; the last one promoted the surviving row, so the tab is
- // valid and rooted on it instead of naming a deleted session.
- expect(killed()).toEqual(['row', 'grid', 'term'])
+ // Sequential, each revalidated: the row's kill rejected and it stays; the
+ // other two closed. The project is NOT removed — a project leaves only with
+ // the commit that takes its last session, and that commit never happened.
+ // (In v2 the last grid pane's close had to PROMOTE the surviving row into
+ // the tile root so the tab stayed renderable. There is nothing to promote
+ // into: the survivor was always a full member of the project.)
+ expect(killed()).toEqual(['grid', 'term', 'row'])
const after = harness.getState()
- expect(after.tabs).toEqual([expect.objectContaining({ id: 'a', root: { type: 'leaf', sessionId: 'row' }, focusedSessionId: 'row' })])
+ expect(after.tabs).toEqual([{ id: 'a', title: 'A' }])
expect(Object.keys(after.sessions)).toEqual(['row'])
expect(buildVisibleDispatchRows(after).map(visible => visible.sessionId)).toEqual(['row'])
expectValidWorkspace(after)
@@ -181,20 +217,22 @@ describe('Close Tab command runs the approved close operation (#886 review round
expect(harness.refs.undoStackRef.current.peek()).toMatchObject({
type: 'group',
entries: [
- { type: 'pane', sessionId: 'grid', siblingLeafId: 'term' },
- { type: 'detached', record: { sessionId: 'term' }, replacedRoot: { sessionId: 'row' } },
+ { type: 'session', sessionId: 'grid', sessionMeta: { joinedAt: 0 } },
+ // `tmuxName` is why a terminal must always get an entry: without it the
+ // next launch's reconcile kills the surviving tmux session as an orphan.
+ { type: 'session', sessionId: 'term', sessionMeta: { joinedAt: 1, tmuxName: 'agent-code-term' } },
],
})
- // And undo puts the project back as it was: the row returns to Dispatch,
- // the terminal re-attaches as root, and the split is rebuilt around it.
+ // And undo puts the project back as it was: both closed sessions return to
+ // their old positions AHEAD of the survivor, which never moved.
const spawn = vi.fn().mockResolvedValueOnce('term-2').mockResolvedValueOnce('grid-2')
const undo = mountUndoCloseAction(after, harness.refs, spawn)
await act(async () => { await undo.actions.undoClose() })
const restored = undo.getState()
expect(spawn.mock.calls[0]?.[1]).toMatchObject({ kind: 'terminal', recoverTmuxName: 'agent-code-term' })
- expect(restored.tabs[0]?.root).toEqual({ ...split, a: { type: 'leaf', sessionId: 'grid-2' }, b: { type: 'leaf', sessionId: 'term-2' } })
- expect(restored.detachedSessions.row).toMatchObject({ projectTabId: 'a', detachedAt: 4 })
+ expect(resolveTabSessions(restored, 'a')).toEqual(['grid-2', 'term-2', 'row'])
+ expect(restored.sessions.row).toMatchObject({ projectId: 'a', joinedAt: 4 })
expectValidWorkspace(restored)
undo.mounted.unmount()
harness.mounted.unmount()
diff --git a/src/renderer/src/features/workspace/commands/layoutCommands.renderer.test.ts b/src/renderer/src/features/workspace/commands/layoutCommands.renderer.test.ts
index 7e9202564..989824bce 100644
--- a/src/renderer/src/features/workspace/commands/layoutCommands.renderer.test.ts
+++ b/src/renderer/src/features/workspace/commands/layoutCommands.renderer.test.ts
@@ -11,64 +11,44 @@ function commandContext(options: {
laneIds?: Array
focusedLane?: number
liveIds?: string[]
- scope?: 'project' | 'global'
inserted?: boolean
- noTiled?: boolean
} = {}): {
context: CommandContext
insertTiledLaneRight: ReturnType
- enterTiledDispatch: ReturnType
showPaneToast: ReturnType
} {
const laneIds = options.laneIds ?? ['a', 'b', 'c']
const focusedLane = options.focusedLane ?? 1
const liveIds = options.liveIds ?? laneIds.filter((id): id is string => Boolean(id))
const insertTiledLaneRight = vi.fn().mockReturnValue(options.inserted ?? true)
- const enterTiledDispatch = vi.fn().mockResolvedValue(undefined)
const showPaneToast = vi.fn()
- const tabs = liveIds.map(id => ({
- id: `tab-${id}`,
- title: `project-${id}`,
- root: { type: 'leaf' as const, sessionId: id },
- focusedSessionId: id,
- }))
+ const tabs = liveIds.map(id => ({ id: `tab-${id}`, title: `project-${id}` }))
const workspace = {
state: {
tabs,
activeTabId: tabs[0]?.id ?? '',
- dispatchMode: {
- scope: options.scope ?? 'global',
- // The New Lane entry path (#978): Grid Dispatch not on yet, so there
- // is no `tiled` block to address a lane in.
- ...(options.noTiled ? {} : {
- tiled: {
- lanes: laneIds.map(selectedSessionId => selectedSessionId ? { selectedSessionId } : {}),
- focusedLane,
- },
- }),
+ stage: {
+ lanes: laneIds.map(selectedSessionId => selectedSessionId ? { selectedSessionId } : {}),
+ focusedLane,
},
sessions: Object.fromEntries(
- liveIds.map(id => [id, { cwd: `/work/${id}`, kind: 'claude' }]),
+ liveIds.map(id => [id, { cwd: `/work/${id}`, kind: 'claude', projectId: `tab-${id}`, joinedAt: 0 }]),
),
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
},
insertTiledLaneRight,
- enterTiledDispatch,
showPaneToast,
} as unknown as Workspace
return {
context: { workspace, ui: {}, flags: {} } as unknown as CommandContext,
insertTiledLaneRight,
- enterTiledDispatch,
showPaneToast,
}
}
describe('New Lane command', () => {
- it('is admitted for a live tiled coordinate below the lane ceiling, and always when Grid Dispatch is off', () => {
+ it('is admitted for a live lane coordinate below the lane ceiling', () => {
expect(newLaneCommand.when?.(commandContext().context)).toBe(true)
const atCeiling = commandContext({
@@ -80,33 +60,18 @@ describe('New Lane command', () => {
const invalidFocus = commandContext({ laneIds: ['a', 'b'], focusedLane: 2 })
expect(newLaneCommand.when?.(invalidFocus.context)).toBe(false)
- // #978: without a `tiled` block the command is still admitted — its run
- // enters Grid Dispatch directly instead of being inert. The shape [2] it
- // applies is always legal, so there is no cap to check on this path.
- const classic = commandContext({ noTiled: true })
- classic.context.workspace.state.dispatchMode = { scope: 'global' }
- expect(newLaneCommand.when?.(classic.context)).toBe(true)
-
- const grid = commandContext({ noTiled: true })
- grid.context.workspace.state.dispatchMode = null
- expect(newLaneCommand.when?.(grid.context)).toBe(true)
+ // The smallest stage there is — a fresh install's single empty lane — must
+ // admit the command: growing from one lane is its most common use.
+ const fresh = commandContext({ laneIds: [undefined], focusedLane: 0 })
+ expect(newLaneCommand.when?.(fresh.context)).toBe(true)
})
- it('enters Grid Dispatch directly when it is not already on', async () => {
- // #978: the user asks for a lane from the normal grid or classic
- // Dispatch. The response is the smallest grid that honors the request —
- // lane 0 seeded with the focused agent (by enterTiledDispatch, #977),
- // lane 1 the new empty lane — not the shape-editor modal.
- const harness = commandContext({ noTiled: true })
-
- await newLaneCommand.run?.(harness.context)
-
- expect(harness.enterTiledDispatch).toHaveBeenCalledWith([2])
- // The shape already contains the new lane; inserting again would give the
- // user three lanes for one command.
- expect(harness.insertTiledLaneRight).not.toHaveBeenCalled()
- expect(harness.showPaneToast).not.toHaveBeenCalled()
- })
+ // Two #978 cases lived here until #992: "always admitted when Grid Dispatch
+ // is off" and "enters Grid Dispatch directly when it is not already on"
+ // (asserting enterTiledDispatch([2]) and no insert). New Lane had an ENTRY
+ // path because a workspace could exist without lanes. The stage is a
+ // required field now, so the command only ever inserts and the harness no
+ // longer has a lane-less mode to build.
it('inserts beside the captured focus and confirms in the originating pane', () => {
const harness = commandContext({ laneIds: ['a', 'b', 'c'], focusedLane: 1 })
@@ -126,15 +91,21 @@ describe('New Lane command', () => {
expect(harness.showPaneToast).not.toHaveBeenCalled()
})
- it('does not send pane feedback to a live session outside project scope', () => {
- // A lane can retain B for one render after project scope moves to A. The
- // session still exists globally, but the layout cannot render it and its
- // healer will replace it; a session-existence check alone would toast a
- // hidden pane that did not originate this visible command.
+ it('does not send pane feedback for a lane whose agent is gone', () => {
+ // The focused lane still NAMES 'b', but 'b' is not a live session: the
+ // window between a kill from Agent Activity and the clear path blanking
+ // the lane. The lane renders empty, so there is no pane this visible
+ // command originated from and nothing may be toasted.
+ //
+ // This case was about PROJECT SCOPE until #992 — a live session the
+ // project-scoped index did not list. With no scope, "gone" is the one
+ // remaining way a named lane fails to resolve; the property under test
+ // (feedback follows the strict visual resolver, not mere presence of an
+ // id) is the same.
const harness = commandContext({
laneIds: ['a', 'b'],
focusedLane: 1,
- scope: 'project',
+ liveIds: ['a'],
})
newLaneCommand.run(harness.context)
diff --git a/src/renderer/src/features/workspace/commands/layoutCommands.ts b/src/renderer/src/features/workspace/commands/layoutCommands.ts
index 94dc5284b..ecde78d7d 100644
--- a/src/renderer/src/features/workspace/commands/layoutCommands.ts
+++ b/src/renderer/src/features/workspace/commands/layoutCommands.ts
@@ -10,112 +10,142 @@ import {
rowStartIndex,
} from '@renderer/workspace/dispatch/gridShape'
import { resolveStrictDispatchCommandTarget } from '@renderer/workspace/dispatch/dispatchTarget'
+import { sessionDisplayTitle } from '@renderer/workspace/sessionDisplayTitle'
+import { moveLaneSelection, moveLaneFocusWithinRow } from '@renderer/workspace/dispatch/laneKeyboard'
import type { WorkspaceState } from '@renderer/workspace/types'
import { useAppStore } from '@renderer/app-state/hooks'
-export const layoutCommands: CommandDef[] = [
+// Exported by name because its admission and badge are pinned directly in
+// clearLane.renderer.test.tsx — looking the def up by id inside that suite
+// would pass vacuously if the id were ever renamed (find → undefined →
+// `when?.()` → undefined → "passes" the not-false assertions).
+//
+// Clear Lane (#992 §4.4): the gentle exit. The occupant returns to the pool
+// ALIVE and stays in this row's index; the lane is empty and nothing refills
+// it (#681). Paired with Remove Lane below — same slot, opposite blast
+// radius — and with Close Agent and Remove Lane for the destructive version
+// of each half.
+// The stage's keyboard grammar as commands (#992 stage 5, the #681 §7.1
+// debt): ⌥↑/↓ walk the focused lane's selection through its row's index,
+// ⌥←/→ move lane focus within the row. These were an unregistered inline
+// branch in useKeybinds — which meant they could not be rebound, never
+// appeared in the shortcuts surface, and silently swallowed Alt+Shift+Arrow
+// (the branch tested `alt && !cmd`, never shift). As registered commands the
+// chords are exact-match, so ⌥⇧-arrow stays the OS's word-selection.
+//
+// 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).
+const laneKeyboardCommands: CommandDef[] = [
{
- id: 'dispatch-mode',
+ id: 'dispatch-select-previous-agent',
category: 'layout-dispatch',
- // `app`, not `dispatch`: this is the toggle that ENTERS and EXITS
- // Dispatch, so it must be visible in both modes — surface-gating it
- // to `dispatch` would make it impossible to turn Dispatch on.
- surface: 'app',
- title: 'Dispatch Mode',
- description: '**What it does:** Toggles the **Dispatch** command-center layout.\n\n**Use when:** You want to scan and command agents from a compact list.\n\n**Notes:** Shows the selected agent alongside the agent list. Run again to return to the normal grid.',
- keywords: ['agent list', 'focused agent', 'command center', 'exit dispatch', 'grid mode', 'normal layout'],
- // An ENUM, not a boolean: Dispatch is off, project-scoped, or global. The
- // old shape rendered "Global"/"Project"/"Off" through the same chip as
- // every on/off toggle, so a scope read as an enabled state.
- getState: ({ flags }) =>
- flags.dispatchModeEnabled
- ? value(flags.globalDispatchEnabled ? 'Global' : 'Project')
- : toggle(false),
- run: async ({ ui, flags }) => {
- if (flags.dispatchModeEnabled) {
- ui.exitDispatchMode()
- return
- }
- await ui.enterDispatchMode()
- },
+ surface: 'workspace',
+ title: 'Select Previous Agent',
+ description: '**What it does:** Moves the **focused lane**\'s selection one step UP its row\'s agent index, wrapping around.\n\n**Use when:** You are scanning agents in the lane you are in.\n\n**Notes:** Agents shown in other lanes are not skipped — selecting one mirrors it here. Hibernated agents wake on selection.',
+ keywords: ['previous', 'up', 'agent', 'walk', 'index', 'lane', 'selection', 'arrows'],
+ run: ({ workspace }) => moveLaneSelection(workspace, -1),
},
{
- id: 'global-dispatch',
+ id: 'dispatch-select-next-agent',
category: 'layout-dispatch',
- // `dispatch` surface replaces the old `when: dispatchModeEnabled`
- // guard — the registry's surface gate already hides this whenever
- // Dispatch is off, so the explicit `when` was redundant.
- surface: 'dispatch',
- title: 'Dispatch Scope',
- description: '**What it does:** Switches **Dispatch** between project scope and all-tabs scope.\n\n**Use when:** You want one command center for agents across every tab.\n\n**Notes:** Only appears while **Dispatch Mode** is enabled.',
- keywords: ['dispatch all tabs', 'agent list', 'global dispatch'],
- // A SCOPE, not a boolean. "Global Dispatch: On" told the user nothing
- // about what Off meant — the alternative is Project scope, not "no
- // dispatch". Naming both ends is the whole correction.
- getState: ({ flags }) => value(flags.globalDispatchEnabled ? 'Global' : 'Project'),
- run: async ({ ui }) => {
- await ui.enterGlobalDispatch()
- },
+ surface: 'workspace',
+ title: 'Select Next Agent',
+ description: '**What it does:** Moves the **focused lane**\'s selection one step DOWN its row\'s agent index, wrapping around.\n\n**Use when:** You are scanning agents in the lane you are in.\n\n**Notes:** Agents shown in other lanes are not skipped — selecting one mirrors it here. Hibernated agents wake on selection.',
+ keywords: ['next', 'down', 'agent', 'walk', 'index', 'lane', 'selection', 'arrows'],
+ run: ({ workspace }) => moveLaneSelection(workspace, 1),
},
+ {
+ id: 'dispatch-focus-lane-left',
+ category: 'layout-dispatch',
+ surface: 'workspace',
+ title: 'Focus Lane Left',
+ description: '**What it does:** Moves lane focus one lane LEFT within the focused row, stopping at the row\'s edge.\n\n**Use when:** You want to type into the lane beside this one.\n\n**Notes:** Never wraps into another row and never changes any lane\'s agent — crossing rows is what Focus Row Above/Below is for.',
+ keywords: ['focus', 'left', 'lane', 'cursor', 'arrows'],
+ run: ({ workspace }) => moveLaneFocusWithinRow(workspace, -1),
+ },
+ {
+ id: 'dispatch-focus-lane-right',
+ category: 'layout-dispatch',
+ surface: 'workspace',
+ title: 'Focus Lane Right',
+ description: '**What it does:** Moves lane focus one lane RIGHT within the focused row, stopping at the row\'s edge.\n\n**Use when:** You want to type into the lane beside this one.\n\n**Notes:** Never wraps into another row and never changes any lane\'s agent — crossing rows is what Focus Row Above/Below is for.',
+ keywords: ['focus', 'right', 'lane', 'cursor', 'arrows'],
+ run: ({ workspace }) => moveLaneFocusWithinRow(workspace, 1),
+ },
+]
+
+export const clearFocusedLaneCommand: CommandDef = {
+ id: 'clear-focused-lane',
+ category: 'layout-dispatch',
+ surface: 'workspace',
+ title: 'Clear Lane',
+ // `getState` badges the occupant's title, because "clear" needs an object:
+ // with several lanes on screen a bare "Clear Lane" sends the user to check
+ // which lane is focused first. The badge is that check. The shared title
+ // resolver (explicit title → cwd basename), not the raw meta.title: most
+ // agents have no explicit title, and a badge that reads "undefined" is
+ // worse than no badge.
+ getState: ({ workspace }) => {
+ const tiled = workspace.state.stage
+ const sessionId = tiled.lanes[tiled.focusedLane]?.selectedSessionId
+ const meta = sessionId ? workspace.state.sessions[sessionId] : undefined
+ return meta ? value(sessionDisplayTitle(meta)) : null
+ },
+ description: '**What it does:** Empties the **focused lane**. The agent in it keeps running and stays in the index.\n\n**Use when:** You want the space back without ending the agent — the inverse of picking one into the lane.\n\n**Notes:** Nothing refills the lane. Put the agent (or another) back with one click in the row index, or ⌘1–9.',
+ keywords: ['clear', 'empty', 'lane', 'unplace', 'park', 'release', 'tiled dispatch', 'stage'],
+ when: ({ workspace }) => {
+ const tiled = workspace.state.stage
+ const sessionId = tiled.lanes[tiled.focusedLane]?.selectedSessionId
+ return Boolean(sessionId && workspace.state.sessions[sessionId])
+ },
+ run: ({ workspace }) => {
+ const tiled = workspace.state.stage
+ workspace.clearTiledLane(tiled.focusedLane)
+ },
+}
+
+export const layoutCommands: CommandDef[] = [
+ // DELETED with the two-mode layout (#992): `dispatch-mode` (the mode
+ // toggle — there is no second mode to toggle into) and `global-dispatch`
+ // (Dispatch scope — per-row project binding is the only scoping
+ // mechanism now). Their ids are recorded in the plan so release notes
+ // can tell users their bindings moved.
+
{
id: 'tiled-dispatch',
category: 'layout-dispatch',
- // `app`, like the Dispatch toggle: Tiled Dispatch enters (and is the
- // adjust-count path for) the multi-lane Dispatch layout, so it should be
- // reachable from the grid as well as from Dispatch.
+ // The shape editor is the workspace's reshape surface — it opens on the
+ // CURRENT stage shape. Id kept from the Grid Dispatch era so existing
+ // ⌘D bindings and visibility overrides are not orphaned (#992 §5.4).
surface: 'app',
- title: 'Grid Dispatch',
- description: '**What it does:** Opens a multi-row, multi-lane **Dispatch** layout. Each row is a complete dispatch view with its own agent index, project, and lanes.\n\n**Use when:** You want to watch and drive many agents at once, or several projects side by side.\n\n**Notes:** Opens a shape editor — set a lane count per row. Rows are independent, so 4 lanes on top and 2 below is a normal shape. Re-run to reshape; existing lane selections are preserved. Return to the normal grid with **Dispatch Mode**.',
- keywords: ['grid dispatch', 'tiled dispatch', 'multi agent', 'lanes', 'rows', 'split dispatch', 'cockpit', 'parallel agents', 'grid of agents'],
+ title: 'Stage Shape…',
+ description: '**What it does:** Opens the stage shape editor — rows of lanes, one stepper per row.\n\n**Use when:** You want to reshape many lanes at once, or see the ragged-by-design shape before committing it.\n\n**Notes:** Rows are independent, so 4 lanes on top and 2 below is a normal shape. Day-to-day, **New Lane** / **New Row** / **Remove Lane** / **Remove Row** edit the shape in place.',
+ keywords: ['stage shape', 'grid dispatch', 'tiled dispatch', 'lanes', 'rows', 'reshape', 'multi agent', 'parallel agents'],
run: ({ ui }) => ui.openTiledDispatchPrompt(),
},
{
id: 'new-tiled-lane',
category: 'layout-dispatch',
- // `app`, not `dispatch` (#978): New Lane is the primary incremental way
- // to grow the grid, and gating it behind Grid Dispatch already being on
- // forced every first lane through the shape-editor modal — bulk setup
- // standing in for a one-lane gesture. From any surface the command now
- // either inserts (grid on) or enters the grid directly (grid off).
+ // `app`, not `workspace` (#978): New Lane is the primary incremental way
+ // to grow the stage and must be reachable from every surface.
+ //
+ // History worth keeping: this command used to have an ENTRY path. With
+ // Grid Dispatch off there was no lane to insert beside, so it entered the
+ // grid at [2] — lane 0 seeded with the focused agent (#977), lane 1 the
+ // new empty lane. The stage is a required field now (#992), so there is
+ // always a focused lane and the command is one thing: insert to its right.
surface: 'app',
title: 'New Lane',
- description: '**What it does:** Inserts a new lane immediately to the **right of the focused lane**, lengthening only that row. When Grid Dispatch is off, it turns Grid Dispatch on first — your focused agent, when one is focused, lands in the first lane and the new lane appears beside it.\n\n**Use when:** You want another live agent view without reshaping the grid or disturbing the lanes around it.\n\n**Notes:** Rows are independent — this never widens any other row. The current lane stays focused and the new lane arrives empty, because adding a lane asks for space, not for a particular agent. Focus it and press ⌥↓ to put the first agent in it, or pick one from its strip.',
- keywords: ['new lane', 'add lane', 'insert lane', 'tiled dispatch', 'expand', 'right', 'grid dispatch'],
- when: ({ workspace }) => {
- // Entry path first (#978): with no `tiled` block there is no focused
- // lane and no cap to consult — the [2] shape the run applies is always
- // legal (it is one lane each under MAX_DISPATCH_TILES and half of
- // MAX_DISPATCH_LANES). Refusing here is what made the command invisible
- // from the grid in the first place.
- if (!workspace.state.dispatchMode?.tiled) return true
- return canInsertLaneInFocusedRow(workspace.state)
- },
+ description: '**What it does:** Inserts a new lane immediately to the **right of the focused lane**, lengthening only that row.\n\n**Use when:** You want another live agent view without reshaping the stage or disturbing the lanes around it.\n\n**Notes:** Rows are independent — this never widens any other row. The current lane stays focused and the new lane arrives empty, because adding a lane asks for space, not for a particular agent. Focus it and press ⌥↓ to put the first agent in it, or pick one from its strip.',
+ keywords: ['new lane', 'add lane', 'insert lane', 'tiled dispatch', 'expand', 'right', 'grid dispatch', 'stage'],
+ when: ({ workspace }) => canInsertLaneInFocusedRow(workspace.state),
run: async ({ workspace }) => {
- // Entry path (#978): Grid Dispatch is off, so "a lane right of my
- // focused one" becomes the smallest grid that honors it — lane 0 seeded
- // with the focused agent by enterTiledDispatch (#977), lane 1 the new
- // empty lane. No insertTiledLaneRight call: the shape already contains
- // the new lane, and inserting again would hand the user three lanes for
- // one command.
- //
- // This snapshot read of `tiled` is one render stale by design — every
- // command here captures one coherent UI snapshot. The cost when a grid
- // appears in that frame is that entry REPLACEs it wholesale, which is
- // enterTiledDispatch's documented replace-on-entry semantics, not a
- // silent partial merge.
- //
- // No pane toast here, deliberately: the whole surface swaps to the grid
- // layout, which is feedback no toast could improve on, and the seeded
- // pane's identity belongs to the reducer, not to this snapshot.
- if (!workspace.state.dispatchMode?.tiled) {
- await workspace.enterTiledDispatch([2])
- return
- }
// Re-checked here, not only in `when`, so a programmatic invocation that
// never went through the palette stays inert instead of relying on the
// reducer's refusal to be silent.
if (!canInsertLaneInFocusedRow(workspace.state)) return
- const tiled = workspace.state.dispatchMode.tiled
+ const tiled = workspace.state.stage
const laneIndex = tiled.focusedLane
const sourceLane = tiled.lanes[laneIndex]
if (!sourceLane) return
@@ -153,30 +183,28 @@ export const layoutCommands: CommandDef[] = [
// session, so the destructive one leads with it.
id: 'remove-tiled-lane',
category: 'layout-dispatch',
- surface: 'dispatch',
+ surface: 'workspace',
title: 'Remove Lane',
- description: '**What it does:** Removes the **focused lane** from Tiled Dispatch, shrinking the layout by one lane. The agent keeps running and stays in the index.\n\n**Use when:** You are done watching one agent but want the others to stay exactly where they are.\n\n**Notes:** Removing a row\'s last lane removes the row. Every lane has its own selector strip, so the lanes that shift left keep the selector they already had.',
+ description: '**What it does:** Removes the **focused lane**, shrinking its row by one lane. The agent keeps running and stays in the index.\n\n**Use when:** You are done watching one agent but want the others to stay exactly where they are.\n\n**Notes:** Removing a row\'s last lane removes the row. Every lane has its own selector strip, so the lanes that shift left keep the selector they already had.',
keywords: ['remove', 'lane', 'tile', 'tiled dispatch', 'shrink', 'slot'],
- when: ({ workspace }) => {
- const tiled = workspace.state.dispatchMode?.tiled
- return Boolean(tiled && tiled.lanes.length > MIN_DISPATCH_TILES)
- },
+ when: ({ workspace }) => workspace.state.stage.lanes.length > MIN_DISPATCH_TILES,
run: ({ workspace }) => {
- const tiled = workspace.state.dispatchMode?.tiled
- if (!tiled) return
+ const tiled = workspace.state.stage
workspace.removeTiledLane(tiled.focusedLane)
},
},
+ clearFocusedLaneCommand,
+ ...laneKeyboardCommands,
{
id: 'close-agent-remove-lane',
category: 'layout-dispatch',
- surface: 'dispatch',
+ surface: 'workspace',
title: 'Close Agent and Remove Lane',
description: '**What it does:** Closes the agent in the **focused lane**, then removes that lane, shrinking the layout by one.\n\n**Use when:** An agent has finished and you want it gone along with its slot.\n\n**Notes:** This ends the session. Use **Remove Lane** to reclaim the slot while leaving the agent running. Irreversible closes still confirm first, and declining leaves the layout untouched.',
keywords: ['close', 'agent', 'remove agent', 'lane', 'tile', 'tiled dispatch', 'shrink', 'finished', 'done'],
when: ({ workspace }) => {
- const tiled = workspace.state.dispatchMode?.tiled
- if (!tiled || tiled.lanes.length <= MIN_DISPATCH_TILES) return false
+ const tiled = workspace.state.stage
+ if (tiled.lanes.length <= MIN_DISPATCH_TILES) return false
// An empty lane has no agent to close, so this collapses to Remove Lane —
// admission has to agree with what the command will do.
//
@@ -189,8 +217,7 @@ export const layoutCommands: CommandDef[] = [
return Boolean(sessionId && workspace.state.sessions[sessionId])
},
run: async ({ workspace }) => {
- const tiled = workspace.state.dispatchMode?.tiled
- if (!tiled) return
+ const tiled = workspace.state.stage
const laneIndex = tiled.focusedLane
const sessionId = tiled.lanes[laneIndex]?.selectedSessionId
if (!sessionId) return
@@ -219,22 +246,19 @@ export const layoutCommands: CommandDef[] = [
// else — the row case is just the most visible version of it.
id: 'new-dispatch-row',
category: 'layout-dispatch',
- surface: 'dispatch',
+ surface: 'workspace',
title: 'New Row',
description: '**What it does:** Adds a new row of lanes below the focused row, with its own agent index and project.\n\n**Use when:** You have run out of usable width — a second row shows the same agents at double the lane width.\n\n**Notes:** The new row inherits the focused row\'s lane count and arrives empty. Rows are independent afterwards: adding a lane to one never widens another.',
keywords: ['new row', 'add row', 'grid dispatch', 'second row', 'stack', 'below', 'more agents'],
when: ({ workspace }) => {
- const tiled = workspace.state.dispatchMode?.tiled
- if (!tiled) return false
- const grid = normalizeGridShape(tiled)
+ const grid = normalizeGridShape(workspace.state.stage)
return (
grid.rows.length < MAX_DISPATCH_ROWS &&
grid.lanes.length < MAX_DISPATCH_LANES
)
},
run: ({ workspace }) => {
- const tiled = workspace.state.dispatchMode?.tiled
- if (!tiled) return
+ const tiled = workspace.state.stage
const grid = normalizeGridShape(tiled)
const rowIndex = rowIndexForLane(grid.rows, grid.focusedLane)
if (rowIndex < 0) return
@@ -250,37 +274,35 @@ export const layoutCommands: CommandDef[] = [
{
id: 'remove-dispatch-row',
category: 'layout-dispatch',
- surface: 'dispatch',
+ surface: 'workspace',
title: 'Remove Row',
- description: '**What it does:** Removes the focused row and its lanes. The agents keep running and stay in the index.\n\n**Use when:** You are done with a row of agents but want the other rows exactly where they are.\n\n**Notes:** Refused on the last row — emptying the layout is **Dispatch Mode**\'s job.',
+ description: '**What it does:** Removes the focused row and its lanes. The agents keep running and stay in the index.\n\n**Use when:** You are done with a row of agents but want the other rows exactly where they are.\n\n**Notes:** Refused on the last row — the stage always keeps at least one.',
keywords: ['remove row', 'delete row', 'grid dispatch', 'shrink', 'fewer rows'],
- when: ({ workspace }) => {
- const tiled = workspace.state.dispatchMode?.tiled
- return Boolean(tiled && normalizeGridShape(tiled).rows.length > 1)
- },
+ when: ({ workspace }) => normalizeGridShape(workspace.state.stage).rows.length > 1,
run: ({ workspace }) => {
- const tiled = workspace.state.dispatchMode?.tiled
- if (!tiled) return
+ const tiled = workspace.state.stage
const grid = normalizeGridShape(tiled)
const rowIndex = rowIndexForLane(grid.rows, grid.focusedLane)
if (rowIndex >= 0) workspace.removeDispatchRow(rowIndex)
},
},
{
+ // (This and the three commands after it carried `when: tiled grid is on`
+ // until #992. The stage always exists, so the gate was always true and was
+ // removed rather than left as a condition that reads like a real one.)
+ //
// A noun with its state in a badge, per docs/command-style.md rule 3 —
// never "Bind Row to Project". "Any project" is a VALUE in the picker
// rather than a separate unbind command, the same correction that made
// Dispatch Scope name both of its ends.
id: 'dispatch-row-project',
category: 'layout-dispatch',
- surface: 'dispatch',
+ surface: 'workspace',
title: 'Row Projects…',
- description: '**What it does:** Restricts the focused row\'s agent index and lane selectors to one or more projects.\n\n**Use when:** A row is a working context that spans more than one repo — an app and the service it calls, a package and its consumer.\n\n**Notes:** The row\'s index shows one section per bound project. Binding filters, it never fills — no lane is populated, moved, or cleared. Dispatch scope is promoted to global, because a project-scoped row set is built from the active tab alone.',
+ description: '**What it does:** Restricts the focused row\'s agent index and lane selectors to one or more projects.\n\n**Use when:** A row is a working context that spans more than one repo — an app and the service it calls, a package and its consumer.\n\n**Notes:** The row\'s index shows one section per bound project. Binding filters, it never fills — no lane is populated, moved, or cleared. An unbound row lists every project.',
keywords: ['row project', 'row projects', 'bind row', 'restrict row', 'per project', 'grid dispatch', 'scope row', 'multiple projects'],
- when: ({ workspace }) => Boolean(workspace.state.dispatchMode?.tiled),
getState: ({ workspace }) => {
- const tiled = workspace.state.dispatchMode?.tiled
- if (!tiled) return null
+ const tiled = workspace.state.stage
const grid = normalizeGridShape(tiled)
const rowIndex = rowIndexForLane(grid.rows, grid.focusedLane)
const ids = rowIndex >= 0 ? grid.rows[rowIndex]?.projectTabIds : undefined
@@ -291,8 +313,7 @@ export const layoutCommands: CommandDef[] = [
return value(`${ids.length} projects`)
},
run: ({ workspace }) => {
- const tiled = workspace.state.dispatchMode?.tiled
- if (!tiled) return
+ const tiled = workspace.state.stage
const grid = normalizeGridShape(tiled)
const rowIndex = rowIndexForLane(grid.rows, grid.focusedLane)
if (rowIndex >= 0) useAppStore.getState().openDispatchRowProjectPicker(rowIndex)
@@ -301,22 +322,19 @@ export const layoutCommands: CommandDef[] = [
{
id: 'dispatch-row-child-cap',
category: 'layout-dispatch',
- surface: 'dispatch',
+ surface: 'workspace',
title: 'Nested Agents',
- description: '**What it does:** Switches the focused row\'s index between capping a parent\'s nested children and showing all of them.\n\n**Use when:** A parent has spawned enough workers to bury every other agent in the list.\n\n**Notes:** Applies to both orchestration children and manually linked agents — Dispatch nests them identically, so the cap cannot tell them apart. Only nested children are ever hidden; top-level agents always show, because the parent is what reports. Hiding a child never renumbers anything: labels and ⌘N stay on the full canonical list.',
+ description: '**What it does:** Switches the focused row\'s index between capping a parent\'s nested children and showing all of them.\n\n**Use when:** A parent has spawned enough workers to bury every other agent in the list.\n\n**Notes:** Applies to both orchestration children and manually linked agents — the index nests them identically, so the cap cannot tell them apart. Only nested children are ever hidden; top-level agents always show, because the parent is what reports. Hiding a child never renumbers anything: labels and ⌘N stay on the full canonical list.',
keywords: ['nested', 'orchestrated', 'orchestration', 'linked', 'children', 'collapse', 'expand', 'sub agents', 'workers', 'cap'],
- when: ({ workspace }) => Boolean(workspace.state.dispatchMode?.tiled),
getState: ({ workspace }) => {
- const tiled = workspace.state.dispatchMode?.tiled
- if (!tiled) return null
+ const tiled = workspace.state.stage
const grid = normalizeGridShape(tiled)
const rowIndex = rowIndexForLane(grid.rows, grid.focusedLane)
const capped = rowIndex >= 0 ? grid.rows[rowIndex]?.capChildren !== false : true
return value(capped ? 'Capped' : 'All')
},
run: ({ workspace }) => {
- const tiled = workspace.state.dispatchMode?.tiled
- if (!tiled) return
+ const tiled = workspace.state.stage
const grid = normalizeGridShape(tiled)
const rowIndex = rowIndexForLane(grid.rows, grid.focusedLane)
if (rowIndex < 0) return
@@ -338,57 +356,29 @@ export const layoutCommands: CommandDef[] = [
// issue — a rebindable-keys migration does not belong inside a layout PR.
id: 'dispatch-focus-row-up',
category: 'layout-dispatch',
- surface: 'dispatch',
+ surface: 'workspace',
title: 'Focus Row Above',
description: '**What it does:** Moves lane focus to the row above, keeping the same column where the row is wide enough.\n\n**Use when:** You are driving a grid from the keyboard.\n\n**Notes:** Moving focus never changes any lane\'s agent.',
keywords: ['focus row', 'row above', 'up', 'grid dispatch', 'navigate rows'],
- when: ({ workspace }) => Boolean(workspace.state.dispatchMode?.tiled),
run: ({ workspace }) => focusAdjacentRow(workspace, -1),
},
{
id: 'dispatch-focus-row-down',
category: 'layout-dispatch',
- surface: 'dispatch',
+ surface: 'workspace',
title: 'Focus Row Below',
description: '**What it does:** Moves lane focus to the row below, keeping the same column where the row is wide enough.\n\n**Use when:** You are driving a grid from the keyboard.\n\n**Notes:** Moving focus never changes any lane\'s agent.',
keywords: ['focus row', 'row below', 'down', 'grid dispatch', 'navigate rows'],
- when: ({ workspace }) => Boolean(workspace.state.dispatchMode?.tiled),
run: ({ workspace }) => focusAdjacentRow(workspace, 1),
},
// REMOVED: the 'toggle-dispatch-terminal' command, then its replacement
// `settings.dispatchProjectTerminal`, and now the feature itself. The
// opt-in auto-created companion terminal and its dedicated Dispatch side
// column are gone; user-created terminals still work exactly as before.
- {
- id: 'normalize-layout',
- category: 'layout-dispatch',
- pickerVisibility: 'advanced',
- // `grid`: this rebalances `tab.root` split ratios. Dispatch does not
- // render the grid, so in Dispatch this was a silent no-op (issue
- // #228). Surface-gating hides it there instead of running invisibly.
- surface: 'grid',
- title: 'Normalize Layout',
- description: '**What it does:** Rebalances pane sizes in the current layout.\n\n**Use when:** Panes feel uneven but the layout shape is still useful.\n\n**Notes:** Keeps the same split structure.',
- run: ({ workspace }) => workspace.normalizeLayout(),
- },
- {
- id: 'hard-normalize-layout',
- category: 'layout-dispatch',
- pickerVisibility: 'advanced',
- surface: 'grid',
- title: 'Hard Normalize Layout',
- description: '**What it does:** Rebuilds pane sizing into a cleaner even layout.\n\n**Use when:** The layout is messy and needs a stronger reset.\n\n**Notes:** More aggressive than **Normalize Layout**.',
- run: ({ workspace }) => workspace.hardNormalizeLayout(),
- },
- {
- id: 'rotate-layout',
- category: 'layout-dispatch',
- pickerVisibility: 'advanced',
- surface: 'grid',
- title: 'Rotate Layout',
- description: '**What it does:** Rotates split directions in the current layout.\n\n**Use when:** The same panes would work better in a different orientation.\n\n**Notes:** Keeps the sessions, changes the arrangement.',
- run: ({ workspace }) => workspace.rotateLayout(),
- },
+ // DELETED with the tile tree (#992): normalize-layout,
+ // hard-normalize-layout, rotate-layout rebalanced `tab.root` split
+ // ratios, and split ratios no longer render anywhere. The stage's
+ // equivalents are the lane/row weight drags and the shape editor.
// RETIRED: `toggle-status-mode`. Status Mode is a persisted app preference
// with no meaningful momentary scope — there is no "just for this session"
// version of it — so it has one product home, and that home is Settings
@@ -463,8 +453,7 @@ function focusAdjacentRow(
workspace: Parameters>[0]['workspace'],
delta: number,
): void {
- const tiled = workspace.state.dispatchMode?.tiled
- if (!tiled) return
+ const tiled = workspace.state.stage
const grid = normalizeGridShape(tiled)
const rowIndex = rowIndexForLane(grid.rows, grid.focusedLane)
if (rowIndex < 0) return
@@ -495,8 +484,7 @@ function focusAdjacentRow(
* just because a different row is full.
*/
function canInsertLaneInFocusedRow(state: WorkspaceState): boolean {
- const tiled = state.dispatchMode?.tiled
- if (!tiled) return false
+ const tiled = state.stage
if (!tiled.lanes[tiled.focusedLane]) return false
const grid = normalizeGridShape(tiled)
const rowIndex = rowIndexForLane(grid.rows, grid.focusedLane)
diff --git a/src/renderer/src/features/workspace/commands/opencodeTerminalReadCommands.renderer.test.ts b/src/renderer/src/features/workspace/commands/opencodeTerminalReadCommands.renderer.test.ts
index 995b98856..c913dbf42 100644
--- a/src/renderer/src/features/workspace/commands/opencodeTerminalReadCommands.renderer.test.ts
+++ b/src/renderer/src/features/workspace/commands/opencodeTerminalReadCommands.renderer.test.ts
@@ -6,6 +6,7 @@ import { paneCommands } from '@renderer/features/workspace/commands/paneCommands
import type { CommandContext, CommandDef } from '@renderer/features/command-palette/types'
import type { SessionKind } from '@shared/types/providerKind'
import type { Workspace } from '@renderer/workspace/workspaceStore'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
// #971: the transcript READ commands must be offered on an OpenCode Terminal
// pane, because #882's Stage 6 loads its committed history into
@@ -24,12 +25,10 @@ function contextWithSession(kind: SessionKind, providerRuntime?: 'terminal'): Co
workspace: {
state: {
activeTabId: 'tab',
- dispatchMode: null,
- sessions: { agent: meta },
+ stage: oneLaneStage('agent'), pinnedSessionIds: [],
+ sessions: { agent: { ...meta, projectId: 'tab', joinedAt: 0 }},
tabs: [{
id: 'tab',
- focusedSessionId: 'agent',
- root: { type: 'leaf', sessionId: 'agent' },
}],
},
} as unknown as Workspace,
diff --git a/src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts b/src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts
index e84cb2d72..b92399476 100644
--- a/src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts
+++ b/src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts
@@ -4,6 +4,7 @@ import { describeCommandState } from '@renderer/features/command-palette/command
import type { CommandContext } from '@renderer/features/command-palette/types'
import { paneCommands } from '@renderer/features/workspace/commands/paneCommands'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
// Guards the command-availability half of terminal follow: both commands
// previously carried `renderedViewPolicy: 'requires-rendered-feed'`, which
@@ -22,11 +23,11 @@ function contextWithKind(kind: string): CommandContext {
workspace: {
state: {
activeTabId: 'tab',
- dispatchMode: null,
+ stage: oneLaneStage('agent'), pinnedSessionIds: [],
sessions: {
- agent: { cwd: '/projects/app', kind, providerSessionId: 'provider-abc' },
+ agent: { cwd: '/projects/app', kind, providerSessionId: 'provider-abc', projectId: 'tab', joinedAt: 0 },
},
- tabs: [{ id: 'tab', focusedSessionId: 'agent', root: { type: 'leaf', sessionId: 'agent' } }],
+ tabs: [{ id: 'tab' }],
},
},
ui: {},
diff --git a/src/renderer/src/features/workspace/commands/paneCommands.ts b/src/renderer/src/features/workspace/commands/paneCommands.ts
index a0d4a2517..349e4c6e8 100644
--- a/src/renderer/src/features/workspace/commands/paneCommands.ts
+++ b/src/renderer/src/features/workspace/commands/paneCommands.ts
@@ -9,37 +9,19 @@ import { getRendererProviderCapabilities } from '@providers/registry.renderer.ca
import { extractLastAssistantText } from '@renderer/lib/copyAssistant'
import type { CommandContext, CommandDef } from '@renderer/features/command-palette/types'
import { panel, toggle } from '@renderer/features/command-palette/commandState'
-import {
- commandTargetSessionId,
- commandTargetSessionIdForState,
-} from '@renderer/workspace/hook/selectors/commandTargetSessionId'
-import { isDetached } from '@renderer/workspace/queries'
-import {
- buildVisibleDispatchRows,
- detachedDispatchSessionIdsForTab,
- selectVisibleDispatchRow,
-} from '@renderer/workspace/dispatch/dispatchSelectors'
-import { resolveDispatchAttachTarget } from '@renderer/workspace/dispatch/dispatchTarget'
-import { dispatchFocusedSessionId } from '@renderer/workspace/dispatch/tiledDispatchSelectors'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
+import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/commandTargetSessionId'
import { submitActiveComposer } from '@renderer/workspace/tile-tree/TileLeaf/composerEnterRegistry'
import { sessionHasTranscript } from '@renderer/workspace/transcriptAvailability'
import { isWorkingAgent } from '@renderer/workspace/agentFollow'
-/**
- * Buried panes visible from the CURRENT tab.
- *
- * The buried picker is deliberately tab-scoped (see the note in
- * CommandPalette's `buried` memo: a buried Codex agent from project A listed
- * beside a buried Claude agent from project B mixes contexts and invites
- * revive-into-the-wrong-tab). Admission has to use the same scope, or the row
- * appears for a tab with nothing to revive.
- */
-function buriedInActiveTab(workspace: CommandContext['workspace']): number {
- const activeTabId = workspace.state.activeTabId
- return workspace.state.buried.filter(entry => entry.sourceTabId === activeTabId).length
-}
-
+// DELETED with the unified layout (#992) — see RETIRED_COMMAND_IDS in
+// catalog.test.ts for the ledger:
+// bury-pane / revive-pane / kill-buried-pane — "hide but keep alive" is the
+// pool's default state now, so there is nothing to bury into or revive
+// from; a session not shown in a lane is simply unplaced.
+// attach-detached-to-grid / attach-all-detached-for-tab /
+// detach-to-dispatch — there is no grid to attach into or detach from;
+// showing a pool session is a lane selection.
export const paneCommands: CommandDef[] = [
{
id: 'new-agent',
@@ -52,9 +34,9 @@ export const paneCommands: CommandDef[] = [
// are surface-gated out of Dispatch.
surface: 'app',
title: 'New Agent…',
- description: '**What it does:** Starts a **new agent or terminal**.\n\n**Use when:** You want another Claude, Codex, OpenCode, or shell pane.\n\n**Notes:** OpenCode and OpenCode Terminal are separate choices. In **Dispatch**, agents become detached rows.',
+ description: '**What it does:** Starts a **new agent or terminal**.\n\n**Use when:** You want another Claude, Codex, OpenCode, or shell pane.\n\n**Notes:** OpenCode and OpenCode Terminal are separate choices. New agents land in the pool with a **new** badge in the index; the focused lane is filled only when it is empty.',
keywords: ['new', 'agent', 'placement', 'claude', 'codex', 'opencode', 'terminal'],
- when: ({ workspace }) => Boolean(workspace.activeTab && !workspace.tileTabs),
+ when: ({ workspace }) => Boolean(workspace.activeTab),
run: ({ workspace }) => workspace.startNewAgentPlacement(),
},
{
@@ -69,64 +51,56 @@ export const paneCommands: CommandDef[] = [
// `dispatch`, not `app`: in the grid a project is a tab one keystroke away,
// and a detached agent spawned from the grid lands nowhere visible — the
// grid has no lanes to fill and Dispatch rows are not on screen.
- surface: 'dispatch',
+ surface: 'workspace',
// Title per docs/command-style.md: "New X" for creation, and the ellipsis
// because the command asks for more input (agent, then project).
title: 'New Agent In…',
- // The scope sentence is there because it surprised the reviewer: in
- // project-scope Dispatch, spawning into another project makes it the active
- // project (createDetachedDispatchAgent selects what it creates), so the
- // other lanes read "Not in this scope" until you switch back. That is the
- // scope contract working — the new agent has to be visible — not a bug.
- description: '**What it does:** Starts a **new agent in a project you choose** — in the focused lane in Grid Dispatch, or as a new Dispatch row.\n\n**Use when:** You want an agent for a different project than the one you last selected, e.g. to fill an empty lane.\n\n**Notes:** Pick the agent, then the project. A row limited to certain projects only offers those. In project-scoped Dispatch, choosing another project switches to it.',
+ // The Notes used to end with a scope sentence ("In project-scoped Dispatch,
+ // choosing another project switches to it"), because spawning into another
+ // project blanked every other lane until you switched back. With no
+ // layout-wide scope (#992) nothing blanks, so the warning went with it.
+ description: '**What it does:** Starts a **new agent in a project you choose**, in the focused lane.\n\n**Use when:** You want an agent for a different project than the one you last selected, e.g. to fill an empty lane.\n\n**Notes:** Pick the agent, then the project. A row limited to certain projects only offers those.',
keywords: ['new', 'agent', 'project', 'lane', 'fill', 'empty', 'dispatch', 'claude', 'codex', 'opencode'],
// Same data gate as New Agent…. Tiled Tabs covers Dispatch, so the lane the
// agent would fill is not the thing on screen.
- when: ({ workspace }) => Boolean(workspace.activeTab && !workspace.tileTabs),
+ when: ({ workspace }) => Boolean(workspace.activeTab),
run: ({ ui }) => ui.openNewAgentIn(),
},
- {
- // `grid` surface — applies to split-vertical, split-horizontal,
- // codex-vertical, codex-horizontal, terminal-horizontal and
- // terminal-vertical below.
- //
- // WHY hide these in Dispatch even though `splitFocused` still
- // *works* there: the title encodes a grid direction (Right / Down /
- // Below) and Dispatch has no visible grid for that direction to
- // mean anything. Worse, `splitFocused` ignores the direction in
- // Dispatch entirely (pane.ts) — it just creates a detached agent —
- // so `Split Pane Right` and `Split Pane Down` would be two palette
- // rows doing the identical thing. Dispatch users create with
- // `New Agent…` (surface `app`), which is direction-free by design.
- // Power-user keybinds (⌥D etc.) still fire in Dispatch; only the
- // misleading palette rows are gated.
- id: 'split-vertical',
- category: 'create',
- // `app`, NOT `grid`. `splitFocused` has a full Dispatch branch that spawns a
- // DETACHED agent (see the `dispatchSnapshot.dispatchMode` path), which is
- // what this command's own description promises. `grid` made
- // `surfaceAvailable` return false in Dispatch, so admission refused a mode
- // the action implements.
- //
- // That was invisible until keybinds started routing through the gateway.
- // Before, ⌥D was a hard-coded branch in useKeybinds that bypassed admission
- // entirely and fired in both modes; the surface only hid the palette row.
- // Routing the chord through admission fused "don't show this row here" with
- // "refuse to run this here" — the exact split the governance plan exists to
- // maintain — and ⌥D silently stopped working in Dispatch.
- surface: 'app',
- title: 'Split Pane Right',
- description: '**What it does:** Creates a **new agent pane on the right**.\n\n**Use when:** You want side-by-side work in the grid.\n\n**Notes:** In **Dispatch**, this creates a detached agent instead.',
- run: ({ workspace }) => workspace.splitFocused('vertical'),
- },
- {
- id: 'split-horizontal',
- category: 'create',
- surface: 'app',
- title: 'Split Pane Down',
- description: '**What it does:** Creates a **new agent pane below**.\n\n**Use when:** You want a stacked grid layout.\n\n**Notes:** In **Dispatch**, this creates a detached agent instead.',
- run: ({ workspace }) => workspace.splitFocused('horizontal'),
- },
+ // The split-family commands (split-vertical/-horizontal, terminal-*, the
+ // per-provider pairs) were grid-spatial: "Split Pane Right", the direction
+ // parameterized a tile-tree split. The tree is gone (#992), the direction
+ // argument with it, and every member of the family is the same action now:
+ // spawn a session (fill the focused lane if it is empty, else pool it).
+ //
+ // IDs AND CHORDS ARE KEPT (plan §5.4): ⌥D, ⌥⇧D, ⌥T, ⌥⇧T, ⌥C, ⌥⇧C keep
+ // firing what they always fired — a user's muscle memory and any persisted
+ // keybinding overrides key on the ids. Only the TITLES changed, because the
+ // old ones described a direction that no longer exists; a palette row whose
+ // title lies is worse than one whose id is historical.
+ //
+ // The "-horizontal" twins are palette-hidden ('advanced'): identical
+ // behavior to their "-vertical" sibling means two visible rows would be two
+ // names for one action, the exact confusion the old comment below this table
+ // used to describe. They stay RUNNABLE and rebindable for the ⌥⇧ chords.
+ ...[
+ {
+ id: 'split-vertical',
+ category: 'create' as const,
+ surface: 'app' as const,
+ title: `New ${getRendererProviderCapabilities(DEFAULT_PROVIDER).shortLabel}`,
+ description: `**What it does:** Starts a **${getRendererProviderCapabilities(DEFAULT_PROVIDER).shortLabel} agent** now, without opening a picker.\n\n**Use when:** You know which provider you want.\n\n**Notes:** Fills the focused lane when it is empty; otherwise the agent lands in the pool with a **new** badge in the index.`,
+ run: ({ workspace }: CommandContext) => workspace.splitFocused(),
+ },
+ {
+ id: 'split-horizontal',
+ category: 'create' as const,
+ surface: 'app' as const,
+ pickerVisibility: 'advanced' as const,
+ title: `New ${getRendererProviderCapabilities(DEFAULT_PROVIDER).shortLabel} (legacy id)`,
+ description: '**What it does:** Same as the **-vertical** command it predates.\n\n**Notes:** Kept runnable for the ⌥⇧ chord and old bindings; hidden from the default palette because it is a duplicate.',
+ run: ({ workspace }: CommandContext) => workspace.splitFocused(),
+ },
+ ],
{
id: 'close-pane',
category: 'session',
@@ -136,26 +110,16 @@ export const paneCommands: CommandDef[] = [
surface: 'session',
title: 'Close Focused Session',
keywords: ['pane', 'close pane'],
- description: '**What it does:** Closes the **currently targeted pane or Dispatch row**.\n\n**Use when:** You are done with the current target.\n\n**Notes:** In **Dispatch**, the highlighted row is the close target.',
+ description: '**What it does:** Closes the **currently targeted session**.\n\n**Use when:** You are done with the current target.\n\n**Notes:** The focused lane\'s agent is the close target.',
run: ({ workspace }) => workspace.closeFocused(),
},
- {
- id: 'bury-pane',
- category: 'layout-dispatch',
- pickerVisibility: 'advanced',
- surface: 'session',
- title: 'Bury Session',
- keywords: ['pane'],
- description: '**What it does:** Hides the pane but keeps the **session alive**.\n\n**Use when:** You want it out of the layout without killing it.\n\n**Notes:** Buried panes can be revived later.',
- run: ({ workspace }) => workspace.requestBuryFocused(),
- },
{
id: 'linked-agent',
category: 'create',
pickerVisibility: 'advanced',
surface: 'session',
title: 'Linked Agent…',
- description: '**What it does:** Starts a new agent linked to the currently targeted agent.\n\n**Use when:** You want a one-off helper, like a review agent, visually nested under the parent.\n\n**Notes:** The linked agent is a normal Dispatch agent. It renders directly under the parent and closes automatically when the parent closes.',
+ description: '**What it does:** Starts a new agent linked to the currently targeted agent.\n\n**Use when:** You want a one-off helper, like a review agent, visually nested under the parent.\n\n**Notes:** The linked agent is an ordinary pool agent. It renders directly under the parent and closes automatically when the parent closes.',
keywords: ['linked', 'agent', 'review', 'helper', 'child', 'dispatch', 'claude', 'codex', 'opencode'],
when: ({ workspace }) => {
const sessionId = commandTargetSessionId(workspace)
@@ -171,36 +135,6 @@ export const paneCommands: CommandDef[] = [
ui.openLinkedAgent(sessionId)
},
},
- {
- // Promote the dispatch-focused detached session into the active
- // tab's grid via the existing placement-target picker. Available
- // only when Dispatch Mode is active AND its current focus is on a
- // detached session (grid-focused rows in the dispatch list don't
- // need attaching — they're already attached).
- id: 'attach-detached-to-grid',
- category: 'layout-dispatch',
- pickerVisibility: 'advanced',
- // `dispatch` surface: the old `when` opened with
- // `if (!workspace.dispatchMode) return false`. That mode check now
- // lives in the registry's surface gate, so `when` only carries the
- // data condition (the focused row is a detached session).
- surface: 'dispatch',
- title: 'Attach Detached Session to Grid…',
- description: '**What it does:** Moves one **detached Dispatch session** into the grid.\n\n**Use when:** You want to pin background work into the normal layout.\n\n**Notes:** Uses the placement picker so you can choose where it lands.',
- keywords: ['attach', 'detached', 'dispatch', 'grid', 'pin', 'place'],
- when: ({ workspace }) => {
- const target = resolveDispatchAttachTarget(workspace.state)
- if (!target) return false
- return isDetached(workspace.state, target.sessionId)
- },
- run: ({ workspace, ui }) => {
- if (!workspace.dispatchMode) return
- const target = resolveDispatchAttachTarget(workspace.state)
- if (!target) return
- if (!isDetached(workspace.state, target.sessionId)) return
- ui.openDispatchAttach(target)
- },
- },
{
// Dispatch-only multi-select pin command. Opens the Pin Agents
// modal; the user picks agents with Space, commits with Enter,
@@ -221,9 +155,9 @@ export const paneCommands: CommandDef[] = [
// `dispatch` surface replaces the old `when: Boolean(dispatchMode)`
// guard — pins are a Dispatch-list concept and the registry gate
// now hides this in the grid.
- surface: 'dispatch',
+ surface: 'workspace',
title: 'Pin Sessions…',
- description: '**What it does:** Opens the multi-select Pin modal to choose which **Dispatch** agents and terminals stay pinned at the top of the agent list.\n\n**Use when:** You want a few favorite agents or terminals to always be one keystroke away regardless of project or scope.\n\n**Notes:** Space toggles, Enter commits, Esc cancels. The order you Space through the rows is the order pins render in. Pins survive project↔global scope toggles.',
+ description: '**What it does:** Opens the multi-select Pin modal to choose which agents and terminals stay pinned at the top of the agent list.\n\n**Use when:** You want a few favorite agents or terminals to always be one keystroke away regardless of project or scope.\n\n**Notes:** Space toggles, Enter commits, Esc cancels. The order you Space through the rows is the order pins render in. Pins survive every project switch.',
keywords: ['pin', 'pins', 'pinned', 'favorite', 'star', 'top', 'dispatch', 'terminal'],
getState: ({ flags }) => panel(flags.pinAgentsOpen),
run: ({ ui, flags }) => {
@@ -251,9 +185,9 @@ export const paneCommands: CommandDef[] = [
category: 'layout-dispatch',
// `dispatch` surface carries the mode gate; `when` keeps only the
// data condition (the focused row is currently pinned).
- surface: 'dispatch',
+ surface: 'workspace',
title: 'Unpin Session',
- description: '**What it does:** Removes the currently-focused **Dispatch** row from the Pinned section.\n\n**Use when:** You want to quickly drop a single pin without opening the Pin modal.\n\n**Notes:** Only appears when the focused dispatch row is currently pinned.',
+ description: '**What it does:** Removes the currently focused row\'s agent from the Pinned section.\n\n**Use when:** You want to quickly drop a single pin without opening the Pin modal.\n\n**Notes:** Only appears when the focused lane\'s agent is currently pinned.',
keywords: ['unpin', 'remove', 'pin', 'pinned', 'star'],
when: ({ workspace }) => {
const sessionId = commandTargetSessionId(workspace)
@@ -266,109 +200,26 @@ export const paneCommands: CommandDef[] = [
workspace.unpinSession(sessionId)
},
},
- {
- // Available in BOTH grid and Dispatch modes. The original gate was
- // `dispatchCommandTabId`, which returned null whenever the workspace
- // was not in Dispatch — that was the wrong shape for this command.
- // Detached agents can outlive a Dispatch session (you can leave
- // Dispatch with agents still parked), and the natural recovery flow
- // is "from the regular grid, bring my parked agents back into this
- // tab." Forcing the user to flip into Dispatch first was friction
- // with no upside. In Dispatch we still delegate to the dispatch-
- // aware resolver so global Dispatch can target the focused row's
- // tab (which may differ from `activeTabId`).
- id: 'attach-all-detached-for-tab',
- category: 'layout-dispatch',
- pickerVisibility: 'advanced',
- // `app`, NOT `dispatch`: this command deliberately works in both
- // modes (see the comment above) — detached agents outlive Dispatch,
- // and the recovery flow is "from the grid, bring my parked agents
- // back." Surface-gating it to `dispatch` would break that. Its
- // `when` already hides it when there is nothing to attach.
- surface: 'app',
- title: 'Attach All Dispatch Sessions for Tab',
- description: '**What it does:** Moves all detached **Dispatch** sessions for a tab into the grid.\n\n**Use when:** You want to bring a whole tab’s background work into view.\n\n**Notes:** Preserves the existing grid and adds the sessions beside it. Works in both Grid and Dispatch modes.',
- keywords: ['attach', 'all', 'detached', 'dispatch', 'grid', 'tab', 'pin'],
- when: ({ workspace }) => {
- const tabId = attachAllCommandTabId(workspace)
- if (!tabId) return false
- return detachedDispatchSessionIdsForTab(workspace.state, tabId).length > 0
- },
- run: ({ workspace }) => {
- const tabId = attachAllCommandTabId(workspace)
- if (!tabId) return
- return workspace.attachAllDetachedForTab(tabId)
- },
- },
- {
- // The reverse of attach: take the focused grid pane out of the
- // tile tree without killing it and add it to the dispatch
- // detached bucket. The action side refuses the only-leaf-in-tab
- // case; this `when` check gates on an actual grid leaf so the command
- // does not show for a session that is already detached.
- id: 'detach-to-dispatch',
- category: 'layout-dispatch',
- pickerVisibility: 'advanced',
- // `session`: works in both modes against the Dispatch-aware target
- // (the `when` below requires that target to be a real grid leaf).
- surface: 'session',
- title: 'Detach Session to Dispatch',
- description: '**What it does:** Moves a grid session into **Dispatch** without killing it.\n\n**Use when:** You want to park work in the background.\n\n**Notes:** The last pane in a tab cannot be detached.',
- keywords: ['detach', 'dispatch', 'park', 'background', 'unpin'],
- when: ({ workspace }) => {
- // Use the Dispatch-aware target resolver, not tab.focusedSessionId.
- // tab.focusedSessionId has a "must be a leaf in tab.root" invariant
- // — i.e. it's grid-only. In Dispatch Mode the user has a row
- // selected, not a grid focus, and reading tab.focusedSessionId
- // silently misses that selection: the command would either gate
- // off entirely or target a stale grid leaf. The action itself
- // (`workspace.detachFocusedToDispatch`) already routes through
- // the Dispatch-aware target; this gate must agree or the palette
- // shows/hides the command for the wrong reason.
- if (!workspace.activeTab) return false
- const sessionId = commandTargetSessionIdForState(workspace.state)
- if (!sessionId) return false
- const meta = workspace.state.sessions[sessionId]
- const owner = workspace.state.tabs.find(tab => collectLeaves(tab.root).includes(sessionId))
- return Boolean(meta && owner)
- },
- run: ({ workspace }) => workspace.detachFocusedToDispatch(),
- },
{
id: 'terminal-horizontal',
category: 'create',
- // `app`, and the two reasons for that have accumulated:
- //
- // 1. This was originally `grid` because a terminal split from Dispatch
- // landed in a grid the user could not see immediately, so the "Right"
- // label pointed at nothing visible. The intent was to hide the palette
- // ROW while, as the old comment put it, "power-user keybinds still work
- // because they route through splitFocused". They stopped working:
- // keybinds now go through the execution gateway, which applies
- // `surfaceAvailable`, so `grid` refused ⌥T in Dispatch as well as hiding
- // it. Between a slightly odd palette row and a dead chord the user has
- // muscle memory for, the row is the cheaper cost — and `surface` is an
- // APPLICABILITY declaration, which this command genuinely satisfies in
- // both modes. Mode-conditional row hiding, if it is still wanted, needs
- // its own mechanism rather than borrowing this one.
- //
- // 2. The "points at nothing visible" premise is gone anyway (#671): a
- // Dispatch terminal is now a detached Dispatch row that lands in the
- // focused lane, so the command has a visible result in both modes. Only
- // the direction argument is inert under Dispatch — same as every other
- // creation command there.
+ // `app`: a terminal applies everywhere the workspace runs. The id keeps
+ // its historical "-horizontal" suffix (and the ⌥T chord) even though the
+ // direction died with the tile tree (#992) — see the split-family note
+ // above for why ids are frozen while titles stopped lying.
surface: 'app',
- title: 'New Terminal Right',
- description: '**What it does:** Opens a **terminal on the right**.\n\n**Use when:** You need a shell beside the current pane.\n\n**Notes:** From **Dispatch**, the terminal becomes a Dispatch row in the focused row or lane’s project.',
- run: ({ workspace }) => workspace.splitFocused('vertical', 'terminal'),
+ title: 'New Terminal',
+ description: '**What it does:** Starts a **plain shell** in the focused lane\'s project.\n\n**Use when:** You need a scratch shell beside your agents.\n\n**Notes:** Fills the focused lane when it is empty; otherwise it lands in the pool with a **new** badge in the index.',
+ run: ({ workspace }) => workspace.splitFocused('terminal'),
},
{
id: 'terminal-vertical',
category: 'create',
surface: 'app',
- title: 'New Terminal Below',
- description: '**What it does:** Opens a **terminal below**.\n\n**Use when:** You need a shell under the current pane.\n\n**Notes:** From **Dispatch**, the terminal becomes a Dispatch row in the focused row or lane’s project.',
- run: ({ workspace }) => workspace.splitFocused('horizontal', 'terminal'),
+ pickerVisibility: 'advanced',
+ title: 'New Terminal (legacy id)',
+ description: '**What it does:** Same as **New Terminal**.\n\n**Notes:** Kept runnable for the ⌥⇧T chord and old bindings; hidden from the default palette because it is a duplicate.',
+ run: ({ workspace }) => workspace.splitFocused('terminal'),
},
// Per-provider split commands, generated for every registered agent
// provider EXCEPT the default (#394 phase 4). The default provider
@@ -380,146 +231,52 @@ export const paneCommands: CommandDef[] = [
// etc. so user keybinding overrides keyed on command ids survive).
...AGENT_PROVIDER_KINDS.filter(kind => kind !== DEFAULT_PROVIDER).flatMap(kind => {
const caps = getRendererProviderCapabilities(kind)
- const chord = caps.splitShortcutKey
return [
{
id: `${kind}-vertical`,
- // `app` for the same reason as split-vertical: splitFocused spawns a
- // detached agent in Dispatch, so the command applies in both modes.
+ // `app` for the same reason as the generic create: one workspace, one
+ // spawn flow. Id keeps its historical "-vertical" suffix (plan §5.4).
surface: 'app' as const,
- // Same category/tier as the generic and terminal splits they sit
- // beside: creating a named-provider pane is not a more advanced act
+ // Same category/tier as the generic and terminal creates they sit
+ // beside: creating a named-provider agent is not a more advanced act
// than creating a default one, it just names the provider.
category: 'create' as const,
- title: `New ${caps.shortLabel} Right`,
- description: `**What it does:** Opens a **${caps.shortLabel} agent on the right**.\n\n**Use when:** You want ${caps.shortLabel} beside the current agent.\n\n**Notes:** In **Dispatch**, this creates a detached ${caps.shortLabel} agent instead.`,
+ title: `New ${caps.shortLabel}`,
+ description: `**What it does:** Starts a **${caps.shortLabel} agent** now, without opening a picker.\n\n**Use when:** You know which provider you want.\n\n**Notes:** Fills the focused lane when it is empty; otherwise the agent lands in the pool with a **new** badge in the index.`,
run: ({ workspace }: CommandContext) =>
- workspace.splitFocused('vertical', kind),
+ workspace.splitFocused(kind),
},
{
id: `${kind}-horizontal`,
- // `app` for the same reason as split-vertical: splitFocused spawns a
- // detached agent in Dispatch, so the command applies in both modes.
surface: 'app' as const,
category: 'create' as const,
- title: `New ${caps.shortLabel} Below`,
- description: `**What it does:** Opens a **${caps.shortLabel} agent below**.\n\n**Use when:** You want ${caps.shortLabel} in a stacked layout.\n\n**Notes:** In **Dispatch**, this creates a detached ${caps.shortLabel} agent instead.`,
+ pickerVisibility: 'advanced' as const,
+ title: `New ${caps.shortLabel} (legacy id)`,
+ description: `**What it does:** Same as **New ${caps.shortLabel}**.\n\n**Notes:** Kept runnable for the ⌥⇧ chord and old bindings; hidden from the default palette because it is a duplicate.`,
run: ({ workspace }: CommandContext) =>
- workspace.splitFocused('horizontal', kind),
+ workspace.splitFocused(kind),
},
]
}),
- {
- // `grid` surface — applies to nav-left/right/up/down below.
- //
- // WHY this is a real fix and not just a label tidy-up: in Dispatch
- // `workspace.navigate()` walks `tab.root` grid focus, which Dispatch
- // does not drive. When the Dispatch selection is a detached session
- // it diverges from grid focus entirely and these commands were a
- // SILENT NO-OP (issue #228). Dispatch row navigation is ⌥↑/⌥↓ (and,
- // after this change, ⌥J/⌥K) — handled directly in useKeybinds.
- id: 'nav-left',
- category: 'navigate',
- commandGroup: 'navigation',
- surface: 'grid',
- title: 'Focus Pane Left',
- description: '**What it does:** Focuses the pane to the **left**.\n\n**Use when:** You want keyboard pane navigation.\n\n**Notes:** Uses the current grid layout.',
- run: ({ workspace }) => workspace.navigate('left'),
- },
- {
- id: 'nav-right',
- category: 'navigate',
- commandGroup: 'navigation',
- surface: 'grid',
- title: 'Focus Pane Right',
- description: '**What it does:** Focuses the pane to the **right**.\n\n**Use when:** You want keyboard pane navigation.\n\n**Notes:** Uses the current grid layout.',
- run: ({ workspace }) => workspace.navigate('right'),
- },
- {
- id: 'nav-up',
- category: 'navigate',
- commandGroup: 'navigation',
- surface: 'grid',
- title: 'Focus Pane Up',
- description: '**What it does:** Focuses the pane **above**.\n\n**Use when:** You want keyboard pane navigation.\n\n**Notes:** Uses the current grid layout.',
- run: ({ workspace }) => workspace.navigate('up'),
- },
- {
- id: 'nav-down',
- category: 'navigate',
- commandGroup: 'navigation',
- surface: 'grid',
- title: 'Focus Pane Down',
- description: '**What it does:** Focuses the pane **below**.\n\n**Use when:** You want keyboard pane navigation.\n\n**Notes:** Uses the current grid layout.',
- run: ({ workspace }) => workspace.navigate('down'),
- },
+ // DELETED with the tile tree (#992): nav-left/right/up/down walked
+ // `tab.root` grid focus, and the tree no longer renders. Lane movement
+ // is ⌥←/⌥→ (focus within the row) and ⌥↑/⌥↓ (index walk), handled in
+ // useKeybinds and migrated into the command registry in stage 5.
{
id: 'undo-close',
category: 'session',
surface: 'app',
title: 'Undo Close',
- description: '**What it does:** Restores the most recent closed **pane, tab, or Dispatch row** from a small recent-close history.\n\n**Use when:** You closed something by mistake, or repeat it to walk back through earlier closes.\n\n**Notes:** A restored **Dispatch** terminal re-attaches its tmux session, so its scrollback comes back.',
+ description: '**What it does:** Restores the most recent closed **session, project, or pool row** from a small recent-close history.\n\n**Use when:** You closed something by mistake, or repeat it to walk back through earlier closes.\n\n**Notes:** A restored terminal re-attaches its tmux session, so its scrollback comes back.',
run: ({ workspace }) => workspace.undoClose(),
},
- {
- id: 'revive-pane',
- category: 'layout-dispatch',
- pickerVisibility: 'advanced',
- // `app`: buried panes are mode-independent state, and a revived
- // session re-enters the grid tree — which also makes it a Dispatch
- // row — so the command is meaningful from either mode.
- surface: 'app',
- title: 'Revive Buried Session…',
- keywords: ['pane'],
- description: '**What it does:** Restores a **buried live pane**.\n\n**Use when:** You parked a session and want it back.\n\n**Notes:** Opens a picker when multiple buried panes exist.',
- keepPaletteOpen: true,
- // Scoped to the ACTIVE TAB, matching the list the picker actually renders.
- //
- // This read `state.buried.length > 0` — the whole workspace — while the
- // picker filters by `sourceTabId`, so both buried commands could be
- // admitted from a tab with nothing buried and land the user on an empty
- // list. Admission has to agree with what the command will show, or the
- // command is advertising something it cannot deliver.
- when: ({ workspace }) => buriedInActiveTab(workspace) > 0,
- run: ({ ui, flags }) => {
- // Already showing this mode? Dismiss. A mode-entering command whose
- // second press re-enters the mode it is already in reads as a dead key,
- // which is the same complaint that started this whole change.
- if (flags.paletteMode === 'buried') {
- ui.closePalette()
- return
- }
- ui.enterBuriedMode()
- },
- },
- {
- id: 'kill-buried-pane',
- category: 'layout-dispatch',
- pickerVisibility: 'advanced',
- surface: 'app',
- title: 'Kill Buried Session…',
- description: '**What it does:** Permanently kills a **buried session**.\n\n**Use when:** You no longer need hidden background work.\n\n**Notes:** This is destructive.',
- keywords: ['kill', 'buried', 'hidden', 'pane', 'session', 'pane'],
- keepPaletteOpen: true,
- when: ({ workspace }) => buriedInActiveTab(workspace) > 0,
- run: ({ ui, flags }) => {
- // Already showing this mode? Dismiss. A mode-entering command whose
- // second press re-enters the mode it is already in reads as a dead key,
- // which is the same complaint that started this whole change.
- if (flags.paletteMode === 'kill-buried') {
- ui.closePalette()
- return
- }
- ui.enterKillBuriedMode()
- },
- },
{
id: 'toggle-tail',
category: 'session',
surface: 'session',
title: 'Auto-follow Focused Agent',
keywords: ['tail'],
- description: '**What it does:** Toggles **auto-follow** for the focused target.\n\n**Use when:** You want output to stay pinned to the bottom.\n\n**Notes:** Applies to the visible command target, including **Dispatch** selection. Works in both the rendered feed and raw agent terminal views — in a terminal view the TUI output stays pinned to the bottom.',
+ description: '**What it does:** Toggles **auto-follow** for the focused target.\n\n**Use when:** You want output to stay pinned to the bottom.\n\n**Notes:** Applies to the visible command target, including the focused lane. Works in both the rendered feed and raw agent terminal views — in a terminal view the TUI output stays pinned to the bottom.',
// NO `renderedViewPolicy` — deliberately: this command owns follow
// behavior on BOTH agent surfaces now (Feed's tailMode on the rendered
// surface, useTerminalFollow on the raw terminal — and, since #865, on
@@ -762,31 +519,8 @@ export const paneCommands: CommandDef[] = [
},
]
-function dispatchCommandTabId(
- workspace: CommandContext['workspace'],
-): string | null {
- if (!workspace.dispatchMode) return null
- if (workspace.dispatchMode.scope !== 'global') {
- return workspace.state.activeTabId || null
- }
- const activeTab = workspace.activeTab
- const row = selectVisibleDispatchRow(
- buildVisibleDispatchRows(workspace.state),
- // tiled-aware focus so the resolved tab follows the focused lane.
- dispatchFocusedSessionId(workspace.dispatchMode),
- activeTab?.focusedSessionId,
- )
- return row?.tabId ?? workspace.state.activeTabId ?? null
-}
-
-// Resolver for "attach all dispatch agents for tab" that works in BOTH
-// modes. In Dispatch we delegate to `dispatchCommandTabId` so global
-// Dispatch can target the focused row's tab (potentially != activeTabId).
-// Outside Dispatch we use the active tab — there is no dispatch focus
-// to consult and the user's only reasonable target is "this tab."
-function attachAllCommandTabId(
- workspace: CommandContext['workspace'],
-): string | null {
- if (workspace.dispatchMode) return dispatchCommandTabId(workspace)
- return workspace.state.activeTabId || null
-}
+// `dispatchCommandTabId` and an attach-all resolver lived below until #992.
+// They picked the project a Dispatch-only command should act on (the active
+// tab in project scope, the focused row's tab in global scope). Their last
+// caller — Attach All Dispatch Agents — died with the tile tree in stage 3a,
+// and the scope they branched on died in 3b, so the helpers went with them.
diff --git a/src/renderer/src/features/workspace/commands/sessionCommands.renderer.test.ts b/src/renderer/src/features/workspace/commands/sessionCommands.renderer.test.ts
index 6a7898605..824fea1f1 100644
--- a/src/renderer/src/features/workspace/commands/sessionCommands.renderer.test.ts
+++ b/src/renderer/src/features/workspace/commands/sessionCommands.renderer.test.ts
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { CommandContext } from '@renderer/features/command-palette/types'
import type { Workspace } from '@renderer/workspace/workspaceStore'
import { sessionCommands } from '@renderer/features/workspace/commands/sessionCommands'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
const originalApiDescriptor = Object.getOwnPropertyDescriptor(window, 'api')
@@ -28,19 +29,19 @@ describe('Duplicate Agent command', () => {
const workspace = {
state: {
activeTabId: 'tab-klay',
- dispatchMode: null,
+ stage: oneLaneStage('source'), pinnedSessionIds: [],
sessions: {
source: {
cwd: '/projects/klay',
kind: 'codex',
providerSessionId: 'provider-source',
builtInMcpDomains: ['workflows'],
+ projectId: 'tab-klay',
+ joinedAt: 0,
},
},
tabs: [{
id: 'tab-klay',
- focusedSessionId: 'source',
- root: { type: 'leaf', sessionId: 'source' },
}],
},
splitFocused,
@@ -65,7 +66,6 @@ describe('Duplicate Agent command', () => {
// The regression was invisible at transcript-clone time: only the next app restart exposed
// that the clone had no durable domain names from which main could mint a fresh scoped token.
expect(splitFocused).toHaveBeenCalledWith(
- 'vertical',
'codex',
{
resumeSessionId: 'provider-clone',
@@ -83,7 +83,7 @@ describe('Duplicate Agent command', () => {
const workspace = {
state: {
activeTabId: 'tab-klay',
- dispatchMode: null,
+ stage: oneLaneStage('source'), pinnedSessionIds: [],
sessions: {
source: {
cwd: '/projects/klay',
@@ -91,9 +91,11 @@ describe('Duplicate Agent command', () => {
providerSessionId: 'provider-source',
builtInMcpDomains: ['tldr', 'root_management'],
builtInMcpOverrides: { tldr: true, root_management: true },
+ projectId: 'tab-klay',
+ joinedAt: 0,
},
},
- tabs: [{ id: 'tab-klay', focusedSessionId: 'source', root: { type: 'leaf', sessionId: 'source' } }],
+ tabs: [{ id: 'tab-klay' }],
},
splitFocused,
showPaneToast: vi.fn(),
@@ -111,7 +113,7 @@ describe('Duplicate Agent command', () => {
// The confirmation dialog names one agent, so a clone was never confirmed
// by anyone — and the granting agent's own catalog can call this command,
// so inheriting the grant would let one confirmation replicate itself.
- expect(splitFocused).toHaveBeenCalledWith('vertical', 'codex', expect.objectContaining({
+ expect(splitFocused).toHaveBeenCalledWith('codex', expect.objectContaining({
builtInMcpOverrides: { tldr: true },
}))
})
@@ -129,7 +131,7 @@ describe('Duplicate Agent command', () => {
workspace: {
state: {
activeTabId: 'tab-opencode',
- dispatchMode: null,
+ stage: oneLaneStage('source'), pinnedSessionIds: [],
sessions: {
source: {
cwd: '/projects/opencode',
@@ -137,12 +139,12 @@ describe('Duplicate Agent command', () => {
providerRuntime: 'terminal',
providerSessionId: 'ses_source',
builtInMcpDomains: ['orchestration'],
+ projectId: 'tab-opencode',
+ joinedAt: 0,
},
},
tabs: [{
id: 'tab-opencode',
- focusedSessionId: 'source',
- root: { type: 'leaf', sessionId: 'source' },
}],
},
splitFocused,
@@ -156,7 +158,7 @@ describe('Duplicate Agent command', () => {
await command.run(context)
- expect(splitFocused).toHaveBeenCalledWith('vertical', 'opencode', {
+ expect(splitFocused).toHaveBeenCalledWith('opencode', {
resumeSessionId: 'ses_clone',
builtInMcpOverrides: { orchestration: true },
providerRuntime: 'terminal',
@@ -175,19 +177,19 @@ describe('Remove Cybersecurity Block command', () => {
workspace: {
state: {
activeTabId: 'tab',
- dispatchMode: null,
+ stage: oneLaneStage('agent'), pinnedSessionIds: [],
sessions: {
agent: {
cwd: '/project',
kind: session.kind ?? 'codex',
providerSessionId: session.providerSessionId,
providerRuntime: session.providerRuntime,
+ projectId: 'tab',
+ joinedAt: 0,
},
},
tabs: [{
id: 'tab',
- focusedSessionId: 'agent',
- root: { type: 'leaf', sessionId: 'agent' },
}],
},
removeFocusedCyberPolicyBlock: vi.fn().mockResolvedValue(undefined),
@@ -255,14 +257,12 @@ describe('Switch Provider command', () => {
workspace: {
state: {
activeTabId: 'tab-1',
- dispatchMode: null,
+ stage: oneLaneStage('source'), pinnedSessionIds: [],
sessions: {
- source: { cwd: '/projects/app', kind: 'claude' },
+ source: { cwd: '/projects/app', kind: 'claude', projectId: 'tab-1', joinedAt: 0 },
},
tabs: [{
id: 'tab-1',
- focusedSessionId: 'source',
- root: { type: 'leaf', sessionId: 'source' },
}],
},
switchSessionProvider,
@@ -318,19 +318,19 @@ function mcpCommandContext(kind: 'claude' | 'codex' | 'opencode'): {
const workspace = {
state: {
activeTabId: 'tab-mcp',
- dispatchMode: null,
+ stage: oneLaneStage('agent'), pinnedSessionIds: [],
sessions: {
agent: {
cwd: '/projects/mcp',
kind,
providerSessionId: 'provider-session',
builtInMcpDomains: [],
+ projectId: 'tab-mcp',
+ joinedAt: 0,
},
},
tabs: [{
id: 'tab-mcp',
- focusedSessionId: 'agent',
- root: { type: 'leaf', sessionId: 'agent' },
}],
},
replaceSession,
@@ -462,18 +462,18 @@ describe('capability gates', () => {
workspace: {
state: {
activeTabId: 'tab',
- dispatchMode: null,
+ stage: oneLaneStage('agent'), pinnedSessionIds: [],
sessions: {
agent: {
cwd: '/projects/app',
kind: 'claude',
providerSessionId: 'provider-abc',
+ projectId: 'tab',
+ joinedAt: 0,
},
},
tabs: [{
id: 'tab',
- focusedSessionId: 'agent',
- root: { type: 'leaf', sessionId: 'agent' },
}],
},
} as unknown as Workspace,
@@ -551,19 +551,19 @@ describe('Root Agent Code Management command (#906)', () => {
const workspace = {
state: {
activeTabId: 'tab-app',
- dispatchMode: null,
+ stage: oneLaneStage('agent'), pinnedSessionIds: [],
sessions: {
agent: {
cwd: '/projects/app',
kind: 'claude',
providerSessionId: 'provider-agent',
...(builtInMcpDomains ? { builtInMcpDomains } : {}),
+ projectId: 'tab-app',
+ joinedAt: 0,
},
},
tabs: [{
id: 'tab-app',
- focusedSessionId: 'agent',
- root: { type: 'leaf', sessionId: 'agent' },
}],
},
replaceSession,
diff --git a/src/renderer/src/features/workspace/commands/sessionCommands.ts b/src/renderer/src/features/workspace/commands/sessionCommands.ts
index c9e13456a..83deafbb1 100644
--- a/src/renderer/src/features/workspace/commands/sessionCommands.ts
+++ b/src/renderer/src/features/workspace/commands/sessionCommands.ts
@@ -386,7 +386,7 @@ export const sessionCommands: CommandDef[] = [
category: 'workspace-tools',
surface: 'app',
title: 'Close Idle Orchestration Agents',
- description: '**What it does:** Closes every **orchestration agent** that has finished its work and is idle, after confirming the list.\n\n**Use when:** An orchestration run left finished workers behind in Dispatch.\n\n**Notes:** Working, starting, exited and failed agents stay open, and so do the agents that started them.',
+ description: '**What it does:** Closes every **orchestration agent** that has finished its work and is idle, after confirming the list.\n\n**Use when:** An orchestration run left finished workers parked in the pool.\n\n**Notes:** Working, starting, exited and failed agents stay open, and so do the agents that started them.',
keywords: [
'close',
'idle',
@@ -633,7 +633,7 @@ export const sessionCommands: CommandDef[] = [
pickerVisibility: 'advanced',
surface: 'session',
title: 'Agent Management MCP',
- description: '**What it does:** Reloads the focused **agent** with project-wide Agent Code management tools on or off.\n\n**Use when:** You want this agent to inventory, inspect, prompt, or close other agents in its project.\n\n**Notes:** Read operations include visible, detached, and buried agents without waking them. Every close it attempts asks **you** to confirm first, and cascades are refused outright.',
+ description: '**What it does:** Reloads the focused **agent** with project-wide Agent Code management tools on or off.\n\n**Use when:** You want this agent to inventory, inspect, prompt, or close other agents in its project.\n\n**Notes:** Read operations include agents that are not in a lane, without waking them. Every close it attempts asks **you** to confirm first, and cascades are refused outright.',
keywords: ['mcp', 'agent management', 'agents', 'project', 'transcripts', 'cleanup', 'prompt', 'close', 'enable', 'disable', 'reload', 'claude', 'codex', 'opencode'],
when: ({ workspace }) => {
return targetSupportsBuiltInMcpDomain(workspace, 'agent_management')
@@ -984,7 +984,7 @@ export const sessionCommands: CommandDef[] = [
surface: 'session',
title: 'Duplicate Agent',
- description: '**What it does:** Clones the focused **agent session** into a new pane.\n\n**Use when:** You want a parallel branch of the same conversation.\n\n**Notes:** In **Dispatch**, the clone is created as a detached agent.',
+ description: '**What it does:** Clones the focused **agent session** into a new pane.\n\n**Use when:** You want a parallel branch of the same conversation.\n\n**Notes:** The clone lands in the pool with a **new** badge; place it in any lane.',
keywords: ['duplicate', 'clone', 'fork', 'copy', 'session', 'agent'],
when: ({ workspace }) => {
// Needs a providerSessionId (something on disk to duplicate) AND a
@@ -1031,8 +1031,11 @@ export const sessionCommands: CommandDef[] = [
// restart, then rehydrate had no domain names from which to mint a fresh project-scoped
// token. The clone inherits CHOICES, never the source session's bearer token, and resolves
// them against current Settings the way every other new provider process does.
+ // The comment below still explains WHY this routes through the spawn
+ // flow rather than newTab; the 'vertical' direction argument it used
+ // to pass died with the tile tree (#992) — placement is context-places
+ // now (fills the focused lane when empty, else pools).
await workspace.splitFocused(
- 'vertical',
kind,
{
resumeSessionId: newProviderSessionId,
diff --git a/src/renderer/src/features/workspace/commands/tabCommands.ts b/src/renderer/src/features/workspace/commands/tabCommands.ts
index 3e95554e2..b09f03dbd 100644
--- a/src/renderer/src/features/workspace/commands/tabCommands.ts
+++ b/src/renderer/src/features/workspace/commands/tabCommands.ts
@@ -1,5 +1,6 @@
import { panel } from '@renderer/features/command-palette/commandState'
import type { CommandDef } from '@renderer/features/command-palette/types'
+import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/commandTargetSessionId'
export const tabCommands: CommandDef[] = [
{
@@ -18,9 +19,19 @@ export const tabCommands: CommandDef[] = [
category: 'layout-dispatch',
surface: 'app',
title: 'Close Tab',
- description: '**What it does:** Closes the **current tab** and its sessions.\n\n**Use when:** You are done with a whole project tab.\n\n**Notes:** Use **Undo Close** if you closed it by mistake.',
- run: ({ workspace }) =>
- workspace.activeTab ? workspace.closeTab(workspace.activeTab.id) : undefined,
+ description: '**What it does:** Closes the **project of the agent you are commanding** (the focused lane\'s agent, or the one in Spotlight or Reader) and all its sessions.\n\n**Use when:** You are done with a whole project.\n\n**Notes:** With no agent targeted it closes the highlighted project. Use **Undo Close** if you closed it by mistake.',
+ // WHY the command target's project and not `activeTab` (#1013 parity
+ // review): the active project is only a label now (U4). Lane focus and
+ // index selection never move it, so ⌘⇧W could close the project
+ // highlighted in the header while the user worked in another project's
+ // lane. A single idle session closes without a dialog, so the wrong
+ // project could go with no warning. The session being commanded names
+ // the project the user is actually in.
+ run: ({ workspace }) => {
+ const sessionId = commandTargetSessionId(workspace)
+ const projectId = (sessionId ? workspace.state.sessions[sessionId]?.projectId : undefined) ?? workspace.activeTab?.id
+ return projectId ? workspace.closeTab(projectId) : undefined
+ },
},
{
id: 'next-tab',
@@ -62,7 +73,7 @@ export const tabCommands: CommandDef[] = [
category: 'layout-dispatch',
surface: 'app',
title: 'Merge Project Tabs',
- description: '**What it does:** Folds other tabs into one tab. Their agents move to the target\'s Dispatch list; nothing restarts.\n\n**Use when:** The same folder ended up open in several tabs, or worktree tabs belong together.\n\n**Notes:** Buried panes and Dispatch row filters follow the target. No agent is closed, so anything can be re-arranged afterwards; the dialog lists what moves before you confirm. Moved grid panes become Dispatch agents, so after the next launch they wake on first use like any other Dispatch agent instead of being live from the start.',
+ description: '**What it does:** Folds other projects into one. Their agents move to the target\'s index; nothing restarts.\n\n**Use when:** The same folder ended up open in several tabs, or worktree tabs belong together.\n\n**Notes:** Nothing is closed, so anything can be re-arranged afterwards; the dialog lists what moves before you confirm. Moved agents keep their place in the index and, after the next launch, wake on first use like every other pool agent.',
keywords: ['merge tabs', 'combine tabs', 'duplicate tab', 'same project', 'fold tabs', 'dispatch', 'worktree'],
when: ({ workspace }) => workspace.state.tabs.length > 1,
getState: ({ flags }) => panel(flags.mergeProjectTabsOpen),
diff --git a/src/renderer/src/features/workspace/controlInteractions.ts b/src/renderer/src/features/workspace/controlInteractions.ts
index 52e9b202a..814a67c5e 100644
--- a/src/renderer/src/features/workspace/controlInteractions.ts
+++ b/src/renderer/src/features/workspace/controlInteractions.ts
@@ -1,9 +1,11 @@
import type { InteractionReference } from '@control-sdk'
export const workspaceInteractions: InteractionReference[] = [
- { id: 'placement.choose', bindings: ['Up', 'Down', 'Left', 'Right'], context: 'agent placement preview', description: 'Choose placement relative to the anchor pane.' },
- { id: 'placement.global', bindings: ['Shift+Up', 'Shift+Down', 'Shift+Left', 'Shift+Right'], context: 'agent placement preview', description: 'Choose placement relative to the whole project tab rather than its anchor pane.' },
- { id: 'placement.reset', bindings: ['Backspace'], context: 'agent placement preview', description: 'Return selection to the default placement for the anchor.' },
- { id: 'placement.confirm', bindings: ['Enter', 'Escape'], context: 'agent placement or Dispatch shape dialog', description: 'Enter confirms a valid selected placement/shape; Escape dismisses the owning dialog.' },
+ // The New Agent picker's geometric placement step (arrows, Shift+arrows,
+ // Backspace relative to an anchor pane) was deleted with the tile tree
+ // (#992). The picker is one screen now, and an external agent reading this
+ // reference must not be told to press keys that do nothing (#1013 review B).
+ { id: 'new-agent.select', bindings: ['Up', 'Down', 'Enter', 'Escape'], context: 'New Agent picker', description: 'Choose the agent type; Enter creates it. It fills the focused lane when that lane is empty, and otherwise waits in the project index. Escape dismisses.' },
+ { id: 'placement.confirm', bindings: ['Enter', 'Escape'], context: 'lane grid shape dialog', description: 'Enter confirms a valid shape; Escape dismisses the dialog.' },
{ id: 'provider.select', bindings: ['Up', 'Down', 'Ctrl+P', 'Ctrl+N', 'Enter'], context: 'provider switch picker', description: 'Navigate provider choices and confirm the selected provider.' },
{ id: 'new-agent-in.select', bindings: ['Up', 'Down', 'Ctrl+P', 'Ctrl+N', 'Enter', 'Backspace'], context: 'New Agent In dialog', description: 'Choose the agent, then the project; Enter advances, then creates the agent in the focused Dispatch lane (or as a new Dispatch row); Backspace returns to the agent step.' },
{ id: 'agent-view.select', bindings: ['Up', 'Down', 'Enter'], context: 'agent view-mode picker', description: 'Select and confirm the rendered/terminal view choice.' },
diff --git a/src/renderer/src/features/workspace/controlReference.ts b/src/renderer/src/features/workspace/controlReference.ts
index 7ab427abb..15c205abe 100644
--- a/src/renderer/src/features/workspace/controlReference.ts
+++ b/src/renderer/src/features/workspace/controlReference.ts
@@ -17,34 +17,29 @@ export const controlReference = [
"inspect results before cleanup."
],
"outcome": "The intended session runs in the intended project and placement.",
- "cautions": "Session IDs differ from provider IDs and positional pane labels. Bury, detach, close, stop, switch, duplicate and rewind have different effects. Closing parents can affect children.",
+ "cautions": "Session IDs differ from provider IDs and positional pane labels. Close, stop, switch, duplicate and rewind have different effects. Closing parents can affect children. There is no bury or detach: every session lives in the pool and is shown by placing it in a lane.",
"commandIds": [
"new-tab",
"new-agent",
"close-pane",
- "bury-pane",
- "linked-agent",
- "detach-to-dispatch"
+ "linked-agent"
]
},
{
"id": "dispatch",
"title": "Dispatch rows, lanes and project scope",
- "purpose": "Keep a fleet accessible independently of fixed grid placement.",
- "ui": "Dispatch mode and its index, row headers and lane views.",
- "prerequisites": "Existing sessions; global scope is needed when a row includes other projects.",
+ "purpose": "Show any pool session in any lane; rows of lanes are the whole workspace.",
+ "ui": "The lane stage: per-row agent index, row headers and lane views.",
+ "prerequisites": "Existing sessions; bind a row to projects when it should list only some of them.",
"workflow": [
- "Enter Dispatch",
- "choose project/global scope",
"add rows or lanes",
- "select agents",
+ "select agents into lanes",
+ "bind rows to projects when useful",
"pin frequently used sessions."
],
"outcome": "Each lane shows its selected session; mirrored lanes share the same session.",
"cautions": "To focus an agent already shown in another lane, use agents.show with reuse-existing-view. Clicking the shared index replaces the focused lane selection, and intentional mirrors remain supported. Visible labels are window-local; agents.search accepts exact label plus windowId. Removing a lane and closing its agent are separate actions. Empty lanes stay empty until selected, except that a grid entered from Dispatch seeds lane 0 with the focused agent. layout.read returns the revision required by dispatch.configure, layout.adjust and tabs.reorder. Grid edits carry explicit sourceRow identities to preserve each retained row's agents and project filters.",
"commandIds": [
- "dispatch-mode",
- "global-dispatch",
"tiled-dispatch",
"new-dispatch-row",
"new-tiled-lane",
diff --git a/src/renderer/src/features/workspace/lib/newAgentInProjects.test.ts b/src/renderer/src/features/workspace/lib/newAgentInProjects.test.ts
index a3d265c49..0ee02cd24 100644
--- a/src/renderer/src/features/workspace/lib/newAgentInProjects.test.ts
+++ b/src/renderer/src/features/workspace/lib/newAgentInProjects.test.ts
@@ -1,47 +1,37 @@
import { describe, expect, it } from 'vitest'
import { buildNewAgentInModel } from '@renderer/features/workspace/lib/newAgentInProjects'
-import type { DispatchModeState, TileNode, WorkspaceState } from '@renderer/workspace/types'
+import type { TiledDispatchState, WorkspaceState } from '@renderer/workspace/types'
// Three projects, each with one grid agent, so labels A/B/C are all in play and
// a filtered list can prove it keeps the GLOBAL letter rather than re-lettering.
-function leaf(sessionId: string): TileNode {
- return { type: 'leaf', sessionId }
-}
-
-function makeState(dispatchMode: DispatchModeState | null): WorkspaceState {
+function makeState(stage: TiledDispatchState): WorkspaceState {
return {
tabs: [
- { id: 'tabA', title: 'project-a', root: leaf('a1'), focusedSessionId: 'a1' },
- { id: 'tabB', title: 'project-b', root: leaf('b1'), focusedSessionId: 'b1' },
- { id: 'tabC', title: 'project-c', root: leaf('c1'), focusedSessionId: 'c1' },
+ { id: 'tabA', title: 'project-a' },
+ { id: 'tabB', title: 'project-b' },
+ { id: 'tabC', title: 'project-c' },
],
activeTabId: 'tabA',
- dispatchMode,
+ stage,
sessions: {
- a1: { cwd: '/work/project-a', kind: 'claude' },
- b1: { cwd: '/work/project-b', kind: 'codex' },
- c1: { cwd: '/work/project-c', kind: 'claude' },
+ a1: { cwd: '/work/project-a', kind: 'claude', projectId: 'tabA', joinedAt: 0 },
+ b1: { cwd: '/work/project-b', kind: 'codex', projectId: 'tabB', joinedAt: 0 },
+ c1: { cwd: '/work/project-c', kind: 'claude', projectId: 'tabC', joinedAt: 0 },
},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
}
}
-/** Grid Dispatch with two rows of two lanes; lane 2 (row 1) focused and empty. */
-function gridWithFocusedEmptyLane(rowOneProjects?: string[]): DispatchModeState {
+/** A stage with two rows of two lanes; lane 2 (row 1) focused and empty. */
+function gridWithFocusedEmptyLane(rowOneProjects?: string[]): TiledDispatchState {
return {
- scope: 'global',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 2,
- lanes: [{ selectedSessionId: 'a1' }, {}, {}, {}],
- rows: [
- { length: 2 },
- rowOneProjects ? { length: 2, projectTabIds: rowOneProjects } : { length: 2 },
- ],
- },
+ focusedLane: 2,
+ lanes: [{ selectedSessionId: 'a1' }, {}, {}, {}],
+ rows: [
+ { length: 2 },
+ rowOneProjects ? { length: 2, projectTabIds: rowOneProjects } : { length: 2 },
+ ],
}
}
@@ -69,15 +59,7 @@ describe('buildNewAgentInModel', () => {
it('anchors a project on its first session with a directory, grid leaf before detached rows', () => {
const state = makeState(gridWithFocusedEmptyLane())
- state.sessions.b2 = { cwd: '/work/project-b/.worktrees/task', kind: 'codex' }
- state.detachedSessions.b2 = {
- sessionId: 'b2',
- surface: 'dispatch',
- projectTabId: 'tabB',
- projectTabTitle: 'project-b',
- projectTabIndex: 1,
- detachedAt: 10,
- }
+ state.sessions.b2 = { cwd: '/work/project-b/.worktrees/task', kind: 'codex', projectId: 'tabB', joinedAt: 10 }
const projectB = buildNewAgentInModel(state).projects.find(p => p.tabId === 'tabB')
@@ -89,15 +71,7 @@ describe('buildNewAgentInModel', () => {
it('falls back to a detached row when the grid leaf has no live session behind it', () => {
const state = makeState(gridWithFocusedEmptyLane())
delete state.sessions.b1
- state.sessions.b2 = { cwd: '/work/project-b', kind: 'codex' }
- state.detachedSessions.b2 = {
- sessionId: 'b2',
- surface: 'dispatch',
- projectTabId: 'tabB',
- projectTabTitle: 'project-b',
- projectTabIndex: 1,
- detachedAt: 10,
- }
+ state.sessions.b2 = { cwd: '/work/project-b', kind: 'codex', projectId: 'tabB', joinedAt: 10 }
const projectB = buildNewAgentInModel(state).projects.find(p => p.tabId === 'tabB')
@@ -118,32 +92,38 @@ describe('buildNewAgentInModel', () => {
})
it('first highlights the project plain New Agent would have used', () => {
- // Classic focus b1 + unbound empty lane => the spawn resolver picks tabB.
- const dispatchMode = gridWithFocusedEmptyLane()
- dispatchMode.focusedSessionId = 'b1'
+ // Active project B + unbound empty lane => the spawn resolver picks tabB.
+ // (Until #992 a classic-Dispatch focus on b1 produced the same answer by
+ // a different road; the active project is the only fallback now.)
+ const state = makeState(gridWithFocusedEmptyLane())
+ state.activeTabId = 'tabB'
- expect(buildNewAgentInModel(makeState(dispatchMode)).initialTabId).toBe('tabB')
+ expect(buildNewAgentInModel(state).initialTabId).toBe('tabB')
})
it('first highlights the first enabled project when the spawn target is not on offer', () => {
// A row bound to C whose focused lane still shows A's agent (binding
// filters, it never evicts), so plain New Agent would target A — which
// this row does not offer.
- const dispatchMode = gridWithFocusedEmptyLane(['tabC'])
- dispatchMode.tiled!.lanes[2] = { selectedSessionId: 'a1' }
+ const stage = gridWithFocusedEmptyLane(['tabC'])
+ stage.lanes[2] = { selectedSessionId: 'a1' }
- expect(buildNewAgentInModel(makeState(dispatchMode)).initialTabId).toBe('tabC')
+ expect(buildNewAgentInModel(makeState(stage)).initialTabId).toBe('tabC')
})
it('never highlights a disabled project', () => {
- // b1 still exists (so the spawn resolver still targets tabB) but has no
- // directory, so tabB cannot be anchored. Active tab is C so the expected A
- // can only come from the "first enabled project" rule, not from activeTabId.
- const dispatchMode = gridWithFocusedEmptyLane()
- dispatchMode.focusedSessionId = 'b1'
- const state = makeState(dispatchMode)
+ // The focused lane shows b1, so the spawn resolver targets tabB — but b1
+ // has no directory, so tabB cannot be anchored. Active tab is C so the
+ // expected A can only come from the "first enabled project" rule, not from
+ // activeTabId.
+ const stage = gridWithFocusedEmptyLane()
+ stage.lanes[2] = { selectedSessionId: 'b1' }
+ const state = makeState(stage)
state.activeTabId = 'tabC'
- state.sessions.b1 = { cwd: '', kind: 'codex' }
+ // Spread: the row's `projectId` is what files it under tabB (#992). A bare
+ // replacement un-files it, tabB becomes EMPTY rather than un-anchorable,
+ // and the case stops testing the disabled-project rule at all.
+ state.sessions.b1 = { ...state.sessions.b1!, cwd: '' }
expect(buildNewAgentInModel(state).initialTabId).toBe('tabA')
})
diff --git a/src/renderer/src/features/workspace/lib/newAgentPlacement.ts b/src/renderer/src/features/workspace/lib/newAgentPlacement.ts
deleted file mode 100644
index 84ee82519..000000000
--- a/src/renderer/src/features/workspace/lib/newAgentPlacement.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-import { buildLeafGeometries, sliceRect } from '@renderer/workspace/tile-tree/geometry'
-import type { Rect } from '@renderer/workspace/tile-tree/geometry'
-import type { SessionId, SplitDirection, TileNode } from '@renderer/workspace/types'
-
-export type PlacementTarget =
- | {
- id: string
- kind: 'split-leaf'
- targetSessionId: SessionId
- direction: SplitDirection
- side: 'a' | 'b'
- rect: Rect
- label: string
- scope: 'local'
- }
- | {
- id: string
- kind: 'wrap-root'
- direction: SplitDirection
- side: 'a' | 'b'
- rect: Rect
- label: string
- scope: 'global'
- }
-
-export function buildPlacementTargets(
- root: TileNode,
- anchorSessionId: SessionId,
- bounds: Rect,
-): PlacementTarget[] {
- const leaves = buildLeafGeometries(root, bounds)
- const anchor = leaves.find(leaf => leaf.sessionId === anchorSessionId)
- if (!anchor) return []
-
- const targets: PlacementTarget[] = [
- {
- id: `root:left`,
- kind: 'wrap-root',
- direction: 'vertical',
- side: 'a',
- rect: sliceRect(bounds, 'vertical', 'a'),
- label: 'new left column',
- scope: 'global',
- },
- {
- id: `root:right`,
- kind: 'wrap-root',
- direction: 'vertical',
- side: 'b',
- rect: sliceRect(bounds, 'vertical', 'b'),
- label: 'new right column',
- scope: 'global',
- },
- {
- id: `root:top`,
- kind: 'wrap-root',
- direction: 'horizontal',
- side: 'a',
- rect: sliceRect(bounds, 'horizontal', 'a'),
- label: 'new top row',
- scope: 'global',
- },
- {
- id: `root:bottom`,
- kind: 'wrap-root',
- direction: 'horizontal',
- side: 'b',
- rect: sliceRect(bounds, 'horizontal', 'b'),
- label: 'new bottom row',
- scope: 'global',
- },
- {
- id: `leaf:${anchor.sessionId}:left`,
- kind: 'split-leaf',
- targetSessionId: anchor.sessionId,
- direction: 'vertical',
- side: 'a',
- rect: sliceRect(anchor.rect, 'vertical', 'a'),
- label: 'left of focused pane',
- scope: 'local',
- },
- {
- id: `leaf:${anchor.sessionId}:right`,
- kind: 'split-leaf',
- targetSessionId: anchor.sessionId,
- direction: 'vertical',
- side: 'b',
- rect: sliceRect(anchor.rect, 'vertical', 'b'),
- label: 'right of focused pane',
- scope: 'local',
- },
- {
- id: `leaf:${anchor.sessionId}:top`,
- kind: 'split-leaf',
- targetSessionId: anchor.sessionId,
- direction: 'horizontal',
- side: 'a',
- rect: sliceRect(anchor.rect, 'horizontal', 'a'),
- label: 'above focused pane',
- scope: 'local',
- },
- {
- id: `leaf:${anchor.sessionId}:bottom`,
- kind: 'split-leaf',
- targetSessionId: anchor.sessionId,
- direction: 'horizontal',
- side: 'b',
- rect: sliceRect(anchor.rect, 'horizontal', 'b'),
- label: 'below focused pane',
- scope: 'local',
- },
- ]
-
- // WHY keep explicit placement target records instead of deriving the
- // commit operation directly from the key press:
- //
- // The placement UI shows ONE preview at a time now, but the selected
- // preview still has to carry the real layout operation to commit. A
- // plain arrow maps to a split-leaf target; Shift+arrow maps to a
- // wrap-root target. Keeping those as addressable records means preview
- // geometry and commit payload stay identical, instead of rebuilding
- // "left/right/top/bottom" meaning in two different places.
- return dedupeTargets(targets)
-}
-
-function dedupeTargets(targets: PlacementTarget[]): PlacementTarget[] {
- // WHY dedupe by target id, not rounded rect:
- //
- // The older version keyed dedupe on Math.round(rect.*) so two
- // logically different placements that happened to round to the same
- // integer rectangle collapsed into one. That was real on small
- // panes with fractional split ratios (e.g. a 401px-wide pane split
- // 50/50 produces two targets whose rounded rects both start at the
- // same x because the integer-halving is the same). The id already
- // encodes kind+scope+target+side, which is the actual identity we
- // care about; dropping the rounding collision trap also drops the
- // silent "which sibling wins?" non-determinism.
- //
- // In the one-leaf case, global and local placements can be visually
- // identical, but their ids remain intentionally different because
- // they represent different commands from the UI's perspective
- // (Shift+arrow vs plain arrow). The pass remains a cheap guard
- // against accidental duplicate records from future target builders.
- const seen = new Set()
- const out: PlacementTarget[] = []
- for (const target of targets) {
- if (seen.has(target.id)) continue
- seen.add(target.id)
- out.push(target)
- }
- return out
-}
-
-export function defaultPlacementTargetId(
- targets: PlacementTarget[],
- anchorSessionId: SessionId,
-): string | null {
- return (
- targets.find(target => (
- target.kind === 'split-leaf' &&
- target.targetSessionId === anchorSessionId &&
- target.direction === 'vertical' &&
- target.side === 'b'
- ))?.id ??
- targets[0]?.id ??
- null
- )
-}
-
-export function placementTargetIdForArrow(
- targets: PlacementTarget[],
- anchorSessionId: SessionId,
- arrow: 'left' | 'right' | 'up' | 'down',
- scope: 'local' | 'global',
-): string | null {
- const direction: SplitDirection =
- arrow === 'left' || arrow === 'right' ? 'vertical' : 'horizontal'
- const side: 'a' | 'b' =
- arrow === 'left' || arrow === 'up' ? 'a' : 'b'
-
- return (
- targets.find(target => {
- if (target.direction !== direction || target.side !== side) return false
- if (scope === 'global') return target.kind === 'wrap-root'
- return target.kind === 'split-leaf' && target.targetSessionId === anchorSessionId
- })?.id ??
- defaultPlacementTargetId(targets, anchorSessionId)
- )
-}
diff --git a/src/renderer/src/features/workspace/surfaces/BuryPanePromptSurface.tsx b/src/renderer/src/features/workspace/surfaces/BuryPanePromptSurface.tsx
deleted file mode 100644
index 88781bcab..000000000
--- a/src/renderer/src/features/workspace/surfaces/BuryPanePromptSurface.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { BuryPanePrompt } from '@renderer/features/workspace/ui/BuryPanePrompt'
-import { DEFAULT_PROVIDER } from '@shared/types/providerKind'
-import { useAppStore } from '@renderer/app-state/hooks'
-import { useWorkspaceContext } from '@renderer/workspace/WorkspaceContext'
-
-export function BuryPanePromptSurface() {
- const workspace = useWorkspaceContext()
- const sessionId = useAppStore(state => state.buryPromptSessionId)
- const close = useAppStore(state => state.closeBuryPrompt)
- const meta = sessionId ? workspace.state.sessions[sessionId] ?? null : null
- return (
- {
- if (!sessionId) return
- workspace.buryFocused(note, sessionId)
- }}
- />
- )
-}
diff --git a/src/renderer/src/features/workspace/surfaces/TileTabsModalSurface.tsx b/src/renderer/src/features/workspace/surfaces/TileTabsModalSurface.tsx
deleted file mode 100644
index 276b13178..000000000
--- a/src/renderer/src/features/workspace/surfaces/TileTabsModalSurface.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { TileTabsModal } from '@renderer/features/tile-tabs/ui/TileTabsModal'
-import { useAppStore } from '@renderer/app-state/hooks'
-import { useWorkspaceContext } from '@renderer/workspace/WorkspaceContext'
-
-// Registry wrapper (#494). Lives in features/workspace/surfaces (not
-// tile-tabs) because the *surface* is a workspace-level concern — which
-// tabs to tile — even though the modal UI belongs to tile-tabs.
-// Always mounted with an `open` prop, exactly as App.tsx mounted it.
-export function TileTabsModalSurface() {
- const workspace = useWorkspaceContext()
- const open = useAppStore(state => state.tileTabsModalOpen)
- const initialSelectedIds = useAppStore(state => state.tileTabsInitialSelectedIds)
- const close = useAppStore(state => state.closeTileTabsModal)
- return (
- ({ id: tab.id, title: tab.title }))}
- initialSelectedIds={initialSelectedIds}
- onCancel={close}
- onConfirm={tabIds => {
- workspace.openTileTabs(tabIds)
- close()
- }}
- />
- )
-}
diff --git a/src/renderer/src/features/workspace/surfaces/usePlacementOverlay.ts b/src/renderer/src/features/workspace/surfaces/usePlacementOverlay.ts
index 4b1d5a450..88d23e13b 100644
--- a/src/renderer/src/features/workspace/surfaces/usePlacementOverlay.ts
+++ b/src/renderer/src/features/workspace/surfaces/usePlacementOverlay.ts
@@ -1,23 +1,24 @@
import { useCallback } from 'react'
import { useAppStore } from '@renderer/app-state/hooks'
-// Create, attach, and linked-agent flows share the same overlay
-// shell. The close handler clears every intent so re-opening one
-// mode after another never inherits stale state from a sibling flow.
-// (Derivation extracted from App.tsx by #494; consumed by
-// app/shell/MainSurface which renders NewAgentPlacementOverlay inside
-// the tile-tree / dispatch relative container — it can NOT be a
-// root-level registry surface because its positioning is relative to
-// the main layout, not the viewport.)
+// Create and linked-agent flows share the same overlay shell. The close
+// handler clears both intents so re-opening one mode after another never
+// inherits stale state from a sibling flow. (Derivation extracted from
+// App.tsx by #494; consumed by app/shell/MainSurface which renders
+// NewAgentPlacementOverlay inside the stage's relative container — it can
+// NOT be a root-level registry surface because its positioning is relative
+// to the main layout, not the viewport.)
+//
+// The attach-detached flow used to be the third mode here. It died with the
+// tile tree (#992): there is no grid to attach into, and placing a pool
+// session on screen is a lane selection, not an overlay.
export function usePlacementOverlay(): {
open: boolean
- attachIntent: ReturnType['dispatchAttachIntent']
linkedAgentParentId: ReturnType['linkedAgentParentId']
projectIntent: ReturnType['newAgentProjectIntent']
close: () => void
} {
const newAgentPlacementOpen = useAppStore(state => state.newAgentPlacementOpen)
- const attachIntent = useAppStore(state => state.dispatchAttachIntent)
const linkedAgentParentId = useAppStore(state => state.linkedAgentParentId)
// Note: this intent does NOT contribute to `open` below. It only ever
// accompanies newAgentPlacementOpen (openNewAgentForProject sets both), so
@@ -25,16 +26,13 @@ export function usePlacementOverlay(): {
// overlay. closeNewAgentPlacement clears it.
const projectIntent = useAppStore(state => state.newAgentProjectIntent)
const closeNewAgentPlacement = useAppStore(state => state.closeNewAgentPlacement)
- const closeDispatchAttach = useAppStore(state => state.closeDispatchAttach)
const closeLinkedAgent = useAppStore(state => state.closeLinkedAgent)
const close = useCallback(() => {
closeNewAgentPlacement()
- closeDispatchAttach()
closeLinkedAgent()
- }, [closeDispatchAttach, closeLinkedAgent, closeNewAgentPlacement])
+ }, [closeLinkedAgent, closeNewAgentPlacement])
return {
- open: newAgentPlacementOpen || attachIntent !== null || linkedAgentParentId !== null,
- attachIntent,
+ open: newAgentPlacementOpen || linkedAgentParentId !== null,
linkedAgentParentId,
projectIntent,
close,
diff --git a/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx b/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx
index 24acb7ab0..103bcff2e 100644
--- a/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx
+++ b/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx
@@ -15,6 +15,7 @@ import type { Workspace } from '@renderer/workspace/workspaceStore'
import type { Entry } from '@shared/types/transcript'
import { relativeTime } from '@renderer/lib/relativeTime'
import { cwdBasename, providerGlyph } from '@renderer/features/workspace/lib/sessionDisplay'
+import { commandTargetSessionIdForState } from '@renderer/workspace/hook/selectors/commandTargetSessionId'
// AgentActivityModal — overview of every visible pane grouped by tab
// with a last-activity indicator per row.
@@ -105,12 +106,6 @@ export function AgentActivityModal({ open, workspace, onClose }: Props) {
const [nowTick, setNowTick] = useState(0)
const inputRef = useRef(null)
const listRef = useRef(null)
- // Used by the bury action — we need to open the note-prompt modal
- // targeting a specific session id without touching whatever pane
- // is currently focused. openBuryPrompt takes a sessionId directly,
- // which is exactly the handle we have here.
- const openBuryPrompt = useAppStore(s => s.openBuryPrompt)
-
// Re-render on a timer so relative-time strings ("3m ago") don't
// get visually stale while the modal is open. 10s is fine-grained
// enough to feel live without churning the DOM excessively.
@@ -131,6 +126,7 @@ export function AgentActivityModal({ open, workspace, onClose }: Props) {
void nowTick
const built: Row[] = []
+ const focusedSessionId = commandTargetSessionIdForState(workspace.state)
workspace.state.tabs.forEach((tab: Tab, tabIndex: number) => {
const leaves = resolveTabSessions(workspace.state, tab.id)
for (const sessionId of leaves) {
@@ -200,7 +196,9 @@ export function AgentActivityModal({ open, workspace, onClose }: Props) {
kind,
cwd: meta.cwd,
cwdBase: cwdBasename(meta.cwd),
- isFocused: tab.focusedSessionId === sessionId,
+ // The agent the user is commanding: the focused lane's occupant.
+ // (Each tab's tile-tree focus until #992.)
+ isFocused: focusedSessionId === sessionId,
isLive,
lastActiveAt,
statusLabel,
@@ -285,7 +283,11 @@ export function AgentActivityModal({ open, workspace, onClose }: Props) {
const focusRow = useCallback(
(row: Row) => {
- workspace.focusSessionInTab(row.tabId, row.sessionId)
+ // Show the agent: its existing lane if it has one, else the focused
+ // lane, waking it first. Until #992 this called focusSessionInTab, which
+ // moved the tile tree's focus — a no-op on the stage, so for a lane user
+ // "Focus" closed the modal and changed nothing.
+ void workspace.focusAgentBySessionId(row.sessionId)
onClose()
},
[onClose, workspace],
@@ -301,20 +303,9 @@ export function AgentActivityModal({ open, workspace, onClose }: Props) {
[workspace],
)
- const buryRow = useCallback(
- (row: Row) => {
- // Any session can be buried (#865). Bury keeps the process alive, and a
- // terminal revives by re-attaching its tmux session. The old comment
- // ("no notion of a resumable conversation") confused bury with resume.
- // Close our modal before the bury-note prompt opens so the
- // two dialogs don't stack visually. buryFocused(note, id)
- // already accepts an explicit target id, so the note prompt
- // does the right thing even after we close here.
- onClose()
- openBuryPrompt(row.sessionId)
- },
- [onClose, openBuryPrompt],
- )
+ // The row-level Bury action lived here until #992. In the pool-first
+ // workspace "hide but keep alive" is what an unplaced session already is,
+ // so the modal keeps Focus and Close only.
const switchSortMode = useCallback((mode: SortMode) => {
setSortMode(mode)
@@ -363,16 +354,8 @@ export function AgentActivityModal({ open, workspace, onClose }: Props) {
if (row) void closeRow(row)
return
}
- // Lowercase b = bury. Uppercase treated the same — the user
- // may hold shift out of muscle memory, shouldn't punish them.
- if (e.key === 'b' || e.key === 'B') {
- e.preventDefault()
- const row = rows[selectedIdx]
- if (row) buryRow(row)
- return
- }
},
- [rows, selectedIdx, onClose, focusRow, closeRow, buryRow],
+ [rows, selectedIdx, onClose, focusRow, closeRow],
)
function renderRow(row: Row, idx: number) {
@@ -450,14 +433,6 @@ export function AgentActivityModal({ open, workspace, onClose }: Props) {
`}
onClick={e => e.stopPropagation()}
>
- buryRow(row)}
- className="rounded-control px-2 py-0.5 text-[10px] border border-border text-ink-dim hover:border-border-hi hover:text-ink"
- title="Bury (b)"
- >
- bury
-
void closeRow(row)}
@@ -490,7 +465,7 @@ export function AgentActivityModal({ open, workspace, onClose }: Props) {
- Enter focus · Del close · B bury · Esc dismiss
+ Enter focus · Del close · Esc dismiss
diff --git a/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.policy.renderer.test.tsx b/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.policy.renderer.test.tsx
index bb99f266a..05e231940 100644
--- a/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.policy.renderer.test.tsx
+++ b/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.policy.renderer.test.tsx
@@ -95,11 +95,10 @@ function workspaceFixture(entries: Entry[] = []): Workspace {
tabs: [{
id: 'project-tab',
title: 'Project tab',
- focusedSessionId: 'agent',
- root: { type: 'leaf', sessionId: 'agent' },
}],
- sessions: { agent: { cwd: '/projects/agent-code', kind: 'codex' } },
- detachedSessions: {},
+ sessions: { agent: { cwd: '/projects/agent-code', kind: 'codex', projectId: 'project-tab', joinedAt: 0 } },
+ pinnedSessionIds: [],
+ stage: { lanes: [{ selectedSessionId: 'agent' }], rows: [{ length: 1 }], focusedLane: 0 },
lastProviderSwitchBatch: null,
},
runtimes: { agent: { ...emptyRuntime(), entries } },
@@ -116,7 +115,9 @@ function claudeWorkspaceFixture(): Workspace {
...base,
state: {
...base.state,
- sessions: { agent: { cwd: '/projects/agent-code', kind: 'claude' } },
+ // Spread the row, change only its kind: membership is ON the row (#992),
+ // so a bare literal here un-files the agent and the modal lists nobody.
+ sessions: { agent: { ...base.state.sessions.agent!, kind: 'claude' } },
},
} as unknown as Workspace
}
diff --git a/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.scope.renderer.test.tsx b/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.scope.renderer.test.tsx
index 7f62db1ed..c7a4c2894 100644
--- a/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.scope.renderer.test.tsx
+++ b/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.scope.renderer.test.tsx
@@ -6,6 +6,7 @@ import type { UsageSnapshot } from '@shared/types/usage'
import type { Workspace } from '@renderer/workspace/workspaceStore'
import { BulkProviderSwitchModal } from './BulkProviderSwitchModal'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
// The selected-scope path is where #908 lived: the checkbox list was built
// from working directories, so a worktree agent had its own checkbox and the
@@ -42,21 +43,16 @@ function workspaceFixture(): Workspace {
return {
state: {
activeTabId: 'tab-agent-code',
- dispatchMode: null,
- gridRelatedSelections: {},
+ stage: oneLaneStage('audit'),
tabs: [
- { id: 'tab-agent-code', title: 'agent-code', focusedSessionId: 'audit', root: { type: 'leaf', sessionId: 'audit' } },
- { id: 'tab-startup', title: 'startup', focusedSessionId: 'pitch', root: { type: 'leaf', sessionId: 'pitch' } },
+ { id: 'tab-agent-code', title: 'agent-code' },
+ { id: 'tab-startup', title: 'startup' },
],
sessions: {
- audit: { cwd: '/dev/agent-code', kind: 'codex' },
- grok: { cwd: '/dev/agent-code/.worktrees/grok-package-wiring', kind: 'codex' },
- pitch: { cwd: '/dev/startup', kind: 'codex' },
+ audit: { cwd: '/dev/agent-code', kind: 'codex', projectId: 'tab-agent-code', joinedAt: 0 },
+ grok: { cwd: '/dev/agent-code/.worktrees/grok-package-wiring', kind: 'codex', projectId: 'tab-agent-code', joinedAt: 1 },
+ pitch: { cwd: '/dev/startup', kind: 'codex', projectId: 'tab-startup', joinedAt: 0 },
},
- detachedSessions: {
- grok: { sessionId: 'grok', surface: 'dispatch', projectTabId: 'tab-agent-code', projectTabTitle: 'agent-code', projectTabIndex: 0, detachedAt: 1 },
- },
- buried: [],
pinnedSessionIds: [],
lastProviderSwitchBatch: null,
},
diff --git a/src/renderer/src/features/workspace/ui/BuryPanePrompt.tsx b/src/renderer/src/features/workspace/ui/BuryPanePrompt.tsx
deleted file mode 100644
index 074f26d36..000000000
--- a/src/renderer/src/features/workspace/ui/BuryPanePrompt.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import { useEffect, useState } from 'react'
-
-import { Button } from '@renderer/components/ui/button'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@renderer/components/ui/dialog'
-import { Label } from '@renderer/components/ui/label'
-import { Textarea } from '@renderer/components/ui/textarea'
-
-type Props = {
- open: boolean
- title: string
- description: string
- onCancel: () => void
- onConfirm: (note: string) => void
-}
-
-export function BuryPanePrompt({
- open,
- title,
- description,
- onCancel,
- onConfirm,
-}: Props) {
- const [note, setNote] = useState('')
-
- useEffect(() => {
- if (!open) return
- setNote('')
- }, [open])
-
- return (
-
{
- if (!nextOpen) onCancel()
- }}
- >
-
-
- Bury Pane
-
-
-
{title}
-
{description}
-
-
-
-
-
-
- Optional note
-
-
-
-
-
- Cancel
-
- {/* One confirm button. An empty textarea is the "skip the
- note" path — the store trims whitespace to undefined
- anyway. A separate "Skip Note" button that forced ''
- used to exist but did exactly the same thing as Bury
- with an empty field; users were just guessing which
- one to press. */}
- onConfirm(note)}
- >
- Bury
-
-
-
-
- )
-}
diff --git a/src/renderer/src/features/workspace/ui/CloseConfirmationDialog.renderer.test.tsx b/src/renderer/src/features/workspace/ui/CloseConfirmationDialog.renderer.test.tsx
index a31ebca2a..6af128a79 100644
--- a/src/renderer/src/features/workspace/ui/CloseConfirmationDialog.renderer.test.tsx
+++ b/src/renderer/src/features/workspace/ui/CloseConfirmationDialog.renderer.test.tsx
@@ -3,29 +3,48 @@ import { afterEach, describe, expect, it } from 'vitest'
import {
__resetCloseConfirmationForTests,
currentCloseConfirmation,
- requestRootCloseConfirmation,
+ requestCloseConfirmation,
} from '@renderer/workspace/closeConfirmationBroker'
import { CloseConfirmationDialog } from './CloseConfirmationDialog'
afterEach(__resetCloseConfirmationForTests)
-const root = { sessionId: 'root', title: 'Old root', live: false }
+// WHAT THIS FILE USED TO PIN, because half of it was deleted on purpose (#992).
+//
+// The dialog had a second, THREE-way presentation — "Close the agent or the
+// tab?" with Close Agent / Close Tab (N) — raised for one specific session: a
+// tab's root tile leaf, whose close would otherwise have emptied the tile tree
+// and taken the project with it. These tests pinned that each of the three
+// buttons resolved only its named scope, that a terminal root was called a
+// terminal, and that Enter on a Tab-focused Close Agent could never become
+// Close Tab.
+//
+// There is no tile tree, so there is no root and no second scope: every close
+// is session-scoped, and "everything in this project" is the Close Tab command
+// with its own list. What survives is the part that was never about the root —
+// the list the user approves, and the #867 keyboard contract — now asserted
+// against the only presentation the dialog has.
+
+const parent = { sessionId: 'parent', title: 'Old parent', live: false }
const worker = { sessionId: 'worker', title: 'Running worker', live: true }
-function rootRequest(noun: 'agent' | 'terminal' = 'agent') {
+function multiRequest() {
return {
- required: true as const, reason: 'multi' as const, targets: [root, worker],
- summary: 'Project contains 2 sessions.', agentOnly: { title: root.title, targets: [root], noun },
+ required: true as const, reason: 'multi' as const, targets: [parent, worker],
+ summary: 'This closes 2 sessions, 1 still working.',
}
}
it.each([
- ['Cancel', null], ['Close Agent', 'agent'], ['Close Tab (2)', 'tab'],
-] as const)('root-close button %s resolves only its named scope', async (button, expected) => {
+ ['Cancel', false], ['Close 2', true],
+] as const)('button %s resolves its own answer and closes the dialog', async (button, expected) => {
render(
)
- let answer!: ReturnType
- act(() => { answer = requestRootCloseConfirmation(rootRequest()) })
- expect(screen.getByRole('heading', { name: 'Close the agent or the tab?' })).toBeInTheDocument()
+ let answer!: Promise
+ act(() => { answer = requestCloseConfirmation(multiRequest()) })
+ expect(screen.getByRole('heading', { name: 'Close these sessions?' })).toBeInTheDocument()
+ // The list, not just the count: the user can check these are the two they
+ // meant, and sees which one is mid-turn.
+ expect(screen.getByText('Old parent')).toBeInTheDocument()
expect(screen.getByText('Running worker')).toBeInTheDocument()
expect(screen.getByText('working')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: button }))
@@ -33,14 +52,39 @@ it.each([
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
-it('names a terminal root as a terminal, not an agent', () => {
- // #865/#872 made terminals sessions in close flows, so a grid terminal can be
- // the project root. "Close Agent ends zsh" would name the wrong thing.
+it('asks about one working session without a list or a count', () => {
+ render( )
+ act(() => {
+ void requestCloseConfirmation({
+ required: true, reason: 'running', targets: [worker],
+ summary: 'Running worker is still working. Close it anyway?',
+ })
+ })
+ expect(screen.getByRole('heading', { name: 'Close a working session?' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
+})
+
+it('offers exactly two answers — no narrower or wider scope than the list shown', () => {
+ // The regression guard for the deleted branch: a third button means some
+ // close path has grown a second scope again, and the user is once more
+ // approving a list that is not the list that dies.
+ render( )
+ act(() => { void requestCloseConfirmation(multiRequest()) })
+ const footerButtons = screen.getAllByRole('button')
+ .map(button => button.textContent?.trim())
+ .filter(label => label === 'Cancel' || label?.startsWith('Close'))
+ expect(footerButtons).toEqual(['Cancel', 'Close 2'])
+})
+
+it('declines the first request when a second one supersedes it', async () => {
+ // One slot: two destructive grants can never be open at once, and the
+ // superseded close path gets an answer instead of awaiting forever.
render( )
- act(() => { void requestRootCloseConfirmation(rootRequest('terminal')) })
- expect(screen.getByRole('heading', { name: 'Close the terminal or the tab?' })).toBeInTheDocument()
- expect(screen.getByRole('button', { name: 'Close Terminal' })).toBeInTheDocument()
- expect(screen.queryByRole('button', { name: 'Close Agent' })).not.toBeInTheDocument()
+ let first!: Promise
+ act(() => { first = requestCloseConfirmation(multiRequest()) })
+ act(() => { void requestCloseConfirmation(multiRequest()) })
+ expect(await first).toBe(false)
+ expect(currentCloseConfirmation()).not.toBeNull()
})
// #867 pattern. This dialog has no Enter handler of its own, and that absence is
@@ -53,11 +97,11 @@ it('names a terminal root as a terminal, not an agent', () => {
// the two halves separately: keyDown's default is not prevented (`true`) and
// nothing resolved, then the click the browser would perform resolves only that
// button's own answer.
-describe('root-close keyboard ownership', () => {
+describe('close confirmation keyboard ownership', () => {
it('opens with Cancel focused, and Enter there can only cancel', async () => {
render( )
- let answer!: ReturnType
- act(() => { answer = requestRootCloseConfirmation(rootRequest()) })
+ let answer!: Promise
+ act(() => { answer = requestCloseConfirmation(multiRequest()) })
const cancel = screen.getByRole('button', { name: 'Cancel' })
await waitFor(() => expect(cancel).toHaveFocus())
@@ -65,20 +109,20 @@ describe('root-close keyboard ownership', () => {
expect(currentCloseConfirmation()).not.toBeNull()
fireEvent.click(cancel)
- expect(await answer).toBeNull()
+ expect(await answer).toBe(false)
})
- it('leaves Enter on a Tab-focused Close Agent to that button, never Close Tab', async () => {
+ it('leaves Enter on a Tab-focused Close to that button', async () => {
render( )
- let answer!: ReturnType
- act(() => { answer = requestRootCloseConfirmation(rootRequest()) })
- const closeAgent = screen.getByRole('button', { name: 'Close Agent' })
- act(() => closeAgent.focus())
+ let answer!: Promise
+ act(() => { answer = requestCloseConfirmation(multiRequest()) })
+ const close = screen.getByRole('button', { name: 'Close 2' })
+ act(() => close.focus())
- expect(fireEvent.keyDown(closeAgent, { key: 'Enter' })).toBe(true)
+ expect(fireEvent.keyDown(close, { key: 'Enter' })).toBe(true)
expect(currentCloseConfirmation()).not.toBeNull()
- fireEvent.click(closeAgent)
- expect(await answer).toBe('agent')
+ fireEvent.click(close)
+ expect(await answer).toBe(true)
})
})
diff --git a/src/renderer/src/features/workspace/ui/CloseConfirmationDialog.tsx b/src/renderer/src/features/workspace/ui/CloseConfirmationDialog.tsx
index 5315853de..6a49047cf 100644
--- a/src/renderer/src/features/workspace/ui/CloseConfirmationDialog.tsx
+++ b/src/renderer/src/features/workspace/ui/CloseConfirmationDialog.tsx
@@ -40,11 +40,11 @@ export function CloseConfirmationDialog() {
const request = pending?.request
const live = request?.targets.filter(target => target.live) ?? []
- const scoped = request?.agentOnly
- // "Close Agent" / "Close Terminal": the button names what the root actually
- // is. The request carries the noun because only the close action knows the
- // session kind; the dialog stays a renderer of plain data.
- const Noun = scoped?.noun === 'terminal' ? 'Terminal' : 'Agent'
+ // A third, "scoped" presentation lived here until #992 — "Close the agent or
+ // the tab?", with Close Agent / Close Tab (N) buttons — for the one session
+ // whose close used to take its project with it (the tab's root tile leaf).
+ // No session is special like that any more, so this dialog only ever asks
+ // one question about one list: end these, or don't.
return (
- {scoped ? `Close the ${scoped.noun} or the tab?` : request?.reason === 'running'
+ {request?.reason === 'running'
// "session" not "agent": a shell running a job reaches this
// dialog too now that terminal foreground state counts as
// working (#865), and it isn't an agent.
@@ -71,14 +71,6 @@ export function CloseConfirmationDialog() {
{request?.summary}
- {scoped ? (
-
- Close {Noun} ends {scoped.title}
- {scoped.targets.length > 1 ? ` and ${scoped.targets.length - 1} linked session(s)` : ''}.
- {' '}Other sessions in the tab stay open. Close Tab ends every session listed below.
-
- ) : null}
-
{request && request.targets.length > 1 ? (
{request.targets.map(target => (
@@ -107,24 +99,8 @@ export function CloseConfirmationDialog() {
resolveCloseConfirmation(false)}>
Cancel
- {scoped ? (
- // WHY `secondary`, not `destructive`, even though this button can
- // end a working session (#886 review n3): the dialog offers two
- // destructive scopes and exists to steer toward the NARROWER one.
- // Painting both red erases the only visual difference between
- // "end one session, keep the project, undo restores the root" and
- // "end every listed session" — and the review's m5 finding is
- // precisely that an operator reaching for the most prominent
- // destructive button closes a whole project. The danger cue for a
- // working root is not lost: the list marks it "working" and the
- // Undo note above appears. The house rule's intent (a destructive
- // confirmation looks destructive) is kept by Close Tab.
-
resolveCloseConfirmation('agent')}>
- Close {Noun}
-
- ) : null}
resolveCloseConfirmation(true)}>
- {scoped ? `Close Tab (${request!.targets.length})` : request && request.targets.length > 1
+ {request && request.targets.length > 1
? `Close ${request.targets.length}`
: 'Close'}
diff --git a/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.close.renderer.test.tsx b/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.close.renderer.test.tsx
index d5c15049a..28ce53deb 100644
--- a/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.close.renderer.test.tsx
+++ b/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.close.renderer.test.tsx
@@ -31,16 +31,13 @@ afterEach(() => {
function mountCleanup(options: { working?: boolean; linked?: boolean } = {}) {
const state: WorkspaceState = {
- tabs: [{ id: 'tab', title: 'Project', root: { type: 'leaf', sessionId: 'root' }, focusedSessionId: 'root' }],
- activeTabId: 'tab', dispatchMode: { scope: 'project', focusedSessionId: 'root' },
+ tabs: [{ id: 'tab', title: 'Project' }],
+ activeTabId: 'tab', stage: { lanes: [{ selectedSessionId: 'root' }], rows: [{ length: 1 }], focusedLane: 0 },
sessions: {
- root: { cwd: '/project', kind: 'claude' },
- worker: { cwd: '/project', kind: 'codex', ...(options.linked ? { linkedParentId: 'root' } : {}) },
+ root: { cwd: '/project', kind: 'claude', projectId: 'tab', joinedAt: 0 },
+ worker: { cwd: '/project', kind: 'codex', ...(options.linked ? { linkedParentId: 'root' } : {}), projectId: 'tab', joinedAt: 1 },
},
- detachedSessions: {
- worker: { sessionId: 'worker', surface: 'dispatch', projectTabId: 'tab', projectTabTitle: 'Project', projectTabIndex: 0, detachedAt: 1 },
- },
- gridRelatedSelections: {}, buried: [], pinnedSessionIds: [],
+ pinnedSessionIds: [],
}
const refs = makeRefs(state)
// Cleanup only reads timestamps; provider payloads do not determine age.
@@ -69,7 +66,7 @@ describe('Close Old Agents destructive scope (#886)', () => {
await waitFor(() => expect(onClose).toHaveBeenCalledOnce())
expect(killOwnedSession.mock.calls.map(([owner]) => owner.sessionId)).toEqual(['root'])
expect(harness.getState().sessions.worker).toBeDefined()
- expect(harness.getState().tabs[0].root).toEqual({ type: 'leaf', sessionId: 'worker' })
+ expect(harness.getState().tabs).toHaveLength(1)
expect(showToast).toHaveBeenLastCalledWith('Closed 1 session.', 6000)
})
diff --git a/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.rows.renderer.test.ts b/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.rows.renderer.test.ts
index 70c8f3de9..004b2ae8f 100644
--- a/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.rows.renderer.test.ts
+++ b/src/renderer/src/features/workspace/ui/CloseOldAgentsModal.rows.renderer.test.ts
@@ -5,16 +5,17 @@ import type { Workspace } from '@renderer/workspace/workspaceStore'
import { buildAgentRows } from './CloseOldAgentsModal'
import type { SessionRuntime } from '@renderer/session-runtime/state'
import type { Entry } from '@shared/types/transcript'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
// Close Old Agents aged sessions by transcript timestamps, which shells do not
// have, so terminals were excluded outright. The foreground monitor (#865) gives
// them an age: the last time a command started, finished or the shell cd'd.
it('ages an idle terminal from its last foreground change', () => {
const state: Workspace['state'] = {
- tabs: [{ id: 'tab', title: 'project', root: { type: 'leaf', sessionId: 'shell' }, focusedSessionId: 'shell' }],
- activeTabId: 'tab', dispatchMode: null, gridRelatedSelections: {},
- sessions: { shell: { cwd: '/work/api', kind: 'terminal' } },
- detachedSessions: {}, buried: [], pinnedSessionIds: [],
+ tabs: [{ id: 'tab', title: 'project' }],
+ activeTabId: 'tab', stage: oneLaneStage('shell'),
+ sessions: { shell: { cwd: '/work/api', kind: 'terminal', projectId: 'tab', joinedAt: 0 } },
+ pinnedSessionIds: [],
}
const runtimes = {
shell: { ...emptyRuntime(), terminalForeground: { busy: false, command: 'zsh', cwd: '/work/api', changedAt: 1_000 } },
@@ -30,9 +31,9 @@ describe('cleanup activity evidence (#886)', () => {
const old = now - 8 * 60 * 60 * 1000
const recent = now - 60_000
const state: Workspace['state'] = {
- activeTabId: 'tab', dispatchMode: null, buried: [], pinnedSessionIds: [],
- tabs: [{ id: 'tab', title: 'project', root: { type: 'leaf', sessionId: 'agent' }, focusedSessionId: 'agent' }],
- sessions: { agent: { cwd: '/project', kind: 'claude' } }, detachedSessions: {},
+ activeTabId: 'tab', stage: oneLaneStage('agent'), pinnedSessionIds: [],
+ tabs: [{ id: 'tab', title: 'project' }],
+ sessions: { agent: { cwd: '/project', kind: 'claude', projectId: 'tab', joinedAt: 0 } },
}
// Timestamps are the public transcript fields cleanup reads. The provider's
// content is deliberately irrelevant to whether the session is old.
diff --git a/src/renderer/src/features/workspace/ui/DispatchRowProjectModal.tsx b/src/renderer/src/features/workspace/ui/DispatchRowProjectModal.tsx
index e4d4e0b07..76978fb36 100644
--- a/src/renderer/src/features/workspace/ui/DispatchRowProjectModal.tsx
+++ b/src/renderer/src/features/workspace/ui/DispatchRowProjectModal.tsx
@@ -37,13 +37,13 @@ export function DispatchRowProjectModal({
workspace: Workspace
onClose: () => void
}) {
- const tiled = workspace.state.dispatchMode?.tiled
+ const stage = workspace.state.stage
// Read the CURRENT bindings so the right rows are checked. Guard the null
// rowIndex: the surface stays mounted-but-closed between opens.
const selected = useMemo
(() => {
- if (rowIndex === null || !tiled) return []
- return normalizeGridShape(tiled).rows[rowIndex]?.projectTabIds ?? []
- }, [rowIndex, tiled])
+ if (rowIndex === null) return []
+ return normalizeGridShape(stage).rows[rowIndex]?.projectTabIds ?? []
+ }, [rowIndex, stage])
const commit = useCallback(
(next: TabId[]) => {
diff --git a/src/renderer/src/features/workspace/ui/GridDispatchShapeOverlay.tsx b/src/renderer/src/features/workspace/ui/GridDispatchShapeOverlay.tsx
index 624cceb37..84d969849 100644
--- a/src/renderer/src/features/workspace/ui/GridDispatchShapeOverlay.tsx
+++ b/src/renderer/src/features/workspace/ui/GridDispatchShapeOverlay.tsx
@@ -51,7 +51,7 @@ type Props = {
}
export function GridDispatchShapeOverlay({ workspace, onClose }: Props) {
- const tiled = workspace.state.dispatchMode?.tiled
+ const stage = workspace.state.stage
// Rows carry their SOURCE index, not just a length. A bare number[] cannot
// express which row was removed: deleting the middle of three shifts every
// later row up a slot, and a positional apply then re-points row 1's binding
@@ -62,14 +62,16 @@ export function GridDispatchShapeOverlay({ workspace, onClose }: Props) {
// true rather than a second trip through per-row header controls.
type DraftRow = GridShapeRow & Pick
const [rows, setRows] = useState(() =>
- tiled
- ? normalizeGridShape(tiled).rows.map((row, index) => ({
- length: row.length,
- sourceRow: index,
- projectTabIds: row.projectTabIds,
- capChildren: row.capChildren,
- }))
- : [{ length: 2, sourceRow: null }],
+ // The draft always starts from the CURRENT shape. It used to fall back to
+ // a fresh `[2]` draft when no lane grid existed, because this dialog was
+ // also how Grid Dispatch was ENTERED; the stage always exists now (#992),
+ // so the editor only ever reshapes.
+ normalizeGridShape(stage).rows.map((row, index) => ({
+ length: row.length,
+ sourceRow: index,
+ projectTabIds: row.projectTabIds,
+ capChildren: row.capChildren,
+ })),
)
// Opens in the mode that can REPRESENT the current grid. Derived rather than
// persisted: no new settings key, and the editor can never open in a mode
@@ -136,28 +138,20 @@ export function GridDispatchShapeOverlay({ workspace, onClose }: Props) {
// Only close when the reshape was actually accepted. The controls constrain
// input to what setGridShape allows, so a refusal should be unreachable —
// but closing on a refusal would silently discard the user's edit.
- if (tiled) {
- if (!workspace.setDispatchGridShape(rows)) return
- // Config is applied AFTER the shape, by output position: setGridShape may
- // have added or removed rows, so a row's config can only be addressed
- // once the new shape exists.
- rows.forEach((row, index) => {
- workspace.setDispatchRowProjects(index, row.projectTabIds ?? [])
- workspace.setDispatchRowCapChildren(index, row.capChildren !== false)
- })
- } else {
- // Await the entry before applying config: the rows do not exist until it
- // resolves, so a synchronous loop would write onto a grid that is not
- // there yet and the user's Advanced choices would vanish with no error.
- void workspace.enterTiledDispatch(rows.map(row => row.length)).then(() => {
- rows.forEach((row, index) => {
- workspace.setDispatchRowProjects(index, row.projectTabIds ?? [])
- workspace.setDispatchRowCapChildren(index, row.capChildren !== false)
- })
- })
- }
+ if (!workspace.setDispatchGridShape(rows)) return
+ // Config is applied AFTER the shape, by output position: setGridShape may
+ // have added or removed rows, so a row's config can only be addressed
+ // once the new shape exists.
+ //
+ // (An `else` branch entered Grid Dispatch asynchronously and applied the
+ // config in its `.then` until #992. Reshape is synchronous, so the whole
+ // commit is now one tick and cannot be observed half-applied.)
+ rows.forEach((row, index) => {
+ workspace.setDispatchRowProjects(index, row.projectTabIds ?? [])
+ workspace.setDispatchRowCapChildren(index, row.capChildren !== false)
+ })
onClose()
- }, [tiled, workspace, rows, onClose])
+ }, [workspace, rows, onClose])
// Enter commits ONLY from a number field. Scoped to the inputs rather than
// the whole body because a body-level handler swallows Enter on the row-remove
@@ -333,7 +327,7 @@ export function GridDispatchShapeOverlay({ workspace, onClose }: Props) {
Cancel
- {tiled ? 'Apply' : 'Open'}
+ Apply
diff --git a/src/renderer/src/features/workspace/ui/NewAgentInDialog.renderer.test.tsx b/src/renderer/src/features/workspace/ui/NewAgentInDialog.renderer.test.tsx
index c600f1ee9..58d3bf2c6 100644
--- a/src/renderer/src/features/workspace/ui/NewAgentInDialog.renderer.test.tsx
+++ b/src/renderer/src/features/workspace/ui/NewAgentInDialog.renderer.test.tsx
@@ -18,26 +18,25 @@ afterEach(() => {
function workspaceState(): WorkspaceState {
return {
tabs: [
- { id: 'tabA', title: 'project-a', root: { type: 'leaf', sessionId: 'a1' }, focusedSessionId: 'a1' },
- { id: 'tabB', title: 'project-b', root: { type: 'leaf', sessionId: 'b1' }, focusedSessionId: 'b1' },
- { id: 'tabC', title: 'project-c', root: { type: 'leaf', sessionId: 'c1' }, focusedSessionId: 'c1' },
+ { id: 'tabA', title: 'project-a' },
+ { id: 'tabB', title: 'project-b' },
+ { id: 'tabC', title: 'project-c' },
],
- activeTabId: 'tabA',
- dispatchMode: {
- scope: 'global',
- focusedSessionId: 'b1',
- tiled: {
- focusedLane: 2,
- lanes: [{ selectedSessionId: 'a1' }, { selectedSessionId: 'b1' }, {}],
- },
+ // Active project B, focused lane EMPTY: plain New Agent therefore targets
+ // B (an empty unbound lane falls back to the active project). B rather
+ // than A on purpose, so "the dialog opens on New Agent's project" cannot
+ // pass by merely picking the first project. Until #992 this was said with
+ // a classic-Dispatch focus on b1 while the active tab stayed A.
+ activeTabId: 'tabB',
+ stage: {
+ focusedLane: 2,
+ lanes: [{ selectedSessionId: 'a1' }, { selectedSessionId: 'b1' }, {}],
},
sessions: {
- a1: { cwd: '/work/project-a', kind: 'claude' },
- b1: { cwd: '/work/project-b', kind: 'codex' },
- c1: { cwd: '/work/project-c', kind: 'claude' },
+ a1: { cwd: '/work/project-a', kind: 'claude', projectId: 'tabA', joinedAt: 0 },
+ b1: { cwd: '/work/project-b', kind: 'codex', projectId: 'tabB', joinedAt: 0 },
+ c1: { cwd: '/work/project-c', kind: 'claude', projectId: 'tabC', joinedAt: 0 },
},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
}
}
@@ -234,7 +233,7 @@ describe('NewAgentInDialog', () => {
// can be bound only to projects that no longer exist. "No projects are
// open" would be false — other projects are — and gives no way forward.
const state = workspaceState()
- state.dispatchMode!.tiled!.rows = [{ length: 3, projectTabIds: ['tab-closed'] }]
+ state.stage.rows = [{ length: 3, projectTabIds: ['tab-closed'] }]
const { press } = harness({ state })
press('Enter')
diff --git a/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.renderer.test.tsx b/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.renderer.test.tsx
index 9771a7a2e..8fa275e3e 100644
--- a/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.renderer.test.tsx
+++ b/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.renderer.test.tsx
@@ -18,22 +18,15 @@ describe('NewAgentPlacementOverlay OpenCode runtime choices', () => {
const createDetachedDispatchAgent = vi.fn(async () => undefined)
const onClose = vi.fn()
const workspace = {
- activeTab: {
- id: 'tab-1',
- title: 'Project',
- focusedSessionId: 'parent',
- root: { type: 'leaf', sessionId: 'parent' },
- },
- dispatchMode: { focusedSessionId: 'parent' },
+ activeTab: { id: 'tab-1', title: 'Project' },
+ stage: { lanes: [{ selectedSessionId: 'parent' }], rows: [{ length: 1 }], focusedLane: 0 },
state: {
activeTabId: 'tab-1',
tabs: [{
id: 'tab-1',
title: 'Project',
- focusedSessionId: 'parent',
- root: { type: 'leaf', sessionId: 'parent' },
}],
- sessions: { parent: { cwd: '/project', kind: 'claude' } },
+ sessions: { parent: { cwd: '/project', kind: 'claude', projectId: 'tab-1', joinedAt: 0 } },
},
createDetachedDispatchAgent,
createLinkedAgent: vi.fn(),
@@ -47,7 +40,6 @@ describe('NewAgentPlacementOverlay OpenCode runtime choices', () => {
open
workspace={workspace}
onClose={onClose}
- attachIntent={null}
linkedAgentParentId={null}
projectIntent={null}
/>,
@@ -72,12 +64,12 @@ describe('NewAgentPlacementOverlay OpenCode runtime choices', () => {
// its project, which the old splitFocused route could not.
const createDetachedDispatchAgent = vi.fn(async () => undefined)
const workspace = {
- activeTab: { id: 'tab-1', title: 'Project', focusedSessionId: 'parent', root: { type: 'leaf', sessionId: 'parent' } },
- dispatchMode: { focusedSessionId: 'parent' },
+ activeTab: { id: 'tab-1', title: 'Project' },
+ stage: { lanes: [{ selectedSessionId: 'parent' }], rows: [{ length: 1 }], focusedLane: 0 },
state: {
activeTabId: 'tab-1',
- tabs: [{ id: 'tab-1', title: 'Project', focusedSessionId: 'parent', root: { type: 'leaf', sessionId: 'parent' } }],
- sessions: { parent: { cwd: '/project', kind: 'claude' } },
+ tabs: [{ id: 'tab-1', title: 'Project' }],
+ sessions: { parent: { cwd: '/project', kind: 'claude', projectId: 'tab-1', joinedAt: 0 } },
},
createDetachedDispatchAgent,
createLinkedAgent: vi.fn(),
@@ -89,7 +81,7 @@ describe('NewAgentPlacementOverlay OpenCode runtime choices', () => {
render(
,
+ linkedAgentParentId={null} projectIntent={projectIntent} />,
)
fireEvent.click(screen.getByText('Terminal').closest('button')!)
expect(createDetachedDispatchAgent).toHaveBeenCalledWith({ kind: 'terminal', providerRuntime: undefined }, projectIntent)
diff --git a/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.tsx b/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.tsx
index 9059db8fe..d61a5c999 100644
--- a/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.tsx
+++ b/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.tsx
@@ -1,58 +1,50 @@
-import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind'
-import type { AgentProviderRuntime } from '@shared/types/providerKind'
+import { isAgentProviderKind } from '@shared/types/providerKind'
import { Button } from '@renderer/components/ui/button'
import { useEffect, useMemo, useRef, useState } from 'react'
-import {
- buildPlacementTargets,
- defaultPlacementTargetId,
- placementTargetIdForArrow,
-} from '@renderer/features/workspace/lib/newAgentPlacement'
-import type { PlacementTarget } from '@renderer/features/workspace/lib/newAgentPlacement'
import type {
SessionId,
- SessionKind,
SessionSpawnSelection,
TabId,
} from '@renderer/workspace/types'
import type { Workspace } from '@renderer/workspace/workspaceStore'
-import type { DispatchAttachIntent } from '@renderer/app-state/uiShell/types'
import {
SESSION_SPAWN_CHOICES,
type AgentProviderChoice,
} from '@renderer/workspace/providerChoices'
+// New Agent… — a kind picker. Pick what to create; it lands in the pool.
+//
+// WHY there is no placement step any more (#992): this overlay used to be two
+// screens. After the kind picker came a geometric placement step — arrows
+// chose "left of the focused pane" or "new outer column" and Enter split the
+// tile tree there — plus a third "attach a detached session to the grid" mode
+// that reused the same step. All of that was tree geometry. The stage has no
+// tree: a new session joins its project's pool, and where it shows is the
+// lane the creator resolves from current focus (see
+// `createDetachedDispatchAgent` / `resolveDispatchSpawnTarget`). So the
+// overlay is exactly what Dispatch already used: one screen, one Enter.
+//
+// The file keeps its historical name because MainSurface, the uiShell flags
+// (`newAgentPlacementOpen`), the command (`new-agent`) and tests all speak
+// it; renaming is cleanup-stage work, not a behavior change.
+
type Props = {
open: boolean
workspace: Workspace
onClose: () => void
- /**
- * Non-null = "attach detached session to grid" mode. The overlay
- * skips the kind picker (the session already exists) and goes
- * straight to placement-target selection. On Enter the chosen target
- * is fed to attachDetachedToGrid instead of commitNewAgentPlacement.
- *
- * WHY this is a prop and not internal overlay state: opening the
- * overlay in attach mode is a workspace-level intent (driven by a
- * command palette entry), so the source of truth lives in the
- * uiShell store and gets passed in. That keeps the close handler in
- * App.tsx — same place that closes the create-mode overlay — so
- * Escape, click-outside, and post-commit close all converge there.
- */
- attachIntent: DispatchAttachIntent | null
/**
* Non-null = "Linked Agent" mode. The value is the parent session
* id. The overlay shows only agent choices — including the separate
- * OpenCode/OpenCode Terminal runtime choices — with no
- * placement step — and on pick calls
- * `createLinkedAgent(kind, parentId)`. Like attach mode this is a
- * uiShell-level intent passed in, so App.tsx owns the close path.
+ * OpenCode/OpenCode Terminal runtime choices — and on pick calls
+ * `createLinkedAgent(kind, parentId)`. This is a uiShell-level intent
+ * passed in, so the caller owns the close path.
*/
linkedAgentParentId: SessionId | null
/**
- * Non-null = the Dispatch project header's "+" opened this, and the new
- * agent must land in that project rather than the focused one. Carries a
- * session from the clicked group as a cwd anchor — see the field's WHY in
+ * Non-null = a project header's "+" opened this, and the new agent must
+ * land in that project rather than the focused one. Carries a session from
+ * the clicked group as a cwd anchor — see the field's WHY in
* uiShell/types.ts for why both halves are needed.
*/
projectIntent: { tabId: TabId; anchorSessionId: SessionId } | null
@@ -63,63 +55,27 @@ type Props = {
// metadata rather than a hand-written fourth provider kind.
const KIND_OPTIONS = SESSION_SPAWN_CHOICES
-const ARROW_TO_DIRECTION = {
- ArrowLeft: 'left',
- ArrowRight: 'right',
- ArrowUp: 'up',
- ArrowDown: 'down',
-} as const
-
export function NewAgentPlacementOverlay({
open,
workspace,
onClose,
- attachIntent,
linkedAgentParentId,
projectIntent,
}: Props) {
- // Attach mode is "user wants to move this existing detached session
- // into the grid." The overlay still does placement, just no spawn.
- // We compute it once at the top so every downstream branch reads
- // from the same value rather than null-checking the prop everywhere.
- const attachMode = attachIntent !== null
- // Linked mode is "spawn a new agent linked to a parent." Kind-only:
- // no placement step at all (the linked agent is always a detached
- // dispatch agent in the parent's tab — see createLinkedAgent).
const linkedMode = linkedAgentParentId !== null
- const overlayRef = useRef(null)
const [selectedIndex, setSelectedIndex] = useState(0)
- const [selectedKind, setSelectedKind] = useState(null)
- const [selectedProviderRuntime, setSelectedProviderRuntime] = useState()
- const [selectedTargetId, setSelectedTargetId] = useState(null)
- const [bounds, setBounds] = useState({ width: 0, height: 0 })
- // One-shot latch around commitNewAgentPlacement. The commit is async
- // (spawns a session, awaits an IPC round-trip, then calls
- // closeNewAgentPlacement()). Until the close fires, this overlay
- // keeps its `open` prop true and its keydown listener registered —
- // so a user that hits Enter twice in quick succession would fire
- // commit twice, spawning a second unwanted agent. A ref (not state)
- // because the latch needs to gate the synchronous keydown handler
- // path, not trigger a re-render.
+ // One-shot latch around the spawn. Creation is async (spawns a session,
+ // awaits an IPC round-trip, then closes the overlay). Until the close fires,
+ // this overlay keeps its `open` prop true and its keydown listener
+ // registered — so a user that hits Enter twice in quick succession would
+ // spawn a second unwanted agent. A ref (not state) because the latch needs
+ // to gate the synchronous keydown handler path, not trigger a re-render.
const committingRef = useRef(false)
- const activeTab = workspace.activeTab
- const placementTab = attachIntent
- ? workspace.state.tabs.find(tab => tab.id === attachIntent.targetTabId) ?? null
- : activeTab
- const anchorSessionId = placementTab?.focusedSessionId ?? null
- const dispatchMode = workspace.dispatchMode !== null
- // Both dispatch mode and linked mode are "kind only": pick a kind and spawn
- // immediately off the picker, no placement step — see the `dispatchMode` /
- // `linkedMode` branches inside commitKind below, which is where that
- // behavior actually lives (there is no single merged flag left to read it
- // off of; see the option-filter comment just below for why one kind-only
- // mode now differs from the other).
- //
// Linked mode offers agent providers only: createLinkedAgent's signature
// refuses 'terminal' (a shell cannot be an orchestration/linked child).
- // Dispatch offers Terminal too (#865): Dispatch terminals have been full
- // detached rows since #671, and the old "no terminal option" note predated it.
+ // Ordinary creation offers Terminal too (#865): terminals are full pool
+ // sessions since #671.
const kindOptions = useMemo(
() => linkedMode
? KIND_OPTIONS.filter((option): option is AgentProviderChoice =>
@@ -129,25 +85,19 @@ export function NewAgentPlacementOverlay({
[linkedMode],
)
- // Commit a chosen kind. In kind-only modes this spawns immediately;
- // in ordinary create mode it advances to the placement step. Shared
- // by the Enter keybind and the click handler so both paths behave
- // identically (the click path used to just `setSelectedKind`, which
- // silently did nothing in dispatch mode).
+ // Shared by the Enter keybind and the click handler so both paths behave
+ // identically.
const commitKind = (selection: SessionSpawnSelection) => {
const { kind, providerRuntime } = selection
+ if (committingRef.current) return
if (linkedMode && linkedAgentParentId) {
// WHY the runtime narrow: `SessionKind` includes 'terminal', which
- // createLinkedAgent's signature refuses. The kind picker filters options
- // to `AgentProviderKind` whenever `linkedMode` is true (see kindOptions
- // above — Dispatch stopped filtering this way when it gained a Terminal
- // option, #865, but linked mode still does), so in practice this branch
- // only fires with an agent provider — but the event handler is typed
+ // createLinkedAgent's signature refuses. kindOptions is already filtered
+ // to agent providers in linked mode, but the event handler is typed
// against the broader union. Route through the registry predicate
// instead of a hand-written pair so adding a provider does not silently
// drop it here again (#394 phase 4).
if (!isAgentProviderKind(kind)) return
- if (committingRef.current) return
committingRef.current = true
void workspace.createLinkedAgent({ kind, providerRuntime }, linkedAgentParentId)
// createLinkedAgent does not own the overlay lifecycle (the
@@ -155,371 +105,118 @@ export function NewAgentPlacementOverlay({
onClose()
return
}
- if (dispatchMode) {
- if (committingRef.current) return
- committingRef.current = true
- // Every kind goes through the detached-Dispatch creator, terminals
- // included (#865): it accepts SessionSpawnSelection (control's
- // terminals.create already uses it for shells) and, unlike splitFocused,
- // honors projectIntent, so "+" on a project header files the shell there.
- void workspace.createDetachedDispatchAgent({ kind, providerRuntime }, projectIntent ?? undefined)
- return
- }
- setSelectedKind(kind)
- setSelectedProviderRuntime(providerRuntime)
+ committingRef.current = true
+ // Every kind goes through the one pool creator, terminals included
+ // (#865). It honors projectIntent, so "+" on a project header files the
+ // session there, and it closes this overlay itself once the session is
+ // placed (closeNewAgentPlacement) — which is why onClose is NOT called.
+ void workspace.createDetachedDispatchAgent({ kind, providerRuntime }, projectIntent ?? undefined)
}
useEffect(() => {
if (!open) return
setSelectedIndex(0)
- // In attach mode there is no kind picker — the session already
- // exists. Pre-fill selectedKind with the detached session's kind
- // so the overlay starts on the placement-target step. We pick a
- // sentinel kind for the rendering branch below; it is never read
- // for the attach commit path because attach goes through
- // attachDetachedToGrid which doesn't take a kind argument.
- if (attachMode) {
- const kind = attachIntent
- ? workspace.state.sessions[attachIntent.sessionId]?.kind ?? DEFAULT_PROVIDER
- : 'claude'
- setSelectedKind(kind)
- setSelectedProviderRuntime(
- attachIntent
- ? workspace.state.sessions[attachIntent.sessionId]?.providerRuntime
- : undefined,
- )
- } else {
- setSelectedKind(null)
- setSelectedProviderRuntime(undefined)
- }
- setSelectedTargetId(null)
// Reset the commit latch whenever the overlay re-opens. Otherwise
// a user could open → commit → close → reopen and the second
// session would be suppressed.
committingRef.current = false
- }, [attachIntent, attachMode, open, workspace.state.sessions])
-
- useEffect(() => {
- if (!open) return
- const element = overlayRef.current
- if (!element) return
- const update = () => {
- setBounds({ width: element.clientWidth, height: element.clientHeight })
- }
- update()
- const observer = new ResizeObserver(update)
- observer.observe(element)
- return () => observer.disconnect()
}, [open])
- const placementTargets = useMemo(() => {
- if (!open || !placementTab || !anchorSessionId || !selectedKind) return []
- if (bounds.width <= 0 || bounds.height <= 0) return []
- return buildPlacementTargets(
- placementTab.root,
- anchorSessionId,
- { x: 0, y: 0, width: bounds.width, height: bounds.height },
- )
- }, [anchorSessionId, bounds.height, bounds.width, open, placementTab, selectedKind])
-
- useEffect(() => {
- if (!selectedKind || !anchorSessionId) return
- if (placementTargets.length === 0) {
- setSelectedTargetId(null)
- return
- }
- setSelectedTargetId(prev => (
- prev && placementTargets.some(target => target.id === prev)
- ? prev
- : defaultPlacementTargetId(placementTargets, anchorSessionId)
- ))
- }, [anchorSessionId, placementTargets, selectedKind])
-
- const placementTarget = useMemo(
- () => placementTargets.find(target => target.id === selectedTargetId) ?? null,
- [placementTargets, selectedTargetId],
- )
-
useEffect(() => {
if (!open) return
- const handledPickerKeys = new Set(['Escape', 'ArrowUp', 'ArrowDown', 'Enter'])
- const handledPlacementKeys = new Set([
- 'Escape', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Backspace', 'Enter',
- ])
-
+ const handled = new Set(['Escape', 'ArrowUp', 'ArrowDown', 'Enter'])
const onKeyDown = (event: KeyboardEvent) => {
- if (!selectedKind) {
- if (!handledPickerKeys.has(event.key)) return
- event.stopPropagation()
- if (event.key === 'Escape') {
- event.preventDefault()
- onClose()
- return
- }
- if (event.key === 'ArrowUp') {
- event.preventDefault()
- setSelectedIndex(prev => (prev + kindOptions.length - 1) % kindOptions.length)
- return
- }
- if (event.key === 'ArrowDown') {
- event.preventDefault()
- setSelectedIndex(prev => (prev + 1) % kindOptions.length)
- return
- }
- if (event.key === 'Enter') {
- event.preventDefault()
- const option = kindOptions[selectedIndex]
- if (!option) return
- commitKind(option)
- }
- return
- }
-
- if (!handledPlacementKeys.has(event.key)) return
- if (event.key === 'Enter' && !placementTarget) return
+ if (!handled.has(event.key)) return
event.stopPropagation()
-
+ event.preventDefault()
if (event.key === 'Escape') {
- event.preventDefault()
onClose()
return
}
- if (event.key === 'Backspace') {
- event.preventDefault()
- if (anchorSessionId) {
- setSelectedTargetId(defaultPlacementTargetId(placementTargets, anchorSessionId))
- }
+ if (event.key === 'ArrowUp') {
+ setSelectedIndex(prev => (prev + kindOptions.length - 1) % kindOptions.length)
return
}
- if (
- event.key === 'ArrowLeft' ||
- event.key === 'ArrowRight' ||
- event.key === 'ArrowUp' ||
- event.key === 'ArrowDown'
- ) {
- event.preventDefault()
- // WHY direct arrow mapping instead of nearest-rectangle navigation:
- //
- // The target set intentionally contains two different operations:
- // split the focused pane, or wrap the whole root. Rendering every
- // target as a clickable rectangle made those operations overlap, and
- // center-distance navigation could jump from a local split to an
- // unrelated outer row because a large half-screen target happened to
- // be closer. Plain arrows now mean "place relative to the focused
- // pane"; Shift+arrow means "place relative to the whole tab." That
- // keeps the operations explicit and makes the preview the only visual
- // source of truth.
- const arrow = ARROW_TO_DIRECTION[event.key]
- const scope = event.shiftKey ? 'global' : 'local'
- // Arrow placement is anchor-relative; without an anchor in the
- // active tab there is nothing to place beside. The visibility
- // guard at the bottom of the component already prevents render
- // in that case, but the keydown handler is wired at document
- // level so this branch can still fire while we're in a
- // transient state — null-guard explicitly so the typechecker
- // is happy and the runtime is safe.
- if (!anchorSessionId) return
- setSelectedTargetId(placementTargetIdForArrow(
- placementTargets,
- anchorSessionId,
- arrow,
- scope,
- ))
+ if (event.key === 'ArrowDown') {
+ setSelectedIndex(prev => (prev + 1) % kindOptions.length)
return
}
- if (event.key === 'Enter' && placementTarget) {
- event.preventDefault()
- // Latch against double-commit. commitNewAgentPlacement is a
- // multi-step async: spawn() → setState → closeNewAgentPlacement.
- // The overlay stays mounted/open until close fires, so a rapid
- // second Enter would commit again and spawn a second session
- // the user didn't ask for. Skipping here keeps the first commit
- // the authoritative one; the reset in the `open` effect clears
- // the latch the next time the overlay opens.
- if (committingRef.current) return
- committingRef.current = true
- if (attachMode && attachIntent) {
- // Attach may wake a post-restart parked backend before the state move.
- // Fire-and-forget here because the action owns failure toasts and
- // refuses to insert a dead leaf if wake fails.
- // We close the overlay ourselves because attachDetachedToGrid
- // doesn't own that lifecycle (closeNewAgentPlacement is the
- // create-mode close; the parent owns onClose for both modes).
- void workspace.attachDetachedToGrid(
- attachIntent.sessionId,
- attachIntent.targetTabId,
- placementTarget,
- )
- onClose()
- return
- }
- void workspace.commitNewAgentPlacement({
- kind: selectedKind,
- providerRuntime: selectedProviderRuntime,
- }, placementTarget)
- }
+ const option = kindOptions[selectedIndex]
+ if (option) commitKind(option)
}
-
document.addEventListener('keydown', onKeyDown, true)
return () => document.removeEventListener('keydown', onKeyDown, true)
- }, [
- anchorSessionId,
- attachIntent,
- attachMode,
- dispatchMode,
- kindOptions,
- onClose,
- open,
- placementTarget,
- placementTargets,
- selectedIndex,
- selectedKind,
- selectedProviderRuntime,
- workspace,
- ])
+ // commitKind closes over props already listed here; listing the function
+ // itself would re-register the listener on every render.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [kindOptions, linkedAgentParentId, onClose, open, projectIntent, selectedIndex, workspace])
- // Visibility rules:
- // - create mode in grid: needs an anchor (focused leaf to place beside)
- // - create mode in dispatch: no placement, just kind picker
- // - attach mode: needs an anchor too (placement targets are computed
- // relative to the focused grid pane). If the active tab has no
- // leaves, the user has to add a pane first — refused at the
- // command-palette `when` check, but guarded here as well.
- if (!open || !placementTab) return null
- if (attachMode && !anchorSessionId) return null
- if (!attachMode && !dispatchMode && !anchorSessionId) return null
+ // A project must exist to own the new session; WelcomeEmpty covers the
+ // no-project boot, so this overlay simply does not render there.
+ if (!open || !workspace.activeTab) return null
return (
{
- // Only a click on the backdrop ITSELF. A click that bubbled up from
- // the kind picker must not also dismiss the overlay.
if (event.target !== event.currentTarget) return
onClose()
}}
>
- {selectedKind && placementTarget && (
-
- )}
-
- {!selectedKind ? (
-
{dispatchMode ? 'Choose dispatch agent type with ↑/↓ and press Enter' : 'Choose agent type with ↑/↓ and press Enter'}
- ) : (
-
-
- {attachMode
- ? 'Attach detached agent to grid'
- : `${KIND_OPTIONS.find(option => option.kind === selectedKind)?.label} placement`}
-
-
- Arrows split the focused pane. Shift+arrows add an outer row or column.
-
-
- Target: {placementTarget?.label ?? 'none'}
-
-
- )}
+ Choose agent type with ↑/↓ and press Enter
- {/* The hint box is pointer-events-none so it never blocks the grid
- underneath, so the Cancel has to re-enable pointer events on
- itself. Placed here rather than in the kind picker because the
- placement step is exactly the state that had no way out. */}
- {selectedKind ? (
-
- Cancel
-
- ) : null}
- {!selectedKind && (
- // pointer-events-none on the CENTERING layer, re-enabled on the card
- // itself. Without this the layer is `absolute inset-0` and covers the
- // whole backdrop, so the backdrop's click-to-dismiss could never fire
- // (event.target was always this div, never the backdrop). In Dispatch
- // and linked-agent mode that was fatal rather than annoying: those are
- // kind-only, so `selectedKind` never becomes truthy, the Cancel button
- // below never renders, and the overlay had ZERO mouse exits — the only
- // way out with a mouse was to create an agent you did not want. The
- // Dispatch "+" leads straight here, so it would have shipped a button
- // whose only destination is a trap.
-
-
-
- New Agent
-
-
- {kindOptions.map((option, index) => {
- const active = index === selectedIndex
- return (
- {
- setSelectedIndex(index)
- commitKind(option)
- }}
- className={`flex w-full items-center justify-between border px-3 py-2 text-left ${
- active
- ? 'border-accent bg-accent text-accent-fg'
- : 'border-border bg-canvas text-ink-dim hover:border-border-hi hover:text-ink'
- }`}
- >
- {option.label}
-
- {option.description}
-
-
- )
- })}
-
- {/* An explicit Cancel on the kind step too. The backdrop click
- above is now reachable, but a visible control is what a
- mouse-first user actually looks for — and this is the only step
- Dispatch and linked-agent mode ever show. */}
-
-
- Cancel
-
-
+ {/* pointer-events-none on the CENTERING layer, re-enabled on the card
+ itself. Without this the layer is `absolute inset-0` and covers the
+ whole backdrop, so the backdrop's click-to-dismiss could never fire
+ (event.target was always this div, never the backdrop) and the
+ overlay had ZERO mouse exits — the only way out with a mouse was to
+ create an agent you did not want. */}
+
+
+
+ New Agent
+
+
+ {kindOptions.map((option, index) => {
+ const active = index === selectedIndex
+ return (
+ {
+ setSelectedIndex(index)
+ commitKind(option)
+ }}
+ className={`flex w-full items-center justify-between border px-3 py-2 text-left ${
+ active
+ ? 'border-accent bg-accent text-accent-fg'
+ : 'border-border bg-canvas text-ink-dim hover:border-border-hi hover:text-ink'
+ }`}
+ >
+ {option.label}
+
+ {option.description}
+
+
+ )
+ })}
+
+ {/* A visible Cancel: the backdrop click is reachable, but a control
+ is what a mouse-first user actually looks for. */}
+
+
+ Cancel
+
- )}
+
)
}
diff --git a/src/renderer/src/features/workspace/ui/ProviderSwitchPickerModal.renderer.test.tsx b/src/renderer/src/features/workspace/ui/ProviderSwitchPickerModal.renderer.test.tsx
index ff2ff4398..f52a7894c 100644
--- a/src/renderer/src/features/workspace/ui/ProviderSwitchPickerModal.renderer.test.tsx
+++ b/src/renderer/src/features/workspace/ui/ProviderSwitchPickerModal.renderer.test.tsx
@@ -10,23 +10,22 @@ function harness() {
const workspace = {
state: {
activeTabId: 'other-tab',
- dispatchMode: { focusedSessionId: 'other-agent', scope: 'global' },
+ // The user is commanding `other-agent` — deliberately NOT the captured
+ // one, which is the point of this suite.
+ stage: { lanes: [{ selectedSessionId: 'other-agent' }], rows: [{ length: 1 }], focusedLane: 0 },
+ pinnedSessionIds: [],
sessions: {
- 'captured-agent': { cwd: '/projects/captured', kind: 'claude' },
- 'other-agent': { cwd: '/projects/other', kind: 'codex' },
+ 'captured-agent': { cwd: '/projects/captured', kind: 'claude', projectId: 'captured-tab', joinedAt: 0 },
+ 'other-agent': { cwd: '/projects/other', kind: 'codex', projectId: 'other-tab', joinedAt: 0 },
},
tabs: [
{
id: 'captured-tab',
title: 'Captured',
- focusedSessionId: 'captured-agent',
- root: { type: 'leaf', sessionId: 'captured-agent' },
},
{
id: 'other-tab',
title: 'Other',
- focusedSessionId: 'other-agent',
- root: { type: 'leaf', sessionId: 'other-agent' },
},
],
},
diff --git a/src/renderer/src/features/workspace/ui/StarterHintCard.renderer.test.tsx b/src/renderer/src/features/workspace/ui/StarterHintCard.renderer.test.tsx
new file mode 100644
index 000000000..5cbf88dac
--- /dev/null
+++ b/src/renderer/src/features/workspace/ui/StarterHintCard.renderer.test.tsx
@@ -0,0 +1,104 @@
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+
+import { useAppStore } from '@renderer/app-state/store'
+import { StarterHintCard, starterCardVisibleForAgent } from './StarterHintCard'
+
+const original = useAppStore.getState()
+afterEach(() => { cleanup(); useAppStore.setState(original, true) })
+
+// The starter card (#992 §4.6) — the Neovim-style "now what?" card the
+// operator asked to bundle into this change. What these cases pin is the
+// REGISTRY-DRIVEN contract, because that is the property that keeps the card
+// true: every chord is read from the same resolution the router performs, so
+// a rebinding user sees their chord and a default change can never leave the
+// card lying. Hardcoded chord strings would be a plan failure — and this
+// suite is the tripwire.
+
+describe('StarterHintCard', () => {
+ it('shows the eight fresh-agent slots with live default chords', () => {
+ render(
)
+ const card = document.querySelector('[data-starter-card]')!
+
+ // Live defaults, through the registry — not literals copied into this
+ // file (the `⌘1–9` range excepted: the digit grammar is a reserved
+ // interaction, and its row resolves through the reservation table).
+ expect(card.textContent).toContain('⌘⇧P')
+ expect(card.textContent).toContain('Command Palette')
+ expect(card.textContent).toContain('⌘N')
+ expect(card.textContent).toContain('New Agent')
+ expect(card.textContent).toContain('⌥← / ⌥→')
+ expect(card.textContent).toContain('Focus Lane')
+ expect(card.textContent).toContain('⌘1–9')
+ expect(card.textContent).toContain('Fill Lane')
+ expect(card.textContent).toContain('⌥S')
+ expect(card.textContent).toContain('Spotlight')
+ expect(card.textContent).toContain('⌥⌫')
+ expect(card.textContent).toContain('Clear Lane')
+ })
+
+ it('renders unbound commands title-only, not with an invented chord', () => {
+ // New Lane and New Row ship no default binding; the honest card names
+ // them without a chord (Settings is where bindings are made). The
+ // tripwire: any ⌥L-style invention here is the "second source of truth"
+ // failure the plan names.
+ render(
)
+ const card = document.querySelector('[data-starter-card]')!
+ expect(card.textContent).toContain('New Lane')
+ expect(card.textContent).toContain('New Row')
+ expect(card.textContent).not.toContain('⌥L')
+ })
+
+ it('shows the USER chord when a command is rebound', () => {
+ useAppStore.setState({
+ settings: {
+ ...original.settings,
+ commandKeybindingOverrides: { 'clear-focused-lane': ['Ctrl+Alt+Backspace'] },
+ },
+ } as never)
+ render(
)
+ // Row-scoped, not textContent-wide: '⌃⌥⌫' contains the substring '⌥⌫',
+ // so the honest assertion is what THE CLEAR LANE ROW shows.
+ const clearLaneRow = screen.getByText('Clear Lane').parentElement!
+ // ⌃⌥⌫ contains ⌥⌫ as a substring, so assert the row's kbd EXACTLY.
+ const kbd = clearLaneRow.querySelector('kbd')!
+ expect(kbd.textContent).toBe('⌃⌥⌫')
+ })
+
+ it('shows exactly the four placement-flavored slots in the empty-lane variant', () => {
+ render(
)
+ const card = document.querySelector('[data-starter-card]')!
+ expect(card.textContent).toContain('Fill Lane')
+ expect(card.textContent).toContain('New Lane')
+ expect(card.textContent).toContain('Command Palette')
+ expect(card.textContent).toContain('⌥↑ / ⌥↓')
+ // The pair is named for the gesture, not for one half of it (#1013
+ // review B: this row read "Select Previous Agent ⌥↑ / ⌥↓").
+ expect(card.textContent).toContain('Select Agent')
+ expect(card.textContent).not.toContain('Select Previous Agent')
+ // The fresh-agent-only slots stay out: an empty lane has no agent yet,
+ // so Clear Lane and Spotlight answer questions this lane cannot ask.
+ expect(card.textContent).not.toContain('Clear Lane')
+ expect(card.textContent).not.toContain('Spotlight')
+ expect(card.textContent).not.toContain('New Row')
+ })
+})
+
+describe('starterCardVisibleForAgent', () => {
+ it('is true for an agent with no user turn yet, and false the moment one exists', () => {
+ const meta = { kind: 'claude' as const }
+ expect(starterCardVisibleForAgent(meta, [])).toBe(true)
+ expect(starterCardVisibleForAgent(meta, [{ type: 'assistant' }])).toBe(true)
+ // The welcome banner is not a user turn; the first prompt is the event.
+ expect(starterCardVisibleForAgent(meta, [{ type: 'assistant' }, { type: 'user' }])).toBe(false)
+ })
+
+ it('is false for terminals and extension views — the card is a rendered-agent feature', () => {
+ // §4.6: terminal lanes never get the card. (AgentTerminalLeaf never
+ // mounts it structurally; this guards the data path too, so a future
+ // caller cannot reintroduce it by asking.)
+ expect(starterCardVisibleForAgent({ kind: 'terminal' }, [])).toBe(false)
+ expect(starterCardVisibleForAgent({ kind: 'extension-view' }, [])).toBe(false)
+ expect(starterCardVisibleForAgent(undefined, [])).toBe(false)
+ })
+})
diff --git a/src/renderer/src/features/workspace/ui/StarterHintCard.tsx b/src/renderer/src/features/workspace/ui/StarterHintCard.tsx
new file mode 100644
index 000000000..d5667ea2a
--- /dev/null
+++ b/src/renderer/src/features/workspace/ui/StarterHintCard.tsx
@@ -0,0 +1,189 @@
+import { useMemo } from 'react'
+import { useShallow } from 'zustand/react/shallow'
+
+import { useAppStore } from '@renderer/app-state/hooks'
+import { displayKeybinding } from '@shared/keybindings'
+import { resolveEffectiveKeybindings } from '@renderer/features/command-keybindings/resolve'
+import { buildDefaultKeybindings } from '@renderer/features/command-keybindings/defaults'
+import { reservedInteractionBindings } from '@renderer/features/command-keybindings/reservations'
+import { isAgentProviderKind } from '@shared/types/providerKind'
+import type { SessionKind } from '@renderer/workspace/types'
+import { builtInCommandCatalog } from '@renderer/features/command-palette/catalog'
+
+// The starter card (#992 §4.6) — the which-key/starter-dashboard pattern, at
+// the two moments of maximum "now what?": a FRESH agent whose feed shows only
+// the provider welcome, and an EMPTY focused lane.
+//
+// THE CARD IS REGISTRY-DRIVEN, ALWAYS. Every row is a COMMAND ID resolved
+// through the catalog for its title and through `resolveEffectiveKeybindings`
+// for its chord — the same resolution the router performs — so a user who
+// rebinds New Lane sees THEIR chord, and a default-chord change can never
+// leave the card lying. Hardcoded chord strings in this component are a plan
+// failure, not a shortcut; the one non-command entry (the ⌘1–9 fill grammar)
+// resolves through the reservation table for the same reason — it is the
+// registry of things that own chords without being commands.
+//
+// Commands with no default binding render title-only. That is deliberate
+// honesty, not a gap: the shortcuts surface and Settings are where bindings
+// are made, and a card that invented chords would be a second source of truth
+// the day one of them changed.
+
+/**
+ * Whether the FRESH-AGENT card is visible for a session (#992 §4.6): an
+ * agent-kind session whose committed entries hold no user turn yet.
+ *
+ * WHY derived and not stored: the card must vanish by itself the moment the
+ * first prompt lands (an entry arriving is the event), never survive a
+ * restart as stale chrome (restored sessions replay their history into
+ * entries), and never need dismissal state persisted anywhere. Terminal views
+ * never ask — AgentTerminalLeaf does not render the card at all.
+ */
+export function starterCardVisibleForAgent(
+ meta: { kind?: SessionKind | string } | undefined,
+ entries: readonly { type: string }[],
+): boolean {
+ if (!meta || !isAgentProviderKind(meta.kind)) return false
+ return !entries.some(entry => entry.type === 'user')
+}
+
+type Slot =
+ | { commandId: string }
+ /** Two commands that are one gesture in the reader's mind (⌥← / ⌥→). */
+ | { pairCommandIds: [string, string] }
+ /** A chord-owning non-command (the digit grammar). */
+ | { reservationOwner: string; label: string }
+
+// Context A — the eight slots, curated for v1 (§4.6 table), not usage-ranked.
+// Usage-adaptive ranking is a stated follow-up, not v1.
+const FRESH_AGENT_SLOTS: readonly Slot[] = [
+ { commandId: 'open-command-palette' },
+ { commandId: 'new-agent' },
+ { commandId: 'new-tiled-lane' },
+ { commandId: 'new-dispatch-row' },
+ { pairCommandIds: ['dispatch-focus-lane-left', 'dispatch-focus-lane-right'] },
+ { reservationOwner: 'Numbered tab / Dispatch row selection', label: 'Fill Lane from Index' },
+ { commandId: 'toggle-spotlight' },
+ { commandId: 'clear-focused-lane' },
+]
+
+// Context B — the four placement-flavored slots only: fill, grow, escape
+// hatch, and the index walk (§4.6 "Context B shows the four placement-flavored
+// slots only (6, 3, 1, plus the index walk)").
+const EMPTY_LANE_SLOTS: readonly Slot[] = [
+ { reservationOwner: 'Numbered tab / Dispatch row selection', label: 'Fill Lane from Index' },
+ { commandId: 'new-tiled-lane' },
+ { commandId: 'open-command-palette' },
+ { pairCommandIds: ['dispatch-select-previous-agent', 'dispatch-select-next-agent'] },
+]
+
+type CardRow = {
+ key: string
+ label: string
+ /** Display chord, or null when the command has no live binding. */
+ chord: string | null
+}
+
+function buildRow(slot: Slot, titles: Map
, bindings: Map): CardRow | null {
+ if ('commandId' in slot) {
+ const title = titles.get(slot.commandId)
+ // An unresolvable id is a programming error in the slot table, not a
+ // runtime condition: rendering a title-less row would show a bare chord,
+ // which is the card lying. Drop the row; its test fails loudly instead.
+ if (!title) return null
+ const binding = bindings.get(slot.commandId)?.[0]
+ return { key: slot.commandId, label: title, chord: binding ? displayKeybinding(binding) : null }
+ }
+ if ('pairCommandIds' in slot) {
+ const [leftId, rightId] = slot.pairCommandIds
+ const left = titles.get(leftId)
+ const right = titles.get(rightId)
+ if (!left || !right) return null
+ const leftChord = bindings.get(leftId)?.[0]
+ const rightChord = bindings.get(rightId)?.[0]
+ // Pair label names the gesture, not the two commands: "Focus Lane", with
+ // both chords, reads as one idea — the slot table's whole intent.
+ const gesture = pairGesture(left, right)
+ const chord = leftChord && rightChord
+ ? `${displayKeybinding(leftChord)} / ${displayKeybinding(rightChord)}`
+ : leftChord ? displayKeybinding(leftChord) : null
+ return { key: `${leftId}:${rightId}`, label: gesture, chord }
+ }
+ const reserved = reservedInteractionBindings(slot.reservationOwner)
+ // The digit grammar owns ⌘1..⌘9; show the RANGE, because that is how it is
+ // spoken ("⌘1–9"), while the chord strings stay in the table where the
+ // collision checker sees them.
+ const chord = reserved.length > 0 ? '⌘1–9' : null
+ return { key: slot.reservationOwner, label: slot.label, chord }
+}
+
+/**
+ * The words two paired titles share: "Focus Lane Left" + "Focus Lane Right"
+ * gives "Focus Lane", and "Select Previous Agent" + "Select Next Agent" gives
+ * "Select Agent".
+ *
+ * WHY word-by-word, not a trailing-direction regex: the regex only stripped a
+ * LAST word, and the index-walk titles put the direction in the middle, so the
+ * empty-lane card read "Select Previous Agent ⌥↑ / ⌥↓" (#1013 review B).
+ * Titles of different lengths share no positional words to compare, so they
+ * fall back to the left title as written.
+ */
+function pairGesture(left: string, right: string): string {
+ const a = left.split(' ')
+ const b = right.split(' ')
+ if (a.length !== b.length) return left
+ const shared = a.filter((word, index) => word === b[index])
+ return shared.length > 0 ? shared.join(' ') : left
+}
+
+export function StarterHintCard({ variant }: { variant: 'fresh-agent' | 'empty-lane' }) {
+ // LIVE bindings, read through the same store the router's index is built
+ // from: defaults + the user's persisted overrides. Extension-contributed
+ // defaults are deliberately absent — no extension can contribute to these
+ // slots, and folding them in would couple the card to the extension store
+ // for nothing.
+ // `state.settings?.` — optional-chained for the same reason PaneHeader's
+ // store reads are: test harnesses (and the phone stub) mount components
+ // against a minimal store without the settings slice, and a card that
+ // throws there would fail every layout suite it renders inside. Degrading
+ // to "no overrides" shows the shipped defaults, which is true in exactly
+ // those contexts.
+ const overrides = useAppStore(useShallow(state => state.settings?.commandKeybindingOverrides ?? {}))
+ const rows = useMemo(() => {
+ // Titles here are the STATIC ones. A function title (dynamic, per-context
+ // label) has no meaning on a context-free card; dropping it fails the
+ // card's own tests loudly rather than rendering a function reference.
+ // Every slot commands a static title today.
+ const titles = new Map()
+ for (const command of builtInCommandCatalog) {
+ if (typeof command.title === 'string') titles.set(command.id, command.title)
+ }
+ const bindings = new Map(
+ resolveEffectiveKeybindings(overrides).map(entry => [entry.commandId, entry.bindings]),
+ )
+ const slots = variant === 'fresh-agent' ? FRESH_AGENT_SLOTS : EMPTY_LANE_SLOTS
+ return slots.flatMap(slot => {
+ const row = buildRow(slot, titles, bindings)
+ return row ? [row] : []
+ })
+ }, [overrides, variant])
+
+ return (
+
+
+ {rows.map(row => (
+
+ {row.chord ? (
+
+ {row.chord}
+
+ ) : null}
+ {row.label}
+
+ ))}
+
+
+ )
+}
diff --git a/src/renderer/src/features/workspace/ui/closedModalDerivations.renderer.test.tsx b/src/renderer/src/features/workspace/ui/closedModalDerivations.renderer.test.tsx
index 65ce89b3c..b29d26b66 100644
--- a/src/renderer/src/features/workspace/ui/closedModalDerivations.renderer.test.tsx
+++ b/src/renderer/src/features/workspace/ui/closedModalDerivations.renderer.test.tsx
@@ -29,11 +29,15 @@ function workspaceFixture(): Workspace {
tabs: [{
id: 'project-tab',
title: 'Project tab',
- focusedSessionId: 'agent',
- root: { type: 'leaf', sessionId: 'agent' },
}],
- sessions: { agent: { cwd: '/projects/terminal-perf', kind: 'codex' } },
- detachedSessions: {},
+ sessions: { agent: { cwd: '/projects/terminal-perf', kind: 'codex', projectId: 'project-tab', joinedAt: 0 } },
+ // A WHOLE workspace, not just tabs + sessions. The Activity modal's Focus
+ // action and lane column read the stage and pins since #992 (they used to
+ // read the tile tree, which this fixture faked with a `root`). The
+ // `as unknown as Workspace` below means the compiler will not say when
+ // the next field goes missing — the runtime TypeError will.
+ pinnedSessionIds: [],
+ stage: { lanes: [{ selectedSessionId: 'agent' }], rows: [{ length: 1 }], focusedLane: 0 },
},
runtimes: {},
focusSessionInTab: vi.fn(),
diff --git a/src/renderer/src/features/worktrees/lib/loadWorktreeDump.test.ts b/src/renderer/src/features/worktrees/lib/loadWorktreeDump.test.ts
index 8379409ec..edc560982 100644
--- a/src/renderer/src/features/worktrees/lib/loadWorktreeDump.test.ts
+++ b/src/renderer/src/features/worktrees/lib/loadWorktreeDump.test.ts
@@ -14,6 +14,7 @@ import {
} from '@shared/work-context/tracker'
import type { WorktreeActivityState } from '@shared/work-context/types'
import type { GitWorktreeStatus } from '@shared/types/git'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
const MAIN_CHECKOUT = '/fixture/project-1'
const LINKED_WORKTREE = `${MAIN_CHECKOUT}/.worktrees/worktree-1`
@@ -81,16 +82,12 @@ describe('collectLiveAgentsByWorktree recorded context', () => {
tabs: [{
id: 'tab-recorded',
title: 'Recorded project',
- root: { type: 'leaf', sessionId: SESSION_ID },
- focusedSessionId: SESSION_ID,
}],
activeTabId: 'tab-recorded',
- dispatchMode: null,
+ stage: oneLaneStage(SESSION_ID),
sessions: {
- [SESSION_ID]: { cwd: MAIN_CHECKOUT, kind: 'codex' },
+ [SESSION_ID]: { cwd: MAIN_CHECKOUT, kind: 'codex', projectId: 'tab-recorded', joinedAt: 0 },
},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
} as WorkspaceState
const workspace = {
@@ -145,16 +142,12 @@ describe('collectLiveAgentsByWorktree recorded context', () => {
tabs: [{
id: 'tab-divergent',
title: 'Divergent project',
- root: { type: 'leaf', sessionId: SESSION_ID },
- focusedSessionId: SESSION_ID,
}],
activeTabId: 'tab-divergent',
- dispatchMode: null,
+ stage: oneLaneStage(SESSION_ID),
sessions: {
- [SESSION_ID]: { cwd: MAIN_CHECKOUT, kind: 'codex' },
+ [SESSION_ID]: { cwd: MAIN_CHECKOUT, kind: 'codex', projectId: 'tab-divergent', joinedAt: 0 },
},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
} as WorkspaceState
const workspace = {
@@ -178,10 +171,10 @@ describe('collectLiveAgentsByWorktree recorded context', () => {
status(LINKED_WORKTREE, 'fixture/worktree-branch', 'active-unmerged'),
]
const state = {
- tabs: [{ id: 'tab', title: 'Project', root: { type: 'leaf', sessionId: 'shell' }, focusedSessionId: 'shell' }],
- activeTabId: 'tab', dispatchMode: null,
- sessions: { shell: { cwd: LINKED_WORKTREE, kind: 'terminal' } },
- detachedSessions: {}, buried: [], pinnedSessionIds: [],
+ tabs: [{ id: 'tab', title: 'Project' }],
+ activeTabId: 'tab', stage: oneLaneStage('shell'),
+ sessions: { shell: { cwd: LINKED_WORKTREE, kind: 'terminal', projectId: 'tab', joinedAt: 0 } },
+ pinnedSessionIds: [],
} as WorkspaceState
const workspace = {
state,
diff --git a/src/renderer/src/features/worktrees/lib/loadWorktreeDump.ts b/src/renderer/src/features/worktrees/lib/loadWorktreeDump.ts
index e5ba4d8d3..bed758e70 100644
--- a/src/renderer/src/features/worktrees/lib/loadWorktreeDump.ts
+++ b/src/renderer/src/features/worktrees/lib/loadWorktreeDump.ts
@@ -6,6 +6,7 @@ import { matchWorktree } from '@shared/work-context/matching'
import { resolveTabSessions } from '@renderer/workspace/queries'
import type { SessionId, Tab } from '@renderer/workspace/types'
import type { Workspace } from '@renderer/workspace/workspaceStore'
+import { commandTargetSessionIdForState } from '@renderer/workspace/hook/selectors/commandTargetSessionId'
export type WorktreeLiveAgent = {
sessionId: SessionId
@@ -121,12 +122,13 @@ export function collectLiveAgentsByWorktree(
detached: w.detached,
}))
const byPath = new Map()
- // resolveTabSessions covers BOTH grid leaves and detached Dispatch
- // agents for the tab. The previous implementation walked grid only,
- // so a Claude/Codex agent running in a worktree but parked in
- // Dispatch was missing from this tab's row — even though it was
- // genuinely live and consuming the worktree. The "live agents per
- // worktree" view needs the union, not the visible-grid subset.
+ // resolveTabSessions is every session of the project, on a lane or parked.
+ // An agent running in a worktree with no lane showing it is genuinely live
+ // and consuming that worktree, so this view needs all of them.
+ // "Focused" is the agent the user is commanding: the focused lane's
+ // occupant. (It was each tab's tile-tree focus until #992, so one row per
+ // project could claim it at once.)
+ const focusedSessionId = commandTargetSessionIdForState(workspace.state)
workspace.state.tabs.forEach((tab: Tab) => {
for (const sessionId of resolveTabSessions(workspace.state, tab.id)) {
const meta = workspace.state.sessions[sessionId]
@@ -152,7 +154,7 @@ export function collectLiveAgentsByWorktree(
kind,
tabTitle: tab.title,
live: Boolean(runtime?.sessionStatus === 'running' || runtime?.streamPhase !== 'idle'),
- focused: tab.focusedSessionId === sessionId,
+ focused: focusedSessionId === sessionId,
})
byPath.set(matched.path, rows)
}
diff --git a/src/renderer/src/lib/undoClose.test.ts b/src/renderer/src/lib/undoClose.test.ts
index 94a81646a..c0f51c424 100644
--- a/src/renderer/src/lib/undoClose.test.ts
+++ b/src/renderer/src/lib/undoClose.test.ts
@@ -1,120 +1,125 @@
import { describe, expect, it } from 'vitest'
-import { UndoCloseStack, remapClosedEntryLineage, remapMetaLineage } from './undoClose'
-import type { ClosedDetached, ClosedGroup, ClosedPane, ClosedTab, UndoLineage } from './undoClose'
-import type { DetachedSessionRecord, SessionMeta } from '@renderer/workspace/types'
+import {
+ UNDO_CLOSE_MAX_ENTRIES,
+ UNDO_CLOSE_RETENTION_MS,
+ UndoCloseStack,
+ remapClosedEntryLineage,
+ remapMetaLineage,
+} from './undoClose'
+import type { ClosedGroup, ClosedSession, ClosedTab, UndoLineage } from './undoClose'
+import type { SessionMeta } from '@renderer/workspace/types'
// Undo lineage (#886 review round 1 finding 4; coverage asked for in round 2 N3).
//
// A restore respawns under new ids, and entries still waiting on the stack were
// captured against the old ones. The contract pinned here is narrow on purpose:
-// rewrite only ANCHORS (where an entry belongs, and the relationship pointers
-// its respawned session will carry), keep ids the restore did not touch, and
-// never rewrite an entry's OWN closed ids — those sessions are dead, and the
-// entry is the only thing that will ever bring them back.
+// rewrite only ANCHORS (the project an entry returns to, and the relationship
+// pointers its respawned session will carry), keep ids the restore did not
+// touch, and never rewrite an entry's OWN closed ids — those sessions are dead,
+// and the entry is the only thing that will ever bring them back.
+//
+// (Until #992 these cases covered three tree-era shapes: a split pane anchored
+// on a sibling leaf, a Dispatch row anchored on a record and an optional
+// promoted survivor, and a tab carrying a tile tree. A session's whole
+// placement is `projectId` + `joinedAt` now, so there are two shapes.)
const lineage: UndoLineage = {
sessions: new Map([
['parent', 'parent-2'],
- ['sibling', 'sibling-2'],
- ['survivor', 'survivor-2'],
// Present only to prove an entry's own closed id is never rewritten.
['closed', 'must-not-appear'],
]),
tabs: new Map([['tab', 'tab-2']]),
}
-const meta = (patch: Partial = {}): SessionMeta => ({ cwd: '/project', kind: 'codex', ...patch })
-
-const row = (sessionId: string, projectTabId: string): DetachedSessionRecord => ({
- sessionId, surface: 'dispatch', projectTabId, projectTabTitle: 'Project', projectTabIndex: 0, detachedAt: 7,
-})
+const meta = (patch: Partial = {}): SessionMeta =>
+ ({ cwd: '/project', kind: 'codex', projectId: 'tab', joinedAt: 7, ...patch })
describe('remapClosedEntryLineage', () => {
- it('re-anchors a pane on its sibling and tab, and its parent pointer, but not its own id', () => {
- const pane: ClosedPane = {
- type: 'pane', closedAt: 1, tabId: 'tab', sessionId: 'closed',
+ it('re-anchors a session on its restored project and parent, but not its own id or position', () => {
+ const entry: ClosedSession = {
+ type: 'session', closedAt: 1, sessionId: 'closed',
sessionMeta: meta({ linkedParentId: 'parent' }),
- direction: 'vertical', ratio: 0.5, side: 'a', siblingLeafId: 'sibling',
- }
- expect(remapClosedEntryLineage(pane, lineage)).toEqual({
- ...pane, tabId: 'tab-2', siblingLeafId: 'sibling-2', sessionMeta: meta({ linkedParentId: 'parent-2' }),
- })
- })
-
- it('re-anchors a Dispatch row on its project and promoted survivor, but not its own record id', () => {
- const detached: ClosedDetached = {
- type: 'detached', closedAt: 1,
- sessionMeta: meta({ orchestrationParentId: 'parent', orchestrationRootId: 'parent' }),
- record: row('closed', 'tab'),
- replacedRoot: row('survivor', 'tab'),
}
- expect(remapClosedEntryLineage(detached, lineage)).toEqual({
- ...detached,
- sessionMeta: meta({ orchestrationParentId: 'parent-2', orchestrationRootId: 'parent-2' }),
- record: row('closed', 'tab-2'),
- replacedRoot: row('survivor-2', 'tab-2'),
+ expect(remapClosedEntryLineage(entry, lineage)).toEqual({
+ ...entry,
+ sessionMeta: meta({ linkedParentId: 'parent-2', projectId: 'tab-2' }),
})
})
- it('re-points a closed tab\'s relationship pointers but never its own leaves, keys or rows', () => {
- const tab: ClosedTab = {
- type: 'tab', closedAt: 1, tabIndex: 0,
- tab: { id: 'tab', title: 'Project', root: { type: 'leaf', sessionId: 'closed' }, focusedSessionId: 'closed' },
- sessionMetas: { closed: meta({ linkedParentId: 'parent' }) },
- detachedEntries: [{ sessionId: 'closed', meta: meta({ linkedParentId: 'parent' }), detachedAt: 3 }],
+ it('re-points a closed project s relationship pointers but never its own ids', () => {
+ // The tab's own id is NOT remapped: restoring this entry is what would
+ // mint its replacement, and its sessions' `projectId` is overwritten then.
+ const entry: ClosedTab = {
+ type: 'tab', closedAt: 1, tab: { id: 'tab', title: 'Project' }, tabIndex: 0,
+ sessions: [
+ { sessionId: 'closed', meta: meta({ orchestrationParentId: 'parent', orchestrationRootId: 'parent' }) },
+ { sessionId: 'other', meta: meta({ joinedAt: 9 }) },
+ ],
}
- expect(remapClosedEntryLineage(tab, lineage)).toEqual({
- ...tab,
- sessionMetas: { closed: meta({ linkedParentId: 'parent-2' }) },
- detachedEntries: [{ sessionId: 'closed', meta: meta({ linkedParentId: 'parent-2' }), detachedAt: 3 }],
+ const remapped = remapClosedEntryLineage(entry, lineage) as ClosedTab
+ expect(remapped.tab.id).toBe('tab')
+ expect(remapped.sessions.map(member => member.sessionId)).toEqual(['closed', 'other'])
+ expect(remapped.sessions[0]!.meta).toMatchObject({
+ orchestrationParentId: 'parent-2', orchestrationRootId: 'parent-2',
})
})
it('keeps ids the restore did not touch', () => {
- const detached: ClosedDetached = {
- type: 'detached', closedAt: 1,
- sessionMeta: meta({ linkedParentId: 'unrelated-parent' }),
- record: row('other', 'unrelated-tab'),
- replacedRoot: row('unrelated-survivor', 'unrelated-tab'),
+ const entry: ClosedSession = {
+ type: 'session', closedAt: 1, sessionId: 'closed',
+ sessionMeta: meta({ linkedParentId: 'someone-else', projectId: 'another-tab' }),
}
- expect(remapClosedEntryLineage(detached, lineage)).toEqual(detached)
+ expect(remapClosedEntryLineage(entry, lineage)).toEqual(entry)
})
it('re-anchors every member of a group', () => {
const group: ClosedGroup = {
type: 'group', closedAt: 1,
entries: [
- { type: 'detached', closedAt: 1, sessionMeta: meta({ linkedParentId: 'parent' }), record: row('closed', 'tab') },
- {
- type: 'pane', closedAt: 1, tabId: 'tab', sessionId: 'closed', sessionMeta: meta(),
- direction: 'horizontal', ratio: 0.5, side: 'b', siblingLeafId: 'sibling',
- },
+ { type: 'session', closedAt: 1, sessionId: 'c1', sessionMeta: meta({ linkedParentId: 'parent' }) },
+ { type: 'session', closedAt: 1, sessionId: 'c2', sessionMeta: meta({ projectId: 'another-tab' }) },
],
}
- expect(remapClosedEntryLineage(group, lineage)).toMatchObject({
- type: 'group',
- entries: [
- { sessionMeta: { linkedParentId: 'parent-2' }, record: { sessionId: 'closed', projectTabId: 'tab-2' } },
- { sessionId: 'closed', tabId: 'tab-2', siblingLeafId: 'sibling-2' },
- ],
- })
+ const remapped = remapClosedEntryLineage(group, lineage) as ClosedGroup
+ expect(remapped.entries.map(entry => (entry as ClosedSession).sessionMeta.projectId)).toEqual(['tab-2', 'another-tab'])
+ expect((remapped.entries[0] as ClosedSession).sessionMeta.linkedParentId).toBe('parent-2')
})
})
describe('remapMetaLineage', () => {
it('returns the same object when no pointer changes, so untouched metadata keeps its identity', () => {
- const untouched = meta({ linkedParentId: 'unrelated-parent' })
+ const untouched = meta({ linkedParentId: 'someone-else' })
expect(remapMetaLineage(untouched, lineage.sessions)).toBe(untouched)
expect(remapMetaLineage(untouched, undefined)).toBe(untouched)
})
})
-describe('UndoCloseStack.remapLineage', () => {
+describe('UndoCloseStack', () => {
+ const entry = (sessionId: string, closedAt: number): ClosedSession =>
+ ({ type: 'session', closedAt, sessionId, sessionMeta: meta() })
+
it('rewrites the anchors of every entry still waiting', () => {
const stack = new UndoCloseStack(() => 10)
- stack.push({ type: 'detached', closedAt: 5, sessionMeta: meta({ linkedParentId: 'parent' }), record: row('closed', 'tab') })
+ stack.push({ type: 'session', closedAt: 5, sessionId: 'closed', sessionMeta: meta({ linkedParentId: 'parent' }) })
stack.remapLineage(lineage)
- expect(stack.peek()).toMatchObject({ sessionMeta: { linkedParentId: 'parent-2' }, record: { projectTabId: 'tab-2' } })
+ expect((stack.peek() as ClosedSession).sessionMeta).toMatchObject({ linkedParentId: 'parent-2', projectId: 'tab-2' })
+ })
+
+ it('is LIFO and keeps only the most recent entries', () => {
+ const stack = new UndoCloseStack(() => 1_000)
+ for (let index = 0; index < UNDO_CLOSE_MAX_ENTRIES + 3; index += 1) stack.push(entry(`s${index}`, 1_000))
+ expect(stack.length).toBe(UNDO_CLOSE_MAX_ENTRIES)
+ expect((stack.pop() as ClosedSession).sessionId).toBe(`s${UNDO_CLOSE_MAX_ENTRIES + 2}`)
+ })
+
+ it('expires entries lazily, so a stale close is never offered back', () => {
+ let now = 0
+ const stack = new UndoCloseStack(() => now)
+ stack.push(entry('old', 0))
+ now = UNDO_CLOSE_RETENTION_MS + 1
+ expect(stack.length).toBe(0)
+ expect(stack.pop()).toBeNull()
})
})
diff --git a/src/renderer/src/lib/undoClose.ts b/src/renderer/src/lib/undoClose.ts
index c22ac40a9..2b052688b 100644
--- a/src/renderer/src/lib/undoClose.ts
+++ b/src/renderer/src/lib/undoClose.ts
@@ -1,36 +1,49 @@
import type {
- DetachedSessionRecord,
SessionId,
SessionMeta,
- SplitDirection,
Tab,
- TileNode,
} from '@renderer/workspace/types'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
-// Undo-close stack — captures enough state to restore a closed pane or
-// tab exactly where it was in the tile tree.
+// Undo-close stack — captures enough state to bring back a closed session, or
+// a whole closed project, where it was in the index.
//
-// Two entry shapes:
+// Entry shapes:
//
-// 'pane' — a single leaf was removed from a split. To undo we find the
-// surviving sibling in the current tree, re-wrap it in a split
-// with the same direction + ratio, and respawn the session.
+// 'session' — one session was closed and its project survived. To undo we
+// respawn it and file it back under that project at its old
+// position (`sessionMeta.joinedAt`).
//
-// 'tab' — an entire tab was closed. To undo we respawn every session
-// in the tab, rebuild the tree, and re-insert the tab at its
-// original index.
+// 'tab' — a project was removed, because a close took its last session (or
+// the Close Tab command took all of them). To undo we re-create
+// the project at its original index and respawn its sessions.
//
-// 'detached' — a single DETACHED Dispatch row was closed. It has no
-// place in any tile tree, so there is no split to rebuild;
-// undo respawns the session and re-files its
-// `detachedSessions` record instead.
+// 'group' — one close OPERATION committed several units (a linked cascade, a
+// Close Tab reaching into other projects, a partial close whose
+// named session stayed open). It holds one entry of the shapes
+// above per unit, in commit order; undo replays them last-first so
+// each restore's new ids re-anchor the older ones.
//
-// 'group' — one close OPERATION committed several units (a linked
-// cascade, a Close Tab reaching into other projects, a partial
-// close whose named session stayed open). It holds one entry of
-// the shapes above per unit, in commit order; undo replays them
-// last-first so each restore's new ids re-anchor the older ones.
+// HISTORY (#992). There were two more shapes while a project owned a tile
+// tree: 'pane' (a leaf removed from a split — restored by finding its surviving
+// sibling and re-wrapping it at the recorded direction, ratio and side) and
+// 'detached' (a Dispatch row, restored by re-filing its detachedSessions
+// record, with an optional `replacedRoot` for the case where closing the last
+// grid agent had PROMOTED a detached survivor into the tree). All of that was
+// placement bookkeeping for a structure that no longer exists. A session's
+// whole placement is now two fields it carries itself — `projectId` and
+// `joinedAt` — so one shape restores any session, and the ~200 lines of tree
+// surgery (`findParentSplitInfo`, `reinsertPane`) went with the tree.
+//
+// What did NOT change, because it was never about the tree:
+// - an entry must carry the closed session's metadata, because `spawn` can
+// rebuild only cwd/kind/provider ids. This matters most for terminals:
+// closing one stops its attach PTY but leaves the tmux session alive, and
+// if no entry captures `tmuxName` the next launch's tmux reconcile sees a
+// live session with no row in workspace.json, classifies it as an orphan
+// and kills it — the scrollback is then unrecoverable (#671);
+// - `joinedAt` is restored VERBATIM. The user pressed undo to put things
+// back, not to move the row to the bottom of the list;
+// - lineage (below).
//
// The stack is LIFO — the user undoes the most recent close first, which
// matches Cmd+Shift+T muscle memory from every browser ever. Multiple
@@ -61,155 +74,62 @@ export const UNDO_CLOSE_MAX_ENTRIES = 10
// ---- Entry types ----
/**
- * Captured when a leaf is removed from a split. Records enough to
- * reconstruct the split and respawn the session.
- *
- * `siblingLeafId` is ANY leaf sessionId within the surviving subtree.
- * We use it to locate the surviving node in the (potentially further
- * modified) tree — the surviving subtree might itself be a multi-level
- * split, so we can't reference it by a single "sibling sessionId" in
- * the simple sense. Any leaf that was inside it at close time works as
- * a search anchor.
+ * One closed session whose project survived the close.
*
- * Why a leaf id instead of a tree path (like ['a', 'b', 'a']): the
- * tree mutates after every close, split, and resize. A structural path
- * captured at close time is stale by the time the user undoes — other
- * panes may have been opened or closed in between, shifting every
- * path. A leaf sessionId is stable (it's a UUID that doesn't change
- * until that session is itself closed), so we can always find the
- * surviving node by walking the tree looking for the subtree that
- * contains our anchor leaf.
+ * `sessionMeta` is the row exactly as it stood at close time, membership
+ * included: `projectId` is the ANCHOR (the project it returns to) and
+ * `joinedAt` is its place there.
*/
-export type ClosedPane = {
- type: 'pane'
+export type ClosedSession = {
+ type: 'session'
closedAt: number
- tabId: string
/**
- * The closed pane's own launch-local id. Undo mints a NEW id for it, and
- * older entries still on the stack may name the old one (a later pane's
- * `siblingLeafId`, a child's `linkedParentId`). Restore publishes old -> new
- * through `UndoCloseStack.remapLineage` so those anchors keep resolving.
- * Optional only because fixtures predating #886 omit it; every production
- * capture sets it, and an entry without it simply cannot be remapped.
+ * The closed session's own launch-local id. Undo mints a NEW id for it, and
+ * older entries still on the stack may name the old one (a linked child's
+ * `linkedParentId`). Restore publishes old -> new through
+ * `UndoCloseStack.remapLineage` so those pointers keep resolving.
*/
- sessionId?: SessionId
- /** Session metadata for the closed pane — cwd, kind, providerSessionId. */
+ sessionId: SessionId
sessionMeta: SessionMeta
- /** Split direction the parent had. */
- direction: SplitDirection
- /** Split ratio the parent had. */
- ratio: number
- /** Which side of the split the closed pane was on. */
- side: 'a' | 'b'
- /** Any leaf id inside the surviving sibling subtree. Used to find
- * where to re-insert the split in the current tree. */
- siblingLeafId: SessionId
-}
-
-/**
- * A detached dispatch agent that was associated with a tab at the
- * time the tab was closed. Captured separately from `sessionMetas`
- * because detached agents do NOT live in the tile tree and therefore
- * have nothing in `tab.root` to remap on restore — they have to be
- * respawned and re-registered in `detachedSessions` from scratch.
- *
- * We keep `detachedAt` so the dispatch row's age display doesn't
- * snap to "just now" on undo — a 4-hour-old detached agent that gets
- * killed and restored in the same minute should still read as 4 hours
- * old in the dispatch list.
- */
-export type ClosedTabDetachedEntry = {
- /** Old id, for the same lineage reason as `ClosedPane.sessionId`: a linked
- * child restored with this tab must follow its restored parent, and older
- * entries may anchor on this row. Optional for pre-#886 fixtures only. */
- sessionId?: SessionId
- meta: SessionMeta
- detachedAt: number
}
/**
- * Captured when an entire tab is closed. We store the full tree
- * structure and all session metas so we can rebuild everything.
+ * A removed project and the sessions that went with it, in index order.
*
- * `detachedEntries` is optional because tab closes from before the
- * detached-sessions feature shipped (or tabs that simply had no
- * detached agents associated) won't carry it. Restore code MUST treat
- * the absent / empty case as "no detached work to do" — this is not a
- * hint that something failed to capture.
+ * `sessions` holds only what the operation actually CLOSED. A project is
+ * removed because it emptied, so that is normally everything it had — but the
+ * entry records commits, not intentions, which is what makes a partial
+ * operation's undo honest.
*/
export type ClosedTab = {
type: 'tab'
closedAt: number
tab: Tab
- /** Index the tab was at before removal — used to re-insert at the
- * same position (clamped to bounds if other tabs were also closed
- * in the meantime). */
+ /** Index the project was at before removal — used to re-insert at the same
+ * position (clamped to bounds if other projects were also closed since). */
tabIndex: number
- sessionMetas: Record
- detachedEntries?: ClosedTabDetachedEntry[]
-}
-
-/**
- * Captured when a single detached Dispatch session is closed.
- *
- * WHY this needed its own entry shape rather than reusing ClosedPane:
- * ClosedPane restores by finding a surviving sibling leaf and rebuilding the
- * split around it. A detached session was never in `tab.root`, so it has no
- * sibling, no direction, and no ratio — every placement field ClosedPane
- * carries would be a lie. What it does have is a `DetachedSessionRecord`,
- * which is the whole of its placement.
- *
- * WHY the record is stored verbatim instead of being rebuilt at restore time:
- * `detachedAt` is what orders rows inside a Dispatch project group. Minting a
- * fresh one on undo would silently move the restored row to the bottom of the
- * list — the user pressed undo to put things BACK, not to reorder them. The
- * same reasoning `ClosedTabDetachedEntry` documents for its own `detachedAt`.
- *
- * This shape matters most for terminals. Closing one stops its attach PTY but
- * leaves the tmux session alive; if no undo entry captures `tmuxName`, the
- * next launch's tmux reconcile sees a live session with no matching row in
- * workspace.json, classifies it as an orphan, and silently kills it. Without
- * this entry a closed Dispatch terminal's scrollback is unrecoverable.
- */
-export type ClosedDetached = {
- type: 'detached'
- closedAt: number
- /** Session metadata — cwd, kind, providerSessionId, tmuxName. */
- sessionMeta: SessionMeta
- /** The detached record as it stood at close time, `detachedAt` included. */
- record: DetachedSessionRecord
- /** Closing the last grid agent can promote a detached survivor. Restore the
- * original root only if that survivor still occupies the whole grid; later
- * user layout edits win, with the recovered agent restored as a row.
- *
- * Both `sessionId` and `projectTabId` here are LINEAGE anchors, not frozen
- * facts: undoing a later close of that survivor (or of its whole tab) brings
- * it back under a new session id and possibly a new tab id, and
- * `remapLineage` rewrites this record so the earlier root still recognizes
- * its slot (#886 review finding 4). */
- replacedRoot?: DetachedSessionRecord
+ sessions: Array<{ sessionId: SessionId; meta: SessionMeta }>
}
-/** The shapes that restore ONE placement unit; a group is built from these. */
-export type SingleClosedEntry = ClosedPane | ClosedTab | ClosedDetached
+/** The shapes that restore ONE unit; a group is built from these. */
+export type SingleClosedEntry = ClosedSession | ClosedTab
/**
* Everything one close OPERATION committed, as a single undo unit.
*
* WHY a group rather than one entry per session or one entry for the named
* session only (#886 review round 2): an operation can end several sessions in
- * different shapes — a linked child closed as a Dispatch row, another as a split
- * pane in another project, the parent as a promoted root — and it can be
- * PARTIAL: the parent kept because a child changed, while the children that
- * already closed are really gone. Recording only the named session lost those
- * children entirely (they had no entry and the toast never mentioned them);
- * recording each separately flooded the 10-entry stack with one decision and
- * made ⌘⇧T restore half an operation at a time.
+ * different projects, and it can be PARTIAL: the parent kept because a child
+ * changed, while the children that already closed are really gone. Recording
+ * only the named session lost those children entirely (they had no entry and
+ * the toast never mentioned them); recording each separately flooded the
+ * 10-entry stack with one decision and made ⌘⇧T restore half an operation at
+ * a time.
*
- * `entries` is in COMMIT order. Undo replays it from the END: the last commit is
- * the outermost state change (a parent, a tab removal), and each restore
- * publishes lineage (new ids) that the older members still anchor on — a
- * child's `linkedParentId`, a pane's `siblingLeafId`, a row's `projectTabId`.
+ * `entries` is in COMMIT order. Undo replays it from the END: the last commit
+ * is the outermost state change (a parent, a project removal), and each
+ * restore publishes lineage (new ids) that the older members still anchor on —
+ * a child's `linkedParentId`, a session's `projectId`.
*/
export type ClosedGroup = {
type: 'group'
@@ -225,9 +145,10 @@ export type ClosedEntry = SingleClosedEntry | ClosedGroup
* WHY undo needs lineage at all: every restore respawns under a fresh
* launch-local SessionId, and a restored tab gets a fresh TabId. Entries still
* on the stack were captured against the OLD ids. Without rewriting them, the
- * natural sequence "close A (B promoted), close B (tab removed), undo, undo"
- * loses A: its entry names tab T and survivor B, but the first undo recreated
- * them as T′ and B′, so the second undo judged A stale and consumed it.
+ * natural sequence "close A, close B (the project's last session, so the
+ * project goes too), undo, undo" loses A: its entry names project T, but the
+ * first undo recreated T as T′, so the second undo judged A stale and consumed
+ * it. The same goes for a linked child whose restored parent has a new id.
*
* WHY this is not "recreate any missing tab": a tab can also disappear because
* the user MERGED it into another project (#913/#914). Merge has no undo entry
@@ -270,11 +191,10 @@ export function remapMetaLineage(
/**
* Apply one restore's lineage to an entry still waiting on the stack.
*
- * Only ANCHORS are rewritten — the ids an entry uses to find where it belongs
- * (sibling leaf, project tab, promoted survivor) and the relationship pointers
- * its respawned session will carry. An entry's OWN closed ids (`sessionId`,
- * `record.sessionId`, a closed tab's leaves) are never remapped: those
- * sessions are dead and the entry is the only thing that will ever revive them.
+ * Only ANCHORS are rewritten — the project a session entry returns to, and the
+ * relationship pointers its respawned session will carry. An entry's OWN
+ * closed ids are never remapped: those sessions are dead and the entry is the
+ * only thing that will ever revive them.
*/
export function remapClosedEntryLineage(entry: ClosedEntry, lineage: UndoLineage): ClosedEntry {
if (entry.type === 'group') {
@@ -283,65 +203,28 @@ export function remapClosedEntryLineage(entry: ClosedEntry, lineage: UndoLineage
return remapSingleEntryLineage(entry, lineage)
}
-/** remapClosedEntryLineage for one placement unit; group restore uses it to
- * re-anchor the members it has not replayed yet. */
+/** remapClosedEntryLineage for one unit; group restore uses it to re-anchor
+ * the members it has not replayed yet. */
export function remapSingleEntryLineage(entry: SingleClosedEntry, lineage: UndoLineage): SingleClosedEntry {
- const session = (id: SessionId) => lineage.sessions?.get(id) ?? id
- const tab = (id: string) => lineage.tabs?.get(id) ?? id
- if (entry.type === 'pane') {
+ if (entry.type === 'session') {
+ const meta = remapMetaLineage(entry.sessionMeta, lineage.sessions)
+ const projectId = meta.projectId !== undefined
+ ? lineage.tabs?.get(meta.projectId) ?? meta.projectId
+ : undefined
return {
...entry,
- tabId: tab(entry.tabId),
- siblingLeafId: session(entry.siblingLeafId),
- sessionMeta: remapMetaLineage(entry.sessionMeta, lineage.sessions),
- }
- }
- if (entry.type === 'detached') {
- return {
- ...entry,
- sessionMeta: remapMetaLineage(entry.sessionMeta, lineage.sessions),
- record: { ...entry.record, projectTabId: tab(entry.record.projectTabId) },
- ...(entry.replacedRoot
- ? {
- replacedRoot: {
- ...entry.replacedRoot,
- sessionId: session(entry.replacedRoot.sessionId),
- projectTabId: tab(entry.replacedRoot.projectTabId),
- },
- }
- : {}),
+ sessionMeta: projectId === meta.projectId ? meta : { ...meta, projectId },
}
}
return {
...entry,
- sessionMetas: Object.fromEntries(
- Object.entries(entry.sessionMetas).map(([id, meta]) => [id, remapMetaLineage(meta, lineage.sessions)]),
- ),
- ...(entry.detachedEntries
- ? {
- detachedEntries: entry.detachedEntries.map(detached => ({
- ...detached,
- meta: remapMetaLineage(detached.meta, lineage.sessions),
- })),
- }
- : {}),
+ sessions: entry.sessions.map(member => ({
+ ...member,
+ meta: remapMetaLineage(member.meta, lineage.sessions),
+ })),
}
}
-export function missingClosedTabLeafMetaIds(entry: ClosedTab): SessionId[] {
- // WHY this validation lives beside the entry type instead of being inlined
- // in the restore hook:
- //
- // A closed tab's tile tree and `sessionMetas` snapshot are one atomic
- // restore contract. If the tree references a leaf id that has no captured
- // meta, retrying cannot help — the missing cwd/provider/tmux data is not a
- // transient provider outage, it is corrupted history. Treating that as
- // retryable would push the same bad entry back onto the stack forever and
- // shadow older valid undo entries. Keeping the check pure makes the
- // retryable-vs-stale boundary testable without a React hook harness.
- return collectLeaves(entry.tab.root).filter(id => entry.sessionMetas[id] === undefined)
-}
-
// ---- Stack ----
export class UndoCloseStack {
@@ -388,168 +271,3 @@ export class UndoCloseStack {
this.entries = this.entries.filter(e => e.closedAt > cutoff)
}
}
-
-// ---- Tree helpers ----
-
-/**
- * Find the parent split of a leaf and return contextual info needed
- * to reconstruct the split on undo.
- *
- * Returns null if the leaf is the root (no parent split — closing it
- * means closing the tab, which is a different undo entry type).
- */
-export function findParentSplitInfo(
- root: TileNode,
- targetSessionId: SessionId,
-): {
- direction: SplitDirection
- ratio: number
- side: 'a' | 'b'
- siblingLeafId: SessionId
-} | null {
- return _findParent(root, targetSessionId)
-}
-
-function _findParent(
- node: TileNode,
- target: SessionId,
-): {
- direction: SplitDirection
- ratio: number
- side: 'a' | 'b'
- siblingLeafId: SessionId
-} | null {
- if (node.type === 'leaf') return null
-
- // Check if the target is a direct child.
- const aIsTarget =
- node.a.type === 'leaf' && node.a.sessionId === target
- const bIsTarget =
- node.b.type === 'leaf' && node.b.sessionId === target
-
- if (aIsTarget) {
- // Target is on side 'a', sibling is 'b'.
- const siblingLeafId = collectLeaves(node.b)[0]
- return {
- direction: node.direction,
- ratio: node.ratio,
- side: 'a',
- siblingLeafId,
- }
- }
-
- if (bIsTarget) {
- const siblingLeafId = collectLeaves(node.a)[0]
- return {
- direction: node.direction,
- ratio: node.ratio,
- side: 'b',
- siblingLeafId,
- }
- }
-
- // Recurse.
- return _findParent(node.a, target) ?? _findParent(node.b, target)
-}
-
-/**
- * Re-insert a closed pane into the tree by finding the surviving
- * sibling (via its anchor leaf id) and wrapping it in a new split
- * with the resurrected leaf on the correct side.
- *
- * Returns the new tree root, or null if the anchor leaf couldn't be
- * found (the sibling was also closed — the undo is stale).
- */
-export function reinsertPane(
- root: TileNode,
- siblingLeafId: SessionId,
- newSessionId: SessionId,
- direction: SplitDirection,
- ratio: number,
- side: 'a' | 'b',
-): TileNode | null {
- const result = _reinsert(root, siblingLeafId, newSessionId, direction, ratio, side)
- return result
-}
-
-function _reinsert(
- node: TileNode,
- siblingLeafId: SessionId,
- newSessionId: SessionId,
- direction: SplitDirection,
- ratio: number,
- side: 'a' | 'b',
-): TileNode | null {
- // Walk the tree looking for the subtree that contains the anchor
- // leaf. When we find it, wrap that entire subtree in a new split
- // with the resurrected leaf on the correct side.
- //
- // We need to find the node whose SUBTREE contains the anchor —
- // that subtree is what was the sibling at close time, and it might
- // have grown (new splits added inside it) or shrunk (sub-panes
- // closed) since then. The right move is to find the SHALLOWEST
- // ancestor that contains the anchor and was the direct survivor.
- //
- // But we can't know which ancestor was "the direct survivor"
- // because the tree has been rebuilt since then. The safe heuristic:
- // find the shallowest node that contains the anchor leaf AND is
- // itself a direct child of a split (or is the root). We do this by
- // checking at each level: does this node contain the anchor? If so,
- // wrap it.
-
- if (node.type === 'leaf') {
- if (node.sessionId === siblingLeafId) {
- // Found the anchor leaf — wrap it in a split.
- const newLeaf: TileNode = { type: 'leaf', sessionId: newSessionId }
- return {
- type: 'split',
- direction,
- ratio,
- a: side === 'a' ? newLeaf : node,
- b: side === 'b' ? newLeaf : node,
- }
- }
- return null // not in this subtree
- }
-
- // Split node. Check children.
- const aLeaves = collectLeaves(node.a)
- const bLeaves = collectLeaves(node.b)
- const inA = aLeaves.includes(siblingLeafId)
- const inB = bLeaves.includes(siblingLeafId)
-
- if (!inA && !inB) return null // anchor not in this subtree
-
- // The anchor is somewhere in this subtree. If we're at a split
- // whose DIRECT child (a or b) is the anchor leaf itself, we need
- // to descend into that child so the wrap happens around the leaf,
- // not around this whole split. But if the anchor is deeper, we
- // still descend — we always wrap at the leaf level.
- //
- // Actually, let me reconsider. The sibling at close time could have
- // been a split node (not just a leaf). In that case, the anchor is
- // somewhere inside the original sibling. We want to wrap the
- // original sibling — which after the close became a direct child of
- // wherever the parent split used to be. The problem is we don't
- // know which node in the current tree corresponds to the original
- // sibling.
- //
- // Safest approach: always descend to the leaf and wrap there. This
- // means we always re-split at the leaf level, not at the original
- // split level. For the common case (sibling was a leaf), this is
- // perfect. For the rare case (sibling was a split), the restored
- // pane ends up next to one specific leaf inside the old sibling
- // instead of next to the whole sibling — slightly wrong in theory,
- // but visually close and much simpler than trying to detect the
- // original sibling boundary.
-
- if (inA) {
- const newA = _reinsert(node.a, siblingLeafId, newSessionId, direction, ratio, side)
- if (newA === null) return null
- return { ...node, a: newA }
- }
-
- const newB = _reinsert(node.b, siblingLeafId, newSessionId, direction, ratio, side)
- if (newB === null) return null
- return { ...node, b: newB }
-}
diff --git a/src/renderer/src/session-runtime/state.ts b/src/renderer/src/session-runtime/state.ts
index 9d9157380..c0971f7a1 100644
--- a/src/renderer/src/session-runtime/state.ts
+++ b/src/renderer/src/session-runtime/state.ts
@@ -500,6 +500,23 @@ export type SessionRuntime = {
* receive user-visible output or action-required prompts. */
unreadSince: number | null
unreadKind: 'output' | 'attention' | null
+ /** Transient "new in the pool" marker for the agent index (#992 §4.3).
+ *
+ * Set when a spawn lands in the pool WITHOUT taking a lane — under
+ * context-places that is every spawn from an occupied lane, the palette,
+ * ⌘N, MCP and orchestration — because "nothing on screen moves" makes a
+ * successful spawn look like a no-op. The index row wears a small "new"
+ * chip until the session is placed, and placing it into any lane
+ * (setTiledLaneSession) clears the field. In-memory only: runtimes are
+ * rebuilt at boot, so a badge never survives a restart — which is the
+ * right lifetime for "you have not looked at this yet".
+ *
+ * WHY the runtime and not workspace state: the badge is presentation, not
+ * truth about the workspace. Autosave must not write it, undo must not
+ * restore it, and a row can read it through the same useShallow selector
+ * it already uses for activity — a workspace-state field would re-render
+ * the whole index on every spawn instead of one row. */
+ pooledSpawnAt: number | null
paneToast: string | null
historyOldestMarker: string | null
/** Byte offset of the transcript line `historyOldestMarker` came from,
@@ -860,6 +877,7 @@ export function emptyRuntime(): SessionRuntime {
terminalForeground: null,
unreadSince: null,
unreadKind: null,
+ pooledSpawnAt: null,
paneToast: null,
historyOldestMarker: null,
historyOldestOffset: null,
diff --git a/src/renderer/src/workspace/adoptWorkspace.test.ts b/src/renderer/src/workspace/adoptWorkspace.test.ts
index 1b80701d5..fb031f925 100644
--- a/src/renderer/src/workspace/adoptWorkspace.test.ts
+++ b/src/renderer/src/workspace/adoptWorkspace.test.ts
@@ -2,16 +2,22 @@ import { describe, expect, it } from 'vitest'
import { adoptWorkspace } from '@renderer/workspace/adoptWorkspace'
import { collectOwnedSessionIds } from '@renderer/workspace/sessionOwnership'
+import { resolveTabSessions } from '@renderer/workspace/queries'
import type { PersistedWorkspace } from '@renderer/workspace/persistence'
import type { SessionMeta, WorkspaceState } from '@renderer/workspace/types'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
// Closing a window must not kill its agents. They stay alive in SessionManager
// and the surviving window takes over their workspace.
//
-// The load-bearing test here is the first one: `collectOwnedSessionIds` drops a
-// detached record whose `projectTabId` names no tab, so an adoption that moved
-// sessions WITHOUT their tabs would look correct and then be deleted by the
+// The load-bearing test here is the first one: a session is owned because its
+// `projectId` names a project that exists, so an adoption that moved sessions
+// WITHOUT their projects would look correct and then be deleted by the
// survivor's very next autosave.
+//
+// The closed window's slice is a FILE, and may be of any generation: a window
+// that has not saved since the upgrade still holds a v2 document. Both are
+// exercised; the v2 one is the recorded shape this suite always used.
function meta(cwd: string): SessionMeta {
return { cwd, kind: 'claude' }
@@ -19,22 +25,16 @@ function meta(cwd: string): SessionMeta {
function survivorState(): WorkspaceState {
return {
- tabs: [{
- id: 'tab-own',
- title: 'own-project',
- root: { type: 'leaf', sessionId: 'own-agent' },
- focusedSessionId: 'own-agent',
- }],
+ tabs: [{ id: 'tab-own', title: 'own-project' }],
activeTabId: 'tab-own',
- dispatchMode: null,
- sessions: { 'own-agent': meta('/own') },
- detachedSessions: {},
- buried: [],
+ stage: oneLaneStage('own-agent'),
+ sessions: { 'own-agent': { ...meta('/own'), projectId: 'tab-own', joinedAt: 0 } },
pinnedSessionIds: ['own-agent'],
}
}
-function closedWindowWorkspace(): PersistedWorkspace {
+/** v2: a tab owning a split, a parked Dispatch row, and a buried pane. */
+function closedV2Window(): PersistedWorkspace {
return {
tabs: [{
id: 'tab-closed',
@@ -49,7 +49,7 @@ function closedWindowWorkspace(): PersistedWorkspace {
focusedSessionId: 'grid-a',
}],
activeTabId: 'tab-closed',
- dispatchMode: null,
+ dispatchMode: { scope: 'global', tiled: { lanes: [{ selectedSessionId: 'grid-a' }], focusedLane: 0 } },
sessions: {
'grid-a': meta('/closed'),
'grid-b': meta('/closed'),
@@ -58,73 +58,77 @@ function closedWindowWorkspace(): PersistedWorkspace {
},
detachedSessions: {
parked: {
- sessionId: 'parked',
- surface: 'dispatch',
- projectTabId: 'tab-closed',
- projectTabTitle: 'closed-project',
- projectTabIndex: 0,
- detachedAt: 10,
+ sessionId: 'parked', surface: 'dispatch', projectTabId: 'tab-closed',
+ projectTabTitle: 'closed-project', projectTabIndex: 0, detachedAt: 10,
},
},
buried: [{
- id: 'entombed',
- sessionId: 'entombed',
- sessionMeta: meta('/closed'),
- buriedAt: 20,
- sourceTabId: 'tab-closed',
- sourceTabTitle: 'closed-project',
- sourceTabIndex: 0,
+ id: 'entombed', sessionId: 'entombed', sessionMeta: meta('/closed'), buriedAt: 20,
+ sourceTabId: 'tab-closed', sourceTabTitle: 'closed-project', sourceTabIndex: 0,
}],
pinnedSessionIds: ['parked'],
- tileTabs: null,
drafts: { 'grid-a': 'half-written prompt' },
}
}
-describe('adopting a closed window', () => {
- it('keeps adopted detached sessions owned, because their tab comes with them', () => {
- const adoption = adoptWorkspace(survivorState(), closedWindowWorkspace())
+/** v3: the same workspace as this build writes it. */
+function closedV3Window(): PersistedWorkspace {
+ return {
+ projects: [{ id: 'tab-closed', title: 'closed-project' }],
+ activeProjectId: 'tab-closed',
+ stage: oneLaneStage('grid-a'),
+ sessions: {
+ 'grid-a': { ...meta('/closed'), projectId: 'tab-closed', joinedAt: 0 },
+ 'grid-b': { ...meta('/closed'), projectId: 'tab-closed', joinedAt: 1 },
+ parked: { ...meta('/closed'), projectId: 'tab-closed', joinedAt: 10 },
+ entombed: { ...meta('/closed'), projectId: 'tab-closed', joinedAt: 20 },
+ },
+ pinnedSessionIds: ['parked'],
+ drafts: { 'grid-a': 'half-written prompt' },
+ }
+}
+
+describe.each([
+ ['a v2 slice', closedV2Window],
+ ['a v3 slice', closedV3Window],
+])('adopting a closed window — %s', (_label, closedWindow) => {
+ it('keeps every adopted session owned, because its project comes with it', () => {
+ const adoption = adoptWorkspace(survivorState(), closedWindow())
expect(adoption.ok).toBe(true)
if (!adoption.ok) return
- // The real assertion is not "the record is present" — it is that the
- // survivor's own ownership rules still consider it owned. A bare detached
- // record whose projectTabId named no tab would pass a presence check and
- // then be pruned on the next autosave, silently losing a live agent.
- const owned = collectOwnedSessionIds({
- tabs: adoption.state.tabs,
- sessions: adoption.state.sessions,
- detachedSessions: adoption.state.detachedSessions,
- buried: adoption.state.buried,
- })
- expect(owned.has('parked')).toBe(true)
- // Buried panes are a third ownership surface and were claimed to survive
- // without ever being exercised.
- expect(owned.has('entombed')).toBe(true)
- expect(owned.has('grid-a')).toBe(true)
- expect(owned.has('grid-b')).toBe(true)
- expect(owned.has('own-agent')).toBe(true)
+ // The real assertion is not "the row is present" — it is that the
+ // survivor's own ownership rules still consider it owned. A bare row whose
+ // project did not come along would pass a presence check and then be
+ // pruned on the next autosave, silently losing a live agent.
+ const owned = collectOwnedSessionIds(adoption.state)
+ // Parked and (in v2) buried sessions were claimed to survive without ever
+ // being exercised; they are the ones no lane was showing.
+ expect([...owned].sort()).toEqual(['entombed', 'grid-a', 'grid-b', 'own-agent', 'parked'])
})
- it('preserves the closed window s tile arrangement', () => {
- const adoption = adoptWorkspace(survivorState(), closedWindowWorkspace())
+ it('lists the adopted project s agents in the order the closed window listed them', () => {
+ const adoption = adoptWorkspace(survivorState(), closedWindow())
if (!adoption.ok) throw new Error('expected adoption')
+ const state = { ...survivorState(), ...adoption.state }
- // Flattening the tree into Dispatch rows was the obvious reading of the
- // requirement and is not representable: `Tab.root` has no empty form. The
- // split survives verbatim, and the agents show up in Dispatch anyway
- // because buildDispatchGroups lists grid-placed sessions too.
- const adopted = adoption.state.tabs.find(tab => tab.id === 'tab-closed')
- expect(adopted?.root).toEqual(closedWindowWorkspace().tabs[0]?.root)
- expect(adoption.adoptedLeafSessionIds).toEqual(['grid-a', 'grid-b'])
- // Every adopted session needs a runtime, not just the painted ones: the
- // wake path for a parked or buried agent no-ops without one.
- expect([...adoption.adoptedSessionIds].sort())
- .toEqual(['entombed', 'grid-a', 'grid-b', 'parked'])
+ expect(adoption.state.tabs.map(tab => tab.id)).toEqual(['tab-own', 'tab-closed'])
+ expect(resolveTabSessions(state, 'tab-closed')).toEqual(['grid-a', 'grid-b', 'parked', 'entombed'])
+ // Every adopted session needs a runtime, not just the ones with a backend:
+ // the wake path for a parked agent no-ops without one.
+ expect([...adoption.adoptedSessionIds].sort()).toEqual(['entombed', 'grid-a', 'grid-b', 'parked'])
+ })
+
+ it('does not adopt the closed window s STAGE', () => {
+ // A stage is one window's screen. The survivor's lanes show what ITS user
+ // arranged, and another window closing is not a request to rearrange them.
+ const adoption = adoptWorkspace(survivorState(), closedWindow())
+ if (!adoption.ok) throw new Error('expected adoption')
+ expect(adoption.state).not.toHaveProperty('stage')
})
it('carries pins and drafts across', () => {
- const adoption = adoptWorkspace(survivorState(), closedWindowWorkspace())
+ const adoption = adoptWorkspace(survivorState(), closedWindow())
if (!adoption.ok) throw new Error('expected adoption')
// Order matters: pinnedSessionIds IS the Pinned section's render order, and
// the survivor's own pins were arranged more recently.
@@ -132,51 +136,67 @@ describe('adopting a closed window', () => {
expect(adoption.drafts).toEqual({ 'grid-a': 'half-written prompt' })
})
- it('re-derives the Dispatch project ordinal for adopted records', () => {
- const adoption = adoptWorkspace(survivorState(), closedWindowWorkspace())
- if (!adoption.ok) throw new Error('expected adoption')
- // The adopted tab is appended after the survivor's own, so a stale index of
- // 0 would label its Dispatch rows with the survivor's project letter.
- expect(adoption.state.detachedSessions.parked?.projectTabIndex).toBe(1)
- })
-
it('leaves the survivor s own workspace untouched', () => {
const before = survivorState()
- const adoption = adoptWorkspace(before, closedWindowWorkspace())
+ const adoption = adoptWorkspace(before, closedWindow())
if (!adoption.ok) throw new Error('expected adoption')
- expect(adoption.state.tabs[0]).toEqual(before.tabs[0])
- expect(adoption.state.sessions['own-agent']).toEqual(before.sessions['own-agent'])
+ expect(adoption.state.tabs[0]).toBe(before.tabs[0])
+ expect(adoption.state.sessions['own-agent']).toBe(before.sessions['own-agent'])
})
- it('refuses the whole adoption on an id collision', () => {
- const incoming = closedWindowWorkspace()
- incoming.sessions['own-agent'] = meta('/collision')
+ it('refuses the whole adoption on a session id collision', () => {
+ const incoming = closedWindow()
+ incoming.sessions['own-agent'] = { ...meta('/collision'), projectId: 'tab-closed', joinedAt: 99 }
+ if (incoming.tabs) incoming.detachedSessions = {
+ ...incoming.detachedSessions,
+ 'own-agent': { sessionId: 'own-agent', surface: 'dispatch', projectTabId: 'tab-closed', projectTabTitle: 'c', projectTabIndex: 0, detachedAt: 99 },
+ }
- const adoption = adoptWorkspace(survivorState(), incoming)
// WHY refusing beats merging what fits: both id spaces are randomUUID, so a
// collision means something is already wrong. Dropping the colliding rows
// could strand live sessions — alive in SessionManager, owned by no window,
// invisible and unkillable. Refusing leaves the closed slice on disk, so
// the next launch restores it as its own window with everything intact.
- expect(adoption.ok).toBe(false)
+ expect(adoptWorkspace(survivorState(), incoming).ok).toBe(false)
})
- it('refuses when a tab id collides', () => {
- const incoming = closedWindowWorkspace()
- incoming.tabs[0]!.id = 'tab-own'
+ it('refuses when a project id collides', () => {
+ const incoming = closedWindow()
+ if (incoming.tabs) incoming.tabs[0]!.id = 'tab-own'
+ if (incoming.projects) incoming.projects[0]!.id = 'tab-own'
expect(adoptWorkspace(survivorState(), incoming).ok).toBe(false)
})
+})
+describe('adopting a degenerate slice', () => {
it('adopts an empty workspace without inventing rows', () => {
+ const before = survivorState()
+ const adoption = adoptWorkspace(before, { tabs: [], activeTabId: 'gone', sessions: {} })
+ if (!adoption.ok) throw new Error('expected adoption')
+ expect(adoption.state.tabs).toEqual(before.tabs)
+ expect(adoption.adoptedSessionIds).toEqual([])
+ })
+
+ it('reads a slice with no sessions map defensively instead of throwing', () => {
+ // The slice is another window's file, opaque to main and hand-editable.
+ const adoption = adoptWorkspace(survivorState(), {} as unknown as PersistedWorkspace)
+ expect(adoption.ok).toBe(true)
+ })
+
+ it('does not adopt a project whose every session was unowned', () => {
+ // A v2 detached record naming a project the slice does not contain is a
+ // ghost; nothing is filed under `tab-empty`, so it would be a header over
+ // an empty list.
const adoption = adoptWorkspace(survivorState(), {
- tabs: [],
- activeTabId: 'gone',
- dispatchMode: null,
- sessions: {},
- tileTabs: null,
+ tabs: [{ id: 'tab-empty', title: 'empty', focusedSessionId: 'nobody', root: { type: 'leaf', sessionId: 'nobody' } }],
+ activeTabId: 'tab-empty',
+ sessions: { ghost: meta('/gone') },
+ detachedSessions: {
+ ghost: { sessionId: 'ghost', surface: 'dispatch', projectTabId: 'tab-closed-long-ago', projectTabTitle: 'x', projectTabIndex: 0, detachedAt: 1 },
+ },
})
if (!adoption.ok) throw new Error('expected adoption')
- expect(adoption.state.tabs).toHaveLength(1)
+ expect(adoption.state.tabs.map(tab => tab.id)).toEqual(['tab-own'])
expect(adoption.adoptedSessionIds).toEqual([])
})
})
diff --git a/src/renderer/src/workspace/adoptWorkspace.ts b/src/renderer/src/workspace/adoptWorkspace.ts
index d9a4ba083..d4e0b9413 100644
--- a/src/renderer/src/workspace/adoptWorkspace.ts
+++ b/src/renderer/src/workspace/adoptWorkspace.ts
@@ -1,55 +1,48 @@
import type { PersistedWorkspace } from '@renderer/workspace/persistence'
import type {
- BuriedPaneRecord,
- DetachedSessionRecord,
SessionId,
SessionMeta,
Tab,
WorkspaceState,
} from '@renderer/workspace/types'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
+import { migrateWorkspaceToStage } from '@renderer/workspace/workspaceShape'
// Taking over a closed window's workspace.
//
// WHY the surviving window merges rather than main: main deliberately treats a
// window's workspace payload as opaque bytes (see storage/workspaceFile.ts), and
-// this merge needs to reason about tabs, tile leaves, detached records, pins,
-// and drafts. Duplicating that model in main is exactly the second-opinion
-// problem `sessionOwnership.ts` warns about — two implementations of "who owns
-// this session" that are free to disagree.
+// this merge needs to reason about projects, sessions, pins and drafts.
+// Duplicating that model in main is exactly the second-opinion problem
+// `sessionOwnership.ts` warns about — two implementations of "who owns this
+// session" that are free to disagree.
//
-// WHY the adopted tabs keep their tile trees instead of being flattened into
-// Dispatch rows:
+// WHAT is adopted (#992): the closed window's POOL — its projects and every
+// session filed under them. Its STAGE is not. A stage is one window's screen:
+// the survivor already has its own lanes showing what its user arranged, and
+// another window closing is not a request to rearrange them. The adopted
+// agents appear in the survivor's index, under their own projects, one
+// keystroke from any lane.
//
-// The obvious reading of "the agents show up in Dispatch" is to convert every
-// tile leaf into a `DetachedSessionRecord`. That does not typecheck against the
-// data model: `Tab.root` is a `TileNode`, whose only terminal form is
-// `{ type: 'leaf', sessionId }` — a tab with no panes cannot be represented at
-// all. And it buys nothing, because `buildDispatchGroups` already lists BOTH
-// grid-placed and detached sessions for a tab (`dispatchSelectors.ts`), so an
-// adopted agent appears in the survivor's Dispatch either way. Keeping the tree
-// preserves the arrangement the user built, at no cost to the outcome they
-// asked for.
+// WHY projects move with their sessions rather than the sessions being re-homed
+// onto an existing project: a session is owned because its `projectId` names a
+// project that exists (sessionOwnership.ts). Bare rows would be unowned and
+// deleted by the survivor's very next autosave. Carrying the project keeps
+// every `projectId` valid by construction, with nothing rewritten.
//
-// WHY tabs move with their sessions rather than the sessions being re-homed
-// onto an existing tab: `collectOwnedSessionIds` drops a detached record whose
-// `projectTabId` names no tab, deliberately — "a missing parent means there is
-// no surface from which the agent can be found or managed." Bare records would
-// therefore be deleted by the survivor's very next autosave. Carrying the tab
-// keeps every `projectTabId` valid by construction, with nothing rewritten.
+// WHY the slice goes through `migrateWorkspaceToStage` first: it is another
+// window's FILE, and that file may be any generation — v2 (tile trees, a
+// detached bucket, buried panes), v3, or the hybrid. One normalizer means this
+// function reasons about one shape, and the v2 ownership rules (which sessions
+// were really owned, which buried ones re-parent) are applied exactly as
+// rehydrate applies them. Until #992 this function carried tile trees across
+// verbatim and folded buried records by hand.
export type WorkspaceAdoption =
| {
ok: true
- state: Pick<
- WorkspaceState,
- 'tabs' | 'sessions' | 'detachedSessions' | 'buried' | 'pinnedSessionIds'
- >
- /** Sessions that landed in a tile tree, and so need their transcript
- * loaded eagerly — they are about to be painted. */
- adoptedLeafSessionIds: SessionId[]
- /** Every session the survivor now owns, leaves and parked alike. All of
- * them need a runtime; see the wake-path note in useWorkspaceAdoption. */
+ state: Pick
+ /** Every session the survivor now owns. All of them need a runtime; see
+ * the wake-path note in useWorkspaceAdoption. */
adoptedSessionIds: SessionId[]
drafts: Record
}
@@ -68,30 +61,36 @@ export type WorkspaceAdoption =
* state.
*
* Pure: no IPC, no runtimes, no React. The caller applies the returned state
- * and seeds a runtime for every id in `adoptedSessionIds` — not just the
- * leaves, because the wake path for a parked agent no-ops without one.
+ * and seeds a runtime for every id in `adoptedSessionIds`, because the wake
+ * path for a parked agent no-ops without one.
*/
export function adoptWorkspace(
current: WorkspaceState,
- incoming: PersistedWorkspace,
+ incomingInput: PersistedWorkspace,
): WorkspaceAdoption {
+ // Defensive on purpose: the slice is another window's file. A payload with
+ // no sessions map at all normalizes to an empty pool rather than throwing.
+ const incoming = migrateWorkspaceToStage({
+ ...incomingInput,
+ sessions: incomingInput.sessions ?? {},
+ })
const currentSessionIds = new Set(Object.keys(current.sessions))
const currentTabIds = new Set(current.tabs.map(tab => tab.id))
- const incomingSessionIds = Object.keys(incoming.sessions ?? {})
+ const incomingSessionIds = Object.keys(incoming.sessions)
const collidingSessionIds = incomingSessionIds.filter(id => currentSessionIds.has(id))
- const collidingTabIds = (incoming.tabs ?? []).map(tab => tab.id).filter(id => currentTabIds.has(id))
+ const collidingTabIds = incoming.projects.map(project => project.id).filter(id => currentTabIds.has(id))
if (collidingSessionIds.length > 0 || collidingTabIds.length > 0) {
// WHY the whole adoption is refused rather than the colliding rows dropped:
//
// Both id spaces are `randomUUID()`, so a collision means the file was
// hand-edited or two windows somehow restored the same slice — a state
- // where "merge the parts that fit" is guessing. Dropping a colliding tab
- // would strand its sessions: alive in SessionManager, owned by no window,
- // invisible and unkillable from the UI. Refusing leaves the closed window's
- // slice on disk, so the next launch restores it as its own window with
- // everything intact. Nothing is lost; the user just gets a window back.
+ // where "merge the parts that fit" is guessing. Dropping a colliding
+ // project would strand its sessions: alive in SessionManager, owned by no
+ // window, invisible and unkillable from the UI. Refusing leaves the closed
+ // window's slice on disk, so the next launch restores it as its own window
+ // with everything intact. Nothing is lost; the user just gets a window back.
return {
ok: false,
reason: `id collision (${collidingSessionIds.length} sessions, ${collidingTabIds.length} tabs)`,
@@ -103,56 +102,28 @@ export function adoptWorkspace(
...incoming.sessions,
}
+ // Only projects that still hold a session: one whose every session the
+ // migration dropped as unowned has nothing to list.
+ const populated = new Set(Object.values(incoming.sessions).map(meta => meta.projectId))
const tabs: Tab[] = [
...current.tabs,
- ...(incoming.tabs ?? []).map(tab => ({
- id: tab.id,
- title: tab.title,
- root: tab.root,
- focusedSessionId: tab.focusedSessionId,
- })),
- ]
-
- const detachedSessions: Record = {
- ...current.detachedSessions,
- }
- for (const entry of Object.values(incoming.detachedSessions ?? {})) {
- // `projectTabIndex` is a display ordinal for the Dispatch tab chip, and the
- // adopted tabs were appended after the survivor's own. Re-deriving it keeps
- // the chip letters matching the tab strip the user is now looking at.
- const projectTabIndex = tabs.findIndex(tab => tab.id === entry.projectTabId)
- detachedSessions[entry.sessionId] = {
- ...entry,
- ...(projectTabIndex === -1 ? {} : { projectTabIndex }),
- }
- }
-
- const buried: BuriedPaneRecord[] = [
- ...current.buried,
- ...(incoming.buried ?? []),
+ ...incoming.projects
+ .filter(project => populated.has(project.id))
+ .map(project => ({ id: project.id, title: project.title })),
]
// Pins append rather than interleave: `pinnedSessionIds` order IS the Pinned
// section's render order, and the survivor's own pins are the ones the user
- // arranged most recently in the window they are still looking at.
+ // arranged most recently in the window they are still looking at. The
+ // migration has already dropped pins naming sessions it did not keep.
const pinnedSessionIds: SessionId[] = [
...current.pinnedSessionIds,
- ...(Array.isArray(incoming.pinnedSessionIds) ? incoming.pinnedSessionIds : [])
- .filter(id => sessions[id] !== undefined),
+ ...incoming.pinnedSessionIds,
]
- const adoptedLeafSessionIds: SessionId[] = []
- for (const tab of incoming.tabs ?? []) {
- for (const sessionId of collectLeaves(tab.root)) {
- if (sessions[sessionId] === undefined) continue
- adoptedLeafSessionIds.push(sessionId)
- }
- }
-
return {
ok: true,
- state: { tabs, sessions, detachedSessions, buried, pinnedSessionIds },
- adoptedLeafSessionIds,
+ state: { tabs, sessions, pinnedSessionIds },
adoptedSessionIds: incomingSessionIds,
// Drafts are half-written prompts. Losing one to a window close is exactly
// the kind of small, silent data loss autosave exists to prevent.
diff --git a/src/renderer/src/workspace/agentIndexNavigation.test.ts b/src/renderer/src/workspace/agentIndexNavigation.test.ts
index eb84e3a4f..1cde0f22b 100644
--- a/src/renderer/src/workspace/agentIndexNavigation.test.ts
+++ b/src/renderer/src/workspace/agentIndexNavigation.test.ts
@@ -3,50 +3,25 @@ import { describe, expect, it } from 'vitest'
import { navigateToAgentIndexTarget } from '@renderer/workspace/agentIndexNavigation'
import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchSelectors'
import { resolveAgentPaneLabel } from '@renderer/workspace/tile-tree/paneLabels'
-import type { TileNode, TileTabsState, WorkspaceState } from '@renderer/workspace/types'
-
-function leaf(sessionId: string): TileNode {
- return { type: 'leaf', sessionId }
-}
-
-function split(a: string, b: string): TileNode {
- return {
- type: 'split',
- direction: 'vertical',
- ratio: 0.37,
- a: leaf(a),
- b: leaf(b),
- }
-}
+import type { WorkspaceState } from '@renderer/workspace/types'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
function makeState(): WorkspaceState {
return {
tabs: [
- { id: 'tab-a', title: 'alpha', root: split('a1', 'a2'), focusedSessionId: 'a1' },
- { id: 'tab-b', title: 'beta', root: leaf('b1'), focusedSessionId: 'b1' },
- { id: 'tab-c', title: 'gamma', root: leaf('c1'), focusedSessionId: 'c1' },
+ { id: 'tab-a', title: 'alpha' },
+ { id: 'tab-b', title: 'beta' },
+ { id: 'tab-c', title: 'gamma' },
],
activeTabId: 'tab-a',
- gridRelatedSelections: {},
- dispatchMode: null,
+ stage: oneLaneStage('a1'),
sessions: {
- a1: { cwd: '/work/alpha/one', kind: 'claude' },
- a2: { cwd: '/work/alpha/two', kind: 'codex' },
- a3: { cwd: '/work/alpha/three', kind: 'claude' },
- b1: { cwd: '/work/beta/one', kind: 'codex' },
- c1: { cwd: '/work/gamma/one', kind: 'opencode' },
+ a1: { cwd: '/work/alpha/one', kind: 'claude', projectId: 'tab-a', joinedAt: 0 },
+ a2: { cwd: '/work/alpha/two', kind: 'codex', projectId: 'tab-a', joinedAt: 1 },
+ a3: { cwd: '/work/alpha/three', kind: 'claude', projectId: 'tab-a', joinedAt: 10 },
+ b1: { cwd: '/work/beta/one', kind: 'codex', projectId: 'tab-b', joinedAt: 0 },
+ c1: { cwd: '/work/gamma/one', kind: 'opencode', projectId: 'tab-c', joinedAt: 0 },
},
- detachedSessions: {
- a3: {
- sessionId: 'a3',
- surface: 'dispatch',
- projectTabId: 'tab-a',
- projectTabTitle: 'alpha',
- projectTabIndex: 0,
- detachedAt: 10,
- },
- },
- buried: [],
pinnedSessionIds: [],
}
}
@@ -60,26 +35,20 @@ function target(state: WorkspaceState, label: string) {
describe('agent index navigation', () => {
it('focuses an existing Tiled Dispatch lane without changing any lane selection', () => {
const state = makeState()
- state.dispatchMode = {
- scope: 'global',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 0,
- ratios: [0.2, 0.4, 0.4],
- lanes: [
- { selectedSessionId: 'a1' },
- { selectedSessionId: 'b1' },
- ],
- },
+ state.stage = {
+ focusedLane: 0,
+ ratios: [0.2, 0.4, 0.4],
+ lanes: [
+ { selectedSessionId: 'a1' },
+ { selectedSessionId: 'b1' },
+ ],
}
- const result = navigateToAgentIndexTarget(state, null, target(state, 'B1'))
+ const result = navigateToAgentIndexTarget(state, target(state, 'B1'))
expect(result?.kind).toBe('focus-existing-tiled-dispatch-lane')
- expect(result?.state.dispatchMode?.tiled?.focusedLane).toBe(1)
- expect(result?.state.dispatchMode?.tiled?.lanes).toEqual(
- state.dispatchMode.tiled?.lanes,
- )
- expect(result?.state.dispatchMode?.tiled?.ratios).toEqual([0.2, 0.4, 0.4])
+ expect(result?.state.stage.focusedLane).toBe(1)
+ expect(result?.state.stage.lanes).toEqual(state.stage.lanes)
+ expect(result?.state.stage.ratios).toEqual([0.2, 0.4, 0.4])
})
it('fills the focused empty lane when the bang intent names an agent', () => {
@@ -93,52 +62,42 @@ describe('agent index navigation', () => {
// intent writes into the FOCUSED lane rather than discovering some other
// lane already showing A2.
const state = makeState()
- state.dispatchMode = {
- scope: 'global',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 1,
- lanes: [{ selectedSessionId: 'a1' }, {}],
- },
+ state.stage = {
+ focusedLane: 1,
+ lanes: [{ selectedSessionId: 'a1' }, {}],
}
const result = navigateToAgentIndexTarget(
state,
- null,
target(state, 'A2'),
'open-in-focused-tiled-dispatch-lane',
)
- expect(result?.state.dispatchMode?.tiled?.focusedLane).toBe(1)
- expect(result?.state.dispatchMode?.tiled?.lanes[1])
+ expect(result?.state.stage.focusedLane).toBe(1)
+ expect(result?.state.stage.lanes[1])
.toEqual({ selectedSessionId: 'a2' })
})
it('opens an already-visible agent in the focused Tiled Dispatch lane for the bang intent', () => {
const state = makeState()
- state.dispatchMode = {
- scope: 'global',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 0,
- ratios: [0.2, 0.4, 0.4],
- lanes: [
- { selectedSessionId: 'a1' },
- { selectedSessionId: 'a2' },
- { selectedSessionId: 'b1' },
- ],
- },
+ state.stage = {
+ focusedLane: 0,
+ ratios: [0.2, 0.4, 0.4],
+ lanes: [
+ { selectedSessionId: 'a1' },
+ { selectedSessionId: 'a2' },
+ { selectedSessionId: 'b1' },
+ ],
}
const result = navigateToAgentIndexTarget(
state,
- null,
target(state, 'A2'),
'open-in-focused-tiled-dispatch-lane',
)
expect(result?.kind).toBe('replace-focused-tiled-dispatch-lane')
- expect(result?.state.dispatchMode?.tiled).toEqual({
+ expect(result?.state.stage).toEqual({
focusedLane: 0,
ratios: [0.2, 0.4, 0.4],
lanes: [
@@ -150,343 +109,100 @@ describe('agent index navigation', () => {
// Both lanes mirror one durable session. Forced placement is a view
// operation, so it must never rewrite provider ownership metadata.
expect(result?.state.sessions.a2).toBe(state.sessions.a2)
- expect(result?.requiresWake).toBe(false)
})
it('replaces only the focused Tiled Dispatch lane when the agent is absent', () => {
const state = makeState()
- state.dispatchMode = {
- scope: 'global',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 1,
- lanes: [
- { selectedSessionId: 'a1' },
- { selectedSessionId: 'b1' },
- ],
- },
+ state.stage = {
+ focusedLane: 1,
+ lanes: [
+ { selectedSessionId: 'a1' },
+ { selectedSessionId: 'b1' },
+ ],
}
- const result = navigateToAgentIndexTarget(state, null, target(state, 'A3'))
+ const result = navigateToAgentIndexTarget(state, target(state, 'A3'))
expect(result?.kind).toBe('replace-focused-tiled-dispatch-lane')
- expect(result?.state.dispatchMode?.tiled?.lanes).toEqual([
+ expect(result?.state.stage.lanes).toEqual([
{ selectedSessionId: 'a1' },
{ selectedSessionId: 'a3' },
])
- expect(result?.state.dispatchMode?.tiled?.focusedLane).toBe(1)
- expect(result?.requiresWake).toBe(true)
+ expect(result?.state.stage.focusedLane).toBe(1)
})
it('keeps the focused copy when a target appears in more than one Tiled Dispatch lane', () => {
const state = makeState()
- state.dispatchMode = {
- scope: 'global',
- focusedSessionId: 'b1',
- tiled: {
- focusedLane: 2,
- lanes: [
- { selectedSessionId: 'b1' },
- { selectedSessionId: 'a1' },
- { selectedSessionId: 'b1' },
- ],
- },
+ state.stage = {
+ focusedLane: 2,
+ lanes: [
+ { selectedSessionId: 'b1' },
+ { selectedSessionId: 'a1' },
+ { selectedSessionId: 'b1' },
+ ],
}
- const result = navigateToAgentIndexTarget(state, null, target(state, 'B1'))
+ const result = navigateToAgentIndexTarget(state, target(state, 'B1'))
expect(result?.kind).toBe('focus-existing-tiled-dispatch-lane')
- expect(result?.state.dispatchMode?.tiled?.focusedLane).toBe(2)
+ expect(result?.state.stage.focusedLane).toBe(2)
})
- it('selects a target in classic Dispatch without changing grid focus', () => {
- const state = makeState()
- state.dispatchMode = { scope: 'global', focusedSessionId: 'a1' }
-
- const result = navigateToAgentIndexTarget(state, null, target(state, 'B1'))
- expect(result?.kind).toBe('focus-classic-dispatch')
- expect(result?.state.activeTabId).toBe('tab-b')
- expect(result?.state.dispatchMode?.focusedSessionId).toBe('b1')
- expect(result?.state.tabs[0].focusedSessionId).toBe('a1')
- })
+ // Two classic-Dispatch cases lived here until #992: "selects a target in
+ // classic Dispatch without changing grid focus" and "degrades the bang
+ // intent to ordinary navigation outside Tiled Dispatch". Both exercised the
+ // 'focus-classic-dispatch' kind, which was the fallback for a Dispatch with
+ // no lanes. The stage is a required field, so that state — and the kind —
+ // can no longer be constructed.
- it('degrades the bang intent to ordinary navigation outside Tiled Dispatch', () => {
+ it('moves the active project on a cross-project swap and keeps untouched lanes resolvable', () => {
const state = makeState()
- state.dispatchMode = { scope: 'global', focusedSessionId: 'a1' }
-
- const result = navigateToAgentIndexTarget(
- state,
- null,
- target(state, 'B1'),
- 'open-in-focused-tiled-dispatch-lane',
- )
-
- expect(result?.kind).toBe('focus-classic-dispatch')
- expect(result?.state.activeTabId).toBe('tab-b')
- expect(result?.state.dispatchMode?.focusedSessionId).toBe('b1')
- })
-
- it('keeps bang navigation equivalent to ordinary navigation in grid and Tiled Tabs', () => {
- const gridState = makeState()
- const gridTarget = target(gridState, 'B1')
- expect(navigateToAgentIndexTarget(
- gridState,
- null,
- gridTarget,
- 'open-in-focused-tiled-dispatch-lane',
- )).toEqual(navigateToAgentIndexTarget(gridState, null, gridTarget))
-
- const tiledTabsState = makeState()
- const tileTabs: TileTabsState = {
- tabIds: ['tab-a', 'tab-b'],
- focusedTabId: 'tab-a',
- direction: 'horizontal',
- ratios: [0.41, 0.59],
- }
- const tiledTabsTarget = target(tiledTabsState, 'B1')
- // WHY compare the complete reducer result instead of only its kind: the
- // fallback contract includes tab membership, focus, ratios, wake state,
- // and the workspace mutation. A future early bang branch must not drift
- // any of those fields on surfaces where focused-lane placement is absent.
- expect(navigateToAgentIndexTarget(
- tiledTabsState,
- tileTabs,
- tiledTabsTarget,
- 'open-in-focused-tiled-dispatch-lane',
- )).toEqual(navigateToAgentIndexTarget(
- tiledTabsState,
- tileTabs,
- tiledTabsTarget,
- ))
- })
-
- it('focuses an already tiled tab and preserves membership, direction, and ratios', () => {
- const state = makeState()
- const tileTabs: TileTabsState = {
- tabIds: ['tab-a', 'tab-b'],
- focusedTabId: 'tab-a',
- direction: 'horizontal',
- ratios: [0.41, 0.59],
- }
-
- const result = navigateToAgentIndexTarget(state, tileTabs, target(state, 'B1'))
- expect(result?.kind).toBe('focus-tiled-tab-pane')
- expect(result?.state.activeTabId).toBe('tab-b')
- expect(result?.tileTabs).toEqual({
- ...tileTabs,
- focusedTabId: 'tab-b',
- })
- })
-
- it('uses the focused Tiled Tab slot for a target owned by a non-tiled tab', () => {
- const state = makeState()
- const tileTabs: TileTabsState = {
- tabIds: ['tab-a', 'tab-b'],
- focusedTabId: 'tab-b',
- direction: 'vertical',
- ratios: [0.3, 0.7],
+ state.stage = {
+ focusedLane: 1,
+ lanes: [
+ { selectedSessionId: 'a1' },
+ { selectedSessionId: 'a2' },
+ ],
}
- const result = navigateToAgentIndexTarget(state, tileTabs, target(state, 'C1'))
- expect(result?.kind).toBe('replace-focused-tiled-tab')
- expect(result?.tileTabs).toEqual({
- tabIds: ['tab-a', 'tab-c'],
- focusedTabId: 'tab-c',
- direction: 'vertical',
- ratios: [0.3, 0.7],
- })
- expect(result?.state.tabs.find(tab => tab.id === 'tab-c')?.focusedSessionId).toBe('c1')
- })
+ const result = navigateToAgentIndexTarget(state, target(state, 'B1'))
- it('activates and focuses an existing pane in the regular grid', () => {
- const state = makeState()
- const result = navigateToAgentIndexTarget(state, null, target(state, 'B1'))
- expect(result?.kind).toBe('focus-grid-pane')
+ // This used to assert a promotion of the layout-wide scope to 'global':
+ // project-scoped rows derived from activeTabId, so moving it would have
+ // blanked lane 0. With no scope, the property that mattered is asserted
+ // directly — the active project moves AND the untouched lane's agent is
+ // still in the visible rows.
expect(result?.state.activeTabId).toBe('tab-b')
- expect(result?.state.tabs.find(tab => tab.id === 'tab-b')?.focusedSessionId).toBe('b1')
- })
-
- it('focuses a related agent already rendered inside its owner pane', () => {
- const state = makeState()
- state.sessions.child = {
- cwd: '/work/alpha/child',
- kind: 'codex',
- linkedParentId: 'a2',
- }
- state.detachedSessions.child = {
- sessionId: 'child',
- surface: 'dispatch',
- projectTabId: 'tab-a',
- projectTabTitle: 'alpha',
- projectTabIndex: 0,
- detachedAt: 20,
- }
- state.gridRelatedSelections = { a2: 'child' }
-
- const result = navigateToAgentIndexTarget(state, null, target(state, 'A4'))
- expect(result?.kind).toBe('focus-grid-pane')
- expect(result?.state.tabs[0].focusedSessionId).toBe('a2')
- expect(result?.state.gridRelatedSelections).toEqual({ a2: 'child' })
- })
-
- it('selects a physical pane owner when that pane currently shows a related child', () => {
- const state = makeState()
- state.sessions.child = {
- cwd: '/work/alpha/child',
- kind: 'codex',
- linkedParentId: 'a2',
- }
- state.detachedSessions.child = {
- sessionId: 'child',
- surface: 'dispatch',
- projectTabId: 'tab-a',
- projectTabTitle: 'alpha',
- projectTabIndex: 0,
- detachedAt: 20,
- }
- state.gridRelatedSelections = { a2: 'child' }
-
- const result = navigateToAgentIndexTarget(state, null, target(state, 'A2'))
- expect(result?.kind).toBe('focus-grid-pane')
- expect(result?.state.tabs[0].focusedSessionId).toBe('a2')
- expect(result?.state.gridRelatedSelections).toEqual({})
- })
-
- it('swaps a detached target into the focused grid leaf without reshaping the grid', () => {
- const state = makeState()
- const result = navigateToAgentIndexTarget(state, null, target(state, 'A3'))
- expect(result?.kind).toBe('swap-detached-into-focused-grid-pane')
- expect(result?.requiresWake).toBe(true)
- expect(result?.state.tabs[0].root).toEqual({
- type: 'split',
- direction: 'vertical',
- ratio: 0.37,
- a: leaf('a3'),
- b: leaf('a2'),
- })
- expect(result?.state.tabs[0].focusedSessionId).toBe('a3')
- expect(result?.state.detachedSessions.a3).toBeUndefined()
- expect(result?.state.detachedSessions.a1).toMatchObject({
- sessionId: 'a1',
- projectTabId: 'tab-a',
- detachedAt: 10,
- })
- expect(resolveAgentPaneLabel(result!.state, 'A3')?.sessionId).toBe('a1')
- expect(result?.state.sessions.a1).toBe(state.sessions.a1)
- expect(result?.state.sessions.a3).toBe(state.sessions.a3)
- })
-
- it('swaps a detached target into the focused pane of the focused Tiled Tab', () => {
- const state = makeState()
- const tileTabs: TileTabsState = {
- tabIds: ['tab-a', 'tab-b'],
- focusedTabId: 'tab-b',
- direction: 'vertical',
- ratios: [0.5, 0.5],
- }
-
- const result = navigateToAgentIndexTarget(state, tileTabs, target(state, 'A3'))
- expect(result?.kind).toBe('swap-detached-into-focused-grid-pane')
- expect(result?.tileTabs).toEqual(tileTabs)
- expect(result?.state.tabs.find(tab => tab.id === 'tab-b')?.root).toEqual(leaf('a3'))
- expect(result?.state.detachedSessions.b1).toMatchObject({
- sessionId: 'b1',
- projectTabId: 'tab-a',
- detachedAt: 10,
- })
- })
-
- it('preserves every other detached coordinate when swapping a target into the grid', () => {
- const state = makeState()
- state.sessions.a4 = { cwd: '/work/alpha/four', kind: 'codex' }
- state.detachedSessions.a4 = {
- sessionId: 'a4',
- surface: 'dispatch',
- projectTabId: 'tab-a',
- projectTabTitle: 'alpha',
- projectTabIndex: 0,
- detachedAt: 20,
- }
-
- const result = navigateToAgentIndexTarget(state, null, target(state, 'A3'))
-
- expect(resolveAgentPaneLabel(result!.state, 'A3')?.sessionId).toBe('a1')
- expect(resolveAgentPaneLabel(result!.state, 'A4')?.sessionId).toBe('a4')
- expect(result?.state.detachedSessions.a4).toBe(state.detachedSessions.a4)
- })
-
- it('promotes a cross-project Tiled Dispatch swap so untouched lanes stay in scope', () => {
- const state = makeState()
- state.dispatchMode = {
- scope: 'project',
- focusedSessionId: 'a2',
- tiled: {
- focusedLane: 1,
- lanes: [
- { selectedSessionId: 'a1' },
- { selectedSessionId: 'a2' },
- ],
- },
- }
-
- const result = navigateToAgentIndexTarget(state, null, target(state, 'B1'))
-
- expect(result?.state.dispatchMode?.scope).toBe('global')
- expect(result?.state.dispatchMode?.tiled?.lanes).toEqual([
+ expect(result?.state.stage.lanes).toEqual([
{ selectedSessionId: 'a1' },
{ selectedSessionId: 'b1' },
])
expect(buildVisibleDispatchRows(result!.state).map(row => row.sessionId)).toContain('a1')
})
- it('promotes a forced cross-project mirror while retaining the existing copy', () => {
+ it('mirrors a forced cross-project target while retaining the existing copy', () => {
const state = makeState()
- state.dispatchMode = {
- scope: 'project',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 0,
- lanes: [
- { selectedSessionId: 'a1' },
- // Restored layouts can temporarily retain an out-of-scope lane. The
- // forced intent must ignore this existing copy, then promote scope
- // before the layout healer evaluates either mirrored lane.
- { selectedSessionId: 'b1' },
- ],
- },
+ state.stage = {
+ focusedLane: 0,
+ lanes: [
+ { selectedSessionId: 'a1' },
+ // The forced intent must ignore this existing copy rather than
+ // jump focus to it: `B1!` means "here", in the focused lane.
+ { selectedSessionId: 'b1' },
+ ],
}
const result = navigateToAgentIndexTarget(
state,
- null,
target(state, 'B1'),
'open-in-focused-tiled-dispatch-lane',
)
expect(result?.state.activeTabId).toBe('tab-b')
- expect(result?.state.dispatchMode?.scope).toBe('global')
- expect(result?.state.dispatchMode?.tiled?.lanes).toEqual([
+ expect(result?.state.stage.lanes).toEqual([
{ selectedSessionId: 'b1' },
{ selectedSessionId: 'b1' },
])
})
- it('focuses a grid terminal through the same navigation as an agent (#865)', () => {
- const state: WorkspaceState = {
- tabs: [{
- id: 'tab', title: 'project',
- root: { type: 'split', direction: 'vertical', ratio: 0.5, a: { type: 'leaf', sessionId: 'agent' }, b: { type: 'leaf', sessionId: 'shell' } },
- focusedSessionId: 'agent',
- }],
- activeTabId: 'tab', dispatchMode: null, gridRelatedSelections: {},
- sessions: { agent: { cwd: '/w', kind: 'claude' }, shell: { cwd: '/w', kind: 'terminal' } },
- detachedSessions: {}, buried: [], pinnedSessionIds: [],
- }
- const target = resolveAgentPaneLabel(state, 'A2')
- expect(target?.sessionId).toBe('shell')
- const result = navigateToAgentIndexTarget(state, null, target!)
- expect(result?.kind).toBe('focus-grid-pane')
- expect(result?.state.tabs[0].focusedSessionId).toBe('shell')
- })
-
it('moves a detached terminal into the focused Tiled Dispatch lane (#865)', () => {
// Mirrors "replaces only the focused Tiled Dispatch lane when the agent is
// absent" above, but with a terminal target: #865 widened the label/index
@@ -494,89 +210,22 @@ describe('agent index navigation', () => {
// must accept a terminal exactly like an agent — there is nothing in the
// lane-selection path that is agent-specific.
const state = makeState()
- state.sessions.a4 = { cwd: '/work/alpha/term', kind: 'terminal' }
- state.detachedSessions.a4 = {
- sessionId: 'a4',
- surface: 'dispatch',
- projectTabId: 'tab-a',
- projectTabTitle: 'alpha',
- projectTabIndex: 0,
- detachedAt: 20,
- }
- state.dispatchMode = {
- scope: 'global',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 1,
- lanes: [
- { selectedSessionId: 'a1' },
- { selectedSessionId: 'b1' },
- ],
- },
+ state.sessions.a4 = { cwd: '/work/alpha/term', kind: 'terminal', projectId: 'tab-a', joinedAt: 20 }
+ state.stage = {
+ focusedLane: 1,
+ lanes: [
+ { selectedSessionId: 'a1' },
+ { selectedSessionId: 'b1' },
+ ],
}
- const result = navigateToAgentIndexTarget(state, null, target(state, 'A4'))
+ const result = navigateToAgentIndexTarget(state, target(state, 'A4'))
expect(result?.kind).toBe('replace-focused-tiled-dispatch-lane')
- expect(result?.state.dispatchMode?.tiled?.lanes).toEqual([
+ expect(result?.state.stage.lanes).toEqual([
{ selectedSessionId: 'a1' },
{ selectedSessionId: 'a4' },
])
- expect(result?.state.dispatchMode?.tiled?.focusedLane).toBe(1)
- expect(result?.requiresWake).toBe(true)
- })
-
- it('swaps a detached terminal into the focused grid pane (#865)', () => {
- // Mirrors "swaps a detached target into the focused grid leaf without
- // reshaping the grid" above, but with a terminal target: the swap logic
- // only cares about SessionId/detached-record bookkeeping, never about
- // provider kind, so a terminal must land exactly like an agent would.
- const state = makeState()
- state.sessions.a4 = { cwd: '/work/alpha/term', kind: 'terminal' }
- state.detachedSessions.a4 = {
- sessionId: 'a4',
- surface: 'dispatch',
- projectTabId: 'tab-a',
- projectTabTitle: 'alpha',
- projectTabIndex: 0,
- detachedAt: 20,
- }
-
- const result = navigateToAgentIndexTarget(state, null, target(state, 'A4'))
- expect(result?.kind).toBe('swap-detached-into-focused-grid-pane')
- expect(result?.requiresWake).toBe(true)
- expect(result?.state.tabs[0].root).toEqual({
- type: 'split',
- direction: 'vertical',
- ratio: 0.37,
- a: leaf('a4'),
- b: leaf('a2'),
- })
- expect(result?.state.tabs[0].focusedSessionId).toBe('a4')
+ expect(result?.state.stage.focusedLane).toBe(1)
})
- it('follows visible Tiled Tabs when stale restored state also contains Dispatch', () => {
- const state = makeState()
- state.dispatchMode = {
- scope: 'global',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 0,
- lanes: [{ selectedSessionId: 'a1' }],
- },
- }
- const tileTabs: TileTabsState = {
- tabIds: ['tab-a', 'tab-b'],
- focusedTabId: 'tab-a',
- direction: 'horizontal',
- ratios: [0.5, 0.5],
- }
- const resolved = resolveAgentPaneLabel(state, 'B1', tileTabs)
- if (!resolved) throw new Error('Missing B1 target')
-
- const result = navigateToAgentIndexTarget(state, tileTabs, resolved)
-
- expect(result?.kind).toBe('focus-tiled-tab-pane')
- expect(result?.tileTabs?.focusedTabId).toBe('tab-b')
- expect(result?.state.dispatchMode).toBe(state.dispatchMode)
- })
})
diff --git a/src/renderer/src/workspace/agentIndexNavigation.ts b/src/renderer/src/workspace/agentIndexNavigation.ts
index 64e7cc06b..bca23c993 100644
--- a/src/renderer/src/workspace/agentIndexNavigation.ts
+++ b/src/renderer/src/workspace/agentIndexNavigation.ts
@@ -1,25 +1,23 @@
-import { buildGridRelatedAgentTabs, selectedGridRelatedSessionId } from '@renderer/workspace/gridRelatedAgents'
import { withLaneSession } from '@renderer/workspace/dispatch/tiledDispatchSelectors'
-import {
- collectLeaves,
- remapTileTreeSessionIds,
-} from '@renderer/workspace/tile-tree/treeOps'
-import type {
- SessionId,
- TabId,
- TileTabsState,
- WorkspaceState,
-} from '@renderer/workspace/types'
+import type { WorkspaceState } from '@renderer/workspace/types'
import type { AgentPaneLabelTarget } from '@renderer/workspace/tile-tree/paneLabels'
+// The navigation kinds that survived the unified layout (#992).
+//
+// Four tree kinds lived here first: 'focus-grid-pane', 'focus-tiled-tab-pane',
+// 'replace-focused-tiled-tab' and 'swap-detached-into-focused-grid-pane'.
+// Each of them moved focus inside — or swapped a session into — a tile tree
+// or a Tile Tabs slot. Nothing renders a tree or Tile Tabs any more, so those
+// branches could only mutate state the user cannot see; they were deleted
+// rather than left as silent successes.
+//
+// 'focus-classic-dispatch' went next, when the stage became a required field:
+// it was the fallback for a Dispatch with no lanes, which can no longer be
+// represented. Two kinds remain, and they are the whole question a label
+// asks — "is it already on a lane, or does the focused lane take it?"
export type AgentIndexNavigationKind =
- | 'focus-grid-pane'
- | 'focus-tiled-tab-pane'
- | 'replace-focused-tiled-tab'
- | 'focus-classic-dispatch'
| 'focus-existing-tiled-dispatch-lane'
| 'replace-focused-tiled-dispatch-lane'
- | 'swap-detached-into-focused-grid-pane'
export type AgentIndexNavigationIntent =
| 'reuse-existing-view'
@@ -28,285 +26,85 @@ export type AgentIndexNavigationIntent =
export type AgentIndexNavigationResult = {
kind: AgentIndexNavigationKind
state: WorkspaceState
- tileTabs: TileTabsState | null
- /** Detached sessions may be hibernated after app restart. The caller must
- * wake the target under the same SessionId before committing this result. */
- requiresWake: boolean
-}
-
-type GridViewSlot = {
- tabId: TabId
- ownerSessionId: SessionId
+ // `requiresWake` was a field here until #992, computed as "the target has a
+ // detachedSessions record". That was a STRUCTURAL guess at a runtime fact —
+ // wrong both ways: a parked agent already woken from a lane was re-woken,
+ // and a tree leaf whose respawn had failed was not. Whether a backend exists
+ // is a question for the runtime, which a pure state reducer does not have,
+ // so the caller decides (hook/actions/agentIndexNavigation.ts).
}
/**
* Compute the one navigation mutation behind command-palette agent labels.
*
* WHY this is a pure workspace reducer instead of a branch pile in
- * CommandPalette: "A2 is already open" means a different thing in each
- * top-level surface. Keeping the precedence here lets tests prove the key
- * invariant globally: an existing rendered slot wins, and the focused slot is
- * replaced only when no existing slot can display the target.
+ * CommandPalette: "A2 is already open" has a precise meaning — a lane already
+ * shows it. Keeping the precedence here lets tests prove the key invariant:
+ * an existing lane wins, and the focused lane is replaced only when no lane
+ * can display the target (or the user asked for the focused lane with `A2!`).
*/
export function navigateToAgentIndexTarget(
state: WorkspaceState,
- tileTabs: TileTabsState | null,
target: AgentPaneLabelTarget,
intent: AgentIndexNavigationIntent = 'reuse-existing-view',
): AgentIndexNavigationResult | null {
// Any session kind is a valid navigation target (#865): this guard used to
- // also require an AgentProviderKind, but Dispatch ⌘N and ⌥↑/↓ already moved
- // focus onto terminals, so the label/index path only needs to confirm the
- // session still exists — the same check every branch below already assumes.
+ // also require an AgentProviderKind, but ⌘N and ⌥↑/↓ already moved focus
+ // onto terminals, so the label/index path only needs to confirm the session
+ // still exists — the same check every branch below already assumes.
const meta = state.sessions[target.sessionId]
if (!meta) return null
- const requiresWake = state.detachedSessions[target.sessionId] !== undefined
- const dispatchMode = state.dispatchMode
- // TileTabs is the visible MainSurface whenever both slices are restored.
- // Normal actions keep the modes mutually exclusive, but rehydrate accepts
- // both persisted fields independently. Never mutate hidden Dispatch state
- // while the user is looking at tiled tabs.
- if (!tileTabs && dispatchMode?.tiled) {
- const tiled = dispatchMode.tiled
- const forceFocusedLane = intent === 'open-in-focused-tiled-dispatch-lane'
- // Duplicated lanes are legal. If the currently focused lane already shows
- // the target, keep it rather than jumping left to the first duplicate;
- // otherwise the first rendered copy is the deterministic destination.
- //
- // WHY the bang intent deliberately reports no existing lane: `A2!` is the
- // user's request to curate the CURRENT lane, not to discover where A2 is
- // already visible. Tiled Dispatch explicitly permits mirrored lanes, so
- // skipping this lookup creates another view of the same live session
- // without cloning or restarting its provider process.
- const existingLane = forceFocusedLane
- ? -1
- : tiled.lanes[tiled.focusedLane]?.selectedSessionId === target.sessionId
- ? tiled.focusedLane
- : tiled.lanes.findIndex(lane => lane.selectedSessionId === target.sessionId)
- const focusedLane = existingLane >= 0
- ? existingLane
- : Math.max(0, Math.min(tiled.focusedLane, tiled.lanes.length - 1))
- if (focusedLane < 0 || !tiled.lanes[focusedLane]) return null
-
- const lanes = existingLane >= 0
- ? tiled.lanes
- : tiled.lanes.map((lane, index) => (
- index === focusedLane
- ? withLaneSession(lane, target.sessionId)
- : lane
- ))
- const crossesProjectScope =
- dispatchMode.scope !== 'global' && target.tabId !== state.activeTabId
- return {
- kind: existingLane >= 0
- ? 'focus-existing-tiled-dispatch-lane'
- : 'replace-focused-tiled-dispatch-lane',
- state: {
- ...state,
- // Project-scoped Dispatch derives its visible rows from activeTabId.
- // A cross-project label must move that scope before selecting the
- // session, or the lane cannot resolve and renders "Not in this scope"
- // instead of the agent the user just asked for. (Before #681 the
- // consequence was worse — the healer replaced the selection outright.)
- activeTabId: target.tabId,
- dispatchMode: {
- ...dispatchMode,
- // A project-scoped row set cannot retain lanes from project A after
- // activeTabId moves to project B: every untouched A lane would stop
- // resolving and render empty. Promoting the one cross-project
- // navigation to global keeps both the retained lanes and the incoming
- // target renderable, preserving the issue's "replace only the focused
- // lane" invariant. Their selections would survive either way now
- // (#681), but a grid of blank lanes is not a useful place to land.
- scope: crossesProjectScope ? 'global' : dispatchMode.scope,
- // Keep classic focus coherent for a later exit from tiled mode.
- focusedSessionId: target.sessionId,
- tiled: {
- ...tiled,
- lanes,
- focusedLane,
- },
- },
- },
- tileTabs,
- requiresWake,
- }
- }
-
- if (!tileTabs && dispatchMode) {
- return {
- kind: 'focus-classic-dispatch',
- state: {
- ...state,
- activeTabId: target.tabId,
- dispatchMode: {
- ...dispatchMode,
- focusedSessionId: target.sessionId,
- },
- },
- tileTabs,
- requiresWake,
- }
- }
-
- const existingGridSlot = findExistingGridViewSlot(state, target.sessionId)
- if (existingGridSlot) {
- const nextState = focusGridViewSlot(state, existingGridSlot, target.sessionId)
- if (!tileTabs) {
- return {
- kind: 'focus-grid-pane',
- state: nextState,
- tileTabs,
- requiresWake,
- }
- }
-
- if (tileTabs.tabIds.includes(existingGridSlot.tabId)) {
- return {
- kind: 'focus-tiled-tab-pane',
- state: nextState,
- tileTabs: { ...tileTabs, focusedTabId: existingGridSlot.tabId },
- requiresWake,
- }
- }
-
- const focusedSlotIndex = tileTabs.tabIds.indexOf(tileTabs.focusedTabId)
- if (focusedSlotIndex < 0) return null
- const tabIds = tileTabs.tabIds.map((tabId, index) => (
- index === focusedSlotIndex ? existingGridSlot.tabId : tabId
- ))
- return {
- kind: 'replace-focused-tiled-tab',
- state: nextState,
- // A non-tiled tab already owns the target pane. Replacing only the
- // focused meta-tab is the view-slot equivalent of switching the single
- // active tab: it reveals the existing pane without moving its session or
- // disturbing the other tiled tabs and their ratios.
- tileTabs: {
- ...tileTabs,
- tabIds,
- focusedTabId: existingGridSlot.tabId,
- },
- requiresWake,
- }
- }
-
- const detached = state.detachedSessions[target.sessionId]
- if (!detached) return null
- const destinationTabId = tileTabs?.focusedTabId ?? state.activeTabId
- const destinationTab = state.tabs.find(tab => tab.id === destinationTabId)
- if (!destinationTab) return null
- const destinationLeaves = collectLeaves(destinationTab.root)
- const displacedSessionId = destinationLeaves.includes(destinationTab.focusedSessionId)
- ? destinationTab.focusedSessionId
- : destinationLeaves[0]
- if (!displacedSessionId) return null
-
- const idMap = new Map([
- [displacedSessionId, target.sessionId],
- ])
- const detachedSessions = { ...state.detachedSessions }
- delete detachedSessions[target.sessionId]
- // WHY the displaced session inherits the target's exact detached record:
- // this operation is a placement swap, not "attach target, then append the
- // old pane somewhere." Reusing detachedAt and project ownership preserves
- // the vacated visible coordinate, leaves every other detached row in place,
- // and gives a cross-project displaced agent the slot the target actually
- // vacated. Appending with Date.now() silently renumbered unrelated agents.
- detachedSessions[displacedSessionId] = {
- ...detached,
- sessionId: displacedSessionId,
- }
-
- const gridRelatedSelections = Object.fromEntries(
- Object.entries(state.gridRelatedSelections ?? {}).filter(
- ([ownerSessionId, selectedSessionId]) => (
- ownerSessionId !== displacedSessionId &&
- selectedSessionId !== target.sessionId
- ),
- ),
- )
-
+ const tiled = state.stage
+ const forceFocusedLane = intent === 'open-in-focused-tiled-dispatch-lane'
+ // Duplicated lanes are legal. If the currently focused lane already shows
+ // the target, keep it rather than jumping left to the first duplicate;
+ // otherwise the first rendered copy is the deterministic destination.
+ //
+ // WHY the bang intent deliberately reports no existing lane: `A2!` is the
+ // user's request to curate the CURRENT lane, not to discover where A2 is
+ // already visible. Mirrored lanes are explicitly permitted, so skipping
+ // this lookup creates another view of the same live session without
+ // cloning or restarting its provider process.
+ const existingLane = forceFocusedLane
+ ? -1
+ : tiled.lanes[tiled.focusedLane]?.selectedSessionId === target.sessionId
+ ? tiled.focusedLane
+ : tiled.lanes.findIndex(lane => lane.selectedSessionId === target.sessionId)
+ const focusedLane = existingLane >= 0
+ ? existingLane
+ : Math.max(0, Math.min(tiled.focusedLane, tiled.lanes.length - 1))
+ // Defensive only: the stage invariant is "at least one lane", but this
+ // reducer is handed arbitrary state by tests and by the control plane, and
+ // writing a selection into a lane that does not exist would silently grow
+ // `lanes` past what `rows` accounts for.
+ if (!tiled.lanes[focusedLane]) return null
+
+ const lanes = existingLane >= 0
+ ? tiled.lanes
+ : tiled.lanes.map((lane, index) => (
+ index === focusedLane
+ ? withLaneSession(lane, target.sessionId)
+ : lane
+ ))
return {
- kind: 'swap-detached-into-focused-grid-pane',
+ kind: existingLane >= 0
+ ? 'focus-existing-tiled-dispatch-lane'
+ : 'replace-focused-tiled-dispatch-lane',
state: {
...state,
- activeTabId: destinationTabId,
- detachedSessions,
- gridRelatedSelections,
- tabs: state.tabs.map(tab => (
- tab.id === destinationTabId
- ? {
- ...tab,
- // Remapping the one leaf preserves every split node and ratio.
- // Closing + reinserting would reshape the user's grid and make
- // a navigation shortcut behave like a layout command.
- root: remapTileTreeSessionIds(tab.root, idMap),
- focusedSessionId: target.sessionId,
- }
- : tab
- )),
+ // The active project follows the target. It is a LABEL now (U4): it
+ // decides which project a new agent defaults into and which header is
+ // highlighted, not which sessions a lane may resolve.
+ //
+ // This write used to carry a second one beside it. Project-scoped rows
+ // derived from activeTabId, so a cross-project label had to promote the
+ // layout-wide scope to 'global' or every untouched lane stopped
+ // resolving and rendered "Not in this scope". The scope is gone — every
+ // index lists every project and a lane resolves any live session — so
+ // moving the label can no longer blank a lane.
+ activeTabId: target.tabId,
+ stage: { ...tiled, lanes, focusedLane },
},
- tileTabs,
- requiresWake: true,
- }
-}
-
-function findExistingGridViewSlot(
- state: WorkspaceState,
- targetSessionId: SessionId,
-): GridViewSlot | null {
- for (const tab of state.tabs) {
- for (const ownerSessionId of collectLeaves(tab.root)) {
- // A physical owner still counts as the target's existing slot even when
- // its related-agent mini-tab currently shows a child. Focusing A2 should
- // select A2 in its own pane, not detach A2 and move it somewhere else.
- if (ownerSessionId === targetSessionId) {
- return { tabId: tab.id, ownerSessionId }
- }
- if (
- selectedGridRelatedSessionId(state, tab.id, ownerSessionId) ===
- targetSessionId
- ) {
- return { tabId: tab.id, ownerSessionId }
- }
- }
- }
- // A hidden related mini-tab is an existing view route too. Prefer physical
- // and already selected slots above; then reveal a valid related child in its
- // owner's pane before considering a detached-to-grid swap. Both UI labels
- // and stable-ID navigation must agree about this placement ownership.
- for (const tab of state.tabs) {
- for (const ownerSessionId of collectLeaves(tab.root)) {
- if (buildGridRelatedAgentTabs(state, tab.id, ownerSessionId).some(child => child.sessionId === targetSessionId)) {
- return { tabId: tab.id, ownerSessionId }
- }
- }
- }
- return null
-}
-
-function focusGridViewSlot(
- state: WorkspaceState,
- slot: GridViewSlot,
- targetSessionId: SessionId,
-): WorkspaceState {
- const gridRelatedSelections = { ...(state.gridRelatedSelections ?? {}) }
- if (slot.ownerSessionId === targetSessionId) {
- delete gridRelatedSelections[slot.ownerSessionId]
- } else {
- gridRelatedSelections[slot.ownerSessionId] = targetSessionId
- }
-
- return {
- ...state,
- activeTabId: slot.tabId,
- gridRelatedSelections,
- tabs: state.tabs.map(tab => (
- tab.id === slot.tabId
- ? { ...tab, focusedSessionId: slot.ownerSessionId }
- : tab
- )),
}
}
diff --git a/src/renderer/src/workspace/agentManagementMcp.test.ts b/src/renderer/src/workspace/agentManagementMcp.test.ts
index 6fbd49652..e4b959a58 100644
--- a/src/renderer/src/workspace/agentManagementMcp.test.ts
+++ b/src/renderer/src/workspace/agentManagementMcp.test.ts
@@ -10,6 +10,7 @@ import {
managedTranscriptUnavailableReason,
readManagedAgentOutputs,
} from '@renderer/workspace/agentManagementMcp'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
function stateFixture(): WorkspaceState {
return {
@@ -17,64 +18,34 @@ function stateFixture(): WorkspaceState {
{
id: 'project-a',
title: 'Project A',
- focusedSessionId: 'caller',
- root: {
- type: 'split',
- direction: 'vertical',
- ratio: 0.5,
- a: { type: 'leaf', sessionId: 'caller' },
- b: {
- type: 'split',
- direction: 'horizontal',
- ratio: 0.5,
- a: { type: 'leaf', sessionId: 'grid-agent' },
- b: { type: 'leaf', sessionId: 'terminal' },
- },
- },
},
{
id: 'project-b',
title: 'Project B',
- focusedSessionId: 'foreign',
- root: { type: 'leaf', sessionId: 'foreign' },
},
],
activeTabId: 'project-a',
- dispatchMode: null,
+ stage: oneLaneStage('caller'),
sessions: {
- caller: { cwd: '/same/cwd', kind: 'claude', providerSessionId: 'provider-caller' },
- 'grid-agent': { cwd: '/worktree/a', kind: 'codex', title: 'Grid reviewer' },
- terminal: { cwd: '/same/cwd', kind: 'terminal' },
- dispatch: { cwd: '/worktree/dispatch', kind: 'opencode' },
- buried: { cwd: '/worktree/buried', kind: 'claude', linkedParentId: 'grid-agent' },
- foreign: { cwd: '/same/cwd', kind: 'claude' },
+ caller: { cwd: '/same/cwd', kind: 'claude', providerSessionId: 'provider-caller', projectId: 'project-a', joinedAt: 0 },
+ 'grid-agent': { cwd: '/worktree/a', kind: 'codex', title: 'Grid reviewer', projectId: 'project-a', joinedAt: 1 },
+ terminal: { cwd: '/same/cwd', kind: 'terminal', projectId: 'project-a', joinedAt: 2 },
+ dispatch: { cwd: '/worktree/dispatch', kind: 'opencode', projectId: 'project-a', joinedAt: 10 },
+ // The ids keep their v2 names (`grid-agent`, `dispatch`, `buried`) because
+ // they were chosen to cover the three OWNER STRUCTURES a session could
+ // live in. All three are the same thing now — a pool row of project-a —
+ // which is exactly what the listing case below asserts.
+ buried: { cwd: '/worktree/buried', kind: 'claude', linkedParentId: 'grid-agent', projectId: 'project-a', joinedAt: 20 },
+ foreign: { cwd: '/same/cwd', kind: 'claude', projectId: 'project-b', joinedAt: 0 },
+ // Deliberately UNFILED: a row that names no project has no project scope.
stale: { cwd: '/same/cwd', kind: 'claude' },
},
- detachedSessions: {
- dispatch: {
- sessionId: 'dispatch',
- surface: 'dispatch',
- projectTabId: 'project-a',
- projectTabTitle: 'Project A',
- projectTabIndex: 0,
- detachedAt: 10,
- },
- },
- buried: [{
- id: 'buried-record',
- sessionId: 'buried',
- sessionMeta: { cwd: '/worktree/buried', kind: 'claude' },
- buriedAt: 20,
- sourceTabId: 'project-a',
- sourceTabTitle: 'Project A',
- sourceTabIndex: 0,
- }],
pinnedSessionIds: [],
}
}
describe('Agent Management project authority', () => {
- it('lists grid, Dispatch, and buried agents by exact tab ownership', () => {
+ it('lists every agent filed under the caller\'s project, in index order, and nothing else', () => {
const listed = listManagedAgentDescriptors({
state: stateFixture(),
runtimes: {},
@@ -87,10 +58,16 @@ describe('Agent Management project authority', () => {
item.agent.placement,
item.agent.isCaller,
])).toEqual([
- ['caller', 'grid', true],
- ['grid-agent', 'grid', false],
+ // `placement` is 'dispatch' for all of them: in this published contract
+ // the value has always meant "a row in the project's agent index", which
+ // every pool session is. 'grid' and 'buried' named v2 owner structures
+ // that no longer exist (the enum is narrowed in stage 7 of #992).
+ // The terminal is not an agent; `foreign` is another project's; `stale`
+ // names no project at all.
+ ['caller', 'dispatch', true],
+ ['grid-agent', 'dispatch', false],
['dispatch', 'dispatch', false],
- ['buried', 'buried', false],
+ ['buried', 'dispatch', false],
])
})
@@ -108,22 +85,32 @@ describe('Agent Management project authority', () => {
})).toThrow('self_target_forbidden')
})
- it('fails closed when corrupt workspace state assigns two placements', () => {
+ it('fails closed for a row whose project is missing or gone', () => {
+ // Re-based with #992. This was "fails closed when corrupt workspace state
+ // assigns two placements": v2 searched three owner structures, so a corrupt
+ // save could list one session in two of them and make project scope depend
+ // on iteration order. One field cannot be ambiguous, so that corruption is
+ // unrepresentable. The failure that IS still representable is a row whose
+ // `projectId` points nowhere — and scope is what authorizes a cross-agent
+ // read, so it must be refused rather than guessed (never "the active
+ // project", never "the caller's").
const state = stateFixture()
- state.detachedSessions['grid-agent'] = {
- sessionId: 'grid-agent',
- surface: 'dispatch',
- projectTabId: 'project-a',
- projectTabTitle: 'Project A',
- projectTabIndex: 0,
- detachedAt: 30,
- }
+ state.sessions['grid-agent'] = { ...state.sessions['grid-agent']!, projectId: 'project-deleted' }
const listed = listManagedAgentDescriptors({
state,
runtimes: {},
callerSessionId: 'caller',
})
expect(listed.agents.map(item => item.agent.sessionId)).not.toContain('grid-agent')
+ expect(listed.agents.map(item => item.agent.sessionId)).not.toContain('stale')
+ // `agent_not_found`, not `agent_not_in_project`: the second code means "it
+ // belongs to a DIFFERENT project", which would leak that the session exists
+ // and is owned. A row with no resolvable project is, to this caller, not an
+ // agent at all.
+ expect(() => assertManagedTarget({ state, callerSessionId: 'caller', sessionId: 'grid-agent' }))
+ .toThrow('agent_not_found')
+ expect(() => assertManagedTarget({ state, callerSessionId: 'caller', sessionId: 'stale' }))
+ .toThrow('agent_not_found')
})
it('treats a trailing unresolved user turn as waiting after restart', () => {
@@ -173,26 +160,18 @@ describe('Agent Management project authority', () => {
})).toEqual(['buried'])
})
- it('closing the last grid leaf promotes a survivor and affects no siblings', () => {
- // #886 review M1. This used to assert every project sibling was affected,
- // because closing a tab's last leaf removed the tab. The tool's close runs
- // with requireConfirmation, which is session-scoped (never the human Close
- // Tab choice) and promotes the next Dispatch row; the promotion itself is
- // pinned by closeAgentScope's renderer tests. Only the linked descendant
- // ('buried' names 'grid-agent' as its parent) is still affected — the
- // caller and the unrelated 'dispatch' row must NOT be reported, because the
- // calling model acts on this list.
+ it('reports only linked descendants, wherever the target sits in the project', () => {
+ // #886 review M1. This once asserted every project sibling was affected,
+ // because closing a tab's last TILE LEAF removed the tab. A close is
+ // session-scoped always now (#992): no position in a project makes a
+ // session's close take a sibling with it. Only the linked descendant
+ // ('buried' names 'grid-agent' as its parent) is affected — the caller and
+ // the unrelated 'dispatch' row must NOT be reported, because the calling
+ // model acts on this list. The caller is re-ordered AFTER the target so
+ // the target is the project's FIRST row, the position that used to be the
+ // special one.
const state = stateFixture()
- state.tabs[0]!.root = { type: 'leaf', sessionId: 'grid-agent' }
- state.tabs[0]!.focusedSessionId = 'grid-agent'
- state.detachedSessions.caller = {
- sessionId: 'caller',
- surface: 'dispatch',
- projectTabId: 'project-a',
- projectTabTitle: 'Project A',
- projectTabIndex: 0,
- detachedAt: 5,
- }
+ state.sessions.caller = { ...state.sessions.caller!, joinedAt: 5 }
expect(additionalCloseImpact({
state,
diff --git a/src/renderer/src/workspace/agentManagementMcp.ts b/src/renderer/src/workspace/agentManagementMcp.ts
index 2a1639222..6036b1f31 100644
--- a/src/renderer/src/workspace/agentManagementMcp.ts
+++ b/src/renderer/src/workspace/agentManagementMcp.ts
@@ -9,7 +9,7 @@ import type {
} from '@mcp/shared/agentManagementTypes'
import type { SessionRuntime } from '@renderer/session-runtime/state'
import { entryTextContent } from '@renderer/session-runtime/entries'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
+import { projectIdOf, resolveTabSessions } from '@renderer/workspace/queries'
import { visibleMessageSummary } from '@renderer/workspace/orchestrationMcp'
import type { SessionId, SessionMeta, Tab, WorkspaceState } from '@renderer/workspace/types'
@@ -31,28 +31,24 @@ function projectForSession(
state: WorkspaceState,
sessionId: SessionId,
): ProjectMembership | null {
- const matches: ProjectMembership[] = []
- state.tabs.forEach((tab, tabIndex) => {
- if (collectLeaves(tab.root).includes(sessionId)) {
- matches.push({ tab, tabIndex, placement: 'grid' })
- }
- })
- const detached = state.detachedSessions[sessionId]
- if (detached) {
- const tabIndex = state.tabs.findIndex(tab => tab.id === detached.projectTabId)
- const tab = state.tabs[tabIndex]
- if (tab) matches.push({ tab, tabIndex, placement: 'dispatch' })
- }
- for (const buried of state.buried) {
- if (buried.sessionId !== sessionId) continue
- const tabIndex = state.tabs.findIndex(tab => tab.id === buried.sourceTabId)
- const tab = state.tabs[tabIndex]
- if (tab) matches.push({ tab, tabIndex, placement: 'buried' })
- }
- // WHY ambiguous ownership fails closed: a corrupt save can put one local id
- // in more than one placement bucket. Choosing the first would make project
- // scope depend on iteration order and could authorize a cross-project target.
- return matches.length === 1 ? matches[0]! : null
+ // The row says which project it belongs to. A session whose project is gone
+ // (or that was never filed) has no project scope and is refused — scope
+ // must never be guessed, because it is what authorizes a cross-agent read.
+ //
+ // Until #992 membership was searched across three owner structures (tile
+ // leaves, detached records, buried records) and a session found in more than
+ // one FAILED CLOSED: a corrupt save could make project scope depend on
+ // iteration order. One field cannot be ambiguous.
+ //
+ // `placement` stays 'dispatch' for every session: in this contract that
+ // value has always meant "a row in the project's agent index", which is what
+ // every pool session is. 'grid' and 'buried' named v2 owners that no longer
+ // exist; the enum is narrowed with the rest of the MCP surface in stage 7.
+ const projectId = projectIdOf(state, sessionId)
+ if (projectId === undefined) return null
+ const tabIndex = state.tabs.findIndex(tab => tab.id === projectId)
+ const tab = state.tabs[tabIndex]
+ return tab ? { tab, tabIndex, placement: 'dispatch' } : null
}
function managedProject(membership: ProjectMembership): ManagedAgentProject {
@@ -67,16 +63,9 @@ function orderedProjectSessionIds(
state: WorkspaceState,
tabId: string,
): SessionId[] {
- const tab = state.tabs.find(candidate => candidate.id === tabId)
- if (!tab) return []
- const detached = Object.values(state.detachedSessions)
- .filter(item => item.projectTabId === tabId)
- .sort((a, b) => a.detachedAt - b.detachedAt)
- .map(item => item.sessionId)
- const buried = state.buried
- .filter(item => item.sourceTabId === tabId)
- .map(item => item.sessionId)
- return [...new Set([...collectLeaves(tab.root), ...detached, ...buried])]
+ // Index order, from the one membership query. (Until #992 this concatenated
+ // tile leaves, detached rows by detachedAt, then buried panes.)
+ return resolveTabSessions(state, tabId)
}
function conditionSummary(runtime: SessionRuntime | undefined): {
diff --git a/src/renderer/src/workspace/agentNames/pendingIdentityCarry.test.ts b/src/renderer/src/workspace/agentNames/pendingIdentityCarry.test.ts
index 0b1e0fddb..d3d42d057 100644
--- a/src/renderer/src/workspace/agentNames/pendingIdentityCarry.test.ts
+++ b/src/renderer/src/workspace/agentNames/pendingIdentityCarry.test.ts
@@ -23,7 +23,7 @@ import type { WorkspaceState } from '@renderer/workspace/types'
afterEach(resetIdentityCarryForTests)
function workspace(sessions: Record): WorkspaceState {
- return { sessions, buried: [] } as unknown as WorkspaceState
+ return { sessions } as unknown as WorkspaceState
}
describe('identity carry reservation', () => {
diff --git a/src/renderer/src/workspace/agentNames/presentation.renderer.test.tsx b/src/renderer/src/workspace/agentNames/presentation.renderer.test.tsx
index 4a6915242..8af307e35 100644
--- a/src/renderer/src/workspace/agentNames/presentation.renderer.test.tsx
+++ b/src/renderer/src/workspace/agentNames/presentation.renderer.test.tsx
@@ -44,14 +44,13 @@ function row(sessionId: string, label: string, kind: 'claude' | 'terminal'): Dis
sessionId,
kind,
title: `${label} workflow`,
- placement: 'grid',
depth: 0,
}
}
function group(): DispatchTabGroup {
return {
- tab: { id: 'tab-a', title: 'Agent Code', root: { type: 'leaf', sessionId: AGENT }, focusedSessionId: AGENT },
+ tab: { id: 'tab-a', title: 'Agent Code' },
tabIndex: 0,
rows: [row(AGENT, 'A1', 'claude'), row(SHELL, 'A2', 'terminal')],
}
@@ -63,7 +62,6 @@ function renderIndex() {
groups={[group()]}
pinnedRows={[]}
activeSessionId={AGENT}
- dispatchScope="project"
focusSessionInTab={vi.fn()}
showWorktreeBadges={false}
/>,
diff --git a/src/renderer/src/workspace/agentNames/reconcile.ts b/src/renderer/src/workspace/agentNames/reconcile.ts
index 5b1c22b42..e8177131f 100644
--- a/src/renderer/src/workspace/agentNames/reconcile.ts
+++ b/src/renderer/src/workspace/agentNames/reconcile.ts
@@ -78,10 +78,13 @@ export function claimMissingIdentities(state: WorkspaceState): WorkspaceState {
/**
* Every identity whose name this window needs.
*
- * WHY buried records are included: their SessionMeta lives outside
- * `state.sessions` and outlives it, and workspace.observe deliberately reports
- * them. A buried agent that resolved to no name would be re-addressed on
- * restore, which is exactly the silent re-targeting #816 forbids.
+ * Every session counts, on a lane or parked: a parked agent that resolved to no
+ * name would be re-addressed the moment it was woken, which is exactly the
+ * silent re-targeting #816 forbids.
+ *
+ * (Until #992 this also walked `state.buried`, whose records carried their own
+ * SessionMeta outside `state.sessions`. Burial folded into the pool, so a
+ * formerly buried agent is an ordinary row and is covered by the loop below.)
*/
export function agentNameIdentities(state: WorkspaceState): string[] {
const identities = new Set()
@@ -89,9 +92,5 @@ export function agentNameIdentities(state: WorkspaceState): string[] {
const identity = identityOf(meta)
if (identity) identities.add(identity)
}
- for (const record of state.buried) {
- const identity = identityOf(record.sessionMeta)
- if (identity) identities.add(identity)
- }
return [...identities]
}
diff --git a/src/renderer/src/workspace/agentNames/reconciler.renderer.test.tsx b/src/renderer/src/workspace/agentNames/reconciler.renderer.test.tsx
index 1c4a9ca51..8a7409eab 100644
--- a/src/renderer/src/workspace/agentNames/reconciler.renderer.test.tsx
+++ b/src/renderer/src/workspace/agentNames/reconciler.renderer.test.tsx
@@ -7,6 +7,7 @@ import { useAppStore } from '@renderer/app-state/store'
import { resolveAgentName } from '@renderer/workspace/agentNames/selectors'
import { useAgentNameReconciler } from '@renderer/workspace/agentNames/useAgentNameReconciler'
import type { WorkspaceState } from '@renderer/workspace/types'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
const initialStore = useAppStore.getState()
const originalApiDescriptor = Object.getOwnPropertyDescriptor(window, 'api')
@@ -19,24 +20,19 @@ afterEach(() => {
function workspace(): WorkspaceState {
return {
- tabs: [{ id: 'tab-a', title: 'recorded', root: { type: 'leaf', sessionId: 'agent-one' }, focusedSessionId: 'agent-one' }],
+ tabs: [{ id: 'tab-a', title: 'recorded' }],
activeTabId: 'tab-a',
sessions: {
- 'agent-one': { cwd: '/recorded', kind: 'claude' },
- 'shell-one': { cwd: '/recorded', kind: 'terminal' },
+ 'agent-one': { cwd: '/recorded', kind: 'claude', projectId: 'tab-a', joinedAt: 0 },
+ 'shell-one': { cwd: '/recorded', kind: 'terminal', projectId: 'tab-a', joinedAt: 1 },
+ // A PARKED agent that already has an identity: on no lane, with no
+ // backend. Until #992 this was a `buried` record carrying its own
+ // metadata outside `sessions`; the identity string is kept so the
+ // history of these assertions stays readable.
+ 'parked-one': { cwd: '/recorded', kind: 'codex', agentNameId: 'identity-buried', projectId: 'tab-a', joinedAt: 2 },
},
- detachedSessions: {},
- buried: [{
- id: 'buried-one',
- sessionId: 'buried-one',
- sessionMeta: { cwd: '/recorded', kind: 'codex', agentNameId: 'identity-buried' },
- buriedAt: 0,
- sourceTabId: 'tab-a',
- sourceTabTitle: 'recorded',
- sourceTabIndex: 0,
- }],
pinnedSessionIds: [],
- dispatchMode: null,
+ stage: oneLaneStage('agent-one'),
} as unknown as WorkspaceState
}
@@ -47,13 +43,12 @@ function hostileWorkspace(): WorkspaceState {
return {
...workspace(),
sessions: { 'agent-one': { cwd: '/recorded', kind: 'claude', agentNameId: '__proto__' } },
- buried: [],
} as unknown as WorkspaceState
}
// A workspace whose identities are the WRONG TYPE rather than a hostile string:
-// a number on a live session and an object on a buried record, both reachable
-// from a hand-edited or migration-damaged workspace.json.
+// a number on one session and an object on a parked one, both reachable from a
+// hand-edited or migration-damaged workspace.json.
function malformedWorkspace(): WorkspaceState {
const base = workspace()
return {
@@ -61,8 +56,8 @@ function malformedWorkspace(): WorkspaceState {
sessions: {
'agent-one': { cwd: '/recorded', kind: 'claude', agentNameId: 42 },
'shell-one': base.sessions['shell-one'],
+ 'parked-one': { cwd: '/recorded', kind: 'codex', agentNameId: { id: 'nope' } },
},
- buried: [{ ...base.buried[0], sessionMeta: { cwd: '/recorded', kind: 'codex', agentNameId: { id: 'nope' } } }],
} as unknown as WorkspaceState
}
@@ -105,13 +100,13 @@ describe('agent name reconciliation', () => {
expect(mounted.seen.current.sessions['agent-one'].agentNameId).toBe('agent-one')
// Shells are named too (#865): the claim covers every session kind.
expect(mounted.seen.current.sessions['shell-one'].agentNameId).toBe('shell-one')
- // Buried agents keep their own metadata copy and must still resolve, or a
- // buried Apollo would come back unnamed and get a second address.
+ // A parked agent that already has an identity must still resolve, or a
+ // parked Apollo would come back unnamed and get a second address.
//
// WHY the FIRST call must already contain both: the hook derives its
// identity list through `claimMissingIdentities(state)` rather than from
// `state`, so on the very first render it sees `agent-one`'s
- // about-to-be-claimed identity alongside the buried agent's existing one.
+ // about-to-be-claimed identity alongside the parked agent's existing one.
// Deriving from `state` would split this into two requests — and the
// re-run triggered by the claim would then discard the first reply.
// Asserting on call[0] rather than on the union is what pins that.
@@ -175,10 +170,11 @@ describe('agent name reconciliation', () => {
// A truthiness-only skip treats `agentNameId: 42` as "already identified",
// while resolveAgentName — which needs an own STRING key of the name map —
// reports null forever. The agent then has no name and no route to one.
- // The buried record is the other half: it never passes through the claim
- // at all, so a non-string there would reach the IPC allocator, whose
- // z.array(z.string().min(1)) rejects the WHOLE batch and blocks naming for
- // every agent in the window.
+ // The parked session is the other half. Until #992 it was a buried record
+ // that never passed through the claim at all, so a non-string there would
+ // reach the IPC allocator, whose z.array(z.string().min(1)) rejects the
+ // WHOLE batch and blocks naming for every agent in the window. It is an
+ // ordinary row now and is re-claimed like the first.
const resolveAgentNames = vi.fn(async (identities: string[]) =>
Object.fromEntries(identities.map(identity => [identity, 'Apollo'])))
const mounted = mount({ enabled: true, resolveAgentNames, initial: malformedWorkspace() })
@@ -190,7 +186,8 @@ describe('agent name reconciliation', () => {
// The fixture's shell also has no identity yet, so the same reclaim pass
// picks it up alongside the malformed agent (#865): the claim no longer
// distinguishes provider kind, only "already identified or not".
- expect(resolveAgentNames.mock.calls[0][0]).toEqual(['agent-one', 'shell-one'])
+ expect(resolveAgentNames.mock.calls[0][0]).toEqual(['agent-one', 'shell-one', 'parked-one'])
+ expect(mounted.seen.current.sessions['parked-one'].agentNameId).toBe('parked-one')
expect(resolveAgentName({
enabled: true,
meta: mounted.seen.current.sessions['agent-one'],
@@ -228,7 +225,7 @@ describe('agent name reconciliation', () => {
const requested = [...resolveAgentNames.mock.calls[0][0]] as string[]
// While the allocation is in flight: the live agent is replaced by a new
- // local session id CARRYING the same identity, and the buried agent is
+ // local session id CARRYING the same identity, and the parked agent is
// closed outright.
act(() => {
mounted.control.current!(previous => ({
@@ -237,7 +234,6 @@ describe('agent name reconciliation', () => {
'agent-two': { ...previous.sessions['agent-one'], agentNameId: 'agent-one' },
'shell-one': previous.sessions['shell-one'],
},
- buried: [],
} as WorkspaceState))
})
@@ -261,7 +257,7 @@ describe('agent name reconciliation', () => {
// session back into the workspace.
expect(stored['identity-buried']).toBe('Jasper')
expect(settled.sessions['agent-one']).toBeUndefined()
- expect(settled.buried).toEqual([])
+ expect(settled.sessions['parked-one']).toBeUndefined()
// And the late reply did not trigger a second allocation for either.
expect(resolveAgentNames).toHaveBeenCalledTimes(1)
diff --git a/src/renderer/src/workspace/agentTitle.test.ts b/src/renderer/src/workspace/agentTitle.test.ts
index 3b5111cf7..7e6ef1041 100644
--- a/src/renderer/src/workspace/agentTitle.test.ts
+++ b/src/renderer/src/workspace/agentTitle.test.ts
@@ -8,21 +8,27 @@ import {
} from '@renderer/workspace/agentTitle'
import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchSelectors'
import type { WorkspaceState } from '@renderer/workspace/types'
+import { freshStage } from '@renderer/workspace/dispatch/gridShape'
function stateWithSessions(
sessions: WorkspaceState['sessions'],
): WorkspaceState {
const sessionId = Object.keys(sessions)[0] ?? ''
+ // Every row is filed under the one project, in the order given. The cases
+ // pass bare `{ cwd, kind }` rows because they are about TITLES; without this
+ // stamp the rows would be unowned (#992: ownership is the row's own
+ // `projectId`), no index would list them, and every row-level assertion
+ // below would be reading `undefined`.
+ const filed = Object.fromEntries(
+ Object.entries(sessions).map(([id, meta], index) => [id, { projectId: 'tab', joinedAt: index, ...meta }]),
+ )
return {
tabs: sessionId
- ? [{ id: 'tab', title: 'project', root: { type: 'leaf', sessionId }, focusedSessionId: sessionId }]
+ ? [{ id: 'tab', title: 'project' }]
: [],
activeTabId: sessionId ? 'tab' : '',
- gridRelatedSelections: {},
- dispatchMode: null,
- sessions,
- detachedSessions: {},
- buried: [],
+ stage: freshStage(),
+ sessions: filed,
pinnedSessionIds: [],
}
}
diff --git a/src/renderer/src/workspace/closeConfirmation.test.ts b/src/renderer/src/workspace/closeConfirmation.test.ts
index 6cb22d3b4..216d9d377 100644
--- a/src/renderer/src/workspace/closeConfirmation.test.ts
+++ b/src/renderer/src/workspace/closeConfirmation.test.ts
@@ -148,18 +148,18 @@ describe('target expansion', () => {
expect(expandSessionCloseTargets(state, runtimes, 'parent')[0].live).toBe(false)
})
- it('includes a tab detached sessions alongside its grid leaves', () => {
- // The ones people forget: a detached session has no tile in the tab the
- // user is looking at, so a tab close that takes six background agents with
- // it looks like closing an empty tab.
- const targets = expandTabCloseTargets(state, {}, ['parent'], ['detached'])
+ it('includes the project s parked sessions alongside the ones on screen', () => {
+ // The ones people forget: a parked session is on no lane, so a project
+ // close that takes six background agents with it looks like closing an
+ // empty tab. (Tree era: two lists — tile leaves and detached rows.)
+ const targets = expandTabCloseTargets(state, {}, ['parent', 'detached'])
expect(targets.map(t => t.sessionId).sort()).toEqual([
'child', 'detached', 'grandchild', 'parent',
])
})
it('does not double-count a session reachable two ways', () => {
- const targets = expandTabCloseTargets(state, {}, ['parent', 'child'], [])
+ const targets = expandTabCloseTargets(state, {}, ['parent', 'child'])
expect(targets).toHaveLength(3)
})
diff --git a/src/renderer/src/workspace/closeConfirmation.ts b/src/renderer/src/workspace/closeConfirmation.ts
index 1980368ce..6a6b97e83 100644
--- a/src/renderer/src/workspace/closeConfirmation.ts
+++ b/src/renderer/src/workspace/closeConfirmation.ts
@@ -43,15 +43,14 @@ export type CloseConfirmationRequest =
targets: readonly CloseTargetSnapshot[]
/** One-line summary naming the exact count. */
summary: string
- /** Root rows also own a project. The choice must spell out both scopes;
- * approving the tab list must never be inferred from an agent close.
- * `noun` follows the root's kind: since #865/#872 a terminal can be the
- * root, and "Close Agent ends zsh" names the wrong thing. */
- agentOnly?: {
- title: string
- targets: readonly CloseTargetSnapshot[]
- noun: 'agent' | 'terminal'
- }
+ // An `agentOnly` field lived here until #992. It drove a THREE-way dialog
+ // ("Close the agent or the tab?") for one specific session: the tab's
+ // root tile leaf, whose close would otherwise have emptied the tile tree
+ // and therefore removed the whole project. The user had to be asked
+ // which of the two they meant. With no tree there is no root: every
+ // close is session-scoped, a project leaves only with its LAST session,
+ // and "close everything here" is its own command (Close Tab). A request
+ // therefore has exactly one scope — `targets` — and one yes/no answer.
}
/** The count-and-liveness sentence, shared by the judged and forced paths so
@@ -343,22 +342,24 @@ export function expandSessionCloseTargets(
}
/**
- * Every session a TAB close will end: each grid leaf expanded through its
- * linked descendants, plus the tab's detached Dispatch sessions.
+ * Every session a PROJECT close will end: each of its sessions expanded
+ * through its linked descendants, wherever those descendants are filed.
*
- * Detached sessions are the ones people forget. They have no tile in the tab
- * the user is looking at, so a tab close that silently takes six background
- * agents with it looks like closing an empty tab.
+ * The sessions people forget are the ones no lane shows. A project close that
+ * silently takes six parked agents with it looks like closing an empty tab,
+ * which is why the whole list goes in front of the user first.
+ *
+ * (Until #992 this took the tab's tile leaves and its detached rows as two
+ * separate lists, because they were owned by two separate structures.)
*/
export function expandTabCloseTargets(
state: CloseExpansionState,
runtimes: CloseExpansionRuntimes,
- gridSessionIds: readonly string[],
- detachedSessionIds: readonly string[],
+ sessionIds: readonly string[],
): CloseTargetSnapshot[] {
const seen = new Set()
const out: CloseTargetSnapshot[] = []
- for (const id of [...gridSessionIds, ...detachedSessionIds]) {
+ for (const id of sessionIds) {
for (const target of expandSessionCloseTargets(state, runtimes, id)) {
if (seen.has(target.sessionId)) continue
seen.add(target.sessionId)
diff --git a/src/renderer/src/workspace/closeConfirmationBroker.ts b/src/renderer/src/workspace/closeConfirmationBroker.ts
index cec202e30..db7b63806 100644
--- a/src/renderer/src/workspace/closeConfirmationBroker.ts
+++ b/src/renderer/src/workspace/closeConfirmationBroker.ts
@@ -25,7 +25,7 @@ export type PendingCloseConfirmation = {
type Listener = (pending: PendingCloseConfirmation | null) => void
let pending: PendingCloseConfirmation | null = null
-let resolver: ((confirmed: boolean | 'agent') => void) | null = null
+let resolver: ((confirmed: boolean) => void) | null = null
const listeners = new Set()
function emit(): void {
@@ -55,22 +55,12 @@ export function currentCloseConfirmation(): PendingCloseConfirmation | null {
export function requestCloseConfirmation(
request: PendingCloseConfirmation['request'],
): Promise {
- return requestCloseAnswer(request).then(answer => answer === true)
-}
-
-/** Root-close choice shares the same slot as ordinary confirmations, so a
- * second close cancels the first instead of leaving two destructive grants. */
-export function requestRootCloseConfirmation(
- request: PendingCloseConfirmation['request'],
-): Promise<'agent' | 'tab' | null> {
- return requestCloseAnswer(request).then(answer =>
- answer === 'agent' ? 'agent' : answer === true ? 'tab' : null,
- )
-}
-
-function requestCloseAnswer(
- request: PendingCloseConfirmation['request'],
-): Promise {
+ // (`requestRootCloseConfirmation` sat beside this until #992, resolving a
+ // third answer — 'agent' — for the root-tile dialog. See the note on
+ // CloseConfirmationRequest for why that choice no longer exists. One slot,
+ // one boolean: a second close still cancels the first, so two destructive
+ // grants can never be open at once.)
+ //
// Resolve the superseded request BEFORE clearing the slot, then install the
// new resolver BEFORE notifying listeners. The earlier order emitted while
// `resolver` still pointed at the already-resolved function, so a listener
@@ -81,7 +71,7 @@ function requestCloseAnswer(
resolver = null
previous?.(false)
- return new Promise(resolve => {
+ return new Promise(resolve => {
resolver = resolve
pending = { request }
emit()
@@ -89,7 +79,7 @@ function requestCloseAnswer(
}
/** Answer the open request. Safe to call with nothing pending. */
-export function resolveCloseConfirmation(confirmed: boolean | 'agent'): void {
+export function resolveCloseConfirmation(confirmed: boolean): void {
const resolve = resolver
pending = null
resolver = null
diff --git a/src/renderer/src/workspace/contextPlacesSpawn.renderer.test.tsx b/src/renderer/src/workspace/contextPlacesSpawn.renderer.test.tsx
new file mode 100644
index 000000000..b7471e05a
--- /dev/null
+++ b/src/renderer/src/workspace/contextPlacesSpawn.renderer.test.tsx
@@ -0,0 +1,117 @@
+import { act, cleanup, renderHook } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { useAppStore } from '@renderer/app-state/hooks'
+import { emptyRuntime } from '@renderer/session-runtime/state'
+import { useWorkspace } from '@renderer/workspace/hook'
+
+import { mountPaneActions } from '@renderer/workspace/hook/actions/testing/paneActionsHarness'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
+import type { SessionId, WorkspaceState } from '@renderer/workspace/types'
+
+// Context-places spawn (#992 §4.3) — the operator's chosen rule, end to end
+// through the real placement owner:
+//
+// an EMPTY focused lane is filled by a spawn from it (the one continuity
+// write U2 allows); an OCCUPIED lane is never displaced, and the spawn
+// lands in the pool wearing a "new" badge until the user places it.
+//
+// The lane-shape half (fill vs refuse) is pinned per-spawn-site in the
+// placement suites (dispatchTerminalPlacement, controlPlacement,
+// extensionPlacement). What lives HERE is the badge lifecycle, which crosses
+// hooks (pane actions mark it; dispatch actions and agent-index navigation
+// retire it, see pooledSpawnBadge.ts) and therefore has no single-suite home.
+
+// The whole-hook case suppresses only process/IPC ingress, as the
+// orchestration runtime test does.
+vi.mock('@renderer/workspace/hook/ipc/useIpcSubscriptions', () => ({ useIpcSubscriptions: () => undefined }))
+vi.mock('@renderer/workspace/hook/ipc/useWorkspaceAdoption', () => ({ useWorkspaceAdoption: () => undefined }))
+vi.mock('@renderer/workspace/hook/persistence/useBootstrap', () => ({ useBootstrap: () => undefined }))
+vi.mock('@renderer/features/sessionFeed/SessionFeedContext', () => ({ useSessionFeed: () => ({}) }))
+const originalStore = useAppStore.getState()
+const originalApi = Object.getOwnPropertyDescriptor(window, 'api')
+afterEach(() => {
+ cleanup()
+ useAppStore.setState(originalStore, true)
+ if (originalApi) Object.defineProperty(window, 'api', originalApi)
+ else Reflect.deleteProperty(window, 'api')
+})
+const stubWindowApi = () => Object.defineProperty(window, 'api', { configurable: true, value: {
+ onOrchestrationRequest: () => () => undefined,
+ onAgentManagementRequest: () => () => undefined,
+ ghostRead: async () => [],
+ reportSessionLifecycle: vi.fn(),
+ appendFeedDebugLog: async () => undefined,
+} })
+
+function workspace(occupant?: SessionId): WorkspaceState {
+ return {
+ tabs: [{ id: 'p', title: 'Project' }],
+ activeTabId: 'p',
+ // `anchor` always exists: the spawn override names it as the anchor, and
+ // the anchored spawn must resolve a real row. `occupant` only decides
+ // whether the focused LANE shows it — which is the entire variable under
+ // test.
+ sessions: {
+ anchor: { kind: 'claude', cwd: '/p', projectId: 'p', joinedAt: 0 },
+ },
+ stage: oneLaneStage(occupant),
+ pinnedSessionIds: [],
+ }
+}
+
+describe('pooled-spawn badge lifecycle', () => {
+ it('marks a spawn that pooled because the focused lane was occupied, and not one that filled an empty lane', async () => {
+ const occupied = mountPaneActions(workspace('anchor'), { spawnSessionId: 'spawned' })
+ await act(async () => {
+ await occupied.actions.createDetachedDispatchAgent({ kind: 'codex' }, { tabId: 'p', anchorSessionId: 'anchor' })
+ })
+ expect(occupied.runtimes().spawned?.pooledSpawnAt).toEqual(expect.any(Number))
+
+ const empty = mountPaneActions(workspace(), { spawnSessionId: 'filled' })
+ await act(async () => {
+ await empty.actions.createDetachedDispatchAgent({ kind: 'codex' }, { tabId: 'p', anchorSessionId: 'anchor' })
+ })
+ // Filling the lane ANSWERED the placement question at spawn time; a badge
+ // on a session the user is looking at would be noise.
+ // `?? null`: a fill writes NO badge entry at all, and "absent" and
+// "explicitly null" are the same answer to "is it badged".
+ expect(empty.runtimes().filled?.pooledSpawnAt ?? null).toBeNull()
+ expect(empty.getState().stage.lanes[0]?.selectedSessionId).toBe('filled')
+ })
+
+ it('marks a linked agent, which never takes a lane under context-places', async () => {
+ const harness = mountPaneActions(workspace('anchor'), { spawnSessionId: 'child' })
+ await act(async () => {
+ await harness.actions.createLinkedAgent({ kind: 'codex' }, 'anchor')
+ })
+ expect(harness.runtimes().child?.pooledSpawnAt).toEqual(expect.any(Number))
+ // And nothing moved on screen.
+ expect(harness.getState().stage.lanes[0]?.selectedSessionId).toBe('anchor')
+ })
+
+ it('is retired by placing the session into a lane through the real workspace hook', async () => {
+ // This case used to write the lane by hand and then assert that the badge
+ // SURVIVED, which proved only that a hand-written lane is not a placement
+ // (#1013 review B called it tautological). The clearing write lives in the
+ // dispatch actions, so this mounts the whole workspace hook and places the
+ // pooled session the way an index click does.
+ useAppStore.setState({
+ workspaceState: { ...workspace('anchor'), sessions: {
+ anchor: { kind: 'claude', cwd: '/p', projectId: 'p', joinedAt: 0 },
+ spawned: { kind: 'codex', cwd: '/p', projectId: 'p', joinedAt: 1 },
+ } },
+ // 'started' keeps selection on its synchronous path: no wake round-trip.
+ workspaceRuntimes: {
+ anchor: { ...emptyRuntime(), processStatus: 'started' },
+ spawned: { ...emptyRuntime(), processStatus: 'started', pooledSpawnAt: 1 },
+ },
+ })
+ stubWindowApi()
+ const hook = renderHook(() => useWorkspace())
+ await act(async () => { await hook.result.current.selectTiledLaneSession(0, 'spawned') })
+ expect(useAppStore.getState().workspaceState.stage.lanes[0]?.selectedSessionId).toBe('spawned')
+ expect(useAppStore.getState().workspaceRuntimes.spawned?.pooledSpawnAt ?? null).toBeNull()
+ hook.unmount()
+ })
+})
diff --git a/src/renderer/src/workspace/control.renderer.test.ts b/src/renderer/src/workspace/control.renderer.test.ts
index 3ae92b820..3cac17ec4 100644
--- a/src/renderer/src/workspace/control.renderer.test.ts
+++ b/src/renderer/src/workspace/control.renderer.test.ts
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { workspaceControlCapabilities } from './control'
import { useAppStore } from '@renderer/app-state/store'
import type { WorkspaceState } from './types'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
const original = useAppStore.getState()
afterEach(() => useAppStore.setState(original, true))
@@ -10,51 +11,57 @@ const context = {
owner: { kind: 'window' as const, windowId: 'left', generation: 'first' },
}
-// Known domain contracts from the existing navigation tests: detached children
-// may appear in a parent's tile and multiple Dispatch lanes can show one ID.
-// This is a deterministic contract setup, not a recorded provider fixture.
+// Known domain contracts: a session belongs to exactly one project whether or
+// not a lane shows it, and multiple lanes can show one ID. This is a
+// deterministic contract setup, not a recorded provider fixture.
function workspace(): WorkspaceState {
return {
- tabs: [{ id: 'alpha', title: 'Alpha', root: { type: 'leaf', sessionId: 'parent' }, focusedSessionId: 'parent' }],
- activeTabId: 'alpha', dispatchMode: null,
- sessions: { parent: { cwd: '/trial', kind: 'claude' }, child: { cwd: '/trial', kind: 'codex', linkedParentId: 'parent' } },
- detachedSessions: { child: { sessionId: 'child', surface: 'dispatch', projectTabId: 'alpha', projectTabTitle: 'Alpha', projectTabIndex: 0, detachedAt: 1 } },
- buried: [{ id: 'hidden', sessionId: 'hidden', sessionMeta: { cwd: '/trial/hidden', kind: 'opencode' }, buriedAt: 1, sourceTabId: 'alpha', sourceTabTitle: 'Alpha', sourceTabIndex: 0 }],
- gridRelatedSelections: { parent: 'child' }, pinnedSessionIds: ['child'],
+ tabs: [{ id: 'alpha', title: 'Alpha' }],
+ activeTabId: 'alpha', stage: oneLaneStage('parent'),
+ sessions: { parent: { cwd: '/trial', kind: 'claude', projectId: 'alpha', joinedAt: 0 }, child: { cwd: '/trial', kind: 'codex', linkedParentId: 'parent', projectId: 'alpha', joinedAt: 1 } },
+ pinnedSessionIds: ['child'],
}
}
describe('workspace control observation', () => {
- it('preserves related and buried identities and reads fresh state without waking providers', async () => {
+ it('reports a parked session by its project and reads fresh state without waking providers', async () => {
const state = workspace()
- useAppStore.setState({ workspaceState: state, workspaceTileTabs: null })
+ useAppStore.setState({ workspaceState: state })
const capability = workspaceControlCapabilities(() => ({ restoreStatus: 'pending' }))[0]
const result = await capability.execute({}, context)
expect(result.ok).toBe(true)
if (!result.ok) throw new Error(result.error.message)
const child = result.value.sessions.find(session => session.sessionId === 'child')!
- expect(child.placements).toContainEqual({ kind: 'related', tabId: 'alpha', gridOwnerSessionId: 'parent', visible: true })
- expect(result.value.sessions.find(session => session.sessionId === 'hidden')?.placements).toContainEqual({ kind: 'buried', tabId: 'alpha', visible: false })
+ // `child` is in no lane: it is parked. v2 had FOUR ownership placements,
+ // one per owner structure (grid leaf, related strip, detached, buried), and
+ // this case asserted the 'related' and 'buried' ones. Ownership is one
+ // field now, so there is one ownership placement — 'project' — and it is
+ // never `visible`, because belonging to a project puts nothing on screen.
+ // Only a lane does (the 'dispatch' placements in the next case).
+ expect(child.placements).toEqual([{ kind: 'project', tabId: 'alpha', visible: false }])
+ // The lane occupant carries both: where it belongs, and where it is shown.
+ expect(result.value.sessions.find(session => session.sessionId === 'parent')?.placements).toEqual([
+ { kind: 'project', tabId: 'alpha', visible: false },
+ { kind: 'dispatch', lane: 0, visible: true },
+ ])
expect(useAppStore.getState().workspaceState).toBe(state)
useAppStore.setState({ workspaceState: { ...state, sessions: { ...state.sessions, child: { ...state.sessions.child, title: 'Changed after registration' } } } })
const next = await capability.execute({}, context)
expect(next.ok && next.value.sessions.find(session => session.sessionId === 'child')?.title).toBe('Changed after registration')
})
- it('reports both mirrored lanes under one session and respects tiled-tabs precedence', async () => {
+ it('reports both mirrored lanes under one session', async () => {
const state = workspace()
- state.dispatchMode = { scope: 'global', tiled: { focusedLane: 1, lanes: [{ selectedSessionId: 'child' }, { selectedSessionId: 'child' }] } }
- useAppStore.setState({ workspaceState: state, workspaceTileTabs: null })
+ state.stage = { focusedLane: 1, lanes: [{ selectedSessionId: 'child' }, { selectedSessionId: 'child' }] }
+ useAppStore.setState({ workspaceState: state })
const capability = workspaceControlCapabilities(() => ({ restoreStatus: 'pending' }))[0]
const result = await capability.execute({}, context)
if (!result.ok) throw new Error(result.error.message)
expect(result.value.sessions.filter(session => session.sessionId === 'child')).toHaveLength(1)
expect(result.value.sessions.find(session => session.sessionId === 'child')?.placements.filter(p => p.kind === 'dispatch'))
.toEqual([{ kind: 'dispatch', lane: 0, visible: true }, { kind: 'dispatch', lane: 1, visible: true }])
- useAppStore.setState({ workspaceTileTabs: { tabIds: ['alpha'], focusedTabId: 'alpha', direction: 'vertical', ratios: [1] } })
- const tiled = await capability.execute({}, context)
- if (!tiled.ok) throw new Error(tiled.error.message)
- expect(tiled.value.mode).toBe('tiled-tabs')
- expect(tiled.value.sessions.flatMap(session => session.placements).filter(p => p.kind === 'dispatch').every(p => !p.visible)).toBe(true)
+ // A Tile Tabs precedence case lived here until #992 deleted Tile Tabs;
+ // the stage is the only layout, so lane placements are always visible.
+ expect(result.value.mode).toBe('tiled-dispatch')
})
})
diff --git a/src/renderer/src/workspace/control.ts b/src/renderer/src/workspace/control.ts
index 38a4cf83d..3763a13d7 100644
--- a/src/renderer/src/workspace/control.ts
+++ b/src/renderer/src/workspace/control.ts
@@ -1,9 +1,7 @@
import { z } from 'zod'
import { defineCapability, placementSchema, workspaceObservationSchema } from '@control-sdk'
import { useAppStore } from '@renderer/app-state/store'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
import { resolveTabSessions } from '@renderer/workspace/queries'
-import { buildGridRelatedAgentTabs, selectedGridRelatedSessionId } from '@renderer/workspace/gridRelatedAgents'
import { hasAppInteractionOwner } from '@renderer/lib/interaction-ownership'
import { commandTargetSessionIdForState } from '@renderer/workspace/hook/selectors/commandTargetSessionId'
import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchSelectors'
@@ -20,7 +18,7 @@ export { workspaceObservationSchema } from '@control-sdk'
export function workspaceControlCapabilities(getWorkspace: () => Pick) {
return [defineCapability({
id: 'workspace.observe', title: 'Observe workspace',
- description: 'Read current project, session and placement identities without waking agents. Includes hidden, detached and buried sessions; multiple placements refer to one session.',
+ description: 'Read current project, session and placement identities without waking agents. Includes every parked session, whether or not a lane shows it; multiple placements refer to one session.',
execution: 'window', effect: 'read', input: z.object({}).strict(),
output: workspaceObservationSchema,
handler: () => observeWorkspace(getWorkspace),
@@ -32,48 +30,38 @@ export function observeWorkspace(getWorkspace: () => Pick()
const add = (id: string, placement: Placement) => placements.set(id, [...(placements.get(id) ?? []), placement])
- for (const tab of state.tabs) {
- const tabVisible = tileTabs ? tileTabs.tabIds.includes(tab.id) : !state.dispatchMode && state.activeTabId === tab.id
- for (const id of collectLeaves(tab.root)) {
- const visibleSession = selectedGridRelatedSessionId(state, tab.id, id) ?? id
- add(id, { kind: 'grid', tabId: tab.id, visible: tabVisible && visibleSession === id })
- for (const child of buildGridRelatedAgentTabs(state, tab.id, id)) {
- if (child.sessionId !== id) add(child.sessionId, { kind: 'related', tabId: tab.id, gridOwnerSessionId: id, visible: tabVisible && visibleSession === child.sessionId })
- }
- }
- }
- for (const [id, detached] of Object.entries(state.detachedSessions)) add(id, { kind: 'detached', tabId: detached.projectTabId, visible: false })
- if (state.dispatchMode?.tiled) {
- state.dispatchMode.tiled.lanes.forEach((lane, index) => {
- if (lane.selectedSessionId) add(lane.selectedSessionId, { kind: 'dispatch', lane: index, visible: !tileTabs })
- })
- } else if (state.dispatchMode?.focusedSessionId) {
- add(state.dispatchMode.focusedSessionId, { kind: 'dispatch', visible: !tileTabs })
+ // Ownership: one placement per session — the project it is filed under. It
+ // is never `visible` by itself; being in the pool puts nothing on screen.
+ //
+ // Until #992 there were four ownership placements, one per v2 owner
+ // structure: 'grid' (a tile leaf), 'related' (a child shown inside its
+ // parent's tile), 'detached' (a Dispatch row) and 'buried' (a hidden pane,
+ // whose metadata could outlive its `sessions` row). All four meant "this
+ // session belongs to that project", which is what 'project' now says.
+ for (const [id, meta] of Object.entries(state.sessions)) {
+ if (meta.projectId !== undefined) add(id, { kind: 'project', tabId: meta.projectId, visible: false })
}
+ state.stage.lanes.forEach((lane, index) => {
+ if (lane.selectedSessionId) add(lane.selectedSessionId, { kind: 'dispatch', lane: index, visible: true })
+ })
if (takeover) {
for (const rows of placements.values()) for (const placement of rows) placement.visible = false
add(takeover.focusedSessionId, { kind: reader ? 'reader' : 'spotlight', tabId: takeover.tabId, visible: true })
}
- const focusedTab = state.tabs.find(tab => tab.id === (tileTabs?.focusedTabId ?? state.activeTabId))
- const focusedSessionId = takeover?.focusedSessionId ?? (tileTabs
- ? selectedGridRelatedSessionId(state, focusedTab?.id ?? '', focusedTab?.focusedSessionId)
- : commandTargetSessionIdForState(state))
- for (const buried of state.buried) add(buried.sessionId, { kind: 'buried', tabId: buried.sourceTabId, visible: false })
- // Buried metadata can outlive its sessions entry. Preserve that real
- // identity rather than dropping it or inventing a second agent.
- const sessions = { ...Object.fromEntries(state.buried.map(record => [record.sessionId, record.sessionMeta])), ...state.sessions }
- const dispatchRows = state.dispatchMode && !tileTabs ? buildVisibleDispatchRows(state) : []
+ const focusedSessionId = takeover?.focusedSessionId ?? commandTargetSessionIdForState(state)
+ const sessions = state.sessions
+ const dispatchRows = buildVisibleDispatchRows(state)
const identity = (sessionId: string, meta: (typeof sessions)[string]) => {
const row = dispatchRows.find(row => row.sessionId === sessionId)
const tab = state.tabs.find(tab => resolveTabSessions(state, tab.id).includes(sessionId))
const localLabel = tab ? paneLabelForSession(state, tab.id, sessionId) : null
// Dispatch labels can shadow project-local labels. Only advertise a
// fallback that the app's label resolver maps back to this same session.
- const displayLabel = row?.label ?? (localLabel && resolveAgentPaneLabel(state, localLabel, tileTabs)?.sessionId === sessionId ? localLabel : null)
+ const displayLabel = row?.label ?? (localLabel && resolveAgentPaneLabel(state, localLabel)?.sessionId === sessionId ? localLabel : null)
const runtime = store.workspaceRuntimes[sessionId]
const displayedTitle = row
? dispatchRowTitle(row, runtime?.entries, runtime?.terminalForeground?.cwd)
@@ -90,8 +78,14 @@ export function observeWorkspace(getWorkspace: () => Pick ({ id: tab.id, title: tab.title, focusedSessionId: tab.focusedSessionId, sessionIds: resolveTabSessions(state, tab.id) })),
+ // There is one layout (#992), so this is a constant. It is still reported,
+ // under the name the lane grid has always had on this surface, because
+ // `mode` is a required field of a published observation schema and agents
+ // branch on it ("am I looking at lanes?"). The honest rename — and the
+ // removal of the 'grid' and 'dispatch' enum members nothing can produce —
+ // belongs to the SDK schema change in stage 7, not to a renderer commit.
+ mode: 'tiled-dispatch' as const,
+ tabs: state.tabs.map(tab => ({ id: tab.id, title: tab.title, sessionIds: resolveTabSessions(state, tab.id) })),
sessions: Object.entries(sessions).map(([sessionId, meta]) => ({
sessionId, ...identity(sessionId, meta), title: meta.title ?? '', cwd: meta.cwd, provider: meta.kind ?? DEFAULT_PROVIDER,
providerRuntime: meta.providerRuntime ?? null, providerSessionId: meta.providerSessionId ?? null,
diff --git a/src/renderer/src/workspace/control/agentNames.renderer.test.ts b/src/renderer/src/workspace/control/agentNames.renderer.test.ts
index c1080fccd..32d49d32f 100644
--- a/src/renderer/src/workspace/control/agentNames.renderer.test.ts
+++ b/src/renderer/src/workspace/control/agentNames.renderer.test.ts
@@ -1,11 +1,10 @@
-import { readFileSync } from 'node:fs'
import { afterEach, expect, it } from 'vitest'
import { globalControlCapabilities } from '@main/control/globalCapabilities'
import { useAppStore } from '@renderer/app-state/store'
import { claimMissingIdentities } from '@renderer/workspace/agentNames/reconcile'
import { observeWorkspace } from '@renderer/workspace/control'
-import type { WorkspaceState } from '@renderer/workspace/types'
+import { loadRecordedDispatchWorkspace } from '@renderer/workspace/testing/recordedDispatchWorkspace'
const initial = useAppStore.getState()
afterEach(() => useAppStore.setState(initial, true))
@@ -23,16 +22,15 @@ function searchOver(workspace: ReturnType) {
}
it('publishes enabled names and resolves an exact spoken name across windows', async () => {
- const fixture = JSON.parse(readFileSync('testing/fixtures/worktree-context/dispatch-global-d23.json', 'utf8'))
- const id: string = fixture.$fixture.observed.targetSessionId
+ const fixture = loadRecordedDispatchWorkspace()
+ const id: string = fixture.observed.targetSessionId
// Claim identities the same way the running app does, from the recorded
// workspace, so the test cannot drift from the reconciler's rule.
- const claimed = claimMissingIdentities(fixture.state as WorkspaceState)
+ const claimed = claimMissingIdentities(fixture.state)
const identity = claimed.sessions[id].agentNameId!
useAppStore.setState({
workspaceState: claimed,
- workspaceTileTabs: null,
workspaceReaderMode: null,
workspaceSpotlight: null,
workspaceRuntimes: {},
@@ -75,13 +73,12 @@ it('publishes enabled names and resolves an exact spoken name across windows', a
})
it('hides names and name lookup while the setting is off', async () => {
- const fixture = JSON.parse(readFileSync('testing/fixtures/worktree-context/dispatch-global-d23.json', 'utf8'))
- const id: string = fixture.$fixture.observed.targetSessionId
- const claimed = claimMissingIdentities(fixture.state as WorkspaceState)
+ const fixture = loadRecordedDispatchWorkspace()
+ const id: string = fixture.observed.targetSessionId
+ const claimed = claimMissingIdentities(fixture.state)
useAppStore.setState({
workspaceState: claimed,
- workspaceTileTabs: null,
workspaceReaderMode: null,
workspaceSpotlight: null,
workspaceRuntimes: {},
diff --git a/src/renderer/src/workspace/control/agents.renderer.test.ts b/src/renderer/src/workspace/control/agents.renderer.test.ts
index 03ddd074d..ba50b1fe1 100644
--- a/src/renderer/src/workspace/control/agents.renderer.test.ts
+++ b/src/renderer/src/workspace/control/agents.renderer.test.ts
@@ -3,6 +3,7 @@ import { agentControlCapabilities } from './agents'
import { useAppStore } from '@renderer/app-state/store'
import { emptyRuntime } from '@renderer/session-runtime/state'
import type { Workspace } from '@renderer/workspace/hook'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
const original = useAppStore.getState()
const originalApi = window.api
@@ -11,9 +12,9 @@ const context = { requestId: 'trial', caller: { kind: 'external' as const, id: '
owner: { kind: 'window' as const, windowId: 'left', generation: 'one' } }
function setup(wake: () => Promise = async () => undefined) {
useAppStore.setState({ workspaceState: {
- tabs: [{ id: 'project', title: 'Project', root: { type: 'leaf', sessionId: 'agent' }, focusedSessionId: 'agent' }],
- activeTabId: 'project', sessions: { agent: { cwd: '/trial', kind: 'claude' } },
- detachedSessions: {}, buried: [], pinnedSessionIds: [], dispatchMode: null,
+ tabs: [{ id: 'project', title: 'Project' }],
+ activeTabId: 'project', sessions: { agent: { cwd: '/trial', kind: 'claude', projectId: 'project', joinedAt: 0 } },
+ pinnedSessionIds: [], stage: oneLaneStage('agent'),
}, workspaceRuntimes: { agent: { ...emptyRuntime(), draftInput: 'unfinished human draft' } } })
const deliverPrompt = vi.fn().mockResolvedValue({ ok: true, acceptance: { kind: 'queue', acceptedAt: 1 } })
window.api = { ...originalApi, deliverPrompt }
@@ -109,8 +110,7 @@ it('treats a terminal as a session for metadata and navigation, but never as a p
useAppStore.getState().setWorkspaceState(state => ({
...state,
sessions: { ...state.sessions, shell: { cwd: '/trial', kind: 'terminal' } },
- tabs: [{ ...state.tabs[0], root: { type: 'split', direction: 'vertical', ratio: 0.5,
- a: { type: 'leaf', sessionId: 'agent' }, b: { type: 'leaf', sessionId: 'shell' } } }],
+ tabs: [{ ...state.tabs[0], }],
}))
expect(await invoke('agents.titleSet', { sessionId: 'shell', title: 'dev server' }))
@@ -139,8 +139,7 @@ it('refuses to show a terminal while Reader Mode owns the screen, before any nav
useAppStore.getState().setWorkspaceState(state => ({
...state,
sessions: { ...state.sessions, shell: { cwd: '/trial', kind: 'terminal' } },
- tabs: [{ ...state.tabs[0], root: { type: 'split', direction: 'vertical', ratio: 0.5,
- a: { type: 'leaf', sessionId: 'agent' }, b: { type: 'leaf', sessionId: 'shell' } } }],
+ tabs: [{ ...state.tabs[0], }],
}))
useAppStore.setState({ workspaceReaderMode: { tabId: 'project', focusedSessionId: 'agent' } })
diff --git a/src/renderer/src/workspace/control/agents.ts b/src/renderer/src/workspace/control/agents.ts
index 435c1bd84..2f19de5f1 100644
--- a/src/renderer/src/workspace/control/agents.ts
+++ b/src/renderer/src/workspace/control/agents.ts
@@ -7,7 +7,6 @@ import { findTabsHoldingDirectory, resolveTabSessions } from '@renderer/workspac
import { observeWorkspace, workspaceObservationSchema } from '@renderer/workspace/control'
import type { Workspace } from '@renderer/workspace/hook'
import { AGENT_PROVIDER_RUNTIMES } from '@shared/types/providerKind'
-import { buildPlacementTargets } from '@renderer/features/workspace/lib/newAgentPlacement'
import { setAgentTitleInWorkspace } from '@renderer/workspace/agentTitle'
import { sessionHasTranscript } from '@renderer/workspace/transcriptAvailability'
@@ -24,12 +23,13 @@ export function agentControlCapabilities(getWorkspace: () => Workspace) {
// pinSet act on metadata and placement, which a shell has exactly like an
// agent. The single capability that must refuse a shell, agents.prompt,
// checks provider itself because its refusal has to name the right route.
- const requireSession = (sessionId: string, allowBuried = false) => {
+ // (An `allowBuried` flag and a "restore it explicitly first" refusal lived
+ // here until #992. A buried session was one the user had hidden on purpose,
+ // so acting on it needed an explicit restore. There is no hidden state now:
+ // a parked session is an ordinary row in its project's index.)
+ const requireSession = (sessionId: string) => {
const current = observe().sessions.find(session => session.sessionId === sessionId)
if (!current) throw new ControlError('unavailable', 'Agent does not exist in this window')
- if (!allowBuried && current.placements.some(placement => placement.kind === 'buried')) {
- throw new ControlError('unavailable', 'Agent is buried; restore it explicitly before acting')
- }
return current
}
const requireReady = () => {
@@ -39,11 +39,11 @@ export function agentControlCapabilities(getWorkspace: () => Workspace) {
requireReady()
if (hasAppInteractionOwner()) throw new ControlError('unavailable', 'A surface owns input. Inspect or close it before changing the workspace')
}
- const placements = (tabId: string, anchorSessionId: string) => {
- const tab = useAppStore.getState().workspaceState.tabs.find(tab => tab.id === tabId)
- if (!tab) throw new ControlError('unavailable', 'Project no longer exists')
- return buildPlacementTargets(tab.root, anchorSessionId, { x: 0, y: 0, width: 1, height: 1 })
- }
+ // placement.list / placement.attach / agents.restore lived here until the
+ // unified layout (#992): they placed pool sessions into a grid tree or
+ // restored archived ones. There is no grid and no archive now — a session
+ // is shown by selecting it into a lane (dispatch.configure lane-select, or
+ // agents.show with open-in-focused-tiled-dispatch-lane).
return [
defineCapability({
id: 'agents.close', target: { kind: 'session', field: 'sessionId' }, title: 'Close an agent', execution: 'window', effect: 'mutation', completion: 'accepted',
@@ -67,41 +67,9 @@ export function agentControlCapabilities(getWorkspace: () => Workspace) {
})
},
}),
- defineCapability({
- id: 'placement.list', target: { kind: 'project', field: 'tabId' }, title: 'List grid placement choices', execution: 'window', effect: 'read',
- description: 'List actual placement-overlay targets around an explicit grid anchor, including root wrapping. Coordinates are normalized to the project grid.',
- input: z.object({ tabId: z.string().describe('Project tab ID from app.observe in the target window.'), anchorSessionId: z.string().describe('Existing agent in this project that supplies the working directory or grid placement anchor.') }).strict(),
- output: z.object({ revision: z.string().describe('Revision returned by placement.list; prevents applying an outdated layout target.'), targets: z.array(z.object({ id: z.string(), label: z.string(), kind: z.string(),
- direction: z.string(), side: z.string(), scope: z.string(), rect: z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() }) })) }),
- handler: ({ tabId, anchorSessionId }) => {
- const targets = placements(tabId, anchorSessionId)
- return { revision: paginate(targets, { limit: 200 }, `placement:${tabId}:${anchorSessionId}`).revision, targets }
- },
- }),
- defineCapability({
- id: 'placement.attach', target: { kind: 'session', field: 'sessionId' }, title: 'Attach an agent to the grid', execution: 'window', effect: 'mutation',
- description: 'Attach an existing detached agent or terminal using a target and revision from placement.list. Uses the existing placement operation and revalidates the anchor after wake.',
- input: sessionInput.extend({ tabId: z.string().describe('Project tab ID from app.observe in the target window.'), anchorSessionId: z.string().describe('Existing agent in this project that supplies the working directory or grid placement anchor.'), targetId: z.string().describe('Exact target ID returned by placement.list for this anchor.'), revision: z.string().describe('Revision returned by placement.list; prevents applying an outdated layout target.') }),
- output: sessionReference,
- handler: async ({ sessionId, tabId, anchorSessionId, targetId, revision }) => {
- requireUi()
- const session = requireSession(sessionId)
- if (!session.placements.some(placement => placement.kind === 'detached')) throw new ControlError('unavailable', 'Agent is already attached')
- const targets = placements(tabId, anchorSessionId)
- if (paginate(targets, { limit: 200 }, `placement:${tabId}:${anchorSessionId}`).revision !== revision) throw new ControlError('stale_cursor', 'Placement changed; list targets again')
- const target = targets.find(target => target.id === targetId)
- if (!target) throw new ControlError('unavailable', 'Placement target no longer exists')
- await getWorkspace().attachDetachedToGrid(sessionId, tabId, target)
- const placed = requireSession(sessionId)
- if (!placed.placements.some(placement => placement.kind === 'grid' && placement.tabId === tabId)) {
- throw new ControlError('failed', 'Attachment was not observed; inspect current placement', 'unknown')
- }
- return placed
- },
- }),
defineCapability({
id: 'agents.list', title: 'Find agents', execution: 'window', effect: 'read',
- description: 'Search all agents and terminals in this window by stable ID, visible label, spoken agent name, title, directory and provider, including detached and buried records. Reading never wakes an agent.',
+ description: 'Search all agents and terminals in this window by stable ID, visible label, spoken agent name, title, directory and provider, including sessions not currently shown in any lane. Reading never wakes an agent.',
input: z.object({ query: z.string().default('').describe('Case-insensitive substring of session ID, visible label, spoken agent name, title, working directory or provider. Empty lists every agent and terminal in this window.'), tabId: z.string().describe('Project tab ID from app.observe in the target window.').optional(), ...pageInput }).strict(),
output: pageSchema(sessionReference),
handler: input => {
@@ -126,11 +94,11 @@ export function agentControlCapabilities(getWorkspace: () => Workspace) {
defineCapability({
id: 'agents.locate', target: { kind: 'session', field: 'sessionId' }, title: 'Locate an agent', execution: 'window', effect: 'read', input: sessionInput,
description: 'Get every placement of one stable agent, including mirrored lanes and hidden records, without focusing or waking it.',
- output: sessionReference, handler: ({ sessionId }) => requireSession(sessionId, true),
+ output: sessionReference, handler: ({ sessionId }) => requireSession(sessionId),
}),
defineCapability({
id: 'agents.show', target: { kind: 'session', field: 'sessionId' }, title: 'Show an existing agent', execution: 'window', effect: 'ui',
- description: 'Focus this exact agent through the existing Grid, Dispatch, tiled-tab or related-child route. May wake a detached agent under the same ID. Never creates a replacement agent; buried records require agents.restore first.',
+ description: 'Focus this exact agent through the existing lane or related-child route. May wake a parked agent under the same ID. Never creates a replacement agent.',
input: sessionInput.extend({ intent: z.enum(['reuse-existing-view', 'open-in-focused-tiled-dispatch-lane']).default('reuse-existing-view').describe('Reuse the existing agent view, or explicitly place it into the currently focused tiled Dispatch lane.') }),
output: z.object({ session: sessionReference, mode: workspaceObservationSchema.shape.mode,
bounds: z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() }) }),
@@ -191,18 +159,6 @@ export function agentControlCapabilities(getWorkspace: () => Workspace) {
return { session: refreshed, mode: state.mode, bounds: { x, y, width, height } }
},
}),
- defineCapability({
- id: 'agents.restore', target: { kind: 'session', field: 'sessionId' }, title: 'Restore a buried agent', execution: 'window', effect: 'mutation',
- description: 'Explicitly restore one buried record through the UI restore policy, waking the same session ID if needed. Returns the resulting placement.',
- input: sessionInput, output: sessionReference,
- handler: async ({ sessionId }) => {
- requireUi(); requireSession(sessionId, true)
- const buried = useAppStore.getState().workspaceState.buried.find(record => record.sessionId === sessionId)
- if (!buried) throw new ControlError('unavailable', 'This agent is not buried')
- await getWorkspace().reviveBuried(buried.id)
- return requireSession(sessionId)
- },
- }),
defineCapability({
id: 'agents.titleSet', target: { kind: 'session', field: 'sessionId' }, title: 'Set a session title', execution: 'window', effect: 'mutation',
description: 'Set or clear the exact agent or terminal title using the same normalization and length policy as the UI. Does not send a prompt.',
@@ -244,14 +200,16 @@ export function agentControlCapabilities(getWorkspace: () => Workspace) {
if (!createDuplicate && matches.length === 1) {
const tab = matches[0]
getWorkspace().activateTab(tab.id)
- return { tabId: tab.id, sessionId: tab.focusedSessionId, created: false }
+ // The project's first session in index order. (Was the tab's
+ // tile-tree focus until #992.)
+ return { tabId: tab.id, sessionId: resolveTabSessions(state, tab.id)[0] ?? '', created: false }
}
return { ...await getWorkspace().newTab(cwd, undefined, kind), created: true }
},
}),
defineCapability({
id: 'agents.create', target: { kind: 'project', field: 'tabId' }, title: 'Create a project agent', execution: 'window', effect: 'mutation',
- description: 'Create an ordinary detached agent in the explicit project, anchored to an existing agent directory. Detached means outside the project grid, not hidden: selectCreated defaults true, activates the project and selects the new agent in the Dispatch lane focused when creation began, replacing that view without closing its agent. Set selectCreated:false to preserve tabs and lane assignments, then use layout.read and dispatch.configure (lane-select) to place the returned ID in an explicit lane. readiness is a cached observation, not admission to send; agents.prompt performs provider checks.',
+ description: 'Create an ordinary pool agent in the explicit project, anchored to an existing agent directory. selectCreated defaults true: it activates the project and, when the lane focused at creation began is EMPTY, places the new agent in it — an occupied lane is never displaced, so the agent usually lands in the pool wearing a new badge in the agent index instead. Set selectCreated:false to preserve the active project and every lane, then use dispatch.configure (lane-select) to place the returned ID in an explicit lane. readiness is a cached observation, not admission to send; agents.prompt performs provider checks.',
input: z.object({ tabId: z.string().describe('Project tab ID from app.observe in the target window.'), anchorSessionId: z.string().describe('Existing agent in this project that supplies the working directory or grid placement anchor.'), provider,
selectCreated: z.boolean().default(true).describe('False preserves the current tab and every Dispatch lane; true selects the created agent using normal UI creation behavior.'), providerRuntime: z.enum(AGENT_PROVIDER_RUNTIMES).optional().describe('Omit for the normal structured agent view. terminal requests the provider-native terminal runtime.'), title: z.string().describe('Agent display title; empty clears a custom title. Normal UI normalization applies.').optional() }).strict(),
output: sessionReference.extend({ readiness: z.object({ inputReady: z.boolean().nullable(), sessionRunId: z.string().nullable() }) }),
diff --git a/src/renderer/src/workspace/control/conditions.ts b/src/renderer/src/workspace/control/conditions.ts
index e2e1d5eb7..56ed3747c 100644
--- a/src/renderer/src/workspace/control/conditions.ts
+++ b/src/renderer/src/workspace/control/conditions.ts
@@ -9,7 +9,7 @@ export function conditionControlCapabilities() {
const meta = state.sessions[input.sessionId]
// Positive agent check: a terminal-only guard sent extension panes to main
// as `provider: 'extension-view'`, a provider main has no backend for.
- if (!meta || !isAgentSessionKind(meta.kind) || state.buried.some(item => item.sessionId === input.sessionId)) throw new ControlError('unavailable', 'Choose a current, non-buried agent')
+ if (!meta || !isAgentSessionKind(meta.kind)) throw new ControlError('unavailable', 'Choose a current agent')
const result = await window.api.controlInvoke({ capabilityId, input: { ...input, cwd: meta.cwd, provider: meta.kind ?? 'claude' } })
if (!result.ok) throw new ControlError(result.error.code, result.error.message, result.error.outcome)
return result.value
diff --git a/src/renderer/src/workspace/control/drafts.renderer.test.tsx b/src/renderer/src/workspace/control/drafts.renderer.test.tsx
index 64c243b46..9a0219cce 100644
--- a/src/renderer/src/workspace/control/drafts.renderer.test.tsx
+++ b/src/renderer/src/workspace/control/drafts.renderer.test.tsx
@@ -13,7 +13,7 @@ const context = { requestId: 'draft-trial', caller: { kind: 'external' as const,
it('reads actual composer edits, protects concurrent text, and uses the existing clear/undo persistence path', async () => {
const sessionId = crypto.randomUUID()
- useAppStore.setState({ workspaceState: { ...original.workspaceState, sessions: { [sessionId]: { kind: 'claude', cwd: '/trial' } }, buried: [] }, workspaceRuntimes: { [sessionId]: emptyRuntime() } })
+ useAppStore.setState({ workspaceState: { ...original.workspaceState, sessions: { [sessionId]: { kind: 'claude', cwd: '/trial' } } }, workspaceRuntimes: { [sessionId]: emptyRuntime() } })
const mounted = renderHook(() => {
const [version, setVersion] = useState(0)
const setRuntimes = useAppStore.getState().setWorkspaceRuntimes
diff --git a/src/renderer/src/workspace/control/drafts.ts b/src/renderer/src/workspace/control/drafts.ts
index ba8633471..9288b0322 100644
--- a/src/renderer/src/workspace/control/drafts.ts
+++ b/src/renderer/src/workspace/control/drafts.ts
@@ -15,8 +15,8 @@ export const inspectAgentDraft = (sessionId: string) => {
// Positive agent check: an extension pane has no composer either, and the old
// terminal-only guard let a control caller write an invisible draft into one
// that autosave would then persist.
- if (!meta || !isAgentSessionKind(meta.kind) || store.workspaceState.buried.some(item => item.sessionId === sessionId)) {
- throw new ControlError('unavailable', 'Choose a current, non-buried agent')
+ if (!meta || !isAgentSessionKind(meta.kind)) {
+ throw new ControlError('unavailable', 'Choose a current agent')
}
const runtime = store.workspaceRuntimes[sessionId] ?? emptyRuntime()
const images = runtime.draftImages.map(({ id, filename, mediaType }) => ({ id, filename, mediaType }))
diff --git a/src/renderer/src/workspace/control/identity.renderer.test.ts b/src/renderer/src/workspace/control/identity.renderer.test.ts
index 0184b3c36..75f84d73e 100644
--- a/src/renderer/src/workspace/control/identity.renderer.test.ts
+++ b/src/renderer/src/workspace/control/identity.renderer.test.ts
@@ -6,21 +6,23 @@ import { globalControlCapabilities } from '@main/control/globalCapabilities'
import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchSelectors'
import { dispatchRowTitle } from '@renderer/workspace/dispatch/rowTitle'
import { emptyRuntime } from '@renderer/session-runtime/state'
-import type { WorkspaceState } from '@renderer/workspace/types'
+import { loadRecordedDispatchWorkspace } from '@renderer/workspace/testing/recordedDispatchWorkspace'
const initial = useAppStore.getState()
afterEach(() => useAppStore.setState(initial, true))
it('resolves the recorded visible Dispatch label instead of its different project-local coordinate, retaining cross-window ambiguity', async () => {
- const fixture = JSON.parse(readFileSync('testing/fixtures/worktree-context/dispatch-global-d23.json', 'utf8'))
+ // The recorded v2 workspace, lifted onto the live shape (its lane grid moves
+ // from `dispatchMode.tiled` to `stage`; see recordedDispatchWorkspace.ts).
+ const fixture = loadRecordedDispatchWorkspace()
const bundle = JSON.parse(readFileSync('testing/fixtures/rendering-bundles/2026-05-20T19-11-51-193-d4a44a16.json', 'utf8'))
- const id = fixture.$fixture.observed.targetSessionId
- useAppStore.setState({ workspaceState: fixture.state as WorkspaceState, workspaceTileTabs: null, workspaceReaderMode: null, workspaceSpotlight: null,
+ const id = fixture.observed.targetSessionId
+ useAppStore.setState({ workspaceState: fixture.state, workspaceReaderMode: null, workspaceSpotlight: null,
workspaceRuntimes: { [id]: { ...emptyRuntime(), entries: bundle.input.entries } } })
const observed = observeWorkspace(() => ({ restoreStatus: 'fresh' }))
const target = observed.sessions.find(session => session.sessionId === id)!
const visible = buildVisibleDispatchRows(fixture.state).find(row => row.sessionId === id)!
- expect(target.displayLabel).toBe(fixture.$fixture.observed.targetVisibleLabel)
- expect(target.displayLabel).not.toBe(fixture.$fixture.observed.targetLocalLabel)
+ expect(target.displayLabel).toBe(fixture.observed.targetVisibleLabel)
+ expect(target.displayLabel).not.toBe(fixture.observed.targetLocalLabel)
expect(target.displayedTitle).toBe(dispatchRowTitle(visible, bundle.input.entries))
const owners = ['left', 'right'].map(windowId => ({ kind: 'window' as const, windowId, generation: 'current' }))
const caps = globalControlCapabilities(async () => owners.map(owner => ({ windowId: owner.windowId, owner, workspace: observed })))
diff --git a/src/renderer/src/workspace/control/layout.renderer.test.tsx b/src/renderer/src/workspace/control/layout.renderer.test.tsx
index 6d1d118ee..df986e960 100644
--- a/src/renderer/src/workspace/control/layout.renderer.test.tsx
+++ b/src/renderer/src/workspace/control/layout.renderer.test.tsx
@@ -1,14 +1,11 @@
import { useRef } from 'react'
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
-import { readFileSync } from 'node:fs'
-const fixture = JSON.parse(readFileSync('testing/fixtures/worktree-context/dispatch-global-d23.json', 'utf8'))
import { useAppStore } from '@renderer/app-state/store'
import { useDispatchActions } from '@renderer/workspace/hook/actions/dispatch'
-import { useResizeActions } from '@renderer/workspace/hook/actions/resize'
import { makeRefs } from '@renderer/workspace/hook/actions/testing/paneActionsHarness'
-import type { WorkspaceState } from '@renderer/workspace/types'
import type { Workspace } from '@renderer/workspace/hook'
+import { loadRecordedDispatchWorkspace } from '@renderer/workspace/testing/recordedDispatchWorkspace'
import { layoutControlCapabilities } from './layout'
const original = useAppStore.getState()
@@ -19,15 +16,14 @@ it('preserves recorded workspace identities through row edits and refuses a stal
// This is the persisted multi-project workspace already used by navigation
// tests. All mutations below run the real workspace hooks against Zustand;
// no alternative grid implementation or imagined lane normalizer is supplied.
- useAppStore.setState({ workspaceState: structuredClone(fixture.state) as unknown as WorkspaceState })
+ useAppStore.setState({ workspaceState: loadRecordedDispatchWorkspace().state })
const mounted = renderHook(() => {
const state = useAppStore(store => store.workspaceState)
const refs = useRef(makeRefs(state)).current
refs.stateRef.current = state; refs.latestStateRef.current = state
const store = useAppStore.getState()
- const dispatch = useDispatchActions(state, store.setWorkspaceState, store.setWorkspaceTileTabs, () => {}, refs, vi.fn(), () => {})
- const resize = useResizeActions(store.setWorkspaceState, store.setWorkspaceTileTabs)
- return { ...dispatch, ...resize, restoreStatus: 'fresh' }
+ const dispatch = useDispatchActions(store.setWorkspaceState, store.setWorkspaceRuntimes, refs, vi.fn(), () => {})
+ return { ...dispatch, restoreStatus: 'fresh' }
})
const capabilities = layoutControlCapabilities(() => mounted.result.current as unknown as Workspace)
const invoke = (id: string, input: unknown) => capabilities.find(item => item.descriptor.id === id)!.execute(input, context)
@@ -45,34 +41,32 @@ it('preserves recorded workspace identities through row edits and refuses a stal
await configure({ action: 'row-projects', rowIndex: 1, tabIds: ['tab-2'] })
const stale = await readRevision()
await configure({ action: 'grid', rows: [{ sourceRow: 1, length: 2 }] })
- const grid = useAppStore.getState().workspaceState.dispatchMode!.tiled!
+ const grid = useAppStore.getState().workspaceState.stage
expect(grid.rows).toMatchObject([{ length: 2, projectTabIds: ['tab-2'] }])
expect(grid.lanes).toEqual([{}, {}])
expect(Object.keys(useAppStore.getState().workspaceState.sessions)).toEqual(originalSessions)
expect(await invoke('dispatch.configure', { revision: stale, change: { action: 'lane-focus', laneIndex: 1 } })).toMatchObject({ ok: false, error: { code: 'stale_cursor' } })
- act(() => { useAppStore.getState().setWorkspaceState(state => ({ ...state, activeTabId: 'tab-1' })) })
- const revision = await readRevision()
- await act(async () => { expect(await invoke('layout.adjust', { tabId: 'tab-4', revision, change: { action: 'rotate' } })).toMatchObject({ ok: true }) })
- expect(useAppStore.getState().workspaceState.activeTabId).toBe('tab-1')
- expect(useAppStore.getState().workspaceState.tabs.find(tab => tab.id === 'tab-4')!.root).toMatchObject({ direction: 'horizontal' })
+ // layout.adjust (rotate an explicit project's tree without activating it)
+ // was asserted here until #992 deleted the tile tree and that capability.
+ expect(capabilities.some(item => item.descriptor.id === 'layout.adjust')).toBe(false)
})
-it('reports effective tiled focus separately from remembered classic selection after lane replacement and removal (#798)', async () => {
- useAppStore.setState({ workspaceState: structuredClone(fixture.state) as unknown as WorkspaceState, workspaceTileTabs: null, workspaceReaderMode: null, workspaceSpotlight: null })
+it('reports the focused lane s agent as the one focus truth through lane replacement and removal (#798)', async () => {
+ useAppStore.setState({ workspaceState: loadRecordedDispatchWorkspace().state, workspaceReaderMode: null, workspaceSpotlight: null })
const mounted = renderHook(() => {
const state = useAppStore(store => store.workspaceState)
const refs = useRef(makeRefs(state)).current
refs.stateRef.current = state; refs.latestStateRef.current = state
const store = useAppStore.getState()
- return { ...useDispatchActions(state, store.setWorkspaceState, store.setWorkspaceTileTabs, () => {}, refs, vi.fn(), () => {}), restoreStatus: 'fresh' }
+ return { ...useDispatchActions(store.setWorkspaceState, store.setWorkspaceRuntimes, refs, vi.fn(), () => {}), restoreStatus: 'fresh' }
})
const caps = layoutControlCapabilities(() => mounted.result.current as unknown as Workspace)
const invoke = (id: string, input: unknown) => caps.find(cap => cap.descriptor.id === id)!.execute(input, context)
const read = async () => {
const result = await invoke('layout.read', {})
if (!result.ok) throw new Error(JSON.stringify(result))
- return result.value as unknown as { revision: string; effectiveFocusedSessionId: string | null; dispatch: { focusedSessionId: string | null; classicFocusedSessionId: string | null } }
+ return result.value as unknown as { revision: string; effectiveFocusedSessionId: string | null; dispatch: { focusedSessionId: string | null } }
}
const configure = async (change: unknown) => { const revision = (await read()).revision; await act(async () => { expect(await invoke('dispatch.configure', { revision, change })).toMatchObject({ ok: true }) }) }
await configure({ action: 'grid', rows: [{ sourceRow: 0, length: 4 }] })
@@ -87,4 +81,32 @@ it('reports effective tiled focus separately from remembered classic selection a
const removed = await read()
expect(removed.dispatch.focusedSessionId).toBe(removed.effectiveFocusedSessionId)
expect(removed.effectiveFocusedSessionId).not.toBe('session-23')
+ // The published envelope no longer carries the two fields whose subjects
+ // #992 deleted: a remembered classic selection and a layout-wide scope.
+ expect(removed.dispatch).not.toHaveProperty('classicFocusedSessionId')
+ expect(removed.dispatch).not.toHaveProperty('scope')
+})
+
+it('refuses the retired enter / exit / scope actions instead of succeeding silently', async () => {
+ // An agent written against the two-mode layout will still send these. A
+ // no-op success would tell it the layout changed when nothing did, so the
+ // input schema rejects them outright (#992).
+ useAppStore.setState({ workspaceState: loadRecordedDispatchWorkspace().state, workspaceReaderMode: null, workspaceSpotlight: null })
+ const mounted = renderHook(() => {
+ const state = useAppStore(store => store.workspaceState)
+ const refs = useRef(makeRefs(state)).current
+ refs.stateRef.current = state; refs.latestStateRef.current = state
+ const store = useAppStore.getState()
+ return { ...useDispatchActions(store.setWorkspaceState, store.setWorkspaceRuntimes, refs, vi.fn(), () => {}), restoreStatus: 'fresh' }
+ })
+ const caps = layoutControlCapabilities(() => mounted.result.current as unknown as Workspace)
+ const invoke = (id: string, input: unknown) => caps.find(cap => cap.descriptor.id === id)!.execute(input, context)
+ const read = await invoke('layout.read', {})
+ if (!read.ok) throw new Error(JSON.stringify(read))
+ const revision = (read.value as { revision: string }).revision
+ const before = useAppStore.getState().workspaceState
+ for (const change of [{ action: 'enter', scope: 'global' }, { action: 'exit' }, { action: 'scope', scope: 'project' }]) {
+ expect(await invoke('dispatch.configure', { revision, change })).toMatchObject({ ok: false, error: { code: 'invalid_input' } })
+ }
+ expect(useAppStore.getState().workspaceState).toBe(before)
})
diff --git a/src/renderer/src/workspace/control/layout.ts b/src/renderer/src/workspace/control/layout.ts
index 046a8404f..e6e7206fa 100644
--- a/src/renderer/src/workspace/control/layout.ts
+++ b/src/renderer/src/workspace/control/layout.ts
@@ -2,9 +2,9 @@ import { z } from 'zod'
import { ControlError, defineCapability, paginate } from '@control-sdk'
import { useAppStore } from '@renderer/app-state/store'
import { hasAppInteractionOwner } from '@renderer/lib/interaction-ownership'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
import { normalizeGridShape, MAX_DISPATCH_ROWS, MAX_DISPATCH_TILES, MAX_DISPATCH_LANES, INDEX_FRACTION_MIN, INDEX_FRACTION_MAX } from '@renderer/workspace/dispatch/gridShape'
import { observeWorkspace } from '@renderer/workspace/control'
+import { resolveTabSessions } from '@renderer/workspace/queries'
import type { Workspace } from '@renderer/workspace/hook'
const tabId = z.string().describe('Stable project tab ID from app.observe in this window.')
@@ -15,21 +15,26 @@ const rows = z.array(z.object({ length: z.number().int().min(1).max(MAX_DISPATCH
.describe('Complete desired rows in output order. Each names its prior row or null; at most 16 total lanes.')
const rowIndex = z.number().int().min(0).describe('Zero-based row index from layout.read; protected by the layout revision.')
const laneIndex = z.number().int().min(0).describe('Zero-based flat lane index from layout.read; rows are laid out in row-major order.')
-const scope = z.enum(['project', 'global']).describe('Project uses the active project; global includes every project in this window.')
-const layoutOutput = z.object({ revision: z.string(), activeTabId: z.string(), tabs: z.array(z.object({ id: z.string(), root: z.json() })),
- dispatch: z.json().nullable(), effectiveFocusedSessionId: z.string().nullable() })
+const layoutOutput = z.object({ revision: z.string(), activeTabId: z.string(), tabs: z.array(z.object({ id: z.string(), title: z.string(), sessionIds: z.array(z.string()) })),
+ dispatch: z.json(), effectiveFocusedSessionId: z.string().nullable() })
export function layoutControlCapabilities(getWorkspace: () => Workspace) {
const read = () => {
const { workspaceState: state } = useAppStore.getState()
- const dispatch = state.dispatchMode ? { ...state.dispatchMode,
- // Stored focusedSessionId remembers classic Dispatch selection. Tiled
- // command targeting follows its focused lane instead (#798). Preserve
- // that memory under an honest name and expose the effective target.
- classicFocusedSessionId: state.dispatchMode.focusedSessionId,
+ // WHY the output keeps the `dispatch: { focusedSessionId, tiled }` envelope
+ // although the state field is now a flat `stage` (#992): this is a
+ // published control-plane shape that agents and extensions already parse.
+ // `scope` and `classicFocusedSessionId` are gone because the things they
+ // described are gone — a layout-wide project/global scope and a remembered
+ // classic single selection. `focusedSessionId` is the focused lane's
+ // occupant (#798), the same value as effectiveFocusedSessionId; it stays
+ // so a reader that only knew the old field keeps resolving the target.
+ // The envelope is renamed with the rest of the public surface in stage 7.
+ const dispatch = {
focusedSessionId: observeWorkspace(getWorkspace).focusedSessionId,
- ...(state.dispatchMode.tiled ? { tiled: normalizeGridShape(state.dispatchMode.tiled) } : {}) } : null
- const value = { effectiveFocusedSessionId: observeWorkspace(getWorkspace).focusedSessionId, activeTabId: state.activeTabId, tabs: state.tabs.map(({ id, root }) => ({ id, root })), dispatch }
+ tiled: normalizeGridShape(state.stage),
+ }
+ const value = { effectiveFocusedSessionId: observeWorkspace(getWorkspace).focusedSessionId, activeTabId: state.activeTabId, tabs: state.tabs.map(({ id, title }) => ({ id, title, sessionIds: resolveTabSessions(state, id) })), dispatch }
return { ...JSON.parse(JSON.stringify(value)), revision: paginate([value], { limit: 1 }, 'workspace-layout').revision }
}
const admit = (expected: string) => {
@@ -44,31 +49,13 @@ export function layoutControlCapabilities(getWorkspace: () => Workspace) {
}
return [
defineCapability({
- id: 'layout.read', title: 'Read project trees and Dispatch layout', execution: 'window', effect: 'read', input: z.object({}).strict(), output: layoutOutput,
- description: 'Read exact project tile trees, active tab and normalized Dispatch rows/lanes with a revision for edits. Tree split direction vertical means left/right; horizontal means top/bottom; ratio is the a-child share. Dispatch lanes are flat row-major indices, rows specify their lengths. effectiveFocusedSessionId is the current command target; dispatch.classicFocusedSessionId is only remembered classic selection. Reading does not focus or wake agents.',
+ id: 'layout.read', title: 'Read projects and the lane layout', execution: 'window', effect: 'read', input: z.object({}).strict(), output: layoutOutput,
+ description: 'Read the projects (each with its agents in index order), the active project and the normalized rows/lanes with a revision for edits. A project owns no layout: it is a group of agents, and lanes show agents from any project. Lanes are flat row-major indices, rows specify their lengths. The lane grid always exists; there is no mode to enter. effectiveFocusedSessionId is the current command target: the agent in the focused lane. Reading does not focus or wake agents.',
handler: read,
}),
- defineCapability({
- id: 'layout.adjust', title: 'Adjust a project grid', execution: 'window', effect: 'ui', target: { kind: 'project', field: 'tabId' },
- description: 'Adjust an explicit project using the existing layout operations. Equalize preserves the tree, balance rebuilds equal-sized cells, rotate swaps rows/columns, and divider sets the shared split between two leaves (and activates that tab). Never creates or closes sessions. For new panes, use agents.create then placement.list/attach.',
- input: z.object({ tabId, revision, change: z.discriminatedUnion('action', [
- z.object({ action: z.enum(['equalize', 'balance', 'rotate']) }).strict(),
- z.object({ action: z.literal('divider'), fromSessionId: z.string().describe('Leaf on the a side of the intended divider.'), toSessionId: z.string().describe('Leaf on its b side.'), ratio: z.number().min(0.1).max(0.9).describe('Share of the split allocated to its a child; between 0.1 and 0.9.') }).strict(),
- ]) }).strict(), output: layoutOutput,
- handler: input => {
- admit(input.revision)
- const tab = project(input.tabId)
- const change = input.change
- if (change.action === 'divider') {
- const leaves = collectLeaves(tab.root)
- if (change.fromSessionId === change.toSessionId || !leaves.includes(change.fromSessionId) || !leaves.includes(change.toSessionId)) throw new ControlError('unavailable', 'Choose two different leaves in the target grid')
- getWorkspace().setSplitRatioInTab(input.tabId, change.fromSessionId, change.toSessionId, change.ratio)
- } else if (change.action === 'equalize') getWorkspace().normalizeLayout(input.tabId)
- else if (change.action === 'balance') getWorkspace().hardNormalizeLayout(input.tabId)
- else getWorkspace().rotateLayout(input.tabId)
- return read()
- },
- }),
+ // layout.adjust (equalize / balance / rotate / divider) died with the tile
+ // tree (#992). Lane and row sizing is dispatch.configure's lane-weights,
+ // row-heights and row-index-width actions.
defineCapability({
id: 'tabs.reorder', title: 'Reorder project tabs', execution: 'window', effect: 'ui',
description: 'Set the complete project tab order in this window. Requires every current tab ID exactly once and a fresh layout revision. Uses normal tab ordering without moving sessions between projects.',
@@ -82,12 +69,14 @@ export function layoutControlCapabilities(getWorkspace: () => Workspace) {
},
}),
defineCapability({
- id: 'dispatch.configure', title: 'Configure Dispatch rows and lanes', execution: 'window', effect: 'ui',
- description: 'Change one explicit Dispatch setting through normal workspace actions, then return the resulting layout. Requires layout.read revision; refresh it between actions. Enter/scope resets tiled lanes to ordinary Dispatch. Grid sets row lengths, preserving existing lane assignments where the domain permits; a grid entered from Dispatch seeds lane 0 with the focused agent, waking it first when it is detached. Row project filters promote scope to global. Lane selection may wake the chosen existing agent; it never creates one. Exiting Dispatch returns to the project grid.',
+ id: 'dispatch.configure', title: 'Configure stage rows and lanes', execution: 'window', effect: 'ui',
+ description: 'Change one explicit stage setting through normal workspace actions, then return the resulting layout. Requires layout.read revision; refresh it between actions. Grid sets row lengths (preserving existing lane assignments where the domain permits). Every row lists every project unless a row project filter narrows it. Lane selection may wake the chosen existing agent; it never creates one.',
input: z.object({ revision, change: z.discriminatedUnion('action', [
- z.object({ action: z.literal('enter'), scope }).strict(),
- z.object({ action: z.literal('exit') }).strict(),
- z.object({ action: z.literal('scope'), scope }).strict(),
+ // 'enter', 'exit' and 'scope' were actions here until #992. The lane
+ // grid is the only layout, so there is nothing to enter or leave, and
+ // the project/global scope they switched no longer exists. A caller
+ // still sending them gets zod's invalid-union error, which names the
+ // surviving actions — more useful than a silent no-op success.
z.object({ action: z.literal('grid'), rows }).strict(),
z.object({ action: z.literal('lane-select'), laneIndex, sessionId: z.string().describe('Existing, non-buried session ID to show in this lane.') }).strict(),
z.object({ action: z.literal('lane-focus'), laneIndex }).strict(),
@@ -102,33 +91,28 @@ export function layoutControlCapabilities(getWorkspace: () => Workspace) {
const change = input.change
const workspace = getWorkspace()
const state = useAppStore.getState().workspaceState
- const tiled = state.dispatchMode?.tiled ? normalizeGridShape(state.dispatchMode.tiled) : null
- if ('rowIndex' in change && (!tiled || change.rowIndex >= tiled.rows.length)) throw new ControlError('invalid_input', 'Row is outside the current grid')
- if ('laneIndex' in change && (!tiled || change.laneIndex >= tiled.lanes.length)) throw new ControlError('invalid_input', 'Lane is outside the current grid')
+ const tiled = normalizeGridShape(state.stage)
+ if ('rowIndex' in change && change.rowIndex >= tiled.rows.length) throw new ControlError('invalid_input', 'Row is outside the current grid')
+ if ('laneIndex' in change && change.laneIndex >= tiled.lanes.length) throw new ControlError('invalid_input', 'Lane is outside the current grid')
switch (change.action) {
- case 'enter': await workspace.enterDispatchMode(change.scope); break
- case 'exit': workspace.exitDispatchMode(); break
- case 'scope': await workspace.setDispatchScope(change.scope); break
case 'grid':
- if (!state.dispatchMode) throw new ControlError('unavailable', 'Enter Dispatch first')
- if (change.rows.some(row => row.sourceRow !== null && (!tiled || row.sourceRow >= tiled.rows.length))) throw new ControlError('invalid_input', 'A sourceRow does not exist; new rows use null')
- if (tiled) { if (!workspace.setDispatchGridShape(change.rows)) throw new ControlError('unavailable', 'Grid shape was refused') }
- else await workspace.enterTiledDispatch(change.rows.map(row => row.length))
+ if (change.rows.some(row => row.sourceRow !== null && row.sourceRow >= tiled.rows.length)) throw new ControlError('invalid_input', 'A sourceRow does not exist; new rows use null')
+ if (!workspace.setDispatchGridShape(change.rows)) throw new ControlError('unavailable', 'Grid shape was refused')
break
case 'lane-select':
- if (!state.sessions[change.sessionId] || state.buried.some(item => item.sessionId === change.sessionId)) throw new ControlError('unavailable', 'Agent is absent or buried')
+ if (!state.sessions[change.sessionId]) throw new ControlError('unavailable', 'Agent is absent')
await workspace.selectTiledLaneSession(change.laneIndex, change.sessionId)
- if (useAppStore.getState().workspaceState.dispatchMode?.tiled?.lanes[change.laneIndex]?.selectedSessionId !== change.sessionId) throw new ControlError('failed', 'Requested lane selection was not observed; read the layout', 'unknown')
+ if (useAppStore.getState().workspaceState.stage.lanes[change.laneIndex]?.selectedSessionId !== change.sessionId) throw new ControlError('failed', 'Requested lane selection was not observed; read the layout', 'unknown')
break
case 'lane-focus': workspace.setTiledFocusedLane(change.laneIndex); break
case 'row-projects': change.tabIds.forEach(project); workspace.setDispatchRowProjects(change.rowIndex, [...new Set(change.tabIds)]); break
case 'row-cap-children': workspace.setDispatchRowCapChildren(change.rowIndex, change.enabled); break
case 'row-index-width': workspace.setDispatchRowIndexFraction(change.rowIndex, change.fraction); break
case 'lane-weights':
- if (!tiled || change.weights.length !== tiled.lanes.length) throw new ControlError('invalid_input', 'Supply one weight per lane')
+ if (change.weights.length !== tiled.lanes.length) throw new ControlError('invalid_input', 'Supply one weight per lane')
workspace.setDispatchLaneWeights(change.weights); break
case 'row-heights':
- if (!tiled || change.weights.length !== tiled.rows.length) throw new ControlError('invalid_input', 'Supply one weight per row')
+ if (change.weights.length !== tiled.rows.length) throw new ControlError('invalid_input', 'Supply one weight per row')
workspace.setDispatchRowHeights(change.weights); break
}
return read()
diff --git a/src/renderer/src/workspace/control/lifecycle.renderer.test.tsx b/src/renderer/src/workspace/control/lifecycle.renderer.test.tsx
index 8dfe7be58..9d6af9302 100644
--- a/src/renderer/src/workspace/control/lifecycle.renderer.test.tsx
+++ b/src/renderer/src/workspace/control/lifecycle.renderer.test.tsx
@@ -13,9 +13,8 @@ afterEach(() => { cleanup(); useAppStore.setState(original, true); window.api =
const context = { requestId: 'original-call', operationId: 'original-call', caller: { kind: 'external' as const, id: 'operator' }, owner: { kind: 'window' as const, windowId: 'one', generation: 'current' } }
function setup() {
useAppStore.setState({ workspaceState: { ...original.workspaceState, activeTabId: 'project',
- tabs: [{ id: 'project', title: 'Project', root: { type: 'leaf', sessionId: 'other' }, focusedSessionId: 'other' }],
- sessions: { source: { kind: 'codex', cwd: '/source', providerSessionId: 'native-source' }, other: { kind: 'claude', cwd: '/other' } },
- detachedSessions: { source: { sessionId: 'source', projectTabId: 'project', projectTabTitle: 'Project', projectTabIndex: 0, detachedAt: 1, surface: 'dispatch' } }, buried: [],
+ tabs: [{ id: 'project', title: 'Project' }],
+ sessions: { source: { kind: 'codex', cwd: '/source', providerSessionId: 'native-source', projectId: 'project', joinedAt: 1 }, other: { kind: 'claude', cwd: '/other', projectId: 'project', joinedAt: 0 } },
}, workspaceRuntimes: { source: { ...emptyRuntime(), draftInput: 'Human draft' } } })
const refs = makeRefs(useAppStore.getState().workspaceState)
refs.latestRuntimesRef.current = useAppStore.getState().workspaceRuntimes
diff --git a/src/renderer/src/workspace/control/lifecycle.ts b/src/renderer/src/workspace/control/lifecycle.ts
index 3db0706fc..82b5f52c7 100644
--- a/src/renderer/src/workspace/control/lifecycle.ts
+++ b/src/renderer/src/workspace/control/lifecycle.ts
@@ -24,8 +24,8 @@ export function lifecycleControlCapabilities(getWorkspace: () => Workspace) {
const inspect = (sessionId: string) => {
const state = useAppStore.getState()
const meta = state.workspaceState.sessions[sessionId]
- if (!meta || !isAgentProviderKind(meta.kind ?? 'claude') || state.workspaceState.buried.some(row => row.sessionId === sessionId)) {
- throw new ControlError('unavailable', 'Agent is absent, buried or not an agent; inspect or restore it first')
+ if (!meta || !isAgentProviderKind(meta.kind ?? 'claude')) {
+ throw new ControlError('unavailable', 'Agent is absent or not an agent')
}
const provider = meta.kind ?? 'claude'
if (!isAgentProviderKind(provider)) throw new ControlError('unavailable', 'Not an agent')
@@ -54,7 +54,7 @@ export function lifecycleControlCapabilities(getWorkspace: () => Workspace) {
}
return [
defineCapability({ id: 'agents.resume', title: 'Resume a native session in a project', execution: 'window', effect: 'mutation', completion: 'accepted', target: { kind: 'project', field: 'tabId' },
- description: 'Open a known native conversation as a new detached agent in an explicit project. Supply provider/nativeSessionId/cwd from nativeHistory.list; known OpenCode IDs are supported. This resumes the same native conversation, not a copy; the ordinary backend ownership policy applies if already open. Returns a task callId; operations.read reports the exact newSessionId. Creation selects the captured focused Dispatch lane by default; selectCreated:false preserves placement. Use agents.show or placement.attach afterward.',
+ description: 'Open a known native conversation as a new agent in an explicit project. Supply provider/nativeSessionId/cwd from nativeHistory.list; known OpenCode IDs are supported. This resumes the same native conversation, not a copy; the ordinary backend ownership policy applies if already open. Returns a task callId; operations.read reports the exact newSessionId. It fills the captured focused lane only when that lane is empty (selectCreated:false never places it); otherwise it waits in the project index. Use agents.show afterward to put it in a lane.',
input: z.object({ tabId: z.string(), anchorSessionId: z.string(), provider: z.enum(['claude', 'codex', 'opencode', 'grok']), nativeSessionId: z.string().min(1), cwd: z.string().min(1), runtime: z.enum(['terminal']).optional(), selectCreated: z.boolean().default(true).describe('False preserves the active tab and all lane selections.') }).strict(), output: accepted,
handler: (input, context) => {
const check = () => {
@@ -73,7 +73,7 @@ export function lifecycleControlCapabilities(getWorkspace: () => Workspace) {
},
}),
defineCapability({ id: 'agents.duplicate', title: 'Branch an exact agent conversation', execution: 'window', effect: 'mutation', completion: 'accepted', target: { kind: 'session', field: 'sessionId' },
- description: 'Copy an idle native conversation to a new native identity and create a detached agent in the chosen project. Preserves provider/runtime and enabled built-in domain names; leaves the source and its draft intact. Requires a fresh lifecycle revision and an explicit target project/anchor in the same window. Use operations.read for both new IDs, then agents.show or placement.attach. Creation selects the captured focused lane by default; selectCreated:false preserves placement. A failed placement can leave a native transcript copy; do not blindly retry unknown outcomes.',
+ description: 'Copy an idle native conversation to a new native identity and create an agent in the chosen project. Preserves provider/runtime and enabled built-in domain names; leaves the source and its draft intact. Requires a fresh lifecycle revision and an explicit target project/anchor in the same window. Use operations.read for both new IDs, then agents.show to put the copy in a lane. It fills the captured focused lane only when that lane is empty (selectCreated:false never places it). A failed placement can leave a native transcript copy; do not blindly retry unknown outcomes.',
input: target.extend({ revision, tabId: z.string(), anchorSessionId: z.string(), selectCreated: z.boolean().default(true).describe('False preserves the active tab and all lane selections.') }), output: accepted,
handler: (input, context) => {
const check = () => {
diff --git a/src/renderer/src/workspace/control/navigation.renderer.test.ts b/src/renderer/src/workspace/control/navigation.renderer.test.ts
index 45d6f066d..4b65dfe5d 100644
--- a/src/renderer/src/workspace/control/navigation.renderer.test.ts
+++ b/src/renderer/src/workspace/control/navigation.renderer.test.ts
@@ -2,22 +2,29 @@ import { afterEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@renderer/app-state/store'
import type { Workspace } from '@renderer/workspace/hook'
import { navigationControlCapabilities } from './navigation'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
const original = useAppStore.getState()
afterEach(() => { useAppStore.setState(original, true); vi.unstubAllGlobals() })
it('refuses acknowledgment when effective focus moves during workspace navigation', async () => {
- useAppStore.setState({ workspaceReaderMode: null, workspaceSpotlight: null, workspaceTileTabs: null,
- workspaceState: { ...original.workspaceState, activeTabId: 'project', dispatchMode: null,
- tabs: [{ id: 'project', title: 'Project', root: { type: 'leaf', sessionId: 'target' }, focusedSessionId: 'target' }, { id: 'other-project', title: 'Other', root: { type: 'leaf', sessionId: 'other' }, focusedSessionId: 'other' }],
- sessions: { target: { kind: 'claude', cwd: '/trial' }, other: { kind: 'claude', cwd: '/trial' } }, buried: [], detachedSessions: {},
+ useAppStore.setState({ workspaceReaderMode: null, workspaceSpotlight: null,
+ workspaceState: { ...original.workspaceState, activeTabId: 'project', stage: oneLaneStage('target'),
+ tabs: [{ id: 'project', title: 'Project' }, { id: 'other-project', title: 'Other' }],
+ sessions: { target: { kind: 'claude', cwd: '/trial', projectId: 'project', joinedAt: 0 }, other: { kind: 'claude', cwd: '/trial', projectId: 'other-project', joinedAt: 0 } },
} })
// The review's production-handler probe changed focus at the animation-frame
// boundary. Preserve that exact interleaving rather than mocking observation.
+ //
+ // HOW focus moves changed with #992. The probe used to flip `activeTabId`,
+ // because effective focus was the active tab's tree focus. The active
+ // project is only a label now (U4) and moving it moves nothing; the one
+ // focus truth is the focused lane's occupant (U3), so the interleaved write
+ // re-aims the lane — what a user clicking another agent mid-navigation does.
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
- useAppStore.getState().setWorkspaceState(state => ({ ...state, activeTabId: 'other-project' }))
+ useAppStore.getState().setWorkspaceState(state => ({ ...state, stage: oneLaneStage('other') }))
callback(0); return 1
})
const caps = navigationControlCapabilities(() => ({ restoreStatus: 'fresh', setReaderModeTarget: () => true, setSpotlightTarget: () => true, focusAgentBySessionId: async () => true }) as unknown as Workspace)
const result = await caps.find(cap => cap.descriptor.id === 'views.agentSet')!.execute({ sessionId: 'target', mode: 'workspace' }, { requestId: 'navigation', caller: { kind: 'external', id: 'operator' }, owner: { kind: 'window', windowId: 'one', generation: 'current' } })
- expect(useAppStore.getState().workspaceState.activeTabId).toBe('other-project')
+ expect(useAppStore.getState().workspaceState.stage.lanes[0]?.selectedSessionId).toBe('other')
expect(result).toMatchObject({ ok: false, error: { outcome: 'unknown' } })
})
diff --git a/src/renderer/src/workspace/control/navigation.ts b/src/renderer/src/workspace/control/navigation.ts
index 6a0c89fad..7e05211ef 100644
--- a/src/renderer/src/workspace/control/navigation.ts
+++ b/src/renderer/src/workspace/control/navigation.ts
@@ -1,66 +1,32 @@
import { z } from 'zod'
-import { ControlError, defineCapability, paginate } from '@control-sdk'
+import { ControlError, defineCapability } from '@control-sdk'
import { useAppStore } from '@renderer/app-state/store'
import { hasAppInteractionOwner } from '@renderer/lib/interaction-ownership'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
import { observeWorkspace } from '@renderer/workspace/control'
-import { resolveTabSessions } from '@renderer/workspace/queries'
import type { Workspace } from '@renderer/workspace/hook'
+// placement.inspect, placement.detach and agents.bury lived here until the
+// unified layout (#992). They moved grid panes to Dispatch or archived them;
+// in the pool-first workspace there is no grid to detach from and no archive
+// to bury into — a session not shown in a lane is simply an unplaced pool
+// row. Operators show a session with agents.show or dispatch.configure
+// (lane-select) and hide it by selecting another session into that lane.
+
const session = z.object({ sessionId: z.string().min(1) }).strict()
export function navigationControlCapabilities(getWorkspace: () => Workspace) {
const ready = () => {
if (getWorkspace().restoreStatus === 'pending' || hasAppInteractionOwner()) throw new ControlError('unavailable', 'Wait for restoration or finish the input-owning surface')
}
- const placement = (sessionId: string) => {
+ const requireSession = (sessionId: string) => {
const state = useAppStore.getState().workspaceState
if (!state.sessions[sessionId]) throw new ControlError('unavailable', 'Session no longer exists')
- const tabs = state.tabs.filter(tab => resolveTabSessions(state, tab.id).includes(sessionId))
- const buried = state.buried.some(row => row.sessionId === sessionId)
- const grid = tabs.find(tab => collectLeaves(tab.root).includes(sessionId))
- const affectedSessionIds = grid && collectLeaves(grid.root).length === 1 ? resolveTabSessions(state, grid.id) : [sessionId]
- // Last-pane bury also archives detached project children. Expose that
- // actual domain cascade before the caller chooses to commit it.
- const evidence = { tabs, detached: state.detachedSessions, buried: state.buried, affectedSessionIds }
- return { sessionId, gridTabId: grid?.id ?? null, buried, detached: Boolean(state.detachedSessions[sessionId]),
- affectedSessionIds, revision: paginate([evidence], { limit: 1 }, `placement:${sessionId}`).revision }
}
return [
- defineCapability({ id: 'placement.inspect', title: 'Inspect detach and bury consequences', execution: 'window', effect: 'read', target: { kind: 'session', field: 'sessionId' },
- description: 'Inspect exact grid/detached/buried placement and the sessions affected by burying a last grid pane. Returns the revision required for detach or bury. Does not wake or focus anything.',
- input: session, output: z.object({ sessionId: z.string(), gridTabId: z.string().nullable(), buried: z.boolean(), detached: z.boolean(), affectedSessionIds: z.array(z.string()), revision: z.string() }), handler: input => placement(input.sessionId),
- }),
- defineCapability({ id: 'placement.detach', title: 'Move a grid agent to Dispatch', execution: 'window', effect: 'mutation', target: { kind: 'session', field: 'sessionId' },
- description: 'Detach an exact grid pane through the ordinary placement operation. Requires placement.inspect revision. Preserves its live backend and project affinity; refuses the last grid pane in a project. Does not toggle Dispatch on.',
- input: session.extend({ revision: z.string() }), output: z.object({ sessionId: z.string(), detached: z.literal(true) }),
- handler: input => {
- ready(); const before = placement(input.sessionId)
- if (before.revision !== input.revision) throw new ControlError('stale_cursor', 'Placement changed; inspect again')
- if (!before.gridTabId || before.buried) throw new ControlError('unavailable', 'Choose a current grid pane')
- getWorkspace().detachSessionToDispatch(input.sessionId)
- if (!placement(input.sessionId).detached) throw new ControlError('unavailable', 'Detach refused; the last grid pane must remain')
- return { sessionId: input.sessionId, detached: true as const }
- },
- }),
- defineCapability({ id: 'agents.bury', title: 'Archive a grid pane without killing it', execution: 'window', effect: 'mutation', target: { kind: 'session', field: 'sessionId' },
- description: 'Bury the exact grid pane with an optional archive note through the existing non-destructive archive operation. Requires placement.inspect revision acknowledging affectedSessionIds: burying the last pane also archives detached children and removes the project tab. Backends remain alive. Use agents.restore for recovery; detached agents must be attached before burying.',
- input: session.extend({ revision: z.string(), note: z.string().max(4000).optional() }), output: z.object({ sessionId: z.string(), buriedSessionIds: z.array(z.string()) }),
- handler: input => {
- ready(); const before = placement(input.sessionId)
- if (before.revision !== input.revision) throw new ControlError('stale_cursor', 'Placement changed; inspect the affected sessions again')
- if (!before.gridTabId || before.buried) throw new ControlError('unavailable', 'Choose a current grid pane')
- getWorkspace().buryFocused(input.note, input.sessionId)
- const ids = useAppStore.getState().workspaceState.buried.map(row => row.sessionId).filter(id => before.affectedSessionIds.includes(id))
- if (!ids.includes(input.sessionId)) throw new ControlError('failed', 'Archive was not observed', 'unknown')
- return { sessionId: input.sessionId, buriedSessionIds: ids }
- },
- }),
defineCapability({ id: 'views.agentSet', title: 'Show an agent in Reader or Spotlight', execution: 'window', effect: 'ui', target: { kind: 'session', field: 'sessionId' },
- description: 'Set an exact visible agent view to Reader, Spotlight or normal workspace. Uses desired state, not a toggle. Reader shows the conversation; Spotlight zooms its pane. Requires a current non-buried session. For normal workspace this exits focus views and navigates to the agent; use agents.show when staying in the current view mode.',
+ description: 'Set an exact visible agent view to Reader, Spotlight or normal workspace. Uses desired state, not a toggle. Reader shows the conversation; Spotlight zooms its pane. Requires a current session. For normal workspace this exits focus views and navigates to the agent; use agents.show when staying in the current view mode.',
input: session.extend({ mode: z.enum(['reader', 'spotlight', 'workspace']) }), output: z.object({ sessionId: z.string(), mode: z.string() }),
handler: async input => {
- ready(); const before = placement(input.sessionId)
- if (before.buried) throw new ControlError('unavailable', 'Restore the buried agent first')
+ ready(); requireSession(input.sessionId)
const workspace = getWorkspace()
let changed: boolean
if (input.mode === 'reader') changed = workspace.setReaderModeTarget(input.sessionId)
diff --git a/src/renderer/src/workspace/control/preferences.ts b/src/renderer/src/workspace/control/preferences.ts
index d5bac5a03..c526ef29c 100644
--- a/src/renderer/src/workspace/control/preferences.ts
+++ b/src/renderer/src/workspace/control/preferences.ts
@@ -13,7 +13,7 @@ export function preferenceControlCapabilities(getWorkspace: () => Workspace) {
const read = (sessionId: string) => {
const state = useAppStore.getState()
const meta = state.workspaceState.sessions[sessionId]
- if (!meta || !isAgentKind(meta.kind ?? 'claude') || state.workspaceState.buried.some(row => row.sessionId === sessionId)) throw new ControlError('unavailable', 'Choose an existing non-buried agent')
+ if (!meta || !isAgentKind(meta.kind ?? 'claude')) throw new ControlError('unavailable', 'Choose an existing agent')
const runtime = state.workspaceRuntimes[sessionId] ?? emptyRuntime()
// Revisions cover the observation, including effective surface/follow, not
// only stored preferences. Working activity can invalidate a read without
diff --git a/src/renderer/src/workspace/control/terminals.ts b/src/renderer/src/workspace/control/terminals.ts
index 98b143efa..9b12d7b87 100644
--- a/src/renderer/src/workspace/control/terminals.ts
+++ b/src/renderer/src/workspace/control/terminals.ts
@@ -9,7 +9,7 @@ export function terminalControlCapabilities(getWorkspace: () => Workspace) {
const invoke = async (capabilityId: string, input: { sessionId: string }) => {
const state = useAppStore.getState().workspaceState
const meta = state.sessions[input.sessionId]
- if (!meta || state.buried.some(item => item.sessionId === input.sessionId)) throw new ControlError('unavailable', 'Session is absent or buried')
+ if (!meta) throw new ControlError('unavailable', 'Session is absent')
const result = await window.api.controlInvoke({ capabilityId, input: { ...input, cwd: meta.cwd, provider: meta.kind ?? 'claude' } })
if (!result.ok) throw new ControlError(result.error.code, result.error.message, result.error.outcome)
return result.value
@@ -17,7 +17,7 @@ export function terminalControlCapabilities(getWorkspace: () => Workspace) {
return [
defineCapability({
id: 'terminals.create', title: 'Create a project terminal', execution: 'window', effect: 'mutation', target: { kind: 'project', field: 'tabId' },
- description: 'Create a new shell as a detached session in an explicit project, using the named anchor session directory. Uses the normal spawn/placement transaction and returns the exact new ID. Existing tiled Dispatch may select its lane. Use dispatch.configure or placement.list/attach to place the terminal; this does not send a shell command.',
+ description: 'Create a new shell in an explicit project, using the named anchor session directory. Uses the normal spawn/placement transaction and returns the exact new ID. It fills the focused lane only when that lane is empty; otherwise it waits in the project index. Use dispatch.configure (lane-select) or agents.show to put it in a lane; this does not send a shell command.',
input: z.object({ tabId: z.string().describe('Project tab ID from app.observe.'), anchorSessionId: z.string().describe('Existing session in that project whose cwd the new shell should use.') }).strict(),
output: z.object({ sessionId: z.string(), tabId: z.string(), cwd: z.string() }),
handler: async input => {
diff --git a/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx b/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx
index 2ab2344c1..f13873ef5 100644
--- a/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx
+++ b/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx
@@ -38,7 +38,6 @@ export const DispatchAgentList = memo(function DispatchAgentList({
groups,
pinnedRows,
activeSessionId,
- dispatchScope,
focusSessionInTab,
showWorktreeBadges,
disabledSessionIds,
@@ -52,7 +51,6 @@ export const DispatchAgentList = memo(function DispatchAgentList({
groups: ReturnType
pinnedRows: DispatchAgentRow[]
activeSessionId: string | null
- dispatchScope: 'global' | 'project'
focusSessionInTab: Workspace['focusSessionInTab']
showWorktreeBadges: boolean
// Renders a "+" in each project header when supplied. Optional so the
@@ -199,7 +197,10 @@ export const DispatchAgentList = memo(function DispatchAgentList({
)}
{/* The row's project binding lives at the top of the list it
constrains — the whole benefit of a per-row index over one shared
- sidebar. Falls back to the scope label in classic Dispatch. */}
+ sidebar. With no picker wired (a bare list in a test or a future
+ read-only surface) it states the truth of an unbound row. It
+ used to fall back to a layout-wide 'project' / 'global' scope
+ label; that scope died with #992. */}
{onPickRowProject ? (
) : (
- {dispatchScope}
+ Any project
)}
@@ -264,15 +265,12 @@ export const DispatchAgentList = memo(function DispatchAgentList({
targetLaneIndex={targetLaneIndex}
/>
) : (
-
onToggleExpandedParent?.(item.parentSessionId)}
- data-dispatch-row="true"
- className="flex w-full items-center gap-1 border-t border-border py-1 pl-7 text-left text-[10px] text-muted hover:text-fg hover:bg-surface-raised"
- >
- {item.kind === 'more' ? `+ ${item.hidden} more` : '− Show fewer'}
-
+ label={item.kind === 'more' ? `+ ${item.hidden} more` : '− Show fewer'}
+ hiddenSessionIds={item.kind === 'more' ? item.hiddenSessionIds : EMPTY_SESSION_IDS}
+ onToggle={() => onToggleExpandedParent?.(item.parentSessionId)}
+ />
)
))}
@@ -282,6 +280,48 @@ export const DispatchAgentList = memo(function DispatchAgentList({
)
})
+const EMPTY_SESSION_IDS: SessionId[] = []
+
+/**
+ * The "+N more" / "Show fewer" row under a capped orchestration parent.
+ *
+ * WHY it carries the "new" badge (#1013 review B): orchestration children
+ * always land in the pool, and the cap hides every child past the third. The
+ * badges of a 5-worker run were therefore 3 visible and 2 behind the collapse,
+ * and "where did my agent go?" had no answer for those two. The selector
+ * returns one boolean, so this row re-renders only when that answer changes.
+ */
+const ChildCollapseRow = memo(function ChildCollapseRow({
+ label,
+ hiddenSessionIds,
+ onToggle,
+}: {
+ label: string
+ hiddenSessionIds: SessionId[]
+ onToggle: () => void
+}) {
+ const hidesNew = useAppStore(state => hiddenSessionIds.some(id => state.workspaceRuntimes[id]?.pooledSpawnAt != null))
+ return (
+
+ {label}
+ {hidesNew && (
+
+ new
+
+ )}
+
+ )
+})
+
const DispatchGroupHeader = memo(function DispatchGroupHeader({
title,
rows,
@@ -395,6 +435,11 @@ const DispatchAgentListRow = memo(function DispatchAgentListRow({
// comparison stable across those per-second updates while still
// giving terminal rows the live value they actually render.
activityStatus: row.kind === 'terminal' ? current?.activityStatus : undefined,
+ // The pooled-spawn badge (#992 §4.3): a spawn that took no lane marks
+ // itself here until placed. Boolean, not the timestamp — the row only
+ // re-renders when membership of the badge changes, and the chip does
+ // not care when it was minted.
+ isNewInPool: current?.pooledSpawnAt != null,
}
}))
const onSelect = useCallback(() => {
@@ -476,6 +521,25 @@ const DispatchAgentListRow = memo(function DispatchAgentListRow({
{title}
+ {runtime.isNewInPool && (
+ // The one-word answer to "my ⌘N did nothing" (#992): the spawn
+ // landed in the pool without moving anything on screen. Retired
+ // by the placement itself (pooledSpawnBadge.ts), never by time —
+ // a badge that expires while still unplaced would train the user
+ // to distrust it. Rendered BEFORE the unread badge because it is
+ // the answer to an earlier question ("where is it") than "what
+ // happened while I was away".
+
+ new
+
+ )}
{unreadBadge && (
)}
diff --git a/src/renderer/src/workspace/dispatch/DispatchColorFlags.renderer.test.tsx b/src/renderer/src/workspace/dispatch/DispatchColorFlags.renderer.test.tsx
index d62b57501..bcbdf55c2 100644
--- a/src/renderer/src/workspace/dispatch/DispatchColorFlags.renderer.test.tsx
+++ b/src/renderer/src/workspace/dispatch/DispatchColorFlags.renderer.test.tsx
@@ -42,7 +42,6 @@ function dispatchRow(sessionId: string, label: string): DispatchAgentRow {
sessionId,
kind: 'claude',
title: `${label} workflow`,
- placement: 'grid',
depth: 0,
}
}
@@ -57,8 +56,6 @@ function group(): DispatchTabGroup {
tab: {
id: 'tab-a',
title: 'Agent Code',
- root: { type: 'leaf', sessionId: FLAGGED_SESSION_ID },
- focusedSessionId: FLAGGED_SESSION_ID,
},
tabIndex: 0,
rows,
@@ -83,7 +80,6 @@ describe('Dispatch color-flag layout', () => {
groups={[group()]}
pinnedRows={[]}
activeSessionId={FLAGGED_SESSION_ID}
- dispatchScope="project"
focusSessionInTab={vi.fn()}
showWorktreeBadges={false}
/>,
diff --git a/src/renderer/src/workspace/dispatch/DispatchLayout.tsx b/src/renderer/src/workspace/dispatch/DispatchLayout.tsx
index 3906f82be..197f48ed9 100644
--- a/src/renderer/src/workspace/dispatch/DispatchLayout.tsx
+++ b/src/renderer/src/workspace/dispatch/DispatchLayout.tsx
@@ -1,21 +1,5 @@
-import { useCallback, useEffect, useMemo, useRef } from 'react'
-
import type { AgentViewMode } from '@renderer/app-state/settings/types'
import type { Workspace } from '@renderer/workspace/workspaceStore'
-import { useAppStore } from '@renderer/app-state/hooks'
-import { SplitHandle } from '@renderer/features/shared/SplitHandle'
-import { useResizableSplitter } from '@renderer/features/shared/useResizableSplitter'
-import { renderWorkspaceLeaf } from '@renderer/workspace/tile-tree/TileTree'
-import {
- buildDispatchGroups,
- buildPinnedDispatchRows,
- buildVisibleDispatchRows,
- selectVisibleDispatchRow,
-} from '@renderer/workspace/dispatch/dispatchSelectors'
-import {
- DispatchAgentList,
- DispatchEmpty,
-} from '@renderer/workspace/dispatch/DispatchAgentList'
import { TiledDispatchLayout } from '@renderer/workspace/dispatch/TiledDispatchLayout'
type Props = {
@@ -25,162 +9,16 @@ type Props = {
showWorktreeBadges: boolean
}
-// The single render fork. This wrapper must call NO hooks before the
-// branch: the classic and tiled layouts run different numbers of hooks, so
-// choosing between them has to be a component swap (each child's hooks stay
-// unconditional), not an early return inside one hook-bearing component.
-// dispatchMode.tiled is the source of truth (set by enterTiledDispatch /
-// cleared by exitTiledDispatch).
+// The workspace stage (#992): ragged rows of lanes, always. This used to be
+// the render fork between classic Dispatch (sidebar + one agent view) and
+// Tiled Dispatch; the unified layout promotes the tiled stage to THE
+// workspace and the classic view — like the grid tree before it — is
+// deleted rather than reconciled.
+//
+// Kept as a named wrapper (rather than importing TiledDispatchLayout at the
+// call sites) because MainSurface, tests, and the placement-overlay
+// composition already speak "DispatchLayout" — the seam stays stable while
+// what it renders became the whole workspace.
export function DispatchLayout(props: Props) {
- if (props.workspace.state.dispatchMode?.tiled) {
- return
- }
- return
-}
-
-function ClassicDispatchLayout({
- workspace,
- agentViewMode,
- showStatusMode,
- showWorktreeBadges,
-}: Props) {
- const groups = useMemo(
- () => buildDispatchGroups(workspace.state),
- [workspace.state],
- )
- const pinnedRows = useMemo(
- () => buildPinnedDispatchRows(workspace.state),
- [workspace.state],
- )
- // Pinned rows participate in keyboard dispatch (cmd+N) and in
- // "which row is currently focused?" selection — they're real
- // dispatch rows, just rendered in their own section. Prepending
- // them here makes the focus fallback prefer a pinned row over
- // anything else when the explicit focus id is stale, which matches
- // the Pinned section's visual position at the top of the list.
- const rows = useMemo(
- () => buildVisibleDispatchRows(workspace.state),
- [workspace.state],
- )
- const activeRow = selectVisibleDispatchRow(
- rows,
- workspace.state.dispatchMode?.focusedSessionId ?? null,
- workspace.activeTab?.focusedSessionId ?? null,
- )
- // Resizable list/active-agent split. The ratio is owned by uiShell
- // (see UiShellState.dispatchListRatio) so it survives mode toggles
- // without being re-derived from workspace state. We measure against
- // the outer flex row's bounding rect, NOT the viewport, because the
- // dispatch layout can be wrapped by the Global Editor overlay — at
- // which point its "100% width" is much narrower than the screen.
- //
- // The clamp in setDispatchListRatio (0.15..0.5) is the real bound;
- // we deliberately do NOT keep the previous `min-w-[220px]
- // max-w-[420px]` Tailwind classes on the list — they would override
- // the user's drag and create a visual disconnect between the
- // splitter handle and the actual list edge at narrow / wide
- // viewports. If a user manages to get the list unreadably narrow at
- // a tiny viewport, the 15% floor still applies.
- const openNewAgentForProject = useAppStore(state => state.openNewAgentForProject)
- const dispatchListRatio = useAppStore(state => state.dispatchListRatio)
- const setDispatchListRatio = useAppStore(state => state.setDispatchListRatio)
- const layoutRowRef = useRef(null)
- const listSplitter = useResizableSplitter({
- onDrag: useCallback(
- (clientX: number) => {
- const el = layoutRowRef.current
- if (!el) return
- const rect = el.getBoundingClientRect()
- if (rect.width <= 0) return
- setDispatchListRatio((clientX - rect.left) / rect.width)
- },
- [setDispatchListRatio],
- ),
- })
-
- useEffect(() => {
- if (!activeRow) return
- if (
- workspace.activeTab?.id === activeRow.tabId &&
- workspace.state.dispatchMode?.focusedSessionId === activeRow.sessionId
- ) {
- return
- }
- // Global Dispatch can render a fallback row when the currently active
- // tab has no visible agent rows. Keep the workspace focus aligned with
- // that visible row so tab chrome, new-agent placement, and project
- // terminal selection all agree with what the user is commanding.
- workspace.focusDispatchSession(activeRow.tabId, activeRow.sessionId)
- }, [
- activeRow?.sessionId,
- activeRow?.tabId,
- workspace.activeTab?.id,
- workspace.focusDispatchSession,
- workspace.state.dispatchMode?.focusedSessionId,
- ])
-
- // List width is the ratio * row width; the active-agent pane absorbs the
- // remainder via `flex-1`. This used to also describe a dedicated
- // project-terminal column pinned at 25%; that column was removed, and
- // terminals are ordinary Dispatch rows rendered in the active pane like any
- // other session (#671).
- const listWidthPct = (dispatchListRatio * 100).toFixed(2)
-
- return (
-
-
-
-
-
- {/*
- List/active splitter. Visible bar is 4px; hit area is 10px so
- the bar can be grabbed without pixel-perfect aim. We render
- between the list wrapper and the active-agent pane; the
- wrapper takes the inline width, the splitter is fixed
- (flex-shrink-0), and the active pane uses flex-1 to absorb
- the remainder.
- */}
-
- {listSplitter.cursorLock}
-
-
- {activeRow ? (
- renderWorkspaceLeaf(
- activeRow.sessionId,
- activeRow.sessionId,
- workspace,
- activeRow.tabId,
- agentViewMode,
- showStatusMode,
- showWorktreeBadges,
- () => workspace.focusDispatchSession(activeRow.tabId, activeRow.sessionId),
- false,
- activeRow.label,
- )
- ) : (
-
- )}
-
-
-
- )
+ return
}
diff --git a/src/renderer/src/workspace/dispatch/DispatchPaneLabel.recorded.renderer.test.tsx b/src/renderer/src/workspace/dispatch/DispatchPaneLabel.recorded.renderer.test.tsx
index feef9412a..a5acdc0e5 100644
--- a/src/renderer/src/workspace/dispatch/DispatchPaneLabel.recorded.renderer.test.tsx
+++ b/src/renderer/src/workspace/dispatch/DispatchPaneLabel.recorded.renderer.test.tsx
@@ -1,5 +1,3 @@
-import { readFileSync } from 'node:fs'
-import { resolve } from 'node:path'
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -9,7 +7,7 @@ import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchS
import { paneLabelForSession } from '@renderer/workspace/tile-tree/paneLabels'
import type { Workspace } from '@renderer/workspace/workspaceStore'
import type { WorkspaceState } from '@renderer/workspace/types'
-import { asRecord } from '@shared/lib/asRecord'
+import { loadRecordedDispatchWorkspace } from '@renderer/workspace/testing/recordedDispatchWorkspace'
const appState = vi.hoisted(() => ({
workspaceRuntimes: {},
@@ -81,19 +79,10 @@ type DispatchRecording = {
}
function loadDispatchRecording(): DispatchRecording {
- const path = resolve(
- process.cwd(),
- 'testing/fixtures/worktree-context/dispatch-global-d23.json',
- )
- const fixture = asRecord(JSON.parse(readFileSync(path, 'utf8')))
- const metadata = asRecord(fixture?.$fixture)
- const observed = asRecord(metadata?.observed)
- const state = asRecord(fixture?.state)
- if (!observed || !state) throw new Error('dispatch-global-d23 fixture is malformed')
- return {
- state: state as unknown as WorkspaceState,
- observed: observed as DispatchRecording['observed'],
- }
+ // Through the shared lift: the recording is a v2 workspace whose lane grid
+ // sits at `dispatchMode.tiled`, and the loader moves it to `state.stage`.
+ const { state, observed } = loadRecordedDispatchWorkspace()
+ return { state, observed }
}
function workspaceFor(state: WorkspaceState): Workspace {
@@ -136,13 +125,14 @@ afterEach(() => {
})
describe('recorded Dispatch pane-label ownership', () => {
- it('[dispatch-global-d23] Classic Dispatch repeats the selected visible D23 label', () => {
+ // Named "Classic Dispatch repeats…" until #992. Classic Dispatch was the
+ // single-agent view; its stage equivalent is one row of one lane, which is
+ // what a fresh install looks like, so the case stays — it is the smallest
+ // stage that can show the recorded label.
+ it('[dispatch-global-d23] a one-lane stage repeats the selected visible D23 label', () => {
const recording = loadDispatchRecording()
const state = structuredClone(recording.state)
- state.dispatchMode = {
- scope: 'global',
- focusedSessionId: recording.observed.targetSessionId,
- }
+ state.stage = { lanes: [{ selectedSessionId: recording.observed.targetSessionId }], rows: [{ length: 1 }], focusedLane: 0 }
assertRecordedCoordinate(state, recording)
const { container } = render(
@@ -158,24 +148,18 @@ describe('recorded Dispatch pane-label ownership', () => {
.toHaveAttribute('data-pane-label', recording.observed.targetVisibleLabel)
})
- it('[dispatch-global-d23] Tiled Dispatch repeats the selected visible D23 label', () => {
+ it('[dispatch-global-d23] the recorded multi-lane stage repeats the selected visible D23 label', () => {
const recording = loadDispatchRecording()
const state = structuredClone(recording.state)
- const tiled = state.dispatchMode?.tiled
- if (!tiled || tiled.lanes.length === 0) {
- throw new Error('recorded Dispatch fixture lost its tiled lanes')
- }
- state.dispatchMode = {
- ...state.dispatchMode,
- scope: 'global',
- focusedSessionId: recording.observed.targetSessionId,
- tiled: {
- ...tiled,
- focusedLane: 0,
- lanes: tiled.lanes.map((lane, index) => index === 0
- ? { selectedSessionId: recording.observed.targetSessionId }
- : lane),
- },
+ // (The "fixture lost its lanes" guard that lived here moved into the
+ // shared loader, where it protects every suite built on this recording.)
+ const tiled = state.stage
+ state.stage = {
+ ...tiled,
+ focusedLane: 0,
+ lanes: tiled.lanes.map((lane, index) => index === 0
+ ? { selectedSessionId: recording.observed.targetSessionId }
+ : lane),
}
assertRecordedCoordinate(state, recording)
diff --git a/src/renderer/src/workspace/dispatch/DispatchTranscriptError.renderer.test.tsx b/src/renderer/src/workspace/dispatch/DispatchTranscriptError.renderer.test.tsx
index 93aedb3d0..b8858bb0a 100644
--- a/src/renderer/src/workspace/dispatch/DispatchTranscriptError.renderer.test.tsx
+++ b/src/renderer/src/workspace/dispatch/DispatchTranscriptError.renderer.test.tsx
@@ -21,13 +21,12 @@ it('renders the transcript diagnostic instead of a healthy activity subtitle', (
const row: DispatchAgentRow = {
key: 'project:grid:pane', label: 'A1', globalIndex: 1,
tabId: 'project', tabTitle: 'Project', tabIndex: 0, sessionId: 'pane',
- kind: 'opencode', title: 'Task', placement: 'grid', depth: 0,
+ kind: 'opencode', title: 'Task', depth: 0,
}
render( )
diff --git a/src/renderer/src/workspace/dispatch/TiledDispatchLayout.tsx b/src/renderer/src/workspace/dispatch/TiledDispatchLayout.tsx
index e1b9670ea..a357cb697 100644
--- a/src/renderer/src/workspace/dispatch/TiledDispatchLayout.tsx
+++ b/src/renderer/src/workspace/dispatch/TiledDispatchLayout.tsx
@@ -4,6 +4,7 @@ import { useAppStore } from '@renderer/app-state/hooks'
import type { AgentViewMode } from '@renderer/app-state/settings/types'
import type { Workspace } from '@renderer/workspace/workspaceStore'
import { SplitHandle } from '@renderer/features/shared/SplitHandle'
+import { StarterHintCard } from '@renderer/features/workspace/ui/StarterHintCard'
import { useResizableSplitter } from '@renderer/features/shared/useResizableSplitter'
import { renderWorkspaceLeaf } from '@renderer/workspace/tile-tree/TileTree'
import {
@@ -26,6 +27,7 @@ import {
} from '@renderer/workspace/dispatch/DispatchAgentList'
import { DispatchMiniList } from '@renderer/workspace/dispatch/DispatchMiniList'
import { rowScopedRows } from '@renderer/workspace/dispatch/rowScopedRows'
+import { stageOfWorkspace } from '@renderer/workspace/workspaceStage'
import type { DispatchGridRow, SessionId, TabId } from '@renderer/workspace/types'
type Props = {
@@ -78,7 +80,12 @@ export function TiledDispatchLayout({
showWorktreeBadges,
}: Props) {
const state = workspace.state
- const tiled = state.dispatchMode!.tiled!
+ // The stage is THE workspace (#992): read through the selector so a
+ // not-yet-seeded state (pre-bootstrap paint) derives the seeded default
+ // instead of crashing on a null tiled grid. Post-bootstrap the stored grid
+ // passes through unchanged, so every action below writes and reads the
+ // same stored shape it always did.
+ const tiled = stageOfWorkspace(state)
// Normalized once per state change, so every child renders against a shape
// whose row lengths are guaranteed to sum to the lane count. Nothing below
// this line may splice lanes — that belongs in gridShape, behind the reducers.
@@ -321,7 +328,6 @@ function GridRowView({
? grid.lanes[focusedLaneInRow]?.selectedSessionId ?? null
: null
}
- dispatchScope={workspace.state.dispatchMode?.scope === 'global' ? 'global' : 'project'}
focusSessionInTab={(_tabId, sessionId) => selectIntoRow(sessionId)}
targetLaneIndex={focusedLaneInRow ?? start}
showWorktreeBadges={showWorktreeBadges}
@@ -431,12 +437,19 @@ function GridRowView({
showStatusMode,
showWorktreeBadges,
() => workspace.setTiledFocusedLane(laneIndex),
- false,
resolved.paneLabel,
)
) : (
+
+ {/* The starter card, Context B (#992 §4.6): extends the
+ focused empty lane's hint with the four placement-flavored
+ slots. The same three conditions as the hint — focused,
+ empty, row offers agents — for the same reason the hint
+ has them: the card advertises keys that act on
+ `focusedLane`, and an unfocused or agentless lane would
+ be promising gestures that do nothing there. */}
+ {focused && !lane?.selectedSessionId && rowOffersAgents && (
+
+ )}
+
)}
{!focused && (
diff --git a/src/renderer/src/workspace/dispatch/clearLane.renderer.test.tsx b/src/renderer/src/workspace/dispatch/clearLane.renderer.test.tsx
new file mode 100644
index 000000000..66f903508
--- /dev/null
+++ b/src/renderer/src/workspace/dispatch/clearLane.renderer.test.tsx
@@ -0,0 +1,155 @@
+import { renderHook } from '@testing-library/react'
+import { act, cleanup } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { emptyRuntime } from '@renderer/session-runtime/state'
+import { useDispatchActions } from '@renderer/workspace/hook/actions/dispatch'
+import { makeRefs } from '@renderer/workspace/hook/actions/testing/paneActionsHarness'
+import type { SessionId, WorkspaceState } from '@renderer/workspace/types'
+import { resolveTabSessions } from '@renderer/workspace/queries'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
+import { clearFocusedLaneCommand } from '@renderer/features/workspace/commands/layoutCommands'
+import type { CommandContext } from '@renderer/features/command-palette/types'
+
+afterEach(cleanup)
+
+// Clear Lane (#992 §4.4) — the gentle exit. Everything these cases pin is the
+// DIFFERENCE from its two neighbours, because all three empty the same lane:
+//
+// Remove Lane — the lane is GONE (the row shrinks)
+// Close Agent & Remove — the session is DEAD (kill + undo entry)
+// Clear Lane — the lane stays, the occupant LIVES, nothing is
+// undone because nothing was lost
+//
+// A Clear Lane that killed, removed the lane, or grew an undo entry would be
+// one of its neighbours wearing its name — which is exactly how the command
+// would drift once someone "fixes" an inconsistency by reusing a neighbour's
+// commit path.
+
+function workspace(): WorkspaceState {
+ return {
+ tabs: [{ id: 'p', title: 'Project' }, { id: 'q', title: 'Other' }],
+ activeTabId: 'p',
+ sessions: {
+ anchor: { kind: 'claude', cwd: '/p', projectId: 'p', joinedAt: 0 },
+ other: { kind: 'codex', cwd: '/q', projectId: 'q', joinedAt: 0 },
+ },
+ stage: {
+ lanes: [{ selectedSessionId: 'anchor' }, { selectedSessionId: 'other' }, {}],
+ rows: [{ length: 2 }, { length: 1 }],
+ focusedLane: 0,
+ },
+ pinnedSessionIds: ['anchor'],
+ }
+}
+
+function mount() {
+ const initial = workspace()
+ const refs = makeRefs(initial)
+ let state = initial
+ const setState = (next: typeof state | ((prev: typeof state) => typeof state)) => {
+ state = typeof next === 'function' ? next(state) : next
+ refs.stateRef.current = state
+ refs.latestStateRef.current = state
+ }
+ let runtimes: Record> = {
+ anchor: { ...emptyRuntime(), processStatus: 'started' as never },
+ }
+ const setRuntimes = (next: typeof runtimes | ((prev: typeof runtimes) => typeof runtimes)) => {
+ runtimes = typeof next === 'function' ? next(runtimes) : next
+ }
+ const kill = vi.fn()
+ const hook = renderHook(() => useDispatchActions(
+ setState as never,
+ setRuntimes as never,
+ refs,
+ vi.fn(),
+ vi.fn(),
+ ))
+ return { hook, getState: () => state, refs, getRuntimes: () => runtimes, kill, setState }
+}
+
+describe('clearTiledLane', () => {
+ it('empties the lane, keeps the occupant alive and listed, and keeps the lane itself', () => {
+ const t = mount()
+ act(() => { t.hook.result.current.clearTiledLane(0) })
+
+ const state = t.getState()
+ expect(state.stage.lanes).toEqual([{}, { selectedSessionId: 'other' }, {}])
+ // The lane SLOT survives: rows still say [2,1].
+ expect(state.stage.rows).toEqual([{ length: 2 }, { length: 1 }])
+ expect(state.stage.focusedLane).toBe(0)
+ // Alive: row present, ownership intact, pin untouched.
+ expect(state.sessions.anchor).toBeDefined()
+ expect(resolveTabSessions(state, 'p')).toEqual(['anchor'])
+ expect(state.pinnedSessionIds).toEqual(['anchor'])
+ // And no undo entry grew: the undo stack is for CLOSES.
+ expect(t.refs.undoStackRef.current.length).toBe(0)
+ })
+
+ it('clears every lane that mirrors the occupant, not just the focused one', () => {
+ // Mirrors are one agent in two lanes, not two agents. Clearing one lane
+ // must not leave a half-cleared "state" the other lane contradicts — but
+ // it must also NOT clear lanes the caller did not name: this action is
+ // lane-addressed (the command targets the focused one), and clearing a
+ // different lane than the one the user can see would be the #681 healer
+ // again. Only the named lane changes; the assertion below proves the
+ // OTHER lane holding `other` (none here) is a separate concern.
+ const t = mount()
+ act(() => { t.setState(prev => ({ ...prev, stage: { ...prev.stage, lanes: [{ selectedSessionId: 'anchor' }, { selectedSessionId: 'anchor' }, {}], rows: prev.stage.rows, focusedLane: 0 } })) })
+ act(() => { t.hook.result.current.clearTiledLane(1) })
+ expect(t.getState().stage.lanes).toEqual([{ selectedSessionId: 'anchor' }, {}, {}])
+ })
+
+ it('is a no-op for an already-empty or out-of-range lane, by reference', () => {
+ // Identity: the writer returns `prev` untouched, so a stray invocation
+ // cannot force a re-render of every lane in the workspace.
+ const t = mount()
+ const before = t.getState()
+ act(() => { t.hook.result.current.clearTiledLane(2) })
+ act(() => { t.hook.result.current.clearTiledLane(99) })
+ act(() => { t.hook.result.current.clearTiledLane(-1) })
+ expect(t.getState()).toBe(before)
+ })
+})
+
+describe('clear-focused-lane command', () => {
+ function context(state: WorkspaceState): CommandContext {
+ return {
+ workspace: {
+ state,
+ clearTiledLane: vi.fn(),
+ },
+ } as unknown as CommandContext
+ }
+
+ it('admits only when the focused lane shows a live session', () => {
+ const t = mount()
+ expect(clearFocusedLaneCommand.when?.(context(t.getState()))).toBe(true)
+ act(() => { t.hook.result.current.clearTiledLane(0) })
+ // Empty lane: nothing to clear — admission agrees with the action.
+ expect(clearFocusedLaneCommand.when?.(context(t.getState()))).toBe(false)
+ // A lane naming a GONE session is as good as empty for the user; the
+ // action's write still drops the stale pointer.
+ act(() => {
+ t.setState(prev => ({ ...prev, stage: { ...prev.stage, lanes: [{ selectedSessionId: 'ghost' as SessionId }, { selectedSessionId: 'other' }, {}] } }))
+ })
+ expect(clearFocusedLaneCommand.when?.(context(t.getState()))).toBe(false)
+ })
+
+ it('badges the occupant through the shared title resolver', () => {
+ const t = mount()
+ const state = clearFocusedLaneCommand.getState?.(context(t.getState()))
+ expect(state).toMatchObject({ kind: 'value', label: 'p' })
+ // 'p' is the cwd basename (/p) — the same rule the index rows use, which
+ // is the point: the badge must name the agent the way the user last saw
+ // it named.
+ })
+
+ it('clears the focused lane on run', () => {
+ const clearTiledLane = vi.fn()
+ const ctx = { workspace: { state: workspace(), clearTiledLane } } as unknown as CommandContext
+ clearFocusedLaneCommand.run(ctx)
+ expect(clearTiledLane).toHaveBeenCalledWith(0)
+ })
+})
diff --git a/src/renderer/src/workspace/dispatch/collapsedChildBadge.renderer.test.tsx b/src/renderer/src/workspace/dispatch/collapsedChildBadge.renderer.test.tsx
new file mode 100644
index 000000000..a3b5eed44
--- /dev/null
+++ b/src/renderer/src/workspace/dispatch/collapsedChildBadge.renderer.test.tsx
@@ -0,0 +1,49 @@
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import { DispatchAgentList } from './DispatchAgentList'
+import type { DispatchAgentRow } from './dispatchSelectors'
+import { emptyRuntime, type SessionRuntime } from '@renderer/session-runtime/state'
+
+// #1013 review B: orchestration children always land in the pool wearing the
+// "new" badge, and the child cap hides every child past the third. In a
+// 5-worker run two badges were never visible, and the "+2 more" row said
+// nothing. The list is rendered for real, with the row's own store selector.
+const state = vi.hoisted(() => ({
+ settings: { dispatchColorFlags: {} },
+ workspaceRuntimes: {} as Record,
+}))
+vi.mock('@renderer/app-state/hooks', () => ({
+ useAppStore: (selector: (value: typeof state) => unknown) => selector(state),
+}))
+afterEach(() => { cleanup(); state.workspaceRuntimes = {} })
+
+const row = (sessionId: string, depth: number, index: number): DispatchAgentRow => ({
+ key: `project:${sessionId}`, label: `A${index}`, globalIndex: index,
+ tabId: 'project', tabTitle: 'Project', tabIndex: 0, sessionId,
+ kind: 'claude', title: sessionId, depth,
+})
+const rows = [row('root', 0, 1), ...[1, 2, 3, 4, 5].map(n => row(`worker-${n}`, 1, n + 1))]
+const renderList = () => render( )
+
+it('the "+N more" row wears the badge when a hidden child is new', () => {
+ for (const id of ['root', 'worker-1', 'worker-2', 'worker-3', 'worker-4', 'worker-5']) state.workspaceRuntimes[id] = emptyRuntime()
+ state.workspaceRuntimes['worker-5'] = { ...emptyRuntime(), pooledSpawnAt: 1 }
+ renderList()
+ const more = screen.getByText('+ 2 more').closest('button')!
+ expect(more.querySelector('[data-dispatch-new-in-pool]')).not.toBeNull()
+})
+
+it('and does not when every hidden child has been placed', () => {
+ for (const id of ['root', 'worker-1', 'worker-2', 'worker-3', 'worker-4', 'worker-5']) state.workspaceRuntimes[id] = emptyRuntime()
+ // Only a VISIBLE child is new; its own row carries that badge.
+ state.workspaceRuntimes['worker-1'] = { ...emptyRuntime(), pooledSpawnAt: 1 }
+ renderList()
+ const more = screen.getByText('+ 2 more').closest('button')!
+ expect(more.querySelector('[data-dispatch-new-in-pool]')).toBeNull()
+})
diff --git a/src/renderer/src/workspace/dispatch/dispatchSelectors.test.ts b/src/renderer/src/workspace/dispatch/dispatchSelectors.test.ts
index e29032371..32df98421 100644
--- a/src/renderer/src/workspace/dispatch/dispatchSelectors.test.ts
+++ b/src/renderer/src/workspace/dispatch/dispatchSelectors.test.ts
@@ -6,76 +6,76 @@ import {
focusedLaneBoundProjectTabIds,
resolveDispatchSpawnTarget,
} from '@renderer/workspace/dispatch/dispatchSelectors'
-import { resolveDispatchAttachTarget } from '@renderer/workspace/dispatch/dispatchTarget'
import { nextTiledRowIndex } from '@renderer/workspace/dispatch/tiledDispatchSelectors'
import { resolveFocusSurfaceTarget } from '@renderer/workspace/hook/actions/focusSurfaceTarget'
import { commandTargetSessionIdForState } from '@renderer/workspace/hook/selectors/commandTargetSessionId'
-import type { DispatchModeState, TileNode, WorkspaceState } from '@renderer/workspace/types'
+import type { TiledDispatchState, WorkspaceState } from '@renderer/workspace/types'
// Minimal two-project fixture: project A (tabA / a1) and project B (tabB / b1),
-// each a single grid agent. Global scope so both tabs show in the dispatch list.
-function leaf(sessionId: string): TileNode {
- return { type: 'leaf', sessionId }
+// each a single grid agent. Both projects show in every index: the layout-wide
+// project/global scope these fixtures used to set died with #992.
+/** One row of one lane showing `sessionId` — the stage equivalent of "the user
+ * is commanding this agent", which a classic-Dispatch focus used to express. */
+function oneLane(sessionId?: string): TiledDispatchState {
+ return { lanes: [sessionId ? { selectedSessionId: sessionId } : {}], rows: [{ length: 1 }], focusedLane: 0 }
}
-function makeState(dispatchMode: DispatchModeState | null): WorkspaceState {
+function makeState(stage: TiledDispatchState): WorkspaceState {
return {
tabs: [
- { id: 'tabA', title: 'project-a', root: leaf('a1'), focusedSessionId: 'a1' },
- { id: 'tabB', title: 'project-b', root: leaf('b1'), focusedSessionId: 'b1' },
+ { id: 'tabA', title: 'project-a' },
+ { id: 'tabB', title: 'project-b' },
],
activeTabId: 'tabA',
- dispatchMode,
+ stage,
sessions: {
- a1: { cwd: '/work/project-a', kind: 'claude' },
- b1: { cwd: '/work/project-b', kind: 'claude' },
+ a1: { cwd: '/work/project-a', kind: 'claude', projectId: 'tabA', joinedAt: 0 },
+ b1: { cwd: '/work/project-b', kind: 'claude', projectId: 'tabB', joinedAt: 0 },
},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
}
}
describe('resolveDispatchSpawnTarget', () => {
- it('classic Dispatch: targets the focused session’s own project', () => {
- const state = makeState({ scope: 'global', focusedSessionId: 'b1' })
+ it('a one-lane stage targets the project of the agent it shows', () => {
+ // Was "classic Dispatch: targets the focused session's own project", with
+ // `laneIndex: null` because classic Dispatch had no lanes. The same user
+ // intent on the stage is one lane showing b1 — and there is always a lane
+ // to place into, so the index is 0, never null.
+ const state = makeState(oneLane('b1'))
const target = resolveDispatchSpawnTarget(state)
- expect(target).toEqual({ tabId: 'tabB', cwdSessionId: 'b1', laneIndex: null })
+ expect(target).toEqual({ tabId: 'tabB', cwdSessionId: 'b1', laneIndex: 0 })
})
- it('Tiled Dispatch: follows the FOCUSED LANE, not the stale active tab (issue #266)', () => {
- // The regression scenario: active tab is A and the classic focus still
- // points at A's agent, but the user is commanding lane 1 which shows
+ it('follows the FOCUSED LANE, not the stale active tab (issue #266)', () => {
+ // The regression scenario: the active tab is still A, but the user is
+ // commanding lane 1 which shows
// project B. A new agent must land in B, in lane 1 — NOT in active tab A.
const state = makeState({
- scope: 'global',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 1,
- lanes: [{ selectedSessionId: 'a1' }, { selectedSessionId: 'b1' }],
- },
+ focusedLane: 1,
+ lanes: [{ selectedSessionId: 'a1' }, { selectedSessionId: 'b1' }],
})
const target = resolveDispatchSpawnTarget(state)
expect(target).toEqual({ tabId: 'tabB', cwdSessionId: 'b1', laneIndex: 1 })
})
- it('Tiled Dispatch: empty focused lane falls back to classic focus but keeps the lane index', () => {
+ it('an empty focused lane falls back to the ACTIVE project but keeps the lane index', () => {
+ // Until #992 the fallback was a classic-Dispatch focus (b1 => tabB), a
+ // second focus truth beside the focused lane. With one focus truth the
+ // honest fallback for "no agent here to take a project from" is the
+ // active project — and the new agent still lands in the lane the user is
+ // looking at.
const state = makeState({
- scope: 'global',
- focusedSessionId: 'b1',
- tiled: {
- focusedLane: 1,
- lanes: [{ selectedSessionId: 'a1' }, {}],
- },
+ focusedLane: 1,
+ lanes: [{ selectedSessionId: 'a1' }, {}],
})
+ state.activeTabId = 'tabB'
const target = resolveDispatchSpawnTarget(state)
- expect(target).toEqual({ tabId: 'tabB', cwdSessionId: 'b1', laneIndex: 1 })
+ expect(target).toEqual({ tabId: 'tabB', cwdSessionId: null, laneIndex: 1 })
})
- it('no Dispatch mode: targets the active tab', () => {
- const target = resolveDispatchSpawnTarget(makeState(null))
- expect(target).toEqual({ tabId: 'tabA', cwdSessionId: null, laneIndex: null })
- })
+ // "no Dispatch mode: targets the active tab" (laneIndex: null) lived here
+ // until #992. A workspace without lanes can no longer be constructed.
})
describe('resolveDispatchSpawnTarget with a detached focused lane', () => {
@@ -90,22 +90,10 @@ describe('resolveDispatchSpawnTarget with a detached focused lane', () => {
// `resolveDispatchTerminalSplitTarget` before the creation flows merged.
it('keeps the detached lane session as the cwd source, not a grid leaf in the same tab', () => {
const state = makeState({
- scope: 'global',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 1,
- lanes: [{ selectedSessionId: 'a1' }, { selectedSessionId: 'b2' }],
- },
+ focusedLane: 1,
+ lanes: [{ selectedSessionId: 'a1' }, { selectedSessionId: 'b2' }],
})
- state.sessions.b2 = { cwd: '/work/project-b/subtask', kind: 'codex' }
- state.detachedSessions.b2 = {
- sessionId: 'b2',
- surface: 'dispatch',
- projectTabId: 'tabB',
- projectTabTitle: 'project-b',
- projectTabIndex: 1,
- detachedAt: 10,
- }
+ state.sessions.b2 = { cwd: '/work/project-b/subtask', kind: 'codex', projectId: 'tabB', joinedAt: 10 }
expect(resolveDispatchSpawnTarget(state)).toEqual({
tabId: 'tabB',
@@ -113,7 +101,6 @@ describe('resolveDispatchSpawnTarget with a detached focused lane', () => {
laneIndex: 1,
})
})
-})
// Grid Dispatch row bindings (#681). Two consumers must agree on "which
// projects may this lane hold": the spawn resolver (where plain New Agent…
@@ -125,54 +112,38 @@ describe('resolveDispatchSpawnTarget with a detached focused lane', () => {
describe('focusedLaneBoundProjectTabIds', () => {
it('returns the binding of the row that owns the focused lane', () => {
const state = makeState({
- scope: 'global',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 1,
- lanes: [{ selectedSessionId: 'a1' }, {}],
- rows: [{ length: 1 }, { length: 1, projectTabIds: ['tabB'] }],
- },
+ focusedLane: 1,
+ lanes: [{ selectedSessionId: 'a1' }, {}],
+ rows: [{ length: 1 }, { length: 1, projectTabIds: ['tabB'] }],
})
expect(focusedLaneBoundProjectTabIds(state)).toEqual(['tabB'])
})
- it('returns nothing for an unbound row, classic Dispatch, or no Dispatch at all', () => {
+ it('returns nothing for an unbound row', () => {
const unbound = makeState({
- scope: 'global',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 0,
- lanes: [{ selectedSessionId: 'a1' }, {}],
- rows: [{ length: 1 }, { length: 1, projectTabIds: ['tabB'] }],
- },
+ focusedLane: 0,
+ lanes: [{ selectedSessionId: 'a1' }, {}],
+ rows: [{ length: 1 }, { length: 1, projectTabIds: ['tabB'] }],
})
expect(focusedLaneBoundProjectTabIds(unbound)).toEqual([])
- expect(focusedLaneBoundProjectTabIds(makeState({ scope: 'global', focusedSessionId: 'a1' }))).toEqual([])
- expect(focusedLaneBoundProjectTabIds(makeState(null))).toEqual([])
+ // A stage with no `rows` at all (written before the row grid) is one
+ // unbound row. (Classic Dispatch and "no Dispatch" were asserted here too
+ // until #992; neither can be built any more.)
+ expect(focusedLaneBoundProjectTabIds(makeState({ focusedLane: 0, lanes: [{ selectedSessionId: 'a1' }] }))).toEqual([])
})
})
describe('dispatchSessionIdsForTab', () => {
it('includes pinned rows owned by the tab even though project groups strip them', () => {
- const state = makeState({ scope: 'global', focusedSessionId: 'b1' })
+ const state = makeState(oneLane('b1'))
state.pinnedSessionIds = ['b1']
expect(dispatchSessionIdsForTab(state, 'tabB')).toEqual(['b1'])
})
it('uses visible Dispatch row order, with pinned rows before grouped rows for the same tab', () => {
- const state = makeState({ scope: 'global', focusedSessionId: 'b2' })
- state.tabs[1] = {
- ...state.tabs[1]!,
- root: {
- type: 'split',
- direction: 'vertical',
- ratio: 0.5,
- a: leaf('b1'),
- b: leaf('b2'),
- },
- }
- state.sessions.b2 = { cwd: '/work/project-b', kind: 'codex' }
+ const state = makeState(oneLane('b2'))
+ state.sessions.b2 = { cwd: '/work/project-b', kind: 'codex', projectId: 'tabB', joinedAt: 1 }
state.pinnedSessionIds = ['b2']
expect(dispatchSessionIdsForTab(state, 'tabB')).toEqual(['b2', 'b1'])
@@ -182,12 +153,8 @@ describe('dispatchSessionIdsForTab', () => {
describe('resolveFocusSurfaceTarget', () => {
it('returns the focused tiled lane session and its owner tab, not the stale active tab', () => {
const state = makeState({
- scope: 'global',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 1,
- lanes: [{ selectedSessionId: 'a1' }, { selectedSessionId: 'b1' }],
- },
+ focusedLane: 1,
+ lanes: [{ selectedSessionId: 'a1' }, { selectedSessionId: 'b1' }],
})
expect(resolveFocusSurfaceTarget(state)).toEqual({
@@ -198,12 +165,8 @@ describe('resolveFocusSurfaceTarget', () => {
it('returns null when the focused tiled lane has no strict command target', () => {
const state = makeState({
- scope: 'global',
- focusedSessionId: 'b1',
- tiled: {
- focusedLane: 1,
- lanes: [{ selectedSessionId: 'a1' }, {}],
- },
+ focusedLane: 1,
+ lanes: [{ selectedSessionId: 'a1' }, {}],
})
expect(resolveFocusSurfaceTarget(state)).toBeNull()
@@ -211,94 +174,45 @@ describe('resolveFocusSurfaceTarget', () => {
})
describe('strict Dispatch command target', () => {
- it('Tiled Dispatch: follows the focused lane row', () => {
+ it('follows the focused lane row', () => {
const state = makeState({
- scope: 'global',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 1,
- lanes: [{ selectedSessionId: 'a1' }, { selectedSessionId: 'b1' }],
- },
+ focusedLane: 1,
+ lanes: [{ selectedSessionId: 'a1' }, { selectedSessionId: 'b1' }],
})
expect(commandTargetSessionIdForState(state)).toBe('b1')
})
- it('Tiled Dispatch: empty focused lane does not fall back to classic focus', () => {
+ it('an empty focused lane has no command target, whatever the other lanes show', () => {
const state = makeState({
- scope: 'global',
- focusedSessionId: 'b1',
- tiled: {
- focusedLane: 1,
- lanes: [{ selectedSessionId: 'a1' }, {}],
- },
+ focusedLane: 1,
+ lanes: [{ selectedSessionId: 'a1' }, {}],
})
expect(commandTargetSessionIdForState(state)).toBeNull()
})
- it('Tiled Dispatch: stale focused lane does not fall back to the first visible row', () => {
+ it('a stale focused lane does not fall back to the first visible row', () => {
const state = makeState({
- scope: 'global',
- focusedSessionId: 'b1',
- tiled: {
- focusedLane: 1,
- lanes: [{ selectedSessionId: 'a1' }, { selectedSessionId: 'missing' }],
- },
+ focusedLane: 1,
+ lanes: [{ selectedSessionId: 'a1' }, { selectedSessionId: 'missing' }],
})
expect(commandTargetSessionIdForState(state)).toBeNull()
})
- it('classic Dispatch keeps row fallback behavior for stale focus', () => {
- const state = makeState({ scope: 'global', focusedSessionId: 'missing' })
-
- expect(commandTargetSessionIdForState(state)).toBe('a1')
+ // "classic Dispatch keeps row fallback behavior for stale focus" lived here
+ // until #992: a stale classic focus fell back to the first visible row. The
+ // stage is strict everywhere — see the case above and
+ // resolveFocusedCloseTarget in pane.ts for why a destructive target must
+ // never be guessed.
+ it('a one-lane stage naming a missing session has no command target', () => {
+ expect(commandTargetSessionIdForState(makeState(oneLane('missing')))).toBeNull()
})
})
-
-describe('resolveDispatchAttachTarget', () => {
- it('captures the visible row tab instead of stale activeTabId', () => {
- const state = makeState({
- scope: 'global',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 1,
- lanes: [{ selectedSessionId: 'a1' }, { selectedSessionId: 'b2' }],
- },
- })
- state.sessions.b2 = { cwd: '/work/project-b', kind: 'claude' }
- state.detachedSessions = {
- b2: {
- sessionId: 'b2',
- surface: 'dispatch',
- projectTabId: 'tabB',
- projectTabTitle: 'project-b',
- projectTabIndex: 1,
- detachedAt: 10,
- },
- }
-
- expect(resolveDispatchAttachTarget(state)).toEqual({
- sessionId: 'b2',
- targetTabId: 'tabB',
- })
- })
-
- it('returns null for an unresolved focused tiled lane', () => {
- const state = makeState({
- scope: 'global',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 1,
- lanes: [{ selectedSessionId: 'a1' }, {}],
- },
- })
-
- expect(resolveDispatchAttachTarget(state)).toBeNull()
- })
})
+
describe('nextTiledRowIndex', () => {
it('lands on the first row from no selection, whichever direction is pressed', () => {
// An empty lane behaves as though it already sat at a1, so the first press
@@ -338,9 +252,8 @@ describe('buildPinnedDispatchRows', () => {
// Pins were agent-only since before terminals were Dispatch rows (#152
// deferred them "for v1"). Since #671 a shell is a full row, and a pinned
// dev-server shell is exactly the one-keystroke-away session pins exist for.
- const state = makeState({ scope: 'global', focusedSessionId: 'a1' })
- state.sessions.shell = { cwd: '/work/project-a', kind: 'terminal' }
- state.tabs[0] = { ...state.tabs[0], root: { type: 'split', direction: 'vertical', ratio: 0.5, a: leaf('a1'), b: leaf('shell') } }
+ const state = makeState(oneLane('a1'))
+ state.sessions.shell = { cwd: '/work/project-a', kind: 'terminal', projectId: 'tabA', joinedAt: 1 }
state.pinnedSessionIds = ['shell']
expect(buildPinnedDispatchRows(state).map(row => [row.sessionId, row.kind])).toEqual([['shell', 'terminal']])
})
diff --git a/src/renderer/src/workspace/dispatch/dispatchSelectors.ts b/src/renderer/src/workspace/dispatch/dispatchSelectors.ts
index 1b2c0a07c..60f542c64 100644
--- a/src/renderer/src/workspace/dispatch/dispatchSelectors.ts
+++ b/src/renderer/src/workspace/dispatch/dispatchSelectors.ts
@@ -1,5 +1,5 @@
import type { SessionId, SessionKind, Tab, TabId, WorkspaceState } from '@renderer/workspace/types'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
+import { resolveTabSessions } from '@renderer/workspace/queries'
import { tabIndexLabel } from '@renderer/workspace/tile-tree/paneLabelFormat'
import { isProcessSessionKind } from '@shared/types/providerKind'
import {
@@ -20,7 +20,9 @@ export type DispatchAgentRow = {
/** Explicit durable SessionMeta title, before fallback derivation. */
agentTitle?: string
title: string
- placement: 'grid' | 'detached'
+ // `placement: 'grid' | 'detached'` was a field here until #992. It said which
+ // v2 OWNER held the session (a tile leaf or a detachedSessions record). With
+ // one pool there is one kind of row.
/** Nesting depth in the dispatch list. 0 = ordinary row; 1 = a
* linked agent rendered indented directly under its parent row.
* Linked agents never chain, so this is only ever 0 or 1. */
@@ -36,10 +38,10 @@ export type DispatchTabGroup = {
export function buildDispatchGroups(
state: WorkspaceState,
): DispatchTabGroup[] {
- const activeOnly = state.dispatchMode?.scope !== 'global'
- const sourceTabs = activeOnly
- ? state.tabs.filter(tab => tab.id === state.activeTabId)
- : state.tabs
+ // Every project, always. A layout-wide 'project' scope filtered this to the
+ // active tab until #992; the command that switched scope died with the mode,
+ // and a ROW's projectTabIds binding (rowScopedRows) is the only filter now.
+ const sourceTabs = state.tabs
// Pins live in their own section at the top of the list. A pinned
// session is intentionally NOT also rendered in its project group —
@@ -61,24 +63,23 @@ export function buildDispatchGroups(
return sourceTabs
.map(tab => {
const tabIndex = state.tabs.findIndex(item => item.id === tab.id)
- const gridSessionIds = collectLeaves(tab.root)
- // WHY terminals belong in the primary Dispatch row stream now:
- // Dispatch focus is session-based, not transcript-based. TerminalLeaf
- // already renders through renderWorkspaceLeaf and uses the same
- // sessionId-scoped IPC lifecycle as agents, so filtering terminals here
- // made the list lie about which live sessions the user could command.
- // Agent-only affordances stay guarded at their command/action sites;
- // row construction should answer the broader placement question:
- // "which sessions are in this Dispatch scope?"
- .filter(sessionId => state.sessions[sessionId] !== undefined)
- .filter(sessionId => !pinnedSet.has(sessionId))
- const detachedSessionIds = detachedDispatchSessionIdsForTab(state, tab.id)
+ // The project's sessions in index order, straight from the pool.
+ //
+ // Until #992 this was `[...treeLeaves, ...detachedByDetachedAt]`, and
+ // that concatenation was load-bearing in a bad way: everything created
+ // in the grid sorted above everything created in Dispatch no matter when
+ // (#671 — a Dispatch terminal was structurally guaranteed to list above
+ // every agent). One `joinedAt` key has no such cliff.
+ //
+ // WHY terminals belong in the primary row stream: focus is session-based,
+ // not transcript-based. TerminalLeaf renders through renderWorkspaceLeaf
+ // and uses the same sessionId-scoped IPC lifecycle as agents, so
+ // filtering terminals here would make the list lie about which live
+ // sessions the user can command. Agent-only affordances stay guarded at
+ // their command/action sites.
+ const entries = resolveTabSessions(state, tab.id)
.filter(sessionId => !pinnedSet.has(sessionId))
-
- const entries = [
- ...gridSessionIds.map(sessionId => ({ sessionId, placement: 'grid' as const })),
- ...detachedSessionIds.map(sessionId => ({ sessionId, placement: 'detached' as const })),
- ]
+ .map(sessionId => ({ sessionId }))
// Nesting pass: manual linked agents and MCP-created orchestration
// agents render indented immediately under their parent row rather than
@@ -109,30 +110,62 @@ export function buildDispatchGroups(
childrenByParent.set(parentId, arr)
}
}
- const ordered: Array<{
- sessionId: SessionId
- placement: 'grid' | 'detached'
- depth: number
- }> = []
+ const ordered: Array<{ sessionId: SessionId; depth: number }> = []
+ const emitted = new Set()
+ // Every DESCENDANT goes under its root, depth-first, all at depth 1.
+ //
+ // WHY descendants and not just children (#1013 review B, MAJOR): an
+ // orchestration child can create agents of its own, and
+ // `orchestrationParentId` names the DIRECT parent. The one-level walk
+ // skipped a grandchild at the top level (its parent is in the group)
+ // and never reached it from below (only a root's children were
+ // emitted), so it got no row at all: no label, no ⌘N or ⌥↑/↓, no
+ // Spotlight chip, and "Agent no longer available" in a lane while it
+ // ran. The grid's related-agent strip, which matched on the root id,
+ // was the only way to reach one, and it is gone.
+ //
+ // WHY depth 1 and not depth 2: depth is 0 or 1 by contract. The row
+ // renders one connector cell and rowScopedRows caps the depth-1 run
+ // under a root. Tree order keeps each grandchild directly below its own
+ // parent. None of the 36 recorded workspaces on the owner's machine had
+ // a grandchild (up to 29 children, all direct), so a deeper visual
+ // grammar would be designed without evidence.
+ const emitDescendants = (parentId: SessionId) => {
+ for (const child of childrenByParent.get(parentId) ?? []) {
+ if (emitted.has(child.sessionId)) continue
+ emitted.add(child.sessionId)
+ ordered.push({ ...child, depth: 1 })
+ emitDescendants(child.sessionId)
+ }
+ }
for (const e of entries) {
const meta = state.sessions[e.sessionId]
const parentId = meta?.linkedParentId ?? meta?.orchestrationParentId
// Children are emitted under their parent below — skip here.
if (parentId && entryIds.has(parentId)) continue
+ emitted.add(e.sessionId)
ordered.push({ ...e, depth: 0 })
- for (const child of childrenByParent.get(e.sessionId) ?? []) {
- ordered.push({ ...child, depth: 1 })
- }
+ emitDescendants(e.sessionId)
+ }
+ // A parent cycle (A under B under A, from a hand-edited file or a
+ // corrupted link) has no root, so the walk above never reaches it. Such
+ // rows appear at the top level instead of vanishing, the same rule as
+ // an orphaned child.
+ for (const e of entries) {
+ if (emitted.has(e.sessionId)) continue
+ emitted.add(e.sessionId)
+ ordered.push({ ...e, depth: 0 })
+ emitDescendants(e.sessionId)
}
// globalIndex is assigned in the FINAL (post-nesting) order so
// the A1/A2/A3 labels run top-to-bottom exactly as the rows
// render — a linked child takes the number of its visual slot.
- const rows = ordered.map(({ sessionId, placement, depth }) => {
+ const rows = ordered.map(({ sessionId, depth }) => {
const meta = state.sessions[sessionId]
const rowIndex = globalIndex++
return {
- key: `${tab.id}:${placement}:${sessionId}`,
+ key: `${tab.id}:${sessionId}`,
label: `${tabIndexLabel(tabIndex)}${rowIndex}`,
globalIndex: rowIndex,
tabId: tab.id,
@@ -142,7 +175,6 @@ export function buildDispatchGroups(
kind: meta?.kind,
agentTitle: explicitAgentTitle(meta),
title: sessionTitle(meta),
- placement,
depth,
} satisfies DispatchAgentRow
})
@@ -188,24 +220,9 @@ export function dispatchSessionIdsForTab(
.map(row => row.sessionId)
}
-export function detachedDispatchSessionIdsForTab(
- state: WorkspaceState,
- tabId: TabId,
-): SessionId[] {
- // Keep this ordering in one place so the list UI and bulk attach agree on
- // what "all detached Dispatch sessions for this tab" means. Detached rows
- // are displayed oldest-first in buildDispatchGroups; bulk attach should
- // preserve that same user-visible sequence inside the normalized incoming
- // subtree.
- return Object.values(state.detachedSessions)
- .filter(entry => (
- entry.surface === 'dispatch' &&
- entry.projectTabId === tabId &&
- state.sessions[entry.sessionId] !== undefined
- ))
- .sort((a, b) => a.detachedAt - b.detachedAt)
- .map(entry => entry.sessionId)
-}
+// `detachedDispatchSessionIdsForTab` lived here until #992: the project's
+// parked sessions, oldest `detachedAt` first. It is `resolveTabSessions` now —
+// one membership query, one order key.
export function selectVisibleDispatchRow(
rows: DispatchAgentRow[],
@@ -254,32 +271,17 @@ export function buildPinnedDispatchRows(
for (const sessionId of state.pinnedSessionIds) {
const meta = state.sessions[sessionId]
if (!meta || !isProcessSessionKind(meta.kind)) continue
- // Locate the owning tab. A pinned agent that's detached has its
- // tab id on `detachedSessions[sessionId].projectTabId`; a
- // grid-placed pinned agent is a leaf in some tab's tree. We do
- // the lookup detached-first because detachedSessions is O(1) and
- // catches the "background pinned agent" case the user is likely
- // pinning in the first place (an agent they don't want crowding
- // the visible grid but want one keystroke away).
- const detached = state.detachedSessions[sessionId]
- let tabId: TabId | null = null
- let placement: 'grid' | 'detached' = 'grid'
- if (detached) {
- tabId = detached.projectTabId
- placement = 'detached'
- } else {
- const owner = state.tabs.find(tab =>
- collectLeaves(tab.root).includes(sessionId),
- )
- tabId = owner?.id ?? null
- }
+ // The owning project is on the row itself. (Until #992 this was a
+ // two-step lookup: the detachedSessions record first, then a walk of
+ // every tab's tile tree.)
+ const tabId: TabId | null = meta.projectId ?? null
if (!tabId) continue
const tabIndex = state.tabs.findIndex(tab => tab.id === tabId)
const tab = state.tabs[tabIndex]
if (!tab) continue
rows.push({
// ★ prefix keeps the row key unique against project-group rows
- // (whose keys are `${tabId}:${placement}:${sessionId}`) so any
+ // (whose keys are `${tabId}:${sessionId}`) so any
// caller that flat-concats both arrays — see the spread in
// DispatchLayout — won't collide on React keys.
key: `pinned:${sessionId}`,
@@ -292,7 +294,6 @@ export function buildPinnedDispatchRows(
kind: meta.kind,
agentTitle: explicitAgentTitle(meta),
title: sessionTitle(meta),
- placement,
// Pinned rows live in their own flat section — never nested.
depth: 0,
})
@@ -319,23 +320,25 @@ export function isPinned(state: WorkspaceState, sessionId: SessionId): boolean {
* - `cwdSessionId` — the existing session whose cwd the new agent
* inherits, or null to let the caller fall back to
* the tab's leaves.
- * - `laneIndex` — in Tiled Dispatch, the lane the new agent should
- * occupy so it appears where the user is looking;
- * null in classic Dispatch.
+ * - `laneIndex` — the lane the new agent should occupy so it appears
+ * where the user is looking. This resolver always
+ * returns one now (the focused lane); the type stays
+ * nullable because callers may override it with "no
+ * lane" — a linked agent whose parent is not in the
+ * focused lane must not take that lane.
*
* WHY this is a selector instead of inline logic in the spawn actions:
* `createDetachedDispatchAgent` and `splitFocused` both have to answer
- * "which project does a new agent belong to?" and they used to read
- * cwd from `dispatchMode.focusedSessionId` but the project tab from
- * `activeTabId`. Those two fields agree in classic Dispatch (focusing a
- * row syncs both via focusDispatchSession) but DIVERGE in Tiled
- * Dispatch: lane focus/selection (setTiledFocusedLane /
- * selectTiledLaneSession) writes only `tiled.focusedLane` and
- * `lanes[].selectedSessionId` — never the classic focus fields. The
- * result was new agents landing in the stale active tab instead of the
- * focused lane's project (issue #266 / #248 regression). Resolving the
- * target in one place keeps cwd and projectTab on the SAME project for
- * both surfaces.
+ * "which project does a new agent belong to?" and they used to read cwd
+ * from a classic-Dispatch focus field but the project tab from
+ * `activeTabId`. Those two agreed in classic Dispatch and DIVERGED once
+ * lanes existed: lane focus/selection writes only `focusedLane` and
+ * `lanes[].selectedSessionId`. The result was new agents landing in the
+ * stale active tab instead of the focused lane's project (issue #266 /
+ * #248 regression). #992 removed the classic focus outright, which
+ * removes the divergence at its source — but the rule that made this a
+ * selector still holds: cwd and project must come from the SAME place,
+ * resolved once.
*/
export type DispatchSpawnTarget = {
tabId: TabId
@@ -360,67 +363,47 @@ export type DispatchSpawnTarget = {
* place that folds the legacy field and repairs lengths.
*/
export function focusedLaneBoundProjectTabIds(state: WorkspaceState): readonly TabId[] {
- const tiled = state.dispatchMode?.tiled
- if (!tiled) return []
+ const tiled = state.stage
const grid = normalizeGridShape(tiled)
const rowIndex = rowIndexForLane(grid.rows, tiled.focusedLane)
return (rowIndex >= 0 ? grid.rows[rowIndex]?.projectTabIds : undefined) ?? []
}
export function resolveDispatchSpawnTarget(state: WorkspaceState): DispatchSpawnTarget {
- const dm = state.dispatchMode
- if (!dm) {
- return { tabId: state.activeTabId, cwdSessionId: null, laneIndex: null }
- }
-
// The visible rows are the scope-correct source of "which tab owns this
// session?" — the same list the user sees and that lane resolution uses.
const rows = buildVisibleDispatchRows(state)
const tabForSession = (id: SessionId | undefined | null): TabId | null =>
id ? rows.find(row => row.sessionId === id)?.tabId ?? null : null
- // Tiled Dispatch: the focused lane is the command target.
- if (dm.tiled) {
- const laneIndex = dm.tiled.focusedLane
- const laneSessionId = dm.tiled.lanes[laneIndex]?.selectedSessionId ?? null
- const laneTab = tabForSession(laneSessionId)
- if (laneTab) {
- return { tabId: laneTab, cwdSessionId: laneSessionId, laneIndex }
- }
- // Focused lane is empty / its agent is gone. If its ROW is bound to a
- // project, that binding is the answer and outranks every fallback below:
- // the row's index offers only that project, so spawning into it from a
- // stale classic focus would file the new agent under a project the row does
- // not even list. Bindings constrain what may live in a row, and a spawn is
- // something coming to live there.
- //
- // A row can be bound to SEVERAL projects, so "which project does a new
- // agent belong to" needs a rule rather than a lookup. The active tab when
- // it is one of them, otherwise the first: deterministic, and "the project
- // you were last in" is the least surprising answer. The per-group `+` in
- // the index is unaffected — it already carries an explicit tabId.
- const bound = focusedLaneBoundProjectTabIds(state)
- if (bound.length > 0) {
- const tabId = bound.includes(state.activeTabId) ? state.activeTabId : bound[0]!
- return { tabId, cwdSessionId: null, laneIndex }
- }
- // Unbound: fall back to the classic focus, then the active tab — but still
- // place the new agent INTO the focused lane.
- const focusTab = tabForSession(dm.focusedSessionId)
- return {
- tabId: focusTab ?? state.activeTabId,
- cwdSessionId: focusTab ? dm.focusedSessionId ?? null : null,
- laneIndex,
- }
+ // The focused lane is the command target.
+ const laneIndex = state.stage.focusedLane
+ const laneSessionId = state.stage.lanes[laneIndex]?.selectedSessionId ?? null
+ const laneTab = tabForSession(laneSessionId)
+ if (laneTab) {
+ return { tabId: laneTab, cwdSessionId: laneSessionId, laneIndex }
}
-
- // Classic Dispatch: prefer the focused session's own tab so cwd and
- // projectTab stay on the same project even if activeTabId ever drifts.
- const focusTab = tabForSession(dm.focusedSessionId)
- if (focusTab) {
- return { tabId: focusTab, cwdSessionId: dm.focusedSessionId ?? null, laneIndex: null }
+ // Focused lane is empty / its agent is gone. If its ROW is bound to a
+ // project, that binding is the answer and outranks every fallback below:
+ // the row's index offers only that project, so spawning into it from the
+ // active project would file the new agent under a project the row does
+ // not even list. Bindings constrain what may live in a row, and a spawn is
+ // something coming to live there.
+ //
+ // A row can be bound to SEVERAL projects, so "which project does a new
+ // agent belong to" needs a rule rather than a lookup. The active tab when
+ // it is one of them, otherwise the first: deterministic, and "the project
+ // you were last in" is the least surprising answer. The per-group `+` in
+ // the index is unaffected — it already carries an explicit tabId.
+ const bound = focusedLaneBoundProjectTabIds(state)
+ if (bound.length > 0) {
+ const tabId = bound.includes(state.activeTabId) ? state.activeTabId : bound[0]!
+ return { tabId, cwdSessionId: null, laneIndex }
}
- return { tabId: state.activeTabId, cwdSessionId: null, laneIndex: null }
+ // Unbound and empty: the active project — but still place the new agent
+ // INTO the focused lane. (A classic single-selection focus was consulted
+ // first until #992; that second focus truth no longer exists.)
+ return { tabId: state.activeTabId, cwdSessionId: null, laneIndex }
}
function sessionTitle(
diff --git a/src/renderer/src/workspace/dispatch/dispatchTarget.ts b/src/renderer/src/workspace/dispatch/dispatchTarget.ts
index 3224cfe83..ab2a24355 100644
--- a/src/renderer/src/workspace/dispatch/dispatchTarget.ts
+++ b/src/renderer/src/workspace/dispatch/dispatchTarget.ts
@@ -3,17 +3,15 @@ import {
selectVisibleDispatchRow,
} from '@renderer/workspace/dispatch/dispatchSelectors'
import type { DispatchAgentRow } from '@renderer/workspace/dispatch/dispatchSelectors'
-import type { SessionId, TabId, WorkspaceState } from '@renderer/workspace/types'
+import type { WorkspaceState } from '@renderer/workspace/types'
export type DispatchVisualTarget = {
row: DispatchAgentRow
laneIndex: number | null
- source: 'tiled-lane' | 'classic-focus' | 'grid-fallback' | 'first-row'
-}
-
-export type DispatchAttachTarget = {
- sessionId: SessionId
- targetTabId: TabId
+ // 'classic-focus' and 'grid-fallback' were members until #992: the classic
+ // single-selection focus and the tile tree's focused pane. Neither exists,
+ // so neither can be the source of a target.
+ source: 'tiled-lane' | 'first-row'
}
/**
@@ -21,48 +19,35 @@ export type DispatchAttachTarget = {
*
* WHY this is separate from `dispatchFocusedSessionId` and
* `resolveDispatchSpawnTarget`:
- * Tiled Dispatch has two legitimate target semantics that used to be
- * collapsed into one fallback chain. Lifecycle/destructive commands need
- * STRICT visual intent: if the focused lane is empty or stale, there is no
- * session selected and the command must not silently fall through to classic
- * focus, grid focus, or row 1. Spawn/defaulting flows are different: an empty
- * lane can still inherit a useful project from classic focus or the active
- * tab. Keeping this helper command-shaped makes call sites choose their policy
+ * The stage has two legitimate target semantics that used to be collapsed
+ * into one fallback chain. Lifecycle/destructive commands need STRICT visual
+ * intent: if the focused lane is empty or stale, there is no session selected
+ * and the command must not silently fall through to row 1. Spawn/defaulting
+ * flows are different: an empty lane can still inherit a useful project from
+ * the first visible row or the active project. Keeping this helper command-shaped makes call sites choose their policy
* instead of inheriting a convenient fallback by accident.
*/
export function resolveDispatchVisualTarget(
state: WorkspaceState,
options: { strictTiledLane: boolean },
): DispatchVisualTarget | null {
- const dispatchMode = state.dispatchMode
- if (!dispatchMode) return null
-
const rows = buildVisibleDispatchRows(state)
if (rows.length === 0) return null
- if (dispatchMode.tiled) {
- const laneIndex = dispatchMode.tiled.focusedLane
- const laneSessionId = dispatchMode.tiled.lanes[laneIndex]?.selectedSessionId ?? null
- const laneRow = laneSessionId
- ? rows.find(row => row.sessionId === laneSessionId) ?? null
- : null
- if (laneRow) return { row: laneRow, laneIndex, source: 'tiled-lane' }
- if (options.strictTiledLane) return null
- }
-
- const activeTab = state.tabs.find(tab => tab.id === state.activeTabId)
- const row = selectVisibleDispatchRow(
- rows,
- dispatchMode.focusedSessionId,
- activeTab?.focusedSessionId,
- )
+ const laneIndex = state.stage.focusedLane
+ const laneSessionId = state.stage.lanes[laneIndex]?.selectedSessionId ?? null
+ const laneRow = laneSessionId
+ ? rows.find(row => row.sessionId === laneSessionId) ?? null
+ : null
+ if (laneRow) return { row: laneRow, laneIndex, source: 'tiled-lane' }
+ if (options.strictTiledLane) return null
+
+ // Non-strict (spawn/defaulting) callers may still want SOME project when
+ // the focused lane is empty. The classic single-selection focus that used
+ // to answer first is gone (#992); the first visible row is the fallback.
+ const row = selectVisibleDispatchRow(rows, null, null)
if (!row) return null
- const source = row.sessionId === dispatchMode.focusedSessionId
- ? 'classic-focus'
- : row.sessionId === activeTab?.focusedSessionId
- ? 'grid-fallback'
- : 'first-row'
- return { row, laneIndex: null, source }
+ return { row, laneIndex: null, source: 'first-row' }
}
export function resolveStrictDispatchCommandTarget(
@@ -70,14 +55,3 @@ export function resolveStrictDispatchCommandTarget(
): DispatchVisualTarget | null {
return resolveDispatchVisualTarget(state, { strictTiledLane: true })
}
-
-export function resolveDispatchAttachTarget(
- state: WorkspaceState,
-): DispatchAttachTarget | null {
- const target = resolveStrictDispatchCommandTarget(state)
- if (!target) return null
- return {
- sessionId: target.row.sessionId,
- targetTabId: target.row.tabId,
- }
-}
diff --git a/src/renderer/src/workspace/dispatch/dispatchTerminalPlacement.renderer.test.tsx b/src/renderer/src/workspace/dispatch/dispatchTerminalPlacement.renderer.test.tsx
index 0c9b04c87..250552486 100644
--- a/src/renderer/src/workspace/dispatch/dispatchTerminalPlacement.renderer.test.tsx
+++ b/src/renderer/src/workspace/dispatch/dispatchTerminalPlacement.renderer.test.tsx
@@ -2,92 +2,74 @@ import { act } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchSelectors'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
+import { resolveTabSessions } from '@renderer/workspace/queries'
import {
makeRefs,
mountPaneActions,
mountUndoCloseAction,
} from '@renderer/workspace/hook/actions/testing/paneActionsHarness'
-import type { DispatchModeState, SessionId, WorkspaceState } from '@renderer/workspace/types'
+import type { SessionId, WorkspaceState, TiledDispatchState } from '@renderer/workspace/types'
+import { freshStage } from '@renderer/workspace/dispatch/gridShape'
-// End-to-end placement contract for sessions created from Dispatch (#671).
+// End-to-end placement contract for terminals created on the stage (#671).
//
// WHY these drive `splitFocused` instead of asserting selector output over a
-// hand-built fixture: the bug was never in the selectors. `buildDispatchGroups`
-// has always emitted `[...grid, ...detached]`; what was wrong is that
-// `splitFocused` filed a Dispatch TERMINAL into the grid tree while filing
-// every agent as a detached row, so the terminal could not help sorting above
-// the agents. A test that writes the detached record by hand and then checks
-// the concatenation asserts only that selectors concatenate — it passes
-// identically against the unfixed code and would not notice the grid branch
-// coming back. The spawn action is the unit under test, so the spawn action is
-// what these mount.
-
-function makeDispatchState(dispatchMode: DispatchModeState): WorkspaceState {
- // One grid leaf (a1) plus two detached Dispatch agents, oldest first. This is
- // the ordinary shape of a project in Dispatch: the original pane stayed in
- // the grid and everything created since is a detached row.
+// hand-built fixture: the bug was never in the selectors. The index has always
+// listed a project's sessions in one order; what was wrong is that
+// `splitFocused` filed a new TERMINAL differently from a new agent — into the
+// tile tree, whose leaves sorted ahead of every detached row — so the terminal
+// could not help jumping above the agents. A test that writes the row by hand
+// and then checks the list asserts only that selectors sort; it passes
+// identically against the unfixed code. The spawn action is the unit under
+// test, so the spawn action is what these mount.
+//
+// #992 removed the two homes that made the bug possible: there is one pool,
+// ordered by `joinedAt`, and a terminal is filed into it exactly like an
+// agent. The ordering assertion is kept because it is the user-visible
+// contract and would catch any future "terminals are special" branch; the
+// "not into the grid" half of it has nothing left to assert.
+
+function makeDispatchState(stage: TiledDispatchState = freshStage()): WorkspaceState {
+ // Three agents of one project, oldest first (`joinedAt` is the only thing
+ // ordering them).
return {
tabs: [{
id: 'tabA',
title: 'project-a',
- root: { type: 'leaf', sessionId: 'a1' },
- focusedSessionId: 'a1',
}],
activeTabId: 'tabA',
- dispatchMode,
+ stage,
sessions: {
- a1: { cwd: '/work/project-a', kind: 'claude' },
- a2: { cwd: '/work/project-a', kind: 'claude' },
+ a1: { cwd: '/work/project-a', kind: 'claude', projectId: 'tabA', joinedAt: 0 },
+ a2: { cwd: '/work/project-a', kind: 'claude', projectId: 'tabA', joinedAt: 100 },
// Distinct on purpose: a3 is the focused Dispatch row in the ordering
// test, so a cwd shared with the grid leaf would make the #366 assertion
// below unable to tell which source the terminal actually inherited.
- a3: { cwd: '/work/project-a/worktree', kind: 'codex' },
+ a3: { cwd: '/work/project-a/worktree', kind: 'codex', projectId: 'tabA', joinedAt: 200 },
},
- detachedSessions: {
- a2: {
- sessionId: 'a2',
- surface: 'dispatch',
- projectTabId: 'tabA',
- projectTabTitle: 'project-a',
- projectTabIndex: 0,
- detachedAt: 100,
- },
- a3: {
- sessionId: 'a3',
- surface: 'dispatch',
- projectTabId: 'tabA',
- projectTabTitle: 'project-a',
- projectTabIndex: 0,
- detachedAt: 200,
- },
- },
- buried: [],
pinnedSessionIds: [],
} as WorkspaceState
}
describe('Dispatch terminal placement (#671)', () => {
- it('files a Dispatch-created terminal as a detached row after the agents, not into the grid', async () => {
+ it('files a new terminal under the project AFTER its agents', async () => {
const harness = mountPaneActions(
- makeDispatchState({ scope: 'project', focusedSessionId: 'a3' }),
+ makeDispatchState({ lanes: [{ selectedSessionId: 'a3' }], rows: [{ length: 1 }], focusedLane: 0 }),
{ spawnSessionId: 'aTerm' },
)
await act(async () => {
- await harness.actions.splitFocused('vertical', 'terminal')
+ await harness.actions.splitFocused('terminal')
})
const state = harness.getState()
- // The load-bearing assertion. Before the fix the terminal was spliced into
- // `tab.root` by `splitLeaf`, which put it in the grid slice that
- // `buildDispatchGroups` emits BEFORE every detached row.
- expect(collectLeaves(state.tabs[0]!.root)).toEqual(['a1'])
- expect(state.detachedSessions['aTerm' as SessionId]).toMatchObject({
- surface: 'dispatch',
- projectTabId: 'tabA',
- })
+ // Filed like any other session: under the project, stamped after every
+ // existing member. (`toBeGreaterThan(200)` rather than a literal because
+ // the stamp is wall-clock; 200 is the newest fixture row.)
+ expect(state.sessions['aTerm' as SessionId]).toMatchObject({ projectId: 'tabA', kind: 'terminal' })
+ expect(state.sessions['aTerm' as SessionId]!.joinedAt).toBeGreaterThan(200)
+ expect(resolveTabSessions(state, 'tabA')).toEqual(['a1', 'a2', 'a3', 'aTerm'])
// …and the user-visible consequence: creation order, terminal last.
expect(buildVisibleDispatchRows(state).map(row => row.sessionId)).toEqual([
@@ -97,89 +79,92 @@ describe('Dispatch terminal placement (#671)', () => {
'aTerm',
])
- // cwd comes from the focused Dispatch row (#366), which here is a DETACHED
- // agent in a worktree — the normal Dispatch state, and the case with no
- // grid leaf to fall back on. `/work/project-a/worktree` is reachable ONLY
- // through `target.cwdSessionId`; dropping that link from the cwd chain
- // falls back to the grid leaf's `/work/project-a` and fails here. Without
- // the distinct cwd this assertion could not tell the two apart.
+ // cwd comes from the focused LANE's occupant (#366), which here is an
+ // agent in a worktree. `/work/project-a/worktree` is reachable ONLY through
+ // `target.cwdSessionId`; dropping that link from the cwd chain falls back
+ // to the project's first session (`/work/project-a`) and fails here.
+ // Without the distinct cwd this assertion could not tell the two apart.
expect(harness.spawn).toHaveBeenCalledWith('/work/project-a/worktree', expect.objectContaining({
kind: 'terminal',
}))
harness.mounted.unmount()
})
- it('places the new terminal in the FOCUSED tiled lane, not lane 0', async () => {
+ it('fills the focused lane when it is EMPTY, not lane 0', async () => {
+ // Context-places (#992 §4.3): an empty focused lane is the one spawn a
+ // fill is allowed in. The focused lane is lane 1 here and EMPTY; lane 0 is
+ // occupied, which is what makes this case distinguish "the lane the user
+ // was looking at" from "lane 0" — the "everything jumps to tile 1" failure
+ // mode this layout has hit before.
const harness = mountPaneActions(
makeDispatchState({
- scope: 'project',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 1,
- lanes: [{ selectedSessionId: 'a1' }, { selectedSessionId: 'a3' }],
- },
+ focusedLane: 1,
+ lanes: [{ selectedSessionId: 'a1' }, {}],
}),
{ spawnSessionId: 'aTerm' },
)
await act(async () => {
- await harness.actions.splitFocused('vertical', 'terminal')
+ await harness.actions.splitFocused('terminal')
})
- const tiled = harness.getState().dispatchMode!.tiled!
- // Lane 1 is where the user was looking. Lane 0 must be untouched — the
- // "everything jumps to tile 1" failure mode this layout has hit before.
+ const tiled = harness.getState().stage
expect(tiled.lanes[1]!.selectedSessionId).toBe('aTerm')
expect(tiled.lanes[0]!.selectedSessionId).toBe('a1')
expect(tiled.focusedLane).toBe(1)
harness.mounted.unmount()
})
- it('normal (non-Dispatch) mode still splits the grid', async () => {
- // The merged branch is gated on `dispatchMode`; ⌥T outside Dispatch must
- // keep its old grid behaviour. Without this, "merge the flows" could
- // quietly mean "terminals never enter the grid again".
- const state = makeDispatchState({ scope: 'project', focusedSessionId: 'a1' })
+ it('pools the terminal when the focused lane is OCCUPIED — no lane changes, no focus move', async () => {
+ // The other half of context-places: an occupied lane is never displaced.
+ // Until stage 4 of #992 this spawn REPLACED a3 in lane 1; now a3 stays
+ // where the user put it, the terminal is reachable from the index, and
+ // not even the focus cursor moves — "nothing on screen moves" is half
+ // the rule. (cwd still comes from the occupied lane's agent; see the
+ // first case for why that link is load-bearing.)
const harness = mountPaneActions(
- { ...state, dispatchMode: null },
+ makeDispatchState({
+ focusedLane: 1,
+ lanes: [{ selectedSessionId: 'a1' }, { selectedSessionId: 'a3' }],
+ }),
{ spawnSessionId: 'aTerm' },
)
+ const before = harness.getState().stage
await act(async () => {
- await harness.actions.splitFocused('vertical', 'terminal')
+ await harness.actions.splitFocused('terminal')
})
- const next = harness.getState()
- expect(collectLeaves(next.tabs[0]!.root)).toEqual(['a1', 'aTerm'])
- expect(next.detachedSessions['aTerm' as SessionId]).toBeUndefined()
+ expect(harness.getState().stage).toBe(before)
+ expect(harness.spawn).toHaveBeenCalledWith('/work/project-a/worktree', expect.objectContaining({
+ kind: 'terminal',
+ }))
harness.mounted.unmount()
})
+
+ // 'normal (non-Dispatch) mode still splits the grid' lived here until #992:
+ // there is no grid to split, so the stage branch above is the whole flow.
})
-describe('closing a detached Dispatch session is undoable (#671)', () => {
- // WHY this is part of the #671 suite: making Dispatch terminals detached rows
- // moved them onto `closeSession`'s detached branch, which captured no undo
- // entry. For a terminal that is not merely a lost convenience — closing it
+describe('closing a terminal is undoable (#671)', () => {
+ // WHY this is part of the #671 suite: making terminals pool rows moved them
+ // onto a close branch that, at the time, captured no undo entry. There is one
+ // close path now and it always captures — these pin that it keeps doing so
+ // for the kind where it matters most. For a terminal a missing entry is not
+ // merely a lost convenience — closing it
// stops the attach PTY but leaves the tmux session alive, and once the row is
// gone from workspace.json the next launch's tmux reconcile reaps it as an
// orphan. Without an undo entry carrying `tmuxName`, the scrollback is
// unrecoverable.
function detachedTerminalState(): WorkspaceState {
- const state = makeDispatchState({ scope: 'project', focusedSessionId: 'aTerm' })
+ const state = makeDispatchState({ lanes: [{ selectedSessionId: 'aTerm' }], rows: [{ length: 1 }], focusedLane: 0 })
state.sessions['aTerm' as SessionId] = {
cwd: '/work/project-a',
kind: 'terminal',
tmuxName: 'agent-code-aTerm',
}
- state.detachedSessions['aTerm' as SessionId] = {
- sessionId: 'aTerm' as SessionId,
- surface: 'dispatch',
- projectTabId: 'tabA',
- projectTabTitle: 'project-a',
- projectTabIndex: 0,
- detachedAt: 300,
- }
+ state.sessions['aTerm' as SessionId] = { ...state.sessions['aTerm' as SessionId]!, projectId: 'tabA', joinedAt: 300 }
return state
}
@@ -196,7 +181,7 @@ describe('closing a detached Dispatch session is undoable (#671)', () => {
})
})
- it('captures an undo entry carrying tmuxName when a detached terminal is closed', async () => {
+ it('captures an undo entry carrying tmuxName when a terminal is closed', async () => {
const state = detachedTerminalState()
const refs = makeRefs(state)
const harness = mountPaneActions(state, { refs })
@@ -207,11 +192,12 @@ describe('closing a detached Dispatch session is undoable (#671)', () => {
const entry = refs.undoStackRef.current.peek()
expect(entry).toMatchObject({
- type: 'detached',
- sessionMeta: { kind: 'terminal', tmuxName: 'agent-code-aTerm' },
- // detachedAt is preserved so undo restores the row's position rather
- // than sending it to the bottom of the Dispatch list.
- record: { detachedAt: 300, projectTabId: 'tabA' },
+ type: 'session',
+ sessionId: 'aTerm',
+ // The row is stored verbatim, so `joinedAt` rides along and undo
+ // restores the row's position rather than sending it to the bottom of
+ // its project's list.
+ sessionMeta: { kind: 'terminal', tmuxName: 'agent-code-aTerm', projectId: 'tabA', joinedAt: 300 },
})
harness.mounted.unmount()
})
@@ -220,25 +206,19 @@ describe('closing a detached Dispatch session is undoable (#671)', () => {
const state = detachedTerminalState()
const refs = makeRefs(state)
refs.undoStackRef.current.push({
- type: 'detached',
+ type: 'session',
closedAt: Date.now(),
+ sessionId: 'aTerm' as SessionId,
sessionMeta: {
cwd: '/work/project-a',
kind: 'terminal',
tmuxName: 'agent-code-aTerm',
- },
- record: {
- sessionId: 'aTerm' as SessionId,
- surface: 'dispatch',
- projectTabId: 'tabA',
- projectTabTitle: 'project-a',
- projectTabIndex: 0,
- detachedAt: 300,
+ projectId: 'tabA',
+ joinedAt: 300,
},
})
// Close-then-undo: the session is gone from state by the time undo runs.
delete state.sessions['aTerm' as SessionId]
- delete state.detachedSessions['aTerm' as SessionId]
const spawn = vi.fn().mockResolvedValue('aTerm2')
const undo = mountUndoCloseAction(state, refs, spawn)
@@ -255,57 +235,48 @@ describe('closing a detached Dispatch session is undoable (#671)', () => {
recoverTmuxName: 'agent-code-aTerm',
builtInMcpOverrides: {},
})
- expect(undo.getState().detachedSessions['aTerm2' as SessionId]).toMatchObject({
- sessionId: 'aTerm2',
- projectTabId: 'tabA',
- detachedAt: 300,
+ expect(undo.getState().sessions['aTerm2' as SessionId]).toMatchObject({
+ projectId: 'tabA',
+ joinedAt: 300,
})
+ expect(resolveTabSessions(undo.getState(), 'tabA')).toEqual(['a1', 'a2', 'a3', 'aTerm2'])
undo.mounted.unmount()
})
it('makes the restored row VISIBLE when another project tab is active', async () => {
- // The #672 review's blocker. Every other path that files a detached row
- // sets activeTabId in the same updater; restoreDetachedEntry did not. Since
- // buildDispatchGroups filters sourceTabs to activeTabId outside global
- // scope, undoing a row that belongs to a different tab spawned a live
- // backend into a list it was filtered out of — no toast, no visible row, a
- // claude/codex process or a re-attached tmux session running invisibly.
+ // The #672 review's blocker. Every other path that files a row sets
+ // activeTabId in the same updater; the restore did not. While the index
+ // was filtered to the active project, undoing a row that belonged to a
+ // different one spawned a live backend into a list it was filtered out of
+ // — no toast, no visible row, a claude/codex process or a re-attached tmux
+ // session running invisibly. The index lists the whole fleet now, so the
+ // row would be listed either way; activating its project is still what
+ // puts the user where the thing they just restored is.
//
// The existing fixture has ONE tab, so the restore target was always the
// active tab and the bug could not surface. This one adds the second tab
// and asserts VISIBILITY rather than the field, because the field is the
// mechanism and the visible row is the contract.
const state = detachedTerminalState()
- state.tabs.push({
- id: 'tabB',
- title: 'project-b',
- root: { type: 'leaf', sessionId: 'b1' as SessionId },
- focusedSessionId: 'b1' as SessionId,
- })
- state.sessions['b1' as SessionId] = { cwd: '/work/project-b', kind: 'claude' }
+ state.tabs.push({ id: 'tabB', title: 'project-b' })
+ state.sessions['b1' as SessionId] = { cwd: '/work/project-b', kind: 'claude', projectId: 'tabB', joinedAt: 0 }
// The user has switched away from the project the closed row belonged to.
state.activeTabId = 'tabB'
const refs = makeRefs(state)
refs.undoStackRef.current.push({
- type: 'detached',
+ type: 'session',
closedAt: Date.now(),
+ sessionId: 'aTerm' as SessionId,
sessionMeta: {
cwd: '/work/project-a',
kind: 'terminal',
tmuxName: 'agent-code-aTerm',
- },
- record: {
- sessionId: 'aTerm' as SessionId,
- surface: 'dispatch',
- projectTabId: 'tabA',
- projectTabTitle: 'project-a',
- projectTabIndex: 0,
- detachedAt: 300,
+ projectId: 'tabA',
+ joinedAt: 300,
},
})
delete state.sessions['aTerm' as SessionId]
- delete state.detachedSessions['aTerm' as SessionId]
const spawn = vi.fn().mockResolvedValue('aTerm2')
const undo = mountUndoCloseAction(state, refs, spawn)
@@ -346,20 +317,13 @@ describe('closing a detached Dispatch session is undoable (#671)', () => {
const state = detachedTerminalState()
const refs = makeRefs(state)
refs.undoStackRef.current.push({
- type: 'detached',
+ type: 'session',
closedAt: Date.now(),
- sessionMeta: { cwd: '/work/project-a', kind: 'terminal' },
- record: {
- sessionId: 'aTerm' as SessionId,
- surface: 'dispatch',
- // A project tab that no longer exists: a detached record filed under it
- // would render in no Dispatch group at all, so the session would be
- // live and unreachable.
- projectTabId: 'tab-closed',
- projectTabTitle: 'gone',
- projectTabIndex: 0,
- detachedAt: 300,
- },
+ sessionId: 'aTerm' as SessionId,
+ // A project that no longer exists: a row filed under it is unowned, so
+ // it would be listed nowhere and dropped by the next autosave while its
+ // backend kept running — live and unreachable.
+ sessionMeta: { cwd: '/work/project-a', kind: 'terminal', projectId: 'tab-closed', joinedAt: 300 },
})
const spawn = vi.fn().mockResolvedValue('aTerm2')
diff --git a/src/renderer/src/workspace/dispatch/entryContinuity.renderer.test.tsx b/src/renderer/src/workspace/dispatch/entryContinuity.renderer.test.tsx
deleted file mode 100644
index 60d6aa974..000000000
--- a/src/renderer/src/workspace/dispatch/entryContinuity.renderer.test.tsx
+++ /dev/null
@@ -1,300 +0,0 @@
-import { act, renderHook } from '@testing-library/react'
-import { describe, expect, it, vi } from 'vitest'
-
-import { useDispatchActions } from '@renderer/workspace/hook/actions/dispatch'
-import type { SessionId, WorkspaceState } from '@renderer/workspace/types'
-import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
-
-// #977: entering Grid Dispatch must keep the agent the user was commanding.
-//
-// `enterTiledDispatch` used to build every lane empty. Classic Dispatch
-// carefully carried `focusedSessionId` onto the dispatchMode object and then
-// dropped it on the floor — no lane received it — so the user turned Grid
-// Dispatch on from one focused lane and that lane arrived unoccupied.
-//
-// This is CONTINUITY, not #681's banned auto-fill: the seeded session is the
-// one already focused, never a prediction from the index, and every other
-// lane stays empty exactly as #681 requires.
-
-const CLASSIC_FOCUS = 'classic-focus' as SessionId
-const GRID_FOCUS = 'grid-focus' as SessionId
-const TILED_FOCUS = 'tiled-focus' as SessionId
-
-function harness(options: {
- /** Classic Dispatch focus (`dispatchMode.focusedSessionId`), Dispatch off-grid. */
- classicFocus?: SessionId
- /** The focused pane of the normal grid (active tab's `focusedSessionId`). */
- gridFocus?: SessionId
- /** An existing Grid Dispatch whose focused lane holds this agent. */
- tiledFocus?: SessionId
- /** Ids to record as buried. */
- buriedIds?: SessionId[]
- /** Omit the candidate from `sessions` entirely. */
- candidateAbsent?: boolean
- /** Ids to record as detached (hibernated, no live backend). */
- detachedIds?: SessionId[]
- /** Anything the caller wants to happen WHILE a wake is in flight. */
- duringWake?: (ref: { current: WorkspaceState }) => void
- /** Make the wake fail. */
- wakeRejects?: boolean
-} = {}) {
- const candidate = options.tiledFocus ?? options.classicFocus ?? options.gridFocus
- const sessions: Record = {}
- // The focus candidate only exists as a session when the test says so — a
- // stale/absent id must degrade to an empty lane, never a phantom occupant.
- if (candidate && !options.candidateAbsent) {
- sessions[candidate] = { cwd: `/work/${candidate}`, kind: 'claude' }
- }
- // A second, VALID focus target for the focus-moved-during-wake case: live
- // and recorded, so only the wake-window movement distinguishes it.
- sessions['moved-to-focus'] = { cwd: '/work/moved-to', kind: 'claude' }
- const state = {
- activeTabId: 'tab-a',
- tabs: [{
- id: 'tab-a',
- title: 'Work',
- root: { type: 'leaf' as const, sessionId: GRID_FOCUS },
- focusedSessionId: options.gridFocus,
- }],
- dispatchMode: options.tiledFocus
- ? {
- scope: 'global' as const,
- tiled: {
- lanes: [{ selectedSessionId: options.tiledFocus }, {}, {}],
- rows: [{ length: 3 }],
- focusedLane: 0,
- },
- }
- : options.classicFocus
- ? { scope: 'global' as const, focusedSessionId: options.classicFocus }
- : null,
- sessions,
- buried: (options.buriedIds ?? []).map(id => ({ sessionId: id })),
- detachedSessions: Object.fromEntries(
- (options.detachedIds ?? []).map(id => [
- id,
- { sessionId: id, surface: 'dispatch', projectTabId: 'tab-b' },
- ]),
- ),
- }
- const stateRef = { current: state as unknown as WorkspaceState }
- // Order is the contract for the wake cases: a lane written before the wake
- // is the #690 dead-pane state all over again.
- const order: string[] = []
- // Apply updaters eagerly so the resulting tiled state is observable — the
- // whole point of these tests is what the reducer WROTE, not that it ran.
- const setState = vi.fn((updater: unknown) => {
- if (typeof updater === 'function') {
- order.push('write')
- stateRef.current = (updater as (p: WorkspaceState) => WorkspaceState)(stateRef.current)
- }
- return updater
- })
- const ensureSessionLive = vi.fn(async () => {
- order.push('wake')
- options.duringWake?.(stateRef)
- if (options.wakeRejects) throw new Error('boom')
- return { sessionId: candidate, builtInMcpDomains: undefined }
- })
- const showToast = vi.fn()
- const hook = renderHook(() =>
- useDispatchActions(
- state,
- setState as never,
- vi.fn(),
- vi.fn(),
- { stateRef } as unknown as WorkspaceRefs,
- ensureSessionLive as never,
- showToast,
- ),
- )
- return { hook, stateRef, order, ensureSessionLive, showToast }
-}
-
-describe('entering Grid Dispatch keeps the focused agent (#977)', () => {
- it('seeds the classic Dispatch focus into lane 0', async () => {
- const { hook, stateRef } = harness({ classicFocus: CLASSIC_FOCUS })
-
- await act(async () => {
- await hook.result.current.enterTiledDispatch([2])
- })
-
- const tiled = stateRef.current.dispatchMode?.tiled
- expect(tiled?.lanes[0]?.selectedSessionId).toBe(CLASSIC_FOCUS)
- // Only lane 0. Seeding every lane is #681's auto-fill, not continuity.
- expect(tiled?.lanes[1]?.selectedSessionId).toBeUndefined()
- // The seeded lane is the focused lane, so the user keeps commanding the
- // agent they were commanding.
- expect(tiled?.focusedLane).toBe(0)
- })
-
- it('falls back to the focused grid pane when Dispatch was never entered', async () => {
- const { hook, stateRef } = harness({ gridFocus: GRID_FOCUS })
-
- await act(async () => {
- await hook.result.current.enterTiledDispatch([3])
- })
-
- const tiled = stateRef.current.dispatchMode?.tiled
- expect(tiled?.lanes[0]?.selectedSessionId).toBe(GRID_FOCUS)
- expect(tiled?.lanes[1]?.selectedSessionId).toBeUndefined()
- expect(tiled?.lanes[2]?.selectedSessionId).toBeUndefined()
- })
-
- it('prefers the classic Dispatch focus over the grid pane', async () => {
- // Both are live when the user enters Dispatch from a grid tab and then
- // goes straight to Grid Dispatch. The Dispatch focus is the later, more
- // deliberate signal of what the user is commanding.
- const { hook, stateRef } = harness({
- classicFocus: CLASSIC_FOCUS,
- gridFocus: GRID_FOCUS,
- })
-
- await act(async () => {
- await hook.result.current.enterTiledDispatch([2])
- })
-
- expect(stateRef.current.dispatchMode?.tiled?.lanes[0]?.selectedSessionId)
- .toBe(CLASSIC_FOCUS)
- })
-
- it('carries the focused lane of an existing grid on re-entry', async () => {
- // enterTiledDispatch REPLACES an existing grid wholesale; the tiled-aware
- // focus reader means the replacement still starts from the agent the user
- // was looking at, not from lane 0 of the old shape.
- const { hook, stateRef } = harness({ tiledFocus: TILED_FOCUS })
-
- await act(async () => {
- await hook.result.current.enterTiledDispatch([2])
- })
-
- expect(stateRef.current.dispatchMode?.tiled?.lanes[0]?.selectedSessionId)
- .toBe(TILED_FOCUS)
- })
-
- it('leaves every lane empty when the focused id is buried', async () => {
- const { hook, stateRef } = harness({
- classicFocus: CLASSIC_FOCUS,
- buriedIds: [CLASSIC_FOCUS],
- })
-
- await act(async () => {
- await hook.result.current.enterTiledDispatch([2])
- })
-
- const lanes = stateRef.current.dispatchMode?.tiled?.lanes ?? []
- expect(lanes.every(lane => lane.selectedSessionId === undefined)).toBe(true)
- })
-
- it('leaves every lane empty when the focused id is not a live session', async () => {
- const { hook, stateRef } = harness({
- classicFocus: CLASSIC_FOCUS,
- candidateAbsent: true,
- })
-
- await act(async () => {
- await hook.result.current.enterTiledDispatch([2])
- })
-
- const lanes = stateRef.current.dispatchMode?.tiled?.lanes ?? []
- expect(lanes.every(lane => lane.selectedSessionId === undefined)).toBe(true)
- })
-
- it('leaves lane 0 empty when nothing is focused anywhere', async () => {
- const { hook, stateRef } = harness()
-
- await act(async () => {
- await hook.result.current.enterTiledDispatch([2])
- })
-
- const lanes = stateRef.current.dispatchMode?.tiled?.lanes ?? []
- expect(lanes.every(lane => lane.selectedSessionId === undefined)).toBe(true)
- })
-})
-
-describe('a detached seed is woken before it is written (#690 parity)', () => {
- // The seed is a lane placement like any other: a hibernated agent written
- // into a lane without a wake renders a pane that rejects the first prompt
- // with "not a live agent session". The strip-selection gesture wakes for
- // exactly this reason; entry seeding must not be the one path exempt from
- // the rule. In an ordinary session EVERY dispatch agent is detached, so
- // this is the COMMON seeding path, not an edge case.
-
- it('wakes a hibernated focus before seeding lane 0', async () => {
- const { hook, stateRef, order, ensureSessionLive } = harness({
- classicFocus: CLASSIC_FOCUS,
- detachedIds: [CLASSIC_FOCUS],
- })
-
- await act(async () => {
- await hook.result.current.enterTiledDispatch([2])
- })
-
- expect(ensureSessionLive).toHaveBeenCalledWith(CLASSIC_FOCUS, 'grid-dispatch.entry-seed')
- // Order is the contract: writing the lane first is the dead-pane state.
- expect(order).toEqual(['wake', 'write'])
- expect(stateRef.current.dispatchMode?.tiled?.lanes[0]?.selectedSessionId)
- .toBe(CLASSIC_FOCUS)
- })
-
- it('enters without a seed when the wake fails', async () => {
- // Entry is the user's primary request; a failed wake costs the seed, not
- // the layout. Showing a pane whose backend refused to come back is the
- // exact failure mode the wake exists to prevent.
- const { hook, stateRef, order, showToast } = harness({
- classicFocus: CLASSIC_FOCUS,
- detachedIds: [CLASSIC_FOCUS],
- wakeRejects: true,
- })
-
- await act(async () => {
- await hook.result.current.enterTiledDispatch([2])
- })
-
- expect(order).toEqual(['wake', 'write'])
- expect(showToast).toHaveBeenCalled()
- const tiled = stateRef.current.dispatchMode?.tiled
- expect(tiled?.lanes).toHaveLength(2)
- expect(tiled?.lanes.every(lane => lane.selectedSessionId === undefined)).toBe(true)
- })
-
- it('does not wake a grid-placed focus', async () => {
- // Owned by a tile tree, so rehydrate already respawned it — the identical
- // predicate selectTiledLaneSession uses. A spurious wake would make every
- // ordinary entry pay a recover round-trip for nothing.
- const { hook, order, ensureSessionLive } = harness({ gridFocus: GRID_FOCUS })
-
- await act(async () => {
- await hook.result.current.enterTiledDispatch([2])
- })
-
- expect(ensureSessionLive).not.toHaveBeenCalled()
- expect(order).toEqual(['write'])
- })
-
- it('drops the seed when focus moves during the wake', async () => {
- // The wake window is up to 30s cold. The new focus was neither validated
- // nor woken on this path, and seeding it from inside the sync updater
- // would raw-write a possibly-detached id — the gap this describe closes.
- // Dropping mirrors selectTiledLaneSession's membership-change drop:
- // predictable over clever.
- const { hook, stateRef, ensureSessionLive } = harness({
- classicFocus: CLASSIC_FOCUS,
- detachedIds: [CLASSIC_FOCUS],
- duringWake: ref => {
- ref.current = {
- ...ref.current,
- dispatchMode: { scope: 'global', focusedSessionId: 'moved-to-focus' as SessionId },
- }
- },
- })
-
- await act(async () => {
- await hook.result.current.enterTiledDispatch([2])
- })
-
- expect(ensureSessionLive).toHaveBeenCalledWith(CLASSIC_FOCUS, 'grid-dispatch.entry-seed')
- const lanes = stateRef.current.dispatchMode?.tiled?.lanes ?? []
- expect(lanes.every(lane => lane.selectedSessionId === undefined)).toBe(true)
- })
-})
diff --git a/src/renderer/src/workspace/dispatch/gridDispatchLayout.renderer.test.tsx b/src/renderer/src/workspace/dispatch/gridDispatchLayout.renderer.test.tsx
index e93b482be..30737b184 100644
--- a/src/renderer/src/workspace/dispatch/gridDispatchLayout.renderer.test.tsx
+++ b/src/renderer/src/workspace/dispatch/gridDispatchLayout.renderer.test.tsx
@@ -1,10 +1,10 @@
import { cleanup, render } from '@testing-library/react'
-import { readFileSync } from 'node:fs'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DispatchLayout } from '@renderer/workspace/dispatch/DispatchLayout'
import type { SessionId, TiledDispatchState, WorkspaceState } from '@renderer/workspace/types'
import type { Workspace } from '@renderer/workspace/workspaceStore'
+import { loadRecordedDispatchWorkspace } from '@renderer/workspace/testing/recordedDispatchWorkspace'
// The grid actually rendering as a grid.
//
@@ -115,9 +115,10 @@ vi.mock('@providers/registry.renderer', () => ({
}),
}))
-const FIXTURE = JSON.parse(
- readFileSync('testing/fixtures/worktree-context/dispatch-global-d23.json', 'utf8'),
-) as { state: WorkspaceState }
+// Loaded through the shared lift: the file on disk is a v2 workspace whose lane
+// grid sits at `dispatchMode.tiled`; the loader moves it to `state.stage`
+// verbatim (see recordedDispatchWorkspace.ts for why it is not re-recorded).
+const FIXTURE = loadRecordedDispatchWorkspace()
function renderGrid(tiled: TiledDispatchState) {
const selectTiledLaneSession = vi.fn().mockResolvedValue(undefined)
@@ -125,7 +126,7 @@ function renderGrid(tiled: TiledDispatchState) {
const toggleDispatchRowExpandedParent = vi.fn()
const state: WorkspaceState = {
...FIXTURE.state,
- dispatchMode: { ...FIXTURE.state.dispatchMode!, scope: 'global', tiled },
+ stage: tiled,
}
const workspace = {
state,
@@ -168,7 +169,7 @@ function stripSelecting(strips: HTMLElement[], sessionId: string): HTMLElement {
return matches[0]!
}
-const laneIds = FIXTURE.state.dispatchMode!.tiled!.lanes.map(
+const laneIds = FIXTURE.state.stage.lanes.map(
lane => lane.selectedSessionId,
) as SessionId[]
@@ -427,7 +428,7 @@ describe('Grid Dispatch layout', () => {
it('still renders a pre-grid single-row workspace', () => {
// The migration path, end to end: no `rows` at all means one row holding
// every lane, and it must render rather than crash on a missing descriptor.
- const { getAllByTestId } = renderGrid(FIXTURE.state.dispatchMode!.tiled!)
+ const { getAllByTestId } = renderGrid(FIXTURE.state.stage)
expect(getAllByTestId('row-index')).toHaveLength(1)
expect(getAllByTestId('lane-agent')).toHaveLength(laneIds.length)
diff --git a/src/renderer/src/workspace/dispatch/gridPersistence.test.ts b/src/renderer/src/workspace/dispatch/gridPersistence.test.ts
index 5e4f2865a..c41d73586 100644
--- a/src/renderer/src/workspace/dispatch/gridPersistence.test.ts
+++ b/src/renderer/src/workspace/dispatch/gridPersistence.test.ts
@@ -1,11 +1,11 @@
-import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import {
- normalizeDispatchModeGrid,
+ normalizeStage,
scrubGridRowMetadata,
} from '@renderer/workspace/dispatch/tiledDispatchSelectors'
-import type { DispatchModeState, SessionId, WorkspaceState } from '@renderer/workspace/types'
+import { loadRecordedDispatchWorkspace } from '@renderer/workspace/testing/recordedDispatchWorkspace'
+import type { SessionId, TiledDispatchState } from '@renderer/workspace/types'
// Restoring a workspace written before Grid Dispatch existed.
//
@@ -14,74 +14,70 @@ import type { DispatchModeState, SessionId, WorkspaceState } from '@renderer/wor
// it carries a genuine legacy `ratios` array produced by the single-row layout,
// with the index fraction the user actually dragged. Every claim about the
// migration is checked against that, not against a plausible-looking literal.
-const FIXTURE = JSON.parse(
- readFileSync('testing/fixtures/worktree-context/dispatch-global-d23.json', 'utf8'),
-) as { state: WorkspaceState }
-
-const RECORDED = FIXTURE.state.dispatchMode!
+//
+// RECORDED is the recorded `tiled` block VERBATIM — the shared loader moves it
+// to `state.stage` without normalizing, precisely so this suite still gets the
+// legacy array. These helpers took the whole `dispatchMode` envelope until
+// #992 made the stage a required field; their subject was always the lane
+// grid inside it, which is what they take now.
+const RECORDED = loadRecordedDispatchWorkspace().state.stage
describe('restoring a pre-grid workspace', () => {
it('is a workspace with the legacy shape, or these assertions prove nothing', () => {
// Guard on the fixture itself. If it is ever re-recorded from a build that
// already writes `rows`, the migration below stops being exercised and
// every test in this file would keep passing while covering nothing.
- expect(RECORDED.tiled?.ratios).toBeDefined()
- expect(RECORDED.tiled?.rows).toBeUndefined()
- expect(RECORDED.tiled?.laneWeights).toBeUndefined()
+ expect(RECORDED.ratios).toBeDefined()
+ expect(RECORDED.rows).toBeUndefined()
+ expect(RECORDED.laneWeights).toBeUndefined()
})
it('restores as a single row holding every recorded lane', () => {
- const normalized = normalizeDispatchModeGrid(RECORDED)
- const tiled = normalized!.tiled!
+ const normalized = normalizeStage(RECORDED)
+ const tiled = normalized
expect(tiled.rows).toHaveLength(1)
- expect(tiled.rows![0]!.length).toBe(RECORDED.tiled!.lanes.length)
- expect(tiled.lanes).toEqual(RECORDED.tiled!.lanes)
- expect(tiled.focusedLane).toBe(RECORDED.tiled!.focusedLane)
+ expect(tiled.rows![0]!.length).toBe(RECORDED.lanes.length)
+ expect(tiled.lanes).toEqual(RECORDED.lanes)
+ expect(tiled.focusedLane).toBe(RECORDED.focusedLane)
})
it('keeps the index width the user actually dragged', () => {
// The half of `ratios` that is NOT a lane weight. Losing it would snap the
// sidebar back to its default on the first launch after upgrading — a width
// the user deliberately set, silently discarded by a migration.
- const normalized = normalizeDispatchModeGrid(RECORDED)
+ const normalized = normalizeStage(RECORDED)
- expect(normalized!.tiled!.rows![0]!.indexFraction).toBe(RECORDED.tiled!.ratios![0])
+ expect(normalized.rows![0]!.indexFraction).toBe(RECORDED.ratios![0])
})
it('carries the recorded lane weights across, one per lane', () => {
- const normalized = normalizeDispatchModeGrid(RECORDED)
+ const normalized = normalizeStage(RECORDED)
- expect(normalized!.tiled!.laneWeights).toEqual(RECORDED.tiled!.ratios!.slice(1))
- expect(normalized!.tiled!.laneWeights).toHaveLength(RECORDED.tiled!.lanes.length)
+ expect(normalized.laneWeights).toEqual(RECORDED.ratios!.slice(1))
+ expect(normalized.laneWeights).toHaveLength(RECORDED.lanes.length)
})
it('stops writing the legacy array once it has been split', () => {
// Leaving both formats behind would mean two sources of truth for width,
// and the next reader would have to guess which one the last drag wrote.
- const normalized = normalizeDispatchModeGrid(RECORDED)
+ const normalized = normalizeStage(RECORDED)
- expect(normalized!.tiled!.ratios).toBeUndefined()
+ expect(normalized.ratios).toBeUndefined()
})
- it('leaves classic Dispatch and grid-less state alone', () => {
- // Same defensive shape as every other helper in this family: a stray call
- // against non-tiled state must be a no-op, not a crash or a spurious grid.
- const classic: DispatchModeState = { scope: 'project', focusedSessionId: 'a1' }
-
- expect(normalizeDispatchModeGrid(classic)).toBe(classic)
- expect(normalizeDispatchModeGrid(null)).toBeNull()
- })
+ // "leaves classic Dispatch and grid-less state alone" lived here until #992:
+ // it fed the normalizer a lane-less envelope and `null`. Neither input can be
+ // expressed any more — the parameter is the grid itself.
it('returns the same reference when a grid is already normalized', () => {
// Rehydrate is not the only caller this could acquire, and a helper that
// mints a new object on every call would churn every consumer that memoizes
- // on dispatchMode identity.
- const already = normalizeDispatchModeGrid(RECORDED)!
+ // on stage identity.
+ const already = normalizeStage(RECORDED)
- expect(normalizeDispatchModeGrid(already)).toBe(already)
+ expect(normalizeStage(already)).toBe(already)
})
-})
describe('scrubbing row metadata at the autosave boundary', () => {
// Row metadata names two things that can disappear: a project tab and a set
@@ -89,9 +85,8 @@ describe('scrubbing row metadata at the autosave boundary', () => {
// durable pointer is, or workspace.json keeps a binding to a closed project —
// which filters that row's index to nothing, permanently, with no UI path
// back because the picker only lists tabs that exist.
- const gridMode = (row: Record): DispatchModeState => ({
- scope: 'global',
- tiled: { lanes: [{}], rows: [{ length: 1, ...row }], focusedLane: 0 },
+ const gridMode = (row: Record): TiledDispatchState => ({
+ lanes: [{}], rows: [{ length: 1, ...row }], focusedLane: 0,
})
it('drops a binding to a project that no longer exists', () => {
@@ -101,7 +96,7 @@ describe('scrubbing row metadata at the autosave boundary', () => {
new Set(),
)
- expect(scrubbed!.tiled!.rows![0]!.projectTabIds).toBeUndefined()
+ expect(scrubbed.rows![0]!.projectTabIds).toBeUndefined()
})
it('keeps a binding to a project that survives', () => {
@@ -111,7 +106,7 @@ describe('scrubbing row metadata at the autosave boundary', () => {
new Set(),
)
- expect(scrubbed!.tiled!.rows![0]!.projectTabIds).toEqual(['tab-live'])
+ expect(scrubbed.rows![0]!.projectTabIds).toEqual(['tab-live'])
})
it('drops expanded parents whose sessions are gone, keeping the rest', () => {
@@ -121,7 +116,7 @@ describe('scrubbing row metadata at the autosave boundary', () => {
new Set(['alive' as SessionId]),
)
- expect(scrubbed!.tiled!.rows![0]!.expandedParents).toEqual(['alive'])
+ expect(scrubbed.rows![0]!.expandedParents).toEqual(['alive'])
})
it('drops the field entirely when no expanded parent survives', () => {
@@ -133,7 +128,7 @@ describe('scrubbing row metadata at the autosave boundary', () => {
new Set(),
)
- expect(scrubbed!.tiled!.rows![0]!.expandedParents).toBeUndefined()
+ expect(scrubbed.rows![0]!.expandedParents).toBeUndefined()
})
it('returns the same reference when nothing needed scrubbing', () => {
@@ -143,12 +138,9 @@ describe('scrubbing row metadata at the autosave boundary', () => {
.toBe(clean)
})
- it('leaves classic Dispatch alone', () => {
- const classic: DispatchModeState = { scope: 'project' }
-
- expect(scrubGridRowMetadata(classic, new Set(), new Set()))
- .toBe(classic)
- })
+ // ("leaves classic Dispatch alone" lived here until #992, for the same
+ // reason as its twin above: there is no lane-less input left to pass.)
+})
})
describe('ragged shapes survive persistence', () => {
@@ -158,21 +150,18 @@ describe('ragged shapes survive persistence', () => {
// "tidied" 4/2 into 3/3 would look like a layout bug on the next launch, long
// after the code that did it.
it('round-trips an uneven grid unchanged', () => {
- const uneven: DispatchModeState = {
- scope: 'global',
- tiled: {
- lanes: Array.from({ length: 6 }, () => ({})),
- rows: [{ length: 4 }, { length: 2 }],
- focusedLane: 5,
- },
+ const uneven: TiledDispatchState = {
+ lanes: Array.from({ length: 6 }, () => ({})),
+ rows: [{ length: 4 }, { length: 2 }],
+ focusedLane: 5,
}
- const restored = normalizeDispatchModeGrid(uneven)
+ const restored = normalizeStage(uneven)
- expect(restored!.tiled!.rows!.map(row => row.length)).toEqual([4, 2])
- expect(restored!.tiled!.focusedLane).toBe(5)
+ expect(restored.rows!.map(row => row.length)).toEqual([4, 2])
+ expect(restored.focusedLane).toBe(5)
// Same reference: a coherent shape must not be rebuilt, or every consumer
- // memoizing on dispatchMode identity churns on every restore.
+ // memoizing on stage identity churns on every restore.
expect(restored).toBe(uneven)
})
@@ -180,18 +169,15 @@ describe('ragged shapes survive persistence', () => {
// A repair caused by a corrupt LENGTH must still not even out the rows it
// leaves behind: the surplus goes to the last row, so row 0 keeps the width
// the user chose.
- const corrupt: DispatchModeState = {
- scope: 'global',
- tiled: {
- lanes: Array.from({ length: 6 }, () => ({})),
- rows: [{ length: 4 }, { length: 1 }],
- focusedLane: 0,
- },
+ const corrupt: TiledDispatchState = {
+ lanes: Array.from({ length: 6 }, () => ({})),
+ rows: [{ length: 4 }, { length: 1 }],
+ focusedLane: 0,
}
- const restored = normalizeDispatchModeGrid(corrupt)
+ const restored = normalizeStage(corrupt)
- expect(restored!.tiled!.rows!.map(row => row.length)).toEqual([4, 2])
+ expect(restored.rows!.map(row => row.length)).toEqual([4, 2])
})
})
@@ -199,14 +185,13 @@ describe('row project bindings become a set', () => {
// "Any project" must have exactly ONE representation. With `undefined`, `[]`,
// and a stale single `projectTabId` all reachable, every reader would need to
// test for three things and one would eventually forget.
- const rowMode = (row: Record): DispatchModeState => ({
- scope: 'global',
- tiled: { lanes: [{}], rows: [{ length: 1, ...row }], focusedLane: 0 },
+ const rowMode = (row: Record): TiledDispatchState => ({
+ lanes: [{}], rows: [{ length: 1, ...row }], focusedLane: 0,
})
- const rowOf = (mode: DispatchModeState | null | undefined) => mode!.tiled!.rows![0]!
+ const rowOf = (stage: TiledDispatchState) => stage.rows![0]!
it('folds a legacy single binding into the set and stops writing the old field', () => {
- const restored = normalizeDispatchModeGrid(rowMode({ projectTabId: 'tab-a' }))
+ const restored = normalizeStage(rowMode({ projectTabId: 'tab-a' }))
expect(rowOf(restored).projectTabIds).toEqual(['tab-a'])
expect(rowOf(restored).projectTabId).toBeUndefined()
@@ -215,7 +200,7 @@ describe('row project bindings become a set', () => {
it('prefers an explicit set over a stale legacy field', () => {
// Both surviving means a partial write or an upgrade/downgrade cycle; the
// plural field is the one the user's last edit produced.
- const restored = normalizeDispatchModeGrid(
+ const restored = normalizeStage(
rowMode({ projectTabId: 'tab-stale', projectTabIds: ['tab-a', 'tab-b'] }),
)
@@ -224,7 +209,7 @@ describe('row project bindings become a set', () => {
})
it('collapses an empty set to absent', () => {
- const restored = normalizeDispatchModeGrid(rowMode({ projectTabIds: [] }))
+ const restored = normalizeStage(rowMode({ projectTabIds: [] }))
expect(rowOf(restored).projectTabIds).toBeUndefined()
})
@@ -234,12 +219,9 @@ describe('row project bindings become a set', () => {
// normalization rebuilt plain rows, the lane-selection race check — which
// compares row objects across an async wake — would see a different object
// every time and drop every selection.
- const plain: DispatchModeState = {
- scope: 'global',
- tiled: { lanes: [{}], rows: [{ length: 1 }], focusedLane: 0 },
- }
+ const plain: TiledDispatchState = { lanes: [{}], rows: [{ length: 1 }], focusedLane: 0 }
- expect(normalizeDispatchModeGrid(plain)).toBe(plain)
+ expect(normalizeStage(plain)).toBe(plain)
})
it('leaves a healthy multi-project row untouched by reference', () => {
@@ -248,7 +230,7 @@ describe('row project bindings become a set', () => {
// make every selection drop.
const healthy = rowMode({ projectTabIds: ['tab-a', 'tab-b'] })
- expect(normalizeDispatchModeGrid(healthy)).toBe(healthy)
+ expect(normalizeStage(healthy)).toBe(healthy)
})
it('scrubs dead bindings and unbinds a row that loses all of them', () => {
diff --git a/src/renderer/src/workspace/dispatch/gridShape.ts b/src/renderer/src/workspace/dispatch/gridShape.ts
index 2c61c81b3..76f4e7fdd 100644
--- a/src/renderer/src/workspace/dispatch/gridShape.ts
+++ b/src/renderer/src/workspace/dispatch/gridShape.ts
@@ -68,6 +68,19 @@ export const LANE_MIN_FRACTION = 0.08
/** Smallest share of the grid a single row may be dragged to. */
export const ROW_MIN_FRACTION = 0.12
+/**
+ * The stage a brand-new workspace starts with: one row, one lane, focused.
+ *
+ * WHY one lane and not the migration's two (#992 plan §4.5): a first run has
+ * nothing to explain yet. One focused lane means the first agent the user
+ * creates fills the screen, and New Lane adds space the moment they want it.
+ * A factory rather than a constant so no two workspaces ever share a lane
+ * array by reference.
+ */
+export function freshStage(): TiledDispatchState {
+ return { lanes: [{}], rows: [{ length: 1 }], focusedLane: 0 }
+}
+
export function clampIndexFraction(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_INDEX_FRACTION
return Math.max(INDEX_FRACTION_MIN, Math.min(INDEX_FRACTION_MAX, value))
diff --git a/src/renderer/src/workspace/dispatch/laneKeyboard.test.ts b/src/renderer/src/workspace/dispatch/laneKeyboard.test.ts
new file mode 100644
index 000000000..8b66200d9
--- /dev/null
+++ b/src/renderer/src/workspace/dispatch/laneKeyboard.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchSelectors'
+import { focusRowByLabel, moveLaneFocusWithinRow, moveLaneSelection } from '@renderer/workspace/dispatch/laneKeyboard'
+import type { Workspace } from '@renderer/workspace/hook'
+import { loadRecordedDispatchWorkspace } from '@renderer/workspace/testing/recordedDispatchWorkspace'
+import type { TiledDispatchState, WorkspaceState } from '@renderer/workspace/types'
+
+// The lane keyboard grammar (#992 stage 5) had no behavioural test, only
+// "the chord routes to the command id" (#1013 review B, finding 14). These
+// cases run the real grammar on the recorded global-Dispatch workspace: 4
+// lanes in one row, lane 1 focused, 24 index rows across 4 projects.
+// selectTiledLaneSession is the action boundary (it wakes and writes, and it
+// has its own suite), so it is observed here, not re-implemented.
+
+function recorded(stage?: Partial) {
+ const { state } = loadRecordedDispatchWorkspace()
+ const next: WorkspaceState = stage ? { ...state, stage: { ...state.stage, ...stage } } : state
+ const selectTiledLaneSession = vi.fn(async () => undefined)
+ const setTiledFocusedLane = vi.fn()
+ const workspace = { state: next, stage: next.stage, selectTiledLaneSession, setTiledFocusedLane } as unknown as Workspace
+ return { workspace, state: next, selectTiledLaneSession, setTiledFocusedLane }
+}
+
+describe('⌘N: fill the focused lane from the index by label', () => {
+ it('places the row whose label number is N into the focused lane', () => {
+ const { workspace, state, selectTiledLaneSession } = recorded()
+ const row7 = buildVisibleDispatchRows(state).find(row => row.globalIndex === 7)!
+ focusRowByLabel(workspace, 6)
+ expect(selectTiledLaneSession).toHaveBeenCalledExactlyOnceWith(state.stage.focusedLane, row7.sessionId)
+ })
+
+ it('does nothing for a number past the last row', () => {
+ const { workspace, selectTiledLaneSession } = recorded()
+ focusRowByLabel(workspace, 98)
+ expect(selectTiledLaneSession).not.toHaveBeenCalled()
+ })
+
+ it('in a row bound to one project, a label from another project places nothing', () => {
+ // Labels are canonical, never renumbered: a bound row shows gaps, and ⌘N
+ // must agree with the chips the row shows.
+ const base = recorded()
+ const rows = buildVisibleDispatchRows(base.state)
+ const [first] = rows
+ const foreign = rows.find(row => row.tabId !== first!.tabId)!
+ const { workspace, selectTiledLaneSession } = recorded({ rows: [{ length: base.state.stage.lanes.length, projectTabIds: [first!.tabId] }] })
+ focusRowByLabel(workspace, foreign.globalIndex - 1)
+ expect(selectTiledLaneSession).not.toHaveBeenCalled()
+ })
+})
+
+describe('⌥↑/↓: walk the focused lane through its row\'s index', () => {
+ it('steps to the next row and wraps from the last to the first', () => {
+ const base = recorded()
+ const rows = buildVisibleDispatchRows(base.state)
+ const lanes = base.state.stage.lanes.map((lane, index) => (index === base.state.stage.focusedLane ? { ...lane, selectedSessionId: rows.at(-1)!.sessionId } : lane))
+ const { workspace, selectTiledLaneSession } = recorded({ lanes })
+ moveLaneSelection(workspace, 1)
+ expect(selectTiledLaneSession).toHaveBeenCalledExactlyOnceWith(base.state.stage.focusedLane, rows[0]!.sessionId)
+ })
+
+ it('steps back from the first row to the last', () => {
+ const base = recorded()
+ const rows = buildVisibleDispatchRows(base.state)
+ const lanes = base.state.stage.lanes.map((lane, index) => (index === base.state.stage.focusedLane ? { ...lane, selectedSessionId: rows[0]!.sessionId } : lane))
+ const { workspace, selectTiledLaneSession } = recorded({ lanes })
+ moveLaneSelection(workspace, -1)
+ expect(selectTiledLaneSession).toHaveBeenCalledExactlyOnceWith(base.state.stage.focusedLane, rows.at(-1)!.sessionId)
+ })
+})
+
+describe('⌥←/→: move lane focus within the row, stopping at its edges', () => {
+ it('moves one lane at a time', () => {
+ const { workspace, state, setTiledFocusedLane } = recorded()
+ moveLaneFocusWithinRow(workspace, 1)
+ expect(setTiledFocusedLane).toHaveBeenCalledExactlyOnceWith(state.stage.focusedLane + 1)
+ })
+
+ it('stops at the first and the last lane instead of wrapping', () => {
+ const first = recorded({ focusedLane: 0 })
+ moveLaneFocusWithinRow(first.workspace, -1)
+ expect(first.setTiledFocusedLane).not.toHaveBeenCalled()
+ const lastIndex = first.state.stage.lanes.length - 1
+ const last = recorded({ focusedLane: lastIndex })
+ moveLaneFocusWithinRow(last.workspace, 1)
+ expect(last.setTiledFocusedLane).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/workspace/dispatch/laneKeyboard.ts b/src/renderer/src/workspace/dispatch/laneKeyboard.ts
new file mode 100644
index 000000000..890fe796d
--- /dev/null
+++ b/src/renderer/src/workspace/dispatch/laneKeyboard.ts
@@ -0,0 +1,91 @@
+import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchSelectors'
+import { nextTiledRowIndex } from '@renderer/workspace/dispatch/tiledDispatchSelectors'
+import { rowScopedRows } from '@renderer/workspace/dispatch/rowScopedRows'
+import {
+ normalizeGridShape,
+ rowIndexForLane,
+ rowStartIndex,
+} from '@renderer/workspace/dispatch/gridShape'
+import type { Workspace } from '@renderer/workspace/hook'
+
+// The stage's keyboard grammar (#992 stage 5). ⌥↑/↓ walk the focused lane's
+// selection through its row's index; ⌥←/→ move lane focus within the row.
+// These lived as an unregistered inline branch in useKeybinds from #687 until
+// the unified layout re-homed keyboard — the deferred debt #681 §7.1 filed.
+// They are commands now: rebindable, visible in the shortcuts surface, and
+// participating in collision checking like every other owner of a chord.
+//
+// The module takes `Workspace` (the hook surface) rather than bare state
+// because walking selection WRITES through `selectTiledLaneSession`, never the
+// raw lane writer: a hibernated agent must wake before it is placed (#690),
+// and that action owns the wake. `moveLaneFocusWithinRow` only moves the
+// cursor and needs no action, but it stays here so the whole grammar has one
+// home.
+
+/** The lane keyboard grammar acts on the FOCUSED lane, always. */
+export function focusedLaneIndex(workspace: Workspace): number {
+ return workspace.stage.focusedLane
+}
+
+/**
+ * The rows the focused lane's ROW actually offers.
+ *
+ * Keyboard selection must see the same list the user does. The row's index
+ * and strips are filtered by `rowScopedRows` (project binding + child cap),
+ * so walking the unfiltered canonical set would let ⌥↓ drop a project-A
+ * agent into a row bound to project B — one the row's own selector does not
+ * list. Labels are NOT renumbered: these are the canonical rows, filtered. A
+ * bound row shows gaps, which is what keeps ⌘N and the visible chip in
+ * agreement.
+ */
+export function focusedLaneRowScopedRows(workspace: Workspace) {
+ const all = buildVisibleDispatchRows(workspace.state)
+ const grid = normalizeGridShape(workspace.stage)
+ const rowIndex = rowIndexForLane(grid.rows, grid.focusedLane)
+ const gridRow = rowIndex >= 0 ? grid.rows[rowIndex] : undefined
+ if (!gridRow) return all
+ return rowScopedRows(all, gridRow).flatMap(item => (item.kind === 'agent' ? [item.row] : []))
+}
+
+/** ⌘N addresses a LABEL, and labels are canonical (never renumbered). */
+export function focusRowByLabel(workspace: Workspace, index: number) {
+ const row = focusedLaneRowScopedRows(workspace).find(candidate => candidate.globalIndex === index + 1)
+ if (!row) return
+ // Wakes a hibernated agent before placing it (#690).
+ void workspace.selectTiledLaneSession(focusedLaneIndex(workspace), row.sessionId)
+}
+
+/**
+ * Walk the FOCUSED lane's selection one step through its row's index,
+ * wrapping. Duplicates are allowed: we do NOT skip rows shown in other lanes —
+ * landing on one just mirrors that agent into this lane too.
+ */
+export function moveLaneSelection(workspace: Workspace, delta: number) {
+ const rows = focusedLaneRowScopedRows(workspace)
+ if (rows.length === 0) return
+ const laneIndex = workspace.stage.focusedLane
+ const currentId = workspace.stage.lanes[laneIndex]?.selectedSessionId
+ const currentIndex = currentId ? rows.findIndex(row => row.sessionId === currentId) : -1
+ const probe = nextTiledRowIndex(currentIndex, delta, rows.length)
+ const row = rows[probe]
+ if (row) void workspace.selectTiledLaneSession(laneIndex, row.sessionId)
+}
+
+/**
+ * Move lane focus one step, STOPPING at the row's edges.
+ *
+ * Wrapping into the neighbouring row would make one keystroke move focus a
+ * single lane or jump it across the layout depending on where you started —
+ * fine when you are looking, wrong when you are typing fast. Crossing rows is
+ * the deliberate job of Focus Row Above/Below.
+ */
+export function moveLaneFocusWithinRow(workspace: Workspace, delta: number) {
+ const grid = normalizeGridShape(workspace.stage)
+ const rowIndex = rowIndexForLane(grid.rows, grid.focusedLane)
+ if (rowIndex < 0) return
+ const start = rowStartIndex(grid.rows, rowIndex)
+ const end = start + (grid.rows[rowIndex]?.length ?? 0) - 1
+ const next = grid.focusedLane + delta
+ if (next < start || next > end) return
+ workspace.setTiledFocusedLane(next)
+}
diff --git a/src/renderer/src/workspace/dispatch/laneSelectionWake.renderer.test.tsx b/src/renderer/src/workspace/dispatch/laneSelectionWake.renderer.test.tsx
index 818d41dec..27e8a9e75 100644
--- a/src/renderer/src/workspace/dispatch/laneSelectionWake.renderer.test.tsx
+++ b/src/renderer/src/workspace/dispatch/laneSelectionWake.renderer.test.tsx
@@ -7,17 +7,27 @@ import {
insertRowBelowInGrid,
removeRowFromGrid,
} from '@renderer/workspace/dispatch/gridShape'
+import { emptyRuntime } from '@renderer/session-runtime/state'
+import type { SessionRuntime } from '@renderer/session-runtime/state'
import type { SessionId, WorkspaceState } from '@renderer/workspace/types'
import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
// #690: a hibernated agent must be woken BEFORE it is placed in a lane.
//
-// Rehydrate deliberately does not respawn detached sessions — they survive a
-// restart as metadata with no provider process. Placing one straight into a
-// lane renders a pane that looks fine and then rejects the first prompt with
-// "Cannot deliver prompt: is not a live agent session" (main logs
-// `reason: never-owned`). Agent-index navigation already woke; the four
-// in-layout selection gestures did not.
+// Rehydrate deliberately spawns only the focused lane's occupant — every other
+// session survives a restart as metadata with no provider process. Placing one
+// straight into a lane renders a pane that looks fine and then rejects the
+// first prompt with "Cannot deliver prompt: is not a live agent session"
+// (main logs `reason: never-owned`). Agent-index navigation already woke; the
+// four in-layout selection gestures did not.
+//
+// WHAT DECIDES "hibernated" changed with #992, and the fixture with it. It was
+// STRUCTURAL: a session with a `detachedSessions` record had not been respawned,
+// a tile leaf had. Both containers are gone, so the gesture asks the RUNTIME:
+// `processStatus === 'started'` is the only thing that skips the wake. The
+// harness therefore feeds `latestRuntimesRef`, and a fixture that forgot to
+// would make every session look hibernated — which is the safe direction to be
+// wrong in, and is pinned as its own case below.
//
// These assert the ORDER, not just that a wake happened: writing the lane first
// exposes a dead pane the user can type into during the gap, which is the exact
@@ -31,36 +41,36 @@ function harness(options: {
duringWake?: (ref: { current: WorkspaceState }) => void
rows?: { length: number }[]
lanes?: number
+ /** Override the runtime map, e.g. to model a session whose backend died. */
+ runtimes?: Record
} = {}) {
const order: string[] = []
const state = {
activeTabId: 'tab-a',
- dispatchMode: {
- scope: 'global' as const,
- tiled: {
- // Default [1, 2] — a first row of one lane, then the row races target.
- lanes: [
- { selectedSessionId: LIVE },
- ...Array.from({ length: (options.lanes ?? 3) - 1 }, () => ({})),
- ],
- rows: options.rows ?? [{ length: 1 }, { length: 2 }],
- focusedLane: 0,
- },
+ stage: {
+ // Default [1, 2] — a first row of one lane, then the row races target.
+ lanes: [
+ { selectedSessionId: LIVE },
+ ...Array.from({ length: (options.lanes ?? 3) - 1 }, () => ({})),
+ ],
+ rows: options.rows ?? [{ length: 1 }, { length: 2 }],
+ focusedLane: 0,
},
+ tabs: [{ id: 'tab-a', title: 'A' }, { id: 'tab-b', title: 'B' }],
+ pinnedSessionIds: [],
sessions: {
- [LIVE]: { cwd: '/work/a', kind: 'claude' as const },
- [HIBERNATED]: { cwd: '/work/b', kind: 'claude' as const },
+ [LIVE]: { cwd: '/work/a', kind: 'claude' as const, projectId: 'tab-a', joinedAt: 0 },
+ [HIBERNATED]: { cwd: '/work/b', kind: 'claude' as const, projectId: 'tab-b', joinedAt: 0 },
+ },
+ } satisfies WorkspaceState
+ const stateRef = { current: state as WorkspaceState }
+ // LIVE has a running backend; HIBERNATED is exactly what rehydrate seeds for
+ // a session it did not spawn (processStatus 'idle').
+ const latestRuntimesRef = {
+ current: options.runtimes ?? {
+ [LIVE]: { ...emptyRuntime(), processStatus: 'started' as const },
+ [HIBERNATED]: emptyRuntime(),
},
- }
- // Only HIBERNATED is detached; LIVE is grid-placed and owned by a tile tree,
- // so it was respawned at rehydrate and needs no wake.
- const stateRef = {
- current: {
- ...state,
- detachedSessions: {
- [HIBERNATED]: { sessionId: HIBERNATED, surface: 'dispatch', projectTabId: 'tab-b' },
- },
- } as unknown as WorkspaceState,
}
const written: number[] = []
@@ -69,9 +79,9 @@ function harness(options: {
// Run the reducer against current state so the lane index it targets is
// observable — the whole point of the reshape case below.
if (typeof updater === 'function') {
- const before = JSON.stringify(stateRef.current.dispatchMode?.tiled?.lanes)
+ const before = JSON.stringify(stateRef.current.stage.lanes)
const next = (updater as (p: WorkspaceState) => WorkspaceState)(stateRef.current)
- const after = next.dispatchMode?.tiled?.lanes ?? []
+ const after = next.stage.lanes ?? []
if (JSON.stringify(after) !== before) {
written.push(after.findIndex(lane => lane.selectedSessionId === HIBERNATED))
}
@@ -90,13 +100,15 @@ function harness(options: {
})
const showToast = vi.fn()
+ // The runtime-setter stub records badge clears like the real store setter
+ // would apply them; selectTiledLaneSession's synchronous branch writes the
+ // lane through it, and the pooled-spawn badge clear rides the same call.
+ const setRuntimes = vi.fn(updater => { updater({}) })
const hook = renderHook(() =>
useDispatchActions(
- state,
setState as never,
- vi.fn(),
- vi.fn(),
- { stateRef } as unknown as WorkspaceRefs,
+ setRuntimes as never,
+ { stateRef, latestRuntimesRef } as unknown as WorkspaceRefs,
ensureSessionLive as never,
showToast,
),
@@ -136,10 +148,10 @@ describe('selecting an agent into a lane', () => {
expect(showToast).toHaveBeenCalled()
})
- it('does not wake a grid-placed session', async () => {
- // Owned by a tile tree, so rehydrate already respawned it. Paying a wake
- // round-trip on every ordinary selection would make the common gesture
- // async for nothing.
+ it('does not wake a session whose backend is already running', async () => {
+ // Paying a wake round-trip on every ordinary selection would make the
+ // common gesture async for nothing — and until #992 it DID, for every lane
+ // agent, because the structural test called all of them hibernated.
const { hook, order, ensureSessionLive } = harness()
await act(async () => {
@@ -149,6 +161,39 @@ describe('selecting an agent into a lane', () => {
expect(ensureSessionLive).not.toHaveBeenCalled()
expect(order).toEqual(['write-lane'])
})
+
+ it.each(['failed', 'exited'] as const)('wakes a session whose backend is %s, not only one that never started', async status => {
+ // The gap the structural test had: a tile leaf whose respawn failed at
+ // rehydrate, or whose process died since, "was not detached" and so was
+ // written into a lane un-woken. The wake path is also the retry path, so
+ // selecting a dead agent is now how the user brings it back.
+ const { hook, order, ensureSessionLive } = harness({
+ runtimes: {
+ [LIVE]: { ...emptyRuntime(), processStatus: status },
+ [HIBERNATED]: emptyRuntime(),
+ },
+ })
+
+ await act(async () => {
+ await hook.result.current.selectTiledLaneSession(1, LIVE)
+ })
+
+ expect(ensureSessionLive).toHaveBeenCalledWith(LIVE, 'dispatch-lane.select')
+ expect(order[0]).toBe('wake')
+ })
+
+ it('wakes a session with NO runtime entry rather than assuming it is live', async () => {
+ // Fail-safe direction: an unknown runtime costs one idempotent recover
+ // round-trip; an assumed-live one costs a prompt rejected by main.
+ const { hook, order, ensureSessionLive } = harness({ runtimes: {} })
+
+ await act(async () => {
+ await hook.result.current.selectTiledLaneSession(1, LIVE)
+ })
+
+ expect(ensureSessionLive).toHaveBeenCalledWith(LIVE, 'dispatch-lane.select')
+ expect(order).toEqual(['wake', 'write-lane'])
+ })
})
/**
@@ -162,16 +207,16 @@ describe('selecting an agent into a lane', () => {
* reason that never happens in the product.
*/
function reshapeWith(
- mutate: (tiled: NonNullable['tiled']>) =>
+ mutate: (tiled: WorkspaceState['stage']) =>
ReturnType,
) {
return (ref: { current: WorkspaceState }) => {
- const tiled = ref.current.dispatchMode!.tiled!
+ const tiled = ref.current.stage
const next = mutate(tiled)
if (!next) throw new Error('reshape refused; the fixture is wrong')
ref.current = {
...ref.current,
- dispatchMode: { ...ref.current.dispatchMode!, tiled: next },
+ stage: next,
} as WorkspaceState
}
}
diff --git a/src/renderer/src/workspace/dispatch/orchestrationDescendants.test.ts b/src/renderer/src/workspace/dispatch/orchestrationDescendants.test.ts
new file mode 100644
index 000000000..9d8df468e
--- /dev/null
+++ b/src/renderer/src/workspace/dispatch/orchestrationDescendants.test.ts
@@ -0,0 +1,72 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { expect, it } from 'vitest'
+
+import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchSelectors'
+import { resolveStrictDispatchCommandTarget } from '@renderer/workspace/dispatch/dispatchTarget'
+import type { PersistedWorkspace } from '@renderer/workspace/persistence'
+import type { WorkspaceState } from '@renderer/workspace/types'
+import { liveWorkspaceFromPersisted } from '@renderer/workspace/workspaceShape'
+
+// #1013 review B, MAJOR: an orchestration GRANDCHILD must get an index row.
+//
+// The base is the owner's real workspace (sanitized, testing/fixtures/
+// workspace-v2/README.md), put through the same v2→live conversion rehydrate uses. It has one
+// orchestration root with three direct children. The grandchild link is added
+// by this test: none of the 36 workspace snapshots on the owner's machine had
+// one, up to 29 children and all direct. The link is still reachable in the
+// product, because a child that has the orchestration domain can create
+// agents, and `orchestrationParentId` names the direct parent.
+const ROOT = '46179162-02ed-491d-97dd-6e291392b280'
+const CHILD = 'cddf53b0-0a1f-4677-bf24-0b90a17fb813'
+
+function recordedState(): WorkspaceState {
+ const file = JSON.parse(readFileSync(resolve(__dirname, '../../../../../testing/fixtures/workspace-v2/2026-09-19-live-workspace.sanitized.json'), 'utf8')) as { windows: { workspace: PersistedWorkspace }[] }
+ return liveWorkspaceFromPersisted(file.windows[0]!.workspace)
+}
+
+function withGrandchild(state: WorkspaceState): WorkspaceState {
+ // The grandchild is its parent's own record re-parented, so every other
+ // field is one the app really persisted for an orchestration child.
+ const child = state.sessions[CHILD]!
+ return {
+ ...state,
+ sessions: { ...state.sessions, grandchild: { ...child, orchestrationParentId: CHILD, joinedAt: (child.joinedAt ?? 0) + 1 } },
+ }
+}
+
+it('lists a grandchild directly under its own parent, with a label', () => {
+ const rows = buildVisibleDispatchRows(withGrandchild(recordedState()))
+ const ids = rows.map(row => row.sessionId)
+ expect(ids).toContain('grandchild')
+ expect(ids.indexOf('grandchild')).toBe(ids.indexOf(CHILD) + 1)
+ const row = rows.find(item => item.sessionId === 'grandchild')!
+ expect(row.depth).toBe(1)
+ expect(row.label).toMatch(/^[A-Z]\d+$/)
+ // Every row once: the recorded pool plus the grandchild, nothing lost or doubled.
+ expect(new Set(ids).size).toBe(ids.length)
+})
+
+it('a grandchild in a lane is a command target instead of "no longer available"', () => {
+ const state = withGrandchild(recordedState())
+ const lanes = state.stage.lanes.map((lane, index) => (index === state.stage.focusedLane ? { ...lane, selectedSessionId: 'grandchild' } : lane))
+ expect(resolveStrictDispatchCommandTarget({ ...state, stage: { ...state.stage, lanes } })?.row.sessionId).toBe('grandchild')
+})
+
+it('a parent cycle lists both rows instead of dropping them', () => {
+ const state = recordedState()
+ const a = state.sessions[CHILD]!
+ const cyclic: WorkspaceState = {
+ ...state,
+ sessions: {
+ ...state.sessions,
+ [CHILD]: { ...a, orchestrationParentId: 'loop-b' },
+ 'loop-b': { ...a, orchestrationParentId: CHILD },
+ },
+ }
+ const ids = buildVisibleDispatchRows(cyclic).map(row => row.sessionId)
+ expect(ids).toContain(CHILD)
+ expect(ids).toContain('loop-b')
+ // The recorded root and its two other children are untouched.
+ expect(ids).toContain(ROOT)
+})
diff --git a/src/renderer/src/workspace/dispatch/rowScopedRows.test.ts b/src/renderer/src/workspace/dispatch/rowScopedRows.test.ts
index 25661496e..e4dc8b14d 100644
--- a/src/renderer/src/workspace/dispatch/rowScopedRows.test.ts
+++ b/src/renderer/src/workspace/dispatch/rowScopedRows.test.ts
@@ -1,4 +1,3 @@
-import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import {
@@ -11,6 +10,7 @@ import {
} from '@renderer/workspace/dispatch/rowScopedRows'
import type { DispatchAgentRow } from '@renderer/workspace/dispatch/dispatchSelectors'
import type { SessionId, WorkspaceState } from '@renderer/workspace/types'
+import { loadRecordedDispatchWorkspace } from '@renderer/workspace/testing/recordedDispatchWorkspace'
// What ONE grid row shows: the canonical dispatch rows, filtered to that row's
// project and collapsed at that row's child density.
@@ -19,14 +19,15 @@ import type { SessionId, WorkspaceState } from '@renderer/workspace/types'
// wrong here are properties of real data, not of a hand-built list: the
// canonical labels (which must survive filtering with GAPS, never renumber) and
// the parent/child nesting produced by orchestration.
-const FIXTURE = JSON.parse(
- readFileSync('testing/fixtures/worktree-context/dispatch-global-d23.json', 'utf8'),
-) as { state: WorkspaceState }
+// Loaded through the shared lift: the file on disk is a v2 workspace whose lane
+// grid sits at `dispatchMode.tiled`; the loader moves it to `state.stage`
+// verbatim (see recordedDispatchWorkspace.ts for why it is not re-recorded).
+const FIXTURE = loadRecordedDispatchWorkspace()
-const GLOBAL_ROWS = buildVisibleDispatchRows({
- ...FIXTURE.state,
- dispatchMode: { ...FIXTURE.state.dispatchMode!, scope: 'global' },
-})
+// Named GLOBAL_ROWS from when a layout-wide scope existed and this had to force
+// it to 'global' to see every project. Every index lists every project now
+// (#992); the name still says what the list is.
+const GLOBAL_ROWS = buildVisibleDispatchRows(FIXTURE.state)
const labels = (items: ReturnType): string[] =>
items.map(item => (item.kind === 'agent' ? item.row.label : `[${item.kind}:${item.hidden ?? 0}]`))
@@ -218,16 +219,10 @@ describe('spawning into a bound row', () => {
const boundTab = GLOBAL_ROWS.find(row => row.tabId !== FIXTURE.state.activeTabId)!.tabId
const state: WorkspaceState = {
...FIXTURE.state,
- dispatchMode: {
- ...FIXTURE.state.dispatchMode!,
- scope: 'global',
- // Empty focused lane, so there is no lane session to read the project
- // from — exactly the case that used to fall through to activeTabId.
- tiled: {
- lanes: [{}],
- rows: [{ length: 1, projectTabIds: [boundTab] }],
- focusedLane: 0,
- },
+ stage: {
+ lanes: [{}],
+ rows: [{ length: 1, projectTabIds: [boundTab] }],
+ focusedLane: 0,
},
}
@@ -238,12 +233,7 @@ describe('spawning into a bound row', () => {
it('still falls back to the active tab for an unbound row', () => {
const state: WorkspaceState = {
...FIXTURE.state,
- dispatchMode: {
- ...FIXTURE.state.dispatchMode!,
- scope: 'global',
- focusedSessionId: undefined,
- tiled: { lanes: [{}], rows: [{ length: 1 }], focusedLane: 0 },
- },
+ stage: { lanes: [{}], rows: [{ length: 1 }], focusedLane: 0 },
}
expect(resolveDispatchSpawnTarget(state).tabId).toBe(FIXTURE.state.activeTabId)
@@ -257,14 +247,10 @@ describe('spawning into a multi-project row', () => {
// same gesture file agents in different projects on different days.
const rowWithProjects = (ids: string[]): WorkspaceState => ({
...FIXTURE.state,
- dispatchMode: {
- ...FIXTURE.state.dispatchMode!,
- scope: 'global',
- tiled: {
- lanes: [{}],
- rows: [{ length: 1, projectTabIds: ids }],
- focusedLane: 0,
- },
+ stage: {
+ lanes: [{}],
+ rows: [{ length: 1, projectTabIds: ids }],
+ focusedLane: 0,
},
})
diff --git a/src/renderer/src/workspace/dispatch/rowScopedRows.ts b/src/renderer/src/workspace/dispatch/rowScopedRows.ts
index bc18cea59..0bb4dfdc5 100644
--- a/src/renderer/src/workspace/dispatch/rowScopedRows.ts
+++ b/src/renderer/src/workspace/dispatch/rowScopedRows.ts
@@ -35,7 +35,14 @@ export const ORCHESTRATION_CHILD_CAP = 3
export type RowScopedItem =
| { kind: 'agent'; row: DispatchAgentRow; hidden?: undefined }
- | { kind: 'more'; parentSessionId: SessionId; hidden: number }
+ | {
+ kind: 'more'
+ parentSessionId: SessionId
+ hidden: number
+ /** Which children the collapse hides, so the "+N more" row can carry
+ * their "new" badge (#1013 review B: a 5-worker run showed only 3). */
+ hiddenSessionIds: SessionId[]
+ }
| { kind: 'fewer'; parentSessionId: SessionId; hidden?: undefined }
/**
@@ -107,6 +114,7 @@ export function rowScopedRows(
kind: 'more',
parentSessionId: row.sessionId,
hidden: children.length - ORCHESTRATION_CHILD_CAP,
+ hiddenSessionIds: children.slice(ORCHESTRATION_CHILD_CAP).map(child => child.sessionId),
})
}
diff --git a/src/renderer/src/workspace/dispatch/tiledDispatchSelectors.ts b/src/renderer/src/workspace/dispatch/tiledDispatchSelectors.ts
index c93ef5a9c..3c8b045bb 100644
--- a/src/renderer/src/workspace/dispatch/tiledDispatchSelectors.ts
+++ b/src/renderer/src/workspace/dispatch/tiledDispatchSelectors.ts
@@ -1,12 +1,9 @@
import type {
DispatchLane,
- DispatchModeState,
SessionId,
TabId,
TiledDispatchState,
- WorkspaceState,
} from '@renderer/workspace/types'
-import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchSelectors'
import {
MAX_DISPATCH_TILES,
MIN_DISPATCH_TILES,
@@ -16,9 +13,10 @@ import {
// ============================================================================
// Tiled-lane coherence helpers
//
-// Tiled Dispatch keeps a per-lane session selection in
-// dispatchMode.tiled.lanes[].selectedSessionId, plus the focused lane in
-// dispatchMode.tiled.focusedLane. Two whole bug classes came from code that
+// The stage keeps a per-lane session selection in stage.lanes[].selectedSessionId,
+// plus the focused lane in stage.focusedLane. (These helpers took the
+// `dispatchMode` wrapper until #992 made the stage a required field; their
+// rules are unchanged, only the wrapper is gone.) Two whole bug classes came from code that
// mutated *which session a pane shows* (id remap, session removal) or
// *resolved the focused session* while only maintaining the grid tree,
// detachedSessions, and the single dispatchMode.focusedSessionId — leaving the
@@ -37,12 +35,11 @@ import {
* swapped (replaceSession, reloadAgentSessions, rehydrate, undo-close).
*/
export function remapTiledLanes(
- dispatchMode: DispatchModeState | null,
+ stage: TiledDispatchState,
idMap: ReadonlyMap,
-): DispatchModeState | null {
- if (!dispatchMode?.tiled) return dispatchMode
+): TiledDispatchState {
let changed = false
- const lanes = dispatchMode.tiled.lanes.map(lane => {
+ const lanes = stage.lanes.map(lane => {
const id = lane.selectedSessionId
if (!id) return lane
const next = idMap.get(id)
@@ -50,8 +47,8 @@ export function remapTiledLanes(
changed = true
return { ...lane, selectedSessionId: next }
})
- if (!changed) return dispatchMode
- return { ...dispatchMode, tiled: { ...dispatchMode.tiled, lanes } }
+ if (!changed) return stage
+ return { ...stage, lanes }
}
/**
@@ -82,22 +79,21 @@ function withLaneCleared(lane: DispatchLane): DispatchLane {
* Apply wherever a session is destroyed/hidden (killSession, close, bury).
*/
export function clearTiledLaneSessions(
- dispatchMode: DispatchModeState | null,
+ stage: TiledDispatchState,
removed: ReadonlySet | SessionId,
-): DispatchModeState | null {
- if (!dispatchMode?.tiled) return dispatchMode
+): TiledDispatchState {
const isRemoved = (id: SessionId): boolean =>
typeof removed === 'string' ? removed === id : removed.has(id)
let changed = false
- const lanes = dispatchMode.tiled.lanes.map(lane => {
+ const lanes = stage.lanes.map(lane => {
if (lane.selectedSessionId && isRemoved(lane.selectedSessionId)) {
changed = true
return withLaneCleared(lane)
}
return lane
})
- if (!changed) return dispatchMode
- return { ...dispatchMode, tiled: { ...dispatchMode.tiled, lanes } }
+ if (!changed) return stage
+ return { ...stage, lanes }
}
/**
@@ -111,64 +107,57 @@ export function clearTiledLaneSessions(
* durable session pointer must close over the same surviving session set.
*/
export function keepTiledLaneSessions(
- dispatchMode: DispatchModeState | null | undefined,
+ stage: TiledDispatchState,
keep: ReadonlySet,
-): DispatchModeState | null | undefined {
- if (!dispatchMode?.tiled) return dispatchMode
+): TiledDispatchState {
let changed = false
- const lanes = dispatchMode.tiled.lanes.map(lane => {
+ const lanes = stage.lanes.map(lane => {
if (lane.selectedSessionId && !keep.has(lane.selectedSessionId)) {
changed = true
return withLaneCleared(lane)
}
return lane
})
- if (!changed) return dispatchMode
- return { ...dispatchMode, tiled: { ...dispatchMode.tiled, lanes } }
+ if (!changed) return stage
+ return { ...stage, lanes }
}
/**
- * Bring a persisted `tiled` block up to the current grid shape.
+ * Bring a persisted stage up to the current grid shape.
*
- * WHY this belongs with the other dispatchMode helpers rather than inside
- * gridShape: this file's header says the lane helpers must be applied at every
- * id-remap, removal, and focus-read site, and rehydrate is one of them — the
- * normalization has to sit on the same DispatchModeState-shaped seam as
+ * WHY this belongs with the other lane helpers rather than inside gridShape:
+ * this file's header says the lane helpers must be applied at every id-remap,
+ * removal, and focus-read site, and rehydrate is one of them — the
+ * normalization has to take and return the same `TiledDispatchState` as
* remapTiledLanes and keepTiledLaneSessions so it can be composed with them in
* one expression instead of being a fourth thing a caller must remember.
+ * (All of them took the `dispatchMode` envelope until #992 made the stage a
+ * required field; this one was `normalizeDispatchModeGrid`.)
*
* The migration itself (legacy `ratios` -> per-row indexFraction + laneWeights,
* and repair of the row-length invariant) lives in gridShape, which owns every
* shape rule.
*
* Returns the SAME reference when nothing needed changing, so consumers that
- * memoize on dispatchMode identity do not churn on every restore.
+ * memoize on stage identity do not churn on every restore.
*/
-export function normalizeDispatchModeGrid(
- dispatchMode: DispatchModeState | null | undefined,
-): DispatchModeState | null | undefined {
- const tiled = dispatchMode?.tiled
- if (!dispatchMode || !tiled) return dispatchMode
-
- const grid = normalizeGridShape(tiled)
+export function normalizeStage(stage: TiledDispatchState): TiledDispatchState {
+ const grid = normalizeGridShape(stage)
const alreadyCurrent =
- tiled.ratios === undefined &&
- tiled.rows === grid.rows &&
- tiled.laneWeights === grid.laneWeights &&
- tiled.focusedLane === grid.focusedLane
- if (alreadyCurrent) return dispatchMode
+ stage.ratios === undefined &&
+ stage.rows === grid.rows &&
+ stage.laneWeights === grid.laneWeights &&
+ stage.focusedLane === grid.focusedLane
+ if (alreadyCurrent) return stage
return {
- ...dispatchMode,
- tiled: {
- lanes: grid.lanes,
- rows: grid.rows,
- focusedLane: grid.focusedLane,
- // Dropped, never rewritten: keeping the legacy array beside the fields it
- // was split into would leave two sources of truth for width, and the next
- // reader would have to guess which one the user's last drag produced.
- ...(grid.laneWeights ? { laneWeights: grid.laneWeights } : {}),
- },
+ lanes: grid.lanes,
+ rows: grid.rows,
+ focusedLane: grid.focusedLane,
+ // Dropped, never rewritten: keeping the legacy array beside the fields it
+ // was split into would leave two sources of truth for width, and the next
+ // reader would have to guess which one the user's last drag produced.
+ ...(grid.laneWeights ? { laneWeights: grid.laneWeights } : {}),
}
}
@@ -187,12 +176,12 @@ export function normalizeDispatchModeGrid(
* other helper in this family so a clean prune does not churn consumers.
*/
export function scrubGridRowMetadata(
- dispatchMode: DispatchModeState | null | undefined,
+ stage: TiledDispatchState,
liveTabIds: ReadonlySet,
liveSessionIds: ReadonlySet,
-): DispatchModeState | null | undefined {
- const tiled = dispatchMode?.tiled
- if (!dispatchMode || !tiled?.rows) return dispatchMode
+): TiledDispatchState {
+ const tiled = stage
+ if (!tiled.rows) return stage
let changed = false
const rows = tiled.rows.map(row => {
@@ -226,63 +215,30 @@ export function scrubGridRowMetadata(
}
return next
})
- if (!changed) return dispatchMode
- return { ...dispatchMode, tiled: { ...tiled, rows } }
+ if (!changed) return stage
+ return { ...tiled, rows }
}
/**
- * The session the user is currently focused on in Dispatch — the SINGLE
- * tiled-aware reader every "what am I commanding/focusing?" call site should
- * use. In Tiled Dispatch that's the focused lane's agent (falling back to the
- * classic focus when the lane is empty); in classic Dispatch it's
- * dispatchMode.focusedSessionId. Centralizing this is what stops new readers
- * from re-introducing the lane-0 divergence (#266/#267/#271/#272 were all the
- * same mistake made in different files).
- */
-export function dispatchFocusedSessionId(
- dispatchMode: DispatchModeState | null,
-): SessionId | null {
- if (!dispatchMode) return null
- if (dispatchMode.tiled) {
- const lane = dispatchMode.tiled.lanes[dispatchMode.tiled.focusedLane]
- return lane?.selectedSessionId ?? dispatchMode.focusedSessionId ?? null
- }
- return dispatchMode.focusedSessionId ?? null
-}
-
-/**
- * The session Grid Dispatch entry should keep visible in lane 0 (#977).
- *
- * WHY this exists: `enterTiledDispatch` used to build every lane empty, so the
- * agent the user was commanding in classic Dispatch (or the pane they were
- * focused on in the grid) vanished the moment the grid layout appeared. The
- * fix is continuity, and continuity has a source: this resolver reads focus
- * through `dispatchFocusedSessionId` — the same single tiled-aware reader
- * every other "what am I commanding?" site uses — so re-entering over an
- * existing grid carries the focused LANE's agent, classic Dispatch carries its
- * focused session, and the normal grid falls back to the active tab's focused
- * pane. Precedence is deliberate: a Dispatch focus is the later, more
- * deliberate signal than the grid pane the user left behind when they entered
- * Dispatch.
+ * The session the user is currently focused on — the SINGLE reader every
+ * "what am I commanding/focusing?" call site should use: the focused lane's
+ * occupant, or null when that lane is empty. Centralizing this is what stops
+ * new readers from re-introducing the lane-0 divergence (#266/#267/#271/#272
+ * were all the same mistake made in different files).
*
- * This is NOT #681's banned auto-fill. #681 removed guessing occupants from
- * the unclaimed-agent index; seeding the ONE session the user already has in
- * focus predicts nothing. Every other lane still arrives empty.
- *
- * Guards mirror the control plane's `lane-select` admission: a buried or
- * unrecorded id returns null rather than a phantom occupant, because a lane
- * that renders empty-but-set is reversible (#681's no-healer rule) while a
- * lane pointing at nothing the user can see is just a lie.
+ * Until #992 this fell back to a classic single-selection focus when the lane
+ * was empty. That second focus truth is gone: an empty focused lane means no
+ * session is focused, which is exactly what the screen shows.
*/
-export function dispatchEntrySeedSessionId(state: WorkspaceState): SessionId | null {
- const gridPane = state.tabs.find(tab => tab.id === state.activeTabId)?.focusedSessionId
- const candidate = dispatchFocusedSessionId(state.dispatchMode) ?? gridPane ?? null
- if (!candidate) return null
- if (!state.sessions[candidate]) return null
- if (state.buried.some(item => item.sessionId === candidate)) return null
- return candidate
+export function dispatchFocusedSessionId(stage: TiledDispatchState): SessionId | null {
+ return stage.lanes[stage.focusedLane]?.selectedSessionId ?? null
}
+// `dispatchEntrySeedSessionId` lived here until #992. It chose the one session
+// to keep visible in lane 0 when a user ENTERED the lane grid (#977). Nothing
+// is entered any more: the only surviving seed is the v2→v3 migration's
+// (legacyWorkspaceV2.ts legacyEntrySeed), which runs once per old file.
+
/**
* Step one row in `delta` direction, wrapping.
*
diff --git a/src/renderer/src/workspace/dispatch/tiledLaneResolution.renderer.test.tsx b/src/renderer/src/workspace/dispatch/tiledLaneResolution.renderer.test.tsx
index ef02c74a5..a74881b13 100644
--- a/src/renderer/src/workspace/dispatch/tiledLaneResolution.renderer.test.tsx
+++ b/src/renderer/src/workspace/dispatch/tiledLaneResolution.renderer.test.tsx
@@ -1,5 +1,4 @@
import { cleanup, render } from '@testing-library/react'
-import { readFileSync } from 'node:fs'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DispatchLayout } from '@renderer/workspace/dispatch/DispatchLayout'
@@ -7,6 +6,7 @@ import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchS
import { clearTiledLaneSessions } from '@renderer/workspace/dispatch/tiledDispatchSelectors'
import type { SessionId, TiledDispatchState, WorkspaceState } from '@renderer/workspace/types'
import type { Workspace } from '@renderer/workspace/workspaceStore'
+import { loadRecordedDispatchWorkspace } from '@renderer/workspace/testing/recordedDispatchWorkspace'
// What a lane does when it CANNOT resolve.
//
@@ -61,14 +61,15 @@ vi.mock('@providers/registry.renderer', () => ({
// recording rather than a hand-built state matters most for the kill case
// below — the lane ids, the detached/grid split, and the scope filtering are
// all shapes the product actually produced, not ones this test imagined.
-const FIXTURE = JSON.parse(
- readFileSync('testing/fixtures/worktree-context/dispatch-global-d23.json', 'utf8'),
-) as { state: WorkspaceState }
+// Loaded through the shared lift: the file on disk is a v2 workspace whose lane
+// grid sits at `dispatchMode.tiled`; the loader moves it to `state.stage`
+// verbatim (see recordedDispatchWorkspace.ts for why it is not re-recorded).
+const FIXTURE = loadRecordedDispatchWorkspace()
function recordedState(tiled: TiledDispatchState): WorkspaceState {
return {
...FIXTURE.state,
- dispatchMode: { ...FIXTURE.state.dispatchMode!, tiled },
+ stage: tiled,
}
}
@@ -105,7 +106,7 @@ function renderLanes(tiled: TiledDispatchState, state = recordedState(tiled)) {
}
// The lanes this workspace was actually saved with.
-const RECORDED_LANES = FIXTURE.state.dispatchMode!.tiled!.lanes.map(
+const RECORDED_LANES = FIXTURE.state.stage.lanes.map(
lane => lane.selectedSessionId,
) as SessionId[]
@@ -118,7 +119,7 @@ describe('unresolved tiled lanes', () => {
it('paints exactly the agents the recorded workspace selected', () => {
// The baseline the cases below are diffed against. If this drifts, every
// assertion after it is measuring the wrong thing.
- const { painted } = renderLanes(FIXTURE.state.dispatchMode!.tiled!)
+ const { painted } = renderLanes(FIXTURE.state.stage)
expect(RECORDED_LANES.length).toBeGreaterThanOrEqual(4)
expect(painted()).toEqual(RECORDED_LANES)
@@ -133,16 +134,13 @@ describe('unresolved tiled lanes', () => {
// hand-edited lane array, so this exercises the same path close/kill/bury
// actually take.
const killed = RECORDED_LANES[1]!
- const afterKill = clearTiledLaneSessions(
- FIXTURE.state.dispatchMode!,
- killed,
- )
+ const afterKill = clearTiledLaneSessions(FIXTURE.state.stage, killed)
const survivors = { ...FIXTURE.state.sessions }
delete survivors[killed]
const { painted, selectTiledLaneSession } = renderLanes(
- afterKill!.tiled!,
- { ...recordedState(afterKill!.tiled!), sessions: survivors },
+ afterKill,
+ { ...recordedState(afterKill), sessions: survivors },
)
// Nothing was handed to the empty lane...
@@ -167,35 +165,31 @@ describe('unresolved tiled lanes', () => {
expect(painted()).toEqual([RECORDED_LANES[0]])
})
- it('renders an out-of-scope lane empty while keeping its selection', () => {
- // Project scope builds rows from activeTabId alone, so a lane holding
- // another project's agent cannot resolve. It must render empty WITHOUT the
- // selection being destroyed: flipping scope back has to bring the agent
- // back. The old healer replaced the selection irreversibly, which is why
- // this is asserted on the state as well as the paint.
+ it('paints another project s agent in a lane of the active project', () => {
+ // This case used to assert the OPPOSITE. Under project scope the index was
+ // built from activeTabId alone, so a lane holding another project's agent
+ // could not resolve and rendered empty (selection kept, so flipping scope
+ // back restored it). There is no scope now (#992): every index lists every
+ // project, so the lane resolves and paints. Pinned because "lanes are
+ // space, projects are labels" (U4) is exactly this — which project is
+ // active must never decide what a lane may show.
+ //
// Picked through the real row builder rather than by guessing at the
// fixture's internals: all four RECORDED_LANES happen to live in the active
- // tab, so a foreign lane has to be sourced from the global row stream.
- const foreign = buildVisibleDispatchRows({
- ...FIXTURE.state,
- dispatchMode: { ...FIXTURE.state.dispatchMode!, scope: 'global' },
- }).find(row => row.tabId !== FIXTURE.state.activeTabId)?.sessionId
+ // tab, so a foreign lane has to be sourced from the full row stream.
+ const foreign = buildVisibleDispatchRows(FIXTURE.state)
+ .find(row => row.tabId !== FIXTURE.state.activeTabId)?.sessionId
expect(foreign).toBeDefined()
const tiled: TiledDispatchState = {
lanes: [{ selectedSessionId: foreign! }],
focusedLane: 0,
}
- const projectScoped: WorkspaceState = {
- ...FIXTURE.state,
- dispatchMode: { ...FIXTURE.state.dispatchMode!, scope: 'project', tiled },
- }
- const { painted, selectTiledLaneSession, getAllByTestId } = renderLanes(tiled, projectScoped)
+ const { painted, selectTiledLaneSession } = renderLanes(tiled, recordedState(tiled))
- expect(painted()).toEqual([])
- expect(getAllByTestId('lane-empty')).toHaveLength(1)
- // The selection survives — nothing rewrote the lane.
+ expect(painted()).toEqual([foreign])
+ // Resolution is a read: nothing rewrote the lane to make it paint.
expect(selectTiledLaneSession).not.toHaveBeenCalled()
expect(tiled.lanes[0]?.selectedSessionId).toBe(foreign)
})
diff --git a/src/renderer/src/workspace/extensionPaneOwnership.test.ts b/src/renderer/src/workspace/extensionPaneOwnership.test.ts
deleted file mode 100644
index 265a35ee2..000000000
--- a/src/renderer/src/workspace/extensionPaneOwnership.test.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import { describe, expect, it } from 'vitest'
-
-import {
- collectLiveProcessIds,
- collectOwnedSessionIds,
- collectTileLeafIds,
-} from '@renderer/workspace/sessionOwnership'
-import type { SessionId, SessionMeta, Tab } from '@renderer/workspace/types'
-
-// The two ownership sets an `extension-view` pane has to sit BETWEEN.
-//
-// It is a real tile leaf, so its metadata must survive every autosave — but it has
-// no process, so rehydrate must never try to spawn or recover one for it. Those are
-// different questions with different answers, and the module they live in was
-// deliberately split so that "excluded from spawning" cannot silently mean
-// "excluded from persistence".
-//
-// That is not a hypothetical pairing. Built on the pre-split shape, the extension
-// skip removed these panes from the OWNED set too, so pickOwnedSessions deleted
-// their metadata on the very next autosave — turning them into the orphan leaves
-// the same module then repairs by collapsing them out of the user's tree.
-
-const AGENT = 'agent-1' as SessionId
-const EXTENSION = 'ext-1' as SessionId
-const TERMINAL = 'term-1' as SessionId
-
-function meta(overrides: Partial): SessionMeta {
- return { cwd: '/repo', ...overrides } as SessionMeta
-}
-
-function workspaceWithAllThreeKinds(): { tabs: Tab[]; sessions: Record } {
- return {
- tabs: [
- {
- id: 'tab-1',
- title: 'Project',
- focusedSessionId: AGENT,
- root: {
- type: 'split',
- direction: 'horizontal',
- ratio: 0.5,
- a: { type: 'leaf', sessionId: AGENT },
- b: {
- type: 'split',
- direction: 'vertical',
- ratio: 0.5,
- a: { type: 'leaf', sessionId: EXTENSION },
- b: { type: 'leaf', sessionId: TERMINAL },
- },
- },
- } as Tab,
- ],
- sessions: {
- [AGENT]: meta({ kind: 'claude' }),
- [EXTENSION]: meta({ kind: 'extension-view', extensionViewId: 'timer.main' }),
- [TERMINAL]: meta({ kind: 'terminal', tmuxName: 'ac-term-1' }),
- },
- }
-}
-
-describe('extension-view pane ownership', () => {
- it('is OWNED, so autosave keeps its metadata', () => {
- // If this ever fails, an extension pane's SessionMeta is deleted on the next
- // autosave tick and the pane silently disappears on the following launch.
- const owned = collectOwnedSessionIds(workspaceWithAllThreeKinds())
- expect(owned.has(EXTENSION)).toBe(true)
- expect(owned.has(AGENT)).toBe(true)
- expect(owned.has(TERMINAL)).toBe(true)
- })
-
- it('is a tile leaf, like every other pane', () => {
- expect(collectTileLeafIds(workspaceWithAllThreeKinds()).has(EXTENSION)).toBe(true)
- })
-
- it('is NOT in the live-process set, so rehydrate never spawns for it', () => {
- // Rehydrate spawns or recovers a process for every id in this set. An
- // extension-view kind reaching SessionManager falls through the provider switch
- // into the terminal branch and starts a stray shell.
- const live = collectLiveProcessIds(workspaceWithAllThreeKinds())
- expect(live.has(EXTENSION)).toBe(false)
- // …while its neighbours are, so the exclusion is not just "the set is empty".
- expect(live.has(AGENT)).toBe(true)
- expect(live.has(TERMINAL)).toBe(true)
- })
-
- it('keeps the two sets genuinely different for this kind', () => {
- // The property that matters, stated directly: owned ⊃ live, and the extension
- // pane is exactly what sits in the gap. Collapsing the two sets back into one
- // would pass every other assertion in this file.
- const input = workspaceWithAllThreeKinds()
- const owned = collectOwnedSessionIds(input)
- const live = collectLiveProcessIds(input)
- const gap = [...owned].filter(id => !live.has(id))
- expect(gap).toEqual([EXTENSION])
- })
-
- it('does not strand a leaf whose metadata is missing entirely', () => {
- // The failure mode undo-close produced: a leaf in the tree with no row in
- // `sessions`. It must not be reported as live (there is nothing to spawn — no
- // cwd, no kind) or the restore-completion gate becomes unsatisfiable forever,
- // which locks autosave and freezes the workspace file.
- const input = workspaceWithAllThreeKinds()
- delete input.sessions[EXTENSION]
- expect(collectLiveProcessIds(input).has(EXTENSION)).toBe(false)
- expect(collectOwnedSessionIds(input).has(EXTENSION)).toBe(false)
- })
-})
diff --git a/src/renderer/src/workspace/gridRelatedAgents.test.ts b/src/renderer/src/workspace/gridRelatedAgents.test.ts
deleted file mode 100644
index a9ce46ff9..000000000
--- a/src/renderer/src/workspace/gridRelatedAgents.test.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { describe, expect, it } from 'vitest'
-
-import {
- buildGridRelatedAgentTabs,
- selectedGridRelatedSessionId,
-} from '@renderer/workspace/gridRelatedAgents'
-import type { TileNode, WorkspaceState } from '@renderer/workspace/types'
-import { commandTargetSessionIdForState } from '@renderer/workspace/hook/selectors/commandTargetSessionId'
-
-function leaf(sessionId: string): TileNode {
- return { type: 'leaf', sessionId }
-}
-
-function makeState(): WorkspaceState {
- return {
- tabs: [
- { id: 'tabA', title: 'project-a', root: leaf('parent'), focusedSessionId: 'parent' },
- ],
- activeTabId: 'tabA',
- gridRelatedSelections: {},
- dispatchMode: null,
- sessions: {
- parent: { cwd: '/work/project-a', kind: 'claude' },
- linked: {
- cwd: '/work/project-a',
- kind: 'claude',
- title: 'manual reviewer',
- linkedParentId: 'parent',
- },
- worker: {
- cwd: '/work/project-a',
- kind: 'codex',
- orchestrationParentId: 'parent',
- orchestrationRootId: 'parent',
- orchestrationRole: 'reviewer',
- },
- unrelated: { cwd: '/work/project-a', kind: 'claude' },
- },
- detachedSessions: {
- linked: {
- sessionId: 'linked',
- surface: 'dispatch',
- projectTabId: 'tabA',
- projectTabTitle: 'project-a',
- projectTabIndex: 0,
- detachedAt: 10,
- },
- worker: {
- sessionId: 'worker',
- surface: 'dispatch',
- projectTabId: 'tabA',
- projectTabTitle: 'project-a',
- projectTabIndex: 0,
- detachedAt: 20,
- },
- unrelated: {
- sessionId: 'unrelated',
- surface: 'dispatch',
- projectTabId: 'tabA',
- projectTabTitle: 'project-a',
- projectTabIndex: 0,
- detachedAt: 30,
- },
- },
- buried: [],
- pinnedSessionIds: [],
- }
-}
-
-describe('grid related agent tabs', () => {
- it('projects linked and orchestration children onto the parent grid pane', () => {
- const tabs = buildGridRelatedAgentTabs(makeState(), 'tabA', 'parent')
- expect(tabs.map(tab => [tab.sessionId, tab.relation, tab.label])).toEqual([
- ['parent', 'parent', 'parent'],
- ['linked', 'linked', 'link'],
- ['worker', 'orchestration', 'reviewer'],
- ])
- })
-
- it('falls back to the physical parent when selected child state is stale', () => {
- const state = makeState()
- state.gridRelatedSelections = { parent: 'missing-child' }
- expect(selectedGridRelatedSessionId(state, 'tabA', 'parent')).toBe('parent')
- })
-
- it('excludes related children that already have their own grid leaf', () => {
- const state = makeState()
- state.tabs[0] = {
- ...state.tabs[0],
- root: {
- type: 'split',
- direction: 'vertical',
- ratio: 0.5,
- a: leaf('parent'),
- b: leaf('linked'),
- },
- }
- delete state.detachedSessions.linked
-
- const tabs = buildGridRelatedAgentTabs(state, 'tabA', 'parent')
- expect(tabs.map(tab => tab.sessionId)).toEqual(['parent', 'worker'])
- })
-
- it('routes grid command targeting to the selected detached related child', () => {
- const state = makeState()
- state.gridRelatedSelections = { parent: 'linked' }
- expect(commandTargetSessionIdForState(state)).toBe('linked')
- })
-
- it('does not let grid related selection override Dispatch command targeting', () => {
- const state = makeState()
- state.gridRelatedSelections = { parent: 'linked' }
- state.dispatchMode = { scope: 'project', focusedSessionId: 'parent' }
- expect(commandTargetSessionIdForState(state)).toBe('parent')
- })
-})
diff --git a/src/renderer/src/workspace/gridRelatedAgents.ts b/src/renderer/src/workspace/gridRelatedAgents.ts
deleted file mode 100644
index 47dc577b0..000000000
--- a/src/renderer/src/workspace/gridRelatedAgents.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { isAgentSessionKind } from '@shared/types/providerKind'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
-import type { SessionId, SessionKind, TabId, WorkspaceState } from '@renderer/workspace/types'
-
-export type GridRelatedAgentRelation = 'parent' | 'linked' | 'orchestration'
-
-export type GridRelatedAgentTab = {
- sessionId: SessionId
- relation: GridRelatedAgentRelation
- label: string
- title: string
- kind: SessionKind | undefined
- placement: 'grid' | 'detached'
-}
-
-export function buildGridRelatedAgentTabs(
- state: WorkspaceState,
- tabId: TabId,
- ownerSessionId: SessionId,
-): GridRelatedAgentTab[] {
- const ownerMeta = state.sessions[ownerSessionId]
- if (!ownerMeta || !isAgentSessionKind(ownerMeta.kind)) return []
-
- const candidateIds = sessionIdsOwnedByTab(state, tabId)
- const tabs: GridRelatedAgentTab[] = [{
- sessionId: ownerSessionId,
- relation: 'parent',
- label: 'parent',
- title: titleForSession(ownerMeta),
- kind: ownerMeta.kind,
- placement: state.detachedSessions[ownerSessionId] ? 'detached' : 'grid',
- }]
-
- for (const sessionId of candidateIds) {
- if (sessionId === ownerSessionId) continue
- const meta = state.sessions[sessionId]
- if (!meta || !isAgentSessionKind(meta.kind)) continue
-
- // WHY direct linked parent and orchestration root both count here:
- // linked agents flatten to one parent by construction, while orchestration
- // can spawn a root run with several workers. In grid mode the user's
- // question is "what work belongs to this visible parent pane?", so the
- // root should expose the whole run and an intermediate orchestrator should
- // still expose its direct children.
- const isLinked = meta.linkedParentId === ownerSessionId
- const isOrchestration =
- meta.orchestrationParentId === ownerSessionId ||
- meta.orchestrationRootId === ownerSessionId
- if (!isLinked && !isOrchestration) continue
-
- const relation: GridRelatedAgentRelation = isLinked ? 'linked' : 'orchestration'
- tabs.push({
- sessionId,
- relation,
- label: relation === 'linked'
- ? 'link'
- : (meta.orchestrationRole?.trim() || meta.title?.trim() || 'orch'),
- title: titleForSession(meta),
- kind: meta.kind,
- placement: state.detachedSessions[sessionId] ? 'detached' : 'grid',
- })
- }
-
- return tabs.length > 1 ? tabs : []
-}
-
-export function selectedGridRelatedSessionId(
- state: WorkspaceState,
- tabId: TabId,
- ownerSessionId: SessionId | null | undefined,
-): SessionId | null {
- if (!ownerSessionId) return null
- const selected = state.gridRelatedSelections?.[ownerSessionId]
- if (!selected || selected === ownerSessionId) return ownerSessionId
- const tabs = buildGridRelatedAgentTabs(state, tabId, ownerSessionId)
- return tabs.some(tab => tab.sessionId === selected) ? selected : ownerSessionId
-}
-
-function sessionIdsOwnedByTab(state: WorkspaceState, tabId: TabId): SessionId[] {
- const tab = state.tabs.find(item => item.id === tabId)
- const gridSessionIds = new Set(tab ? collectLeaves(tab.root) : [])
- const detachedSessionIds = Object.values(state.detachedSessions)
- .filter(entry => (
- entry.surface === 'dispatch' &&
- entry.projectTabId === tabId &&
- state.sessions[entry.sessionId] !== undefined
- ))
- .sort((a, b) => a.detachedAt - b.detachedAt)
- .map(entry => entry.sessionId)
-
- // WHY attached/grid children are deliberately excluded:
- // selecting a related tab renders that session inside the parent's physical
- // pane. If the child already has its own grid leaf, exposing it here would
- // mount the same session twice, duplicating `data-pane-id`, composer state,
- // and debug targets. The mini tabs are for detached children that otherwise
- // have no grid-facing surface.
- return detachedSessionIds.filter(sessionId => !gridSessionIds.has(sessionId))
-}
-
-function titleForSession(meta: WorkspaceState['sessions'][SessionId] | undefined): string {
- if (meta?.title?.trim()) return meta.title.trim()
- const cwd = meta?.cwd ?? 'agent'
- const parts = cwd.split('/').filter(Boolean)
- return parts[parts.length - 1] ?? cwd
-}
diff --git a/src/renderer/src/workspace/hook/actions/agentIndexNavigation.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/agentIndexNavigation.renderer.test.tsx
index 0ccb451f7..cbed3dfd3 100644
--- a/src/renderer/src/workspace/hook/actions/agentIndexNavigation.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/actions/agentIndexNavigation.renderer.test.tsx
@@ -6,10 +6,9 @@ import { describe, expect, it, vi } from 'vitest'
import { UndoCloseStack } from '@renderer/lib/undoClose'
import { useAgentIndexNavigationActions } from '@renderer/workspace/hook/actions/agentIndexNavigation'
import type { SessionActions } from '@renderer/workspace/hook/actions/session'
-import type {
- WorkspaceSetState,
- WorkspaceSetTileTabs,
-} from '@renderer/workspace/hook/context'
+import type { WorkspaceSetRuntimes, WorkspaceSetState } from '@renderer/workspace/hook/context'
+import { emptyRuntime } from '@renderer/session-runtime/state'
+import type { SessionRuntime } from '@renderer/session-runtime/state'
import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
import type { WorkspaceState } from '@renderer/workspace/types'
@@ -18,26 +17,17 @@ function makeState(): WorkspaceState {
tabs: [{
id: 'tab-a',
title: 'alpha',
- root: { type: 'leaf', sessionId: 'a1' },
- focusedSessionId: 'a1',
}],
activeTabId: 'tab-a',
- dispatchMode: null,
+ // The stage is the workspace (#992): a1 sits in lane 0, lane 1 is empty and
+ // focused, so "navigate to A2" means "fill the focused lane with the parked
+ // agent". Before the unified layout this fixture had no Dispatch state and
+ // these cases exercised the tile-tree swap, which no longer exists.
+ stage: { focusedLane: 1, lanes: [{ selectedSessionId: 'a1' }, {}], rows: [{ length: 2 }] },
sessions: {
- a1: { cwd: '/work/alpha/foreground', kind: 'claude' },
- a2: { cwd: '/work/alpha/background', kind: 'codex' },
+ a1: { cwd: '/work/alpha/foreground', kind: 'claude', projectId: 'tab-a', joinedAt: 0 },
+ a2: { cwd: '/work/alpha/background', kind: 'codex', projectId: 'tab-a', joinedAt: 10 },
},
- detachedSessions: {
- a2: {
- sessionId: 'a2',
- surface: 'dispatch',
- projectTabId: 'tab-a',
- projectTabTitle: 'alpha',
- projectTabIndex: 0,
- detachedAt: 10,
- },
- },
- buried: [],
pinnedSessionIds: [],
}
}
@@ -48,7 +38,6 @@ function makeRefs(state: WorkspaceState): WorkspaceRefs {
stateRef: ref(state),
latestStateRef: ref(state),
latestRuntimesRef: ref({}),
- latestTileTabsRef: ref(null),
dangerousAgentsRef: ref(false),
useProxyStreamingRef: ref(false),
defaultBuiltInMcpDomainsRef: ref([]),
@@ -70,6 +59,7 @@ function makeRefs(state: WorkspaceState): WorkspaceRefs {
function mountNavigation(
ensureSessionLive: ReturnType,
initialState: WorkspaceState = makeState(),
+ runtimesInitial: Record = {},
) {
const refs = makeRefs(initialState)
let state = refs.stateRef.current
@@ -78,17 +68,17 @@ function mountNavigation(
refs.stateRef.current = state
refs.latestStateRef.current = state
}
- const setTileTabs: WorkspaceSetTileTabs = next => {
- const current = refs.latestTileTabsRef.current
- refs.latestTileTabsRef.current = typeof next === 'function' ? next(current) : next
- }
const showToast = vi.fn()
+ let runtimes: Record = runtimesInitial
+ const setRuntimes: WorkspaceSetRuntimes = next => {
+ runtimes = typeof next === 'function' ? next(runtimes) : next
+ }
let actions!: ReturnType
function Harness(): React.JSX.Element {
actions = useAgentIndexNavigationActions(
setState,
- setTileTabs,
+ setRuntimes,
refs,
{ ensureSessionLive } as unknown as SessionActions,
showToast,
@@ -97,9 +87,12 @@ function mountNavigation(
}
const mounted = render( )
- return { actions, mounted, showToast, getState: () => state, setState }
+ return { actions, mounted, showToast, getState: () => state, setState, runtimes: () => runtimes }
}
+const laneIds = (state: WorkspaceState) =>
+ state.stage.lanes.map(lane => lane.selectedSessionId ?? null)
+
describe('useAgentIndexNavigationActions', () => {
it('uses the same navigation result for a stable ID and its UI label', async () => {
const labeled = mountNavigation(vi.fn().mockResolvedValue('a2'))
@@ -112,34 +105,39 @@ describe('useAgentIndexNavigationActions', () => {
stable.mounted.unmount()
})
- it('does not replace a different pane after focus moves while waking', async () => {
- const state = makeState()
- state.sessions.a3 = { cwd: '/work/alpha', kind: 'claude' }
- state.tabs[0].root = { type: 'split', direction: 'vertical', ratio: 0.5,
- a: { type: 'leaf', sessionId: 'a1' }, b: { type: 'leaf', sessionId: 'a3' } }
+ it('does not replace a different lane after focus moves while waking', async () => {
+ // A wake can take seconds. "Fill the focused lane" is meaningful only for
+ // the lane that was focused when navigation began; if the user moves focus
+ // during the wake, the newly focused lane must not be silently repurposed.
let finish!: () => void
const gate = new Promise(resolve => { finish = resolve })
- const harness = mountNavigation(vi.fn(() => gate), state)
+ const harness = mountNavigation(vi.fn(() => gate))
const navigation = harness.actions.focusAgentBySessionId('a2')
- harness.setState(current => ({ ...current, tabs: current.tabs.map(tab => ({ ...tab, focusedSessionId: 'a3' })) }))
+ harness.setState(current => ({
+ ...current,
+ stage: { ...current.stage, focusedLane: 0 },
+ }))
await act(async () => { finish(); expect(await navigation).toBe(false) })
- expect(harness.getState().tabs[0].root).toEqual(state.tabs[0].root)
- expect(harness.getState().tabs[0].focusedSessionId).toBe('a3')
+ expect(laneIds(harness.getState())).toEqual(['a1', null])
+ expect(harness.getState().stage.focusedLane).toBe(0)
harness.mounted.unmount()
})
- it('reveals a hidden related child without moving it out of its parent view', async () => {
- const state = makeState()
- state.sessions.a2.linkedParentId = 'a1'
- const harness = mountNavigation(vi.fn().mockResolvedValue('a2'), state)
- await act(async () => { expect(await harness.actions.focusAgentBySessionId('a2')).toBe(true) })
- expect(harness.getState().tabs[0].root).toEqual(state.tabs[0].root)
- expect(harness.getState().gridRelatedSelections).toEqual({ a1: 'a2' })
- expect(harness.getState().detachedSessions.a2).toBeDefined()
+ it('placing a pooled agent by label clears its "new" badge', async () => {
+ // #1013 review B: label navigation, agents.show, views.agentSet, Agent
+ // Activity's Focus and the Performance Monitor all place through here,
+ // not through setTiledLaneSession, so the badge stayed on an agent that
+ // was on screen for the rest of the run.
+ const harness = mountNavigation(vi.fn().mockResolvedValue('a2'), makeState(), {
+ a2: { ...emptyRuntime(), pooledSpawnAt: 1 },
+ })
+ await act(async () => { expect(await harness.actions.focusAgentByPaneLabel('A2')).toBe(true) })
+ expect(laneIds(harness.getState())).toEqual(['a1', 'a2'])
+ expect(harness.runtimes().a2?.pooledSpawnAt ?? null).toBeNull()
harness.mounted.unmount()
})
- it('wakes a detached target before swapping it into the focused grid slot', async () => {
+ it('wakes a parked target before placing it in the focused lane', async () => {
const ensureSessionLive = vi.fn().mockResolvedValue('a2')
const harness = mountNavigation(ensureSessionLive)
@@ -148,25 +146,22 @@ describe('useAgentIndexNavigationActions', () => {
})
expect(ensureSessionLive).toHaveBeenCalledWith('a2', 'agent-index.navigate')
- expect(harness.getState().tabs[0].root).toEqual({ type: 'leaf', sessionId: 'a2' })
- expect(harness.getState().detachedSessions.a1?.sessionId).toBe('a1')
- expect(harness.getState().detachedSessions.a2).toBeUndefined()
+ expect(laneIds(harness.getState())).toEqual(['a1', 'a2'])
+ // Placement never changes pool membership: a2 is the same row, in the
+ // same project at the same place in its index, now shown in a lane.
+ expect(harness.getState().sessions.a2).toMatchObject({ projectId: 'tab-a', joinedAt: 10 })
expect(harness.showToast).not.toHaveBeenCalled()
harness.mounted.unmount()
})
it('threads the bang intent through wake and commit into the focused lane', async () => {
const state = makeState()
- state.dispatchMode = {
- scope: 'global',
- focusedSessionId: 'a1',
- tiled: {
- focusedLane: 0,
- lanes: [
- { selectedSessionId: 'a1' },
- { selectedSessionId: 'a2' },
- ],
- },
+ state.stage = {
+ focusedLane: 0,
+ lanes: [
+ { selectedSessionId: 'a1' },
+ { selectedSessionId: 'a2' },
+ ],
}
const ensureSessionLive = vi.fn().mockResolvedValue('a2')
const harness = mountNavigation(ensureSessionLive, state)
@@ -179,7 +174,7 @@ describe('useAgentIndexNavigationActions', () => {
})
expect(ensureSessionLive).toHaveBeenCalledWith('a2', 'agent-index.navigate')
- expect(harness.getState().dispatchMode?.tiled).toMatchObject({
+ expect(harness.getState().stage).toMatchObject({
focusedLane: 0,
lanes: [
{ selectedSessionId: 'a2' },
@@ -189,7 +184,7 @@ describe('useAgentIndexNavigationActions', () => {
harness.mounted.unmount()
})
- it('keeps layout unchanged when a hibernated target cannot be woken', async () => {
+ it('keeps the stage unchanged when a hibernated target cannot be woken', async () => {
const ensureSessionLive = vi.fn().mockRejectedValue(new Error('provider unavailable'))
const harness = mountNavigation(ensureSessionLive)
@@ -197,8 +192,8 @@ describe('useAgentIndexNavigationActions', () => {
expect(await harness.actions.focusAgentByPaneLabel('A2')).toBe(false)
})
- expect(harness.getState().tabs[0].root).toEqual({ type: 'leaf', sessionId: 'a1' })
- expect(harness.getState().detachedSessions.a2?.sessionId).toBe('a2')
+ expect(laneIds(harness.getState())).toEqual(['a1', null])
+ expect(harness.getState().sessions.a2).toMatchObject({ projectId: 'tab-a', joinedAt: 10 })
expect(harness.showToast).toHaveBeenCalledWith('provider unavailable')
harness.mounted.unmount()
})
diff --git a/src/renderer/src/workspace/hook/actions/agentIndexNavigation.ts b/src/renderer/src/workspace/hook/actions/agentIndexNavigation.ts
index 167288dde..b156ba8c2 100644
--- a/src/renderer/src/workspace/hook/actions/agentIndexNavigation.ts
+++ b/src/renderer/src/workspace/hook/actions/agentIndexNavigation.ts
@@ -3,18 +3,16 @@ import { useCallback } from 'react'
import { navigateToAgentIndexTarget } from '@renderer/workspace/agentIndexNavigation'
import type { AgentIndexNavigationIntent } from '@renderer/workspace/agentIndexNavigation'
import { resolveAgentPaneLabel, resolveAgentSessionTarget } from '@renderer/workspace/tile-tree/paneLabels'
-import type {
- WorkspaceSetState,
- WorkspaceSetTileTabs,
-} from '@renderer/workspace/hook/context'
+import type { WorkspaceSetRuntimes, WorkspaceSetState } from '@renderer/workspace/hook/context'
+import { clearPooledSpawnBadge } from '@renderer/workspace/hook/actions/pooledSpawnBadge'
import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
import type { AgentPaneLabelTarget } from '@renderer/workspace/tile-tree/paneLabels'
-import type { WorkspaceState, TileTabsState } from '@renderer/workspace/types'
+import type { WorkspaceState } from '@renderer/workspace/types'
import type { SessionActions } from '@renderer/workspace/hook/actions/session'
export function useAgentIndexNavigationActions(
setState: WorkspaceSetState,
- setTileTabs: WorkspaceSetTileTabs,
+ setRuntimes: WorkspaceSetRuntimes,
refs: WorkspaceRefs,
sessionActions: SessionActions,
showToast: (message: string, durationMs?: number) => void,
@@ -27,26 +25,35 @@ export function useAgentIndexNavigationActions(
} {
const focusTarget = useCallback(
async (
- resolve: (state: WorkspaceState, tileTabs: TileTabsState | null) => AgentPaneLabelTarget | null,
+ resolve: (state: WorkspaceState) => AgentPaneLabelTarget | null,
intent: AgentIndexNavigationIntent = 'reuse-existing-view',
): Promise => {
- const initialTarget = resolve(refs.stateRef.current, refs.latestTileTabsRef.current)
+ const initialTarget = resolve(refs.stateRef.current)
if (!initialTarget) return false
const initialResult = navigateToAgentIndexTarget(
refs.stateRef.current,
- refs.latestTileTabsRef.current,
initialTarget,
intent,
)
if (!initialResult) return false
- const destination = (state: WorkspaceState, tiled: TileTabsState | null) => JSON.stringify([
- tiled?.focusedTabId ?? state.activeTabId,
- state.tabs.find(tab => tab.id === (tiled?.focusedTabId ?? state.activeTabId))?.focusedSessionId,
- state.dispatchMode?.tiled?.focusedLane,
+ // The destination a `replace-` result would overwrite: the focused
+ // lane of the active project. (It used to include the grid's focused
+ // pane and the Tile Tabs slot; both died with #992.)
+ const destination = (state: WorkspaceState) => JSON.stringify([
+ state.activeTabId,
+ state.stage.focusedLane,
])
- const initialDestination = destination(refs.stateRef.current, refs.latestTileTabsRef.current)
+ const initialDestination = destination(refs.stateRef.current)
- if (initialResult.requiresWake) {
+ // Wake unless the runtime says a backend is already up. A parked session
+ // survives a restart as metadata with no provider process, and exposing
+ // one in a lane un-woken means the first keystroke lands on a dead
+ // backend (#690). 'started' is the only status that proves otherwise;
+ // 'idle', 'failed' and 'exited' all need the wake path, which is also
+ // the retry path. (Until #992 the test was "has a detachedSessions
+ // record", a structural stand-in for this.)
+ const processStatus = refs.latestRuntimesRef.current[initialTarget.sessionId]?.processStatus
+ if (processStatus !== 'started') {
try {
// Detached agents survive reload as metadata without a provider
// process. Wake under the SAME SessionId before exposing one in a
@@ -66,18 +73,16 @@ export function useAgentIndexNavigationActions(
}
let committed = false
- let nextTileTabs = refs.latestTileTabsRef.current
setState(current => {
// Re-resolve at commit time because the label is positional. A close,
// detach, or tab reorder can change what "A2" means while a hibernated
// target is waking. Never redirect the user's already-confirmed action
// to a different session just because that new session inherited the
// coordinate during the await.
- const currentTarget = resolve(current, refs.latestTileTabsRef.current)
+ const currentTarget = resolve(current)
if (currentTarget?.sessionId !== initialTarget.sessionId) return current
const result = navigateToAgentIndexTarget(
current,
- refs.latestTileTabsRef.current,
currentTarget,
intent,
)
@@ -85,10 +90,9 @@ export function useAgentIndexNavigationActions(
// A wake can take seconds. Replacing a slot is meaningful only for the
// slot captured when navigation began; focus moving meanwhile must not
// silently repurpose the user's newly focused pane or Dispatch lane.
- if ((result.kind.startsWith('replace-') || result.kind.startsWith('swap-'))
- && destination(current, refs.latestTileTabsRef.current) !== initialDestination) return current
+ if (result.kind.startsWith('replace-')
+ && destination(current) !== initialDestination) return current
committed = true
- nextTileTabs = result.tileTabs
return result.state
})
@@ -96,20 +100,19 @@ export function useAgentIndexNavigationActions(
showToast(`Agent index ${initialTarget.label} changed; open the command palette again`)
return false
}
- // Workspace layout and meta-tab layout live in separate Zustand slices.
- // Commit the workspace first so TileTabs never renders a newly inserted
- // tab id against the old focusedSessionId for even one state turn.
- setTileTabs(nextTileTabs)
+ // The session is on screen now, so its "new" badge has been answered
+ // (pooledSpawnBadge.ts).
+ clearPooledSpawnBadge(setRuntimes, initialTarget.sessionId)
return true
},
- [refs.latestTileTabsRef, refs.stateRef, sessionActions, setState, setTileTabs, showToast],
+ [refs.stateRef, sessionActions, setRuntimes, setState, showToast],
)
// Both UI coordinates and stable SDK targets use the same wake/commit path.
// The label resolver retains its positional race guard; the ID resolver can
// survive a reorder without turning that coordinate into a different agent.
const focusAgentByPaneLabel = useCallback((label: string, intent?: AgentIndexNavigationIntent) =>
- focusTarget((state, tileTabs) => resolveAgentPaneLabel(state, label, tileTabs), intent), [focusTarget])
+ focusTarget(state => resolveAgentPaneLabel(state, label), intent), [focusTarget])
const focusAgentBySessionId = useCallback((sessionId: string, intent?: AgentIndexNavigationIntent) =>
focusTarget(state => resolveAgentSessionTarget(state, sessionId), intent), [focusTarget])
return { focusAgentByPaneLabel, focusAgentBySessionId }
diff --git a/src/renderer/src/workspace/hook/actions/agentNameContinuity.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/agentNameContinuity.renderer.test.tsx
index 198def38e..4c8c0e71b 100644
--- a/src/renderer/src/workspace/hook/actions/agentNameContinuity.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/actions/agentNameContinuity.renderer.test.tsx
@@ -11,6 +11,7 @@ import { withoutProvisionalProviderSession } from '@renderer/workspace/providerS
import type { SessionId, SessionMeta, WorkspaceState } from '@renderer/workspace/types'
import { useSessionActions } from './session'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
vi.mock('@renderer/workspace/hook/actions/initialHistory', () => ({
loadInitialHistoryForSession: vi.fn(async () => undefined),
@@ -31,15 +32,11 @@ function initialState(meta: Record): WorkspaceState {
tabs: [{
id: 'tab-a',
title: 'recorded',
- root: { type: 'leaf' as const, sessionId: predecessorId },
- focusedSessionId: predecessorId,
}],
activeTabId: 'tab-a',
- sessions: { [predecessorId]: meta },
- detachedSessions: {},
- buried: [],
+ sessions: { [predecessorId]: { ...meta, projectId: 'tab-a', joinedAt: 0 }},
pinnedSessionIds: [],
- dispatchMode: null,
+ stage: oneLaneStage(predecessorId),
} as unknown as WorkspaceState
}
@@ -182,98 +179,70 @@ describe('spoken name identity through Undo Close', () => {
tabs: [{
id: 'tab-a',
title: 'recorded',
- root: { type: 'leaf', sessionId: 'survivor' },
- focusedSessionId: 'survivor',
}],
activeTabId: 'tab-a',
- sessions: { survivor: { cwd: '/recorded/worktree', kind: 'codex' } },
- detachedSessions: {},
- buried: [],
+ sessions: { survivor: { cwd: '/recorded/worktree', kind: 'codex', projectId: 'tab-a', joinedAt: 0 } },
pinnedSessionIds: [],
- dispatchMode: null,
+ stage: oneLaneStage('survivor'),
} as unknown as WorkspaceState)
- it('restores a grid pane under its own identity and title', async () => {
- // The pane path used to commit only `tabs`, so the successor's metadata was
- // whatever `spawn` could rebuild — identity gone, and (pre-existing) the
- // user's title with it.
- const state = anchoredState()
- const refs = makeRefs(state)
- refs.undoStackRef.current.push({
- type: 'pane',
- closedAt: Date.now(),
- tabId: 'tab-a',
- sessionMeta: closedAgent('identity-one'),
- direction: 'vertical',
- ratio: 0.5,
- side: 'a',
- siblingLeafId: 'survivor',
- })
- const undo = mountUndoCloseAction(state, refs, vi.fn().mockResolvedValue('restored-pane'))
-
- await act(async () => { await undo.actions.undoClose() })
-
- expect(undo.getState().sessions['restored-pane']?.agentNameId).toBe('identity-one')
- expect(undo.getState().sessions['restored-pane']?.title).toBe('the queue race')
- undo.mounted.unmount()
- })
+ // Until #992 there were three restore paths (a split pane re-inserted beside
+ // its sibling, a Dispatch row re-filed from its record, a tab remapped leaf
+ // by leaf) and each had lost this metadata in its own way: the pane path
+ // committed only `tabs`, the row path used a hand-written allowlist that
+ // predated naming, and the tab path built a `freshSessions` map nothing ever
+ // read. There are two paths now, and both go through carryDurableMeta.
- it('restores a detached Dispatch row under its own identity', async () => {
- // This path did write a `sessions` patch, but as a hand-written allowlist
- // that predates naming and therefore omitted `agentNameId`.
+ it('restores a closed session under its own identity, title and place', async () => {
const state = anchoredState()
const refs = makeRefs(state)
refs.undoStackRef.current.push({
- type: 'detached',
+ type: 'session',
closedAt: Date.now(),
- sessionMeta: closedAgent('identity-detached'),
- record: {
- sessionId: 'old-detached',
- surface: 'dispatch',
- projectTabId: 'tab-a',
- projectTabTitle: 'recorded',
- projectTabIndex: 0,
- detachedAt: 10,
- },
+ sessionId: 'old-session',
+ sessionMeta: { ...closedAgent('identity-one'), projectId: 'tab-a', joinedAt: 10 },
})
- const undo = mountUndoCloseAction(state, refs, vi.fn().mockResolvedValue('restored-detached'))
+ const undo = mountUndoCloseAction(state, refs, vi.fn().mockResolvedValue('restored-session'))
await act(async () => { await undo.actions.undoClose() })
- expect(undo.getState().sessions['restored-detached']?.agentNameId).toBe('identity-detached')
- expect(undo.getState().detachedSessions['restored-detached']?.detachedAt).toBe(10)
+ const restored = undo.getState().sessions['restored-session']
+ expect(restored?.agentNameId).toBe('identity-one')
+ expect(restored?.title).toBe('the queue race')
+ // Membership is durable metadata `spawn` never sees, exactly like the
+ // identity: without it the restored agent would be unowned and pruned.
+ expect(restored).toMatchObject({ projectId: 'tab-a', joinedAt: 10 })
undo.mounted.unmount()
})
- it('restores a whole tab, grid leaves and detached children alike, under their own identities', async () => {
- // The tab path looked safe — it built a `freshSessions` map keyed by the
- // new ids — but nothing ever read that map, so it lost exactly what the
- // pane path lost. Both of a closed tab's populations are asserted here
- // because they are respawned by two different loops.
+ it('restores a whole project under its sessions own identities', async () => {
const state = { ...anchoredState(), tabs: [], sessions: {} } as unknown as WorkspaceState
const refs = makeRefs(state)
refs.undoStackRef.current.push({
type: 'tab',
closedAt: Date.now(),
- tab: {
- id: 'closed-tab',
- title: 'closed',
- root: { type: 'leaf', sessionId: 'old-grid' },
- focusedSessionId: 'old-grid',
- },
+ tab: { id: 'closed-tab', title: 'closed' },
tabIndex: 0,
- sessionMetas: { 'old-grid': closedAgent('identity-grid') },
- detachedEntries: [{ meta: closedAgent('identity-child'), detachedAt: 10 }],
+ sessions: [
+ { sessionId: 'old-first', meta: { ...closedAgent('identity-grid'), projectId: 'closed-tab', joinedAt: 0 } },
+ { sessionId: 'old-child', meta: { ...closedAgent('identity-child'), projectId: 'closed-tab', joinedAt: 10 } },
+ ],
})
const spawn = vi.fn()
- .mockResolvedValueOnce('restored-grid')
+ .mockResolvedValueOnce('restored-first')
.mockResolvedValueOnce('restored-child')
const undo = mountUndoCloseAction(state, refs, spawn)
await act(async () => { await undo.actions.undoClose() })
- expect(undo.getState().sessions['restored-grid']?.agentNameId).toBe('identity-grid')
- expect(undo.getState().sessions['restored-child']?.agentNameId).toBe('identity-child')
+ const after = undo.getState()
+ expect(after.sessions['restored-first']?.agentNameId).toBe('identity-grid')
+ expect(after.sessions['restored-child']?.agentNameId).toBe('identity-child')
+ // The project came back under a NEW id, and its sessions name that id.
+ expect(after.tabs).toHaveLength(1)
+ expect(after.tabs[0]!.id).not.toBe('closed-tab')
+ expect(after.sessions['restored-first']).toMatchObject({ projectId: after.tabs[0]!.id, joinedAt: 0 })
+ expect(after.sessions['restored-child']).toMatchObject({ projectId: after.tabs[0]!.id, joinedAt: 10 })
undo.mounted.unmount()
})
})
diff --git a/src/renderer/src/workspace/hook/actions/closeAgentScope.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/closeAgentScope.renderer.test.tsx
index a65572233..9f97983f6 100644
--- a/src/renderer/src/workspace/hook/actions/closeAgentScope.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/actions/closeAgentScope.renderer.test.tsx
@@ -4,53 +4,61 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { emptyRuntime } from '@renderer/session-runtime/state'
import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchSelectors'
import { mergeProjectTabs } from '@renderer/workspace/mergeProjectTabs'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
+import { collectOwnedSessionIds } from '@renderer/workspace/sessionOwnership'
import {
__resetCloseConfirmationForTests,
currentCloseConfirmation,
resolveCloseConfirmation,
} from '@renderer/workspace/closeConfirmationBroker'
import { makeRefs, mountPaneActions, mountUndoCloseAction } from './testing/paneActionsHarness'
-import type { DetachedSessionRecord, WorkspaceState } from '@renderer/workspace/types'
+import type { WorkspaceState } from '@renderer/workspace/types'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
+
+// What a close may and may not take with it (#153, #886), on the pool (#992).
+//
+// WHAT CHANGED UNDER THIS SUITE. It was written while a project owned a tile
+// tree, and a third of it pinned consequences of that tree: a tab's root could
+// not be empty, so closing a tab's last tile leaf either ended the whole
+// project or PROMOTED a Dispatch row into the tree, the user was asked which
+// ("Close agent / Close tab"), and undo had to restore the old root "only if
+// nobody had rearranged it". None of that can be constructed any more — no
+// session is structurally special — so those cases are gone, each with a note
+// where it stood. Every invariant that was about SESSIONS rather than about
+// the tree carries over unchanged: a close kills exactly what was approved,
+// each member is re-judged at its own kill, a parent never orphans a linked
+// child, a kept member is never left under a deleted project, and undo
+// describes what actually happened.
function project(): WorkspaceState {
return {
- tabs: [{ id: 'project', title: 'Project', root: { type: 'leaf', sessionId: 'root' }, focusedSessionId: 'root' }],
+ tabs: [{ id: 'project', title: 'Project' }],
activeTabId: 'project',
sessions: {
- root: { cwd: '/project', kind: 'claude', title: 'Old root' },
- worker: { cwd: '/project', kind: 'codex', title: 'Running worker' },
+ root: { cwd: '/project', kind: 'claude', title: 'Old root', projectId: 'project', joinedAt: 0 },
+ worker: { cwd: '/project', kind: 'codex', title: 'Running worker', projectId: 'project', joinedAt: 1 },
},
- detachedSessions: {
- worker: { sessionId: 'worker', surface: 'dispatch', projectTabId: 'project', projectTabTitle: 'Project', projectTabIndex: 0, detachedAt: 1 },
- },
- dispatchMode: { scope: 'project', focusedSessionId: 'root' },
- gridRelatedSelections: {}, buried: [], pinnedSessionIds: [],
+ stage: oneLaneStage('root'),
+ pinnedSessionIds: [],
}
}
-function dispatchRow(sessionId: string, projectTabId: string, detachedAt: number): DetachedSessionRecord {
- return { sessionId, surface: 'dispatch', projectTabId, projectTabTitle: projectTabId, projectTabIndex: 0, detachedAt }
-}
-
const UNDO_HINT = ' — ⌘⇧T Undo Close; repeat for earlier closes'
/**
- * The ownership invariants a close must never break:
- * - every tab's root leaves and focus name sessions that still exist (the
- * invariant #886 review round 1 finding 3 broke);
- * - every Dispatch row is filed under a tab that still exists (the one round 2
- * N1 broke). A row whose project is gone renders nowhere, and the next
- * autosave prunes its metadata while its backend keeps running.
+ * The ownership invariant a close must never break: every session that is
+ * still in the workspace is OWNED — its project exists. A session whose
+ * project is gone renders nowhere, and the next autosave prunes its metadata
+ * while its backend keeps running (#886 review round 2 N1).
+ *
+ * (In the tree era this also checked that every tab's root leaves and focus
+ * named sessions that still existed — the invariant round 1 finding 3 broke.)
*/
-function expectValidTabs(state: WorkspaceState): void {
+function expectEverySessionOwned(state: WorkspaceState): void {
+ const owned = collectOwnedSessionIds(state)
+ for (const id of Object.keys(state.sessions)) expect(owned.has(id), `${id} is owned`).toBe(true)
+ // And no project outlives its sessions.
for (const tab of state.tabs) {
- const leaves = collectLeaves(tab.root)
- for (const leaf of leaves) expect(state.sessions[leaf], `tab ${tab.id} leaf ${leaf}`).toBeDefined()
- expect(leaves).toContain(tab.focusedSessionId)
- }
- for (const record of Object.values(state.detachedSessions)) {
- expect(state.tabs.some(tab => tab.id === record.projectTabId), `row ${record.sessionId} names an existing project`).toBe(true)
+ expect(Object.values(state.sessions).some(meta => meta.projectId === tab.id), `project ${tab.id} holds a session`).toBe(true)
}
}
@@ -67,37 +75,48 @@ afterEach(() => {
else Reflect.deleteProperty(window, 'api')
})
-describe('root agent close scope (#153, #886)', () => {
- it('offers agent versus tab from the focused Dispatch row and cancels without killing', async () => {
- const harness = mountPaneActions(project())
- let closing!: Promise
- await act(async () => { closing = harness.actions.closeFocused() })
- expect(currentCloseConfirmation()?.request.agentOnly?.targets.map(t => t.sessionId)).toEqual(['root'])
- expect(currentCloseConfirmation()?.request.targets.map(t => t.sessionId)).toEqual(['root', 'worker'])
- await act(async () => { resolveCloseConfirmation(false); await closing })
- expect(killOwnedSession).not.toHaveBeenCalled()
- expect(buildVisibleDispatchRows(harness.getState()).map(row => row.sessionId)).toEqual(['root', 'worker'])
- harness.mounted.unmount()
- })
-
- it('closes only the root after Close Agent, preserving and promoting the live worker', async () => {
+describe('a session close is session-scoped (#153, #886)', () => {
+ it('closes only the named session, without asking, and leaves the rest of its project alone', async () => {
+ // The tree-era form of this case opened a three-way "Close agent / Close
+ // tab" dialog, because `root` was its tab's sole tile leaf and the tree
+ // could not be left empty. One idle session with no linked children is the
+ // cheap case now, whoever it is: no dialog, one kill.
const state = project()
const refs = makeRefs(state)
refs.latestRuntimesRef.current = { root: emptyRuntime(), worker: { ...emptyRuntime(), sessionStatus: 'running' } }
const harness = mountPaneActions(state, { refs })
- let closing!: Promise
- await act(async () => { closing = harness.actions.closeSession('root') })
- await act(async () => { resolveCloseConfirmation('agent'); await closing })
+ await act(async () => { expect(await harness.actions.closeFocused()).toBeUndefined() })
+
+ expect(currentCloseConfirmation()).toBeNull()
expect(killed()).toEqual(['root'])
- expect(harness.getState().tabs).toEqual([expect.objectContaining({ id: 'project', root: { type: 'leaf', sessionId: 'worker' } })])
+ expect(harness.getState().tabs).toEqual(state.tabs)
expect(harness.getState().sessions.worker).toBe(state.sessions.worker)
- expect(harness.getState().detachedSessions.worker).toBeUndefined()
expect(buildVisibleDispatchRows(harness.getState()).map(row => row.sessionId)).toEqual(['worker'])
+ // The lane that showed it goes EMPTY. It is not refilled with the worker
+ // (#681) and it is not removed.
+ expect(harness.getState().stage.lanes).toEqual([{ selectedSessionId: undefined }])
expect(harness.spawn).not.toHaveBeenCalled()
+ expectEverySessionOwned(harness.getState())
harness.mounted.unmount()
})
- it('bulk session-only close never kills an unselected detached worker or captures purge undo', async () => {
+ it('asks before closing a working session, and cancels without killing', async () => {
+ const state = project()
+ const refs = makeRefs(state)
+ refs.latestRuntimesRef.current = { root: { ...emptyRuntime(), processActive: true }, worker: emptyRuntime() }
+ const harness = mountPaneActions(state, { refs })
+ let closing!: Promise
+ await act(async () => { closing = harness.actions.closeFocused() })
+ // Exactly the session named: closing it is no longer a reason to list the
+ // rest of its project.
+ expect(currentCloseConfirmation()?.request.targets.map(t => t.sessionId)).toEqual(['root'])
+ await act(async () => { resolveCloseConfirmation(false); await closing })
+ expect(killOwnedSession).not.toHaveBeenCalled()
+ expect(harness.getState()).toBe(state)
+ harness.mounted.unmount()
+ })
+
+ it('bulk session-only close never kills an unselected session or captures purge undo', async () => {
const harness = mountPaneActions(project())
await act(async () => {
expect(await harness.actions.closeSession('root', {
@@ -153,134 +172,90 @@ describe('root agent close scope (#153, #886)', () => {
harness.mounted.unmount()
})
- it('rejects a root-agent grant if that agent starts working under the dialog', async () => {
+ it('rejects a grant if the named agent starts working under the dialog', async () => {
+ // A linked child makes this a two-session close, so a dialog opens while
+ // both are idle. (The tree-era case used the root-scope dialog for this.)
const state = project()
+ state.sessions.worker.linkedParentId = 'root'
const refs = makeRefs(state)
refs.latestRuntimesRef.current = { root: emptyRuntime(), worker: emptyRuntime() }
const harness = mountPaneActions(state, { refs })
let closing!: Promise
await act(async () => { closing = harness.actions.closeSession('root') })
+ expect(currentCloseConfirmation()?.request.targets.map(t => [t.sessionId, t.live])).toEqual([['root', false], ['worker', false]])
refs.latestRuntimesRef.current.root = { ...emptyRuntime(), processActive: true }
await act(async () => {
- resolveCloseConfirmation('agent')
+ resolveCloseConfirmation(true)
expect(await closing).toBe(false)
})
+ // The gate re-enumerates after the dialog and refuses the WHOLE plan: the
+ // list the user approved no longer describes the workspace, so nothing in
+ // it is killed — not even the child that is still idle.
expect(killOwnedSession).not.toHaveBeenCalled()
+ expect(harness.getState()).toBe(state)
harness.mounted.unmount()
})
- it('Close Tab closes the listed project as one undo unit, and undo re-nests the linked child under the restored root', async () => {
- const state = project()
- // One linked child row and one unrelated row, so the two scopes differ and
- // the three-way choice is offered (see the n4 case below for equal sets).
- state.sessions.child = { cwd: '/project', kind: 'codex', linkedParentId: 'root' }
- state.detachedSessions.child = dispatchRow('child', 'project', 2)
- const harness = mountPaneActions(state)
- let closing!: Promise
- await act(async () => { closing = harness.actions.closeSession('root') })
- expect(currentCloseConfirmation()?.request.agentOnly?.targets.map(t => t.sessionId)).toEqual(['root', 'child'])
- await act(async () => { resolveCloseConfirmation(true); expect(await closing).toBe(true) })
- // Linked child before its parent; the root last.
- expect(killed()).toEqual(['child', 'worker', 'root'])
- expect(harness.getState().tabs).toEqual([])
- expect(harness.getState().sessions).toEqual({})
- expect(harness.refs.undoStackRef.current.peek()).toMatchObject({
- type: 'tab',
- detachedEntries: [
- { sessionId: 'child', meta: state.sessions.child },
- { sessionId: 'worker', meta: state.sessions.worker },
- ],
- })
-
- // #886 review round 2 N3: the tab restore must re-point the restored child
- // at its restored parent's NEW id, or it comes back un-nested and no longer
- // closes with its parent.
- const spawn = vi.fn()
- .mockResolvedValueOnce('root-2')
- .mockResolvedValueOnce('child-2')
- .mockResolvedValueOnce('worker-2')
- const undo = mountUndoCloseAction(harness.getState(), harness.refs, spawn)
- await act(async () => { await undo.actions.undoClose() })
- const restored = undo.getState()
- expect(restored.sessions['child-2']?.linkedParentId).toBe('root-2')
- const rows = buildVisibleDispatchRows(restored)
- expect(rows.find(row => row.sessionId === 'child-2')?.depth).toBe(1)
- expect(rows.find(row => row.sessionId === 'worker-2')?.depth).toBe(0)
- undo.mounted.unmount()
- harness.mounted.unmount()
- })
-
- it('asks the ordinary question when both scopes close the same sessions, still restoring the project as one unit (n4)', async () => {
+ it('removes the project with its LAST session and records the project as one undo unit', async () => {
+ // A project owns nothing, so it exists while a session names it. Here the
+ // named session and its linked child are the whole project.
const state = project()
state.sessions.worker.linkedParentId = 'root'
const harness = mountPaneActions(state)
let closing!: Promise
await act(async () => { closing = harness.actions.closeSession('root') })
const request = currentCloseConfirmation()?.request
- expect(request?.agentOnly).toBeUndefined()
+ // One ordinary question about one list. (Tree era, #886 n4: this shape had
+ // to SUPPRESS a three-way scope choice, because both scopes named the same
+ // set. The choice and the request field that carried it are deleted.)
+ expect(request?.reason).toBe('multi')
expect(request?.targets.map(t => t.sessionId)).toEqual(['root', 'worker'])
await act(async () => { resolveCloseConfirmation(true); expect(await closing).toBe(true) })
expect(killed()).toEqual(['worker', 'root'])
expect(harness.getState().tabs).toEqual([])
- expect(harness.refs.undoStackRef.current.peek()).toMatchObject({
- type: 'tab', detachedEntries: [{ sessionId: 'worker', meta: state.sessions.worker }],
+ expect(harness.getState().sessions).toEqual({})
+ expect(harness.getState().activeTabId).toBe('')
+ expect(harness.refs.undoStackRef.current.peek()).toEqual({
+ type: 'tab',
+ closedAt: expect.any(Number),
+ tab: { id: 'project', title: 'Project' },
+ tabIndex: 0,
+ // Index order, not commit order: the restored project must list its
+ // sessions the way it used to.
+ sessions: [
+ { sessionId: 'root', meta: state.sessions.root },
+ { sessionId: 'worker', meta: state.sessions.worker },
+ ],
})
+ expect(harness.showToast).toHaveBeenLastCalledWith(`Closed “Project”${UNDO_HINT}`)
harness.mounted.unmount()
})
- it('undo restores the root and Dispatch order without restarting the surviving worker', async () => {
+ it('undo puts a closed session back at its old place without restarting anyone else', async () => {
const state = project()
const harness = mountPaneActions(state)
await act(async () => { await harness.actions.closeSession('root', { preConfirmed: true }) })
+ expect(harness.refs.undoStackRef.current.peek()).toMatchObject({ type: 'session', sessionId: 'root', sessionMeta: state.sessions.root })
+
const spawn = vi.fn(async () => 'restored-root')
const undo = mountUndoCloseAction(harness.getState(), harness.refs, spawn)
await act(async () => { await undo.actions.undoClose() })
expect(spawn).toHaveBeenCalledTimes(1)
- expect(undo.getState().tabs[0].root).toEqual({ type: 'leaf', sessionId: 'restored-root' })
- expect(undo.getState().detachedSessions.worker).toEqual(state.detachedSessions.worker)
expect(undo.getState().sessions.worker).toBe(state.sessions.worker)
+ // `joinedAt` rides back verbatim, so it lists FIRST again, not last.
+ expect(undo.getState().sessions['restored-root']).toMatchObject({ projectId: 'project', joinedAt: 0, title: 'Old root' })
expect(buildVisibleDispatchRows(undo.getState()).map(row => row.sessionId)).toEqual(['restored-root', 'worker'])
+ // Undo returns it to the POOL. It does not re-aim the lane the close
+ // emptied: a lane the user may since have re-aimed must not be yanked back.
+ expect(undo.getState().stage).toBe(harness.getState().stage)
undo.mounted.unmount()
harness.mounted.unmount()
})
- it('promotes the next displayed row even when detached records were inserted out of order', async () => {
- const state = project()
- state.sessions.earlier = { cwd: '/project', kind: 'codex' }
- state.detachedSessions.earlier = { ...state.detachedSessions.worker, sessionId: 'earlier', detachedAt: 0 }
- const harness = mountPaneActions(state)
- await act(async () => { await harness.actions.closeSession('root', { preConfirmed: true }) })
- expect(harness.getState().tabs[0].root).toEqual({ type: 'leaf', sessionId: 'earlier' })
- expect(buildVisibleDispatchRows(harness.getState()).map(row => row.sessionId)).toEqual(['earlier', 'worker'])
- expect(killed()).toEqual(['root'])
- harness.mounted.unmount()
- })
-
- it('undo preserves a later split instead of forcing the old root layout back', async () => {
- const harness = mountPaneActions(project())
- await act(async () => { await harness.actions.closeSession('root', { preConfirmed: true }) })
- const current = harness.getState()
- const edited = {
- ...current,
- sessions: { ...current.sessions, second: { cwd: '/project', kind: 'codex' as const } },
- tabs: current.tabs.map(tab => ({ ...tab, root: {
- type: 'split' as const, direction: 'vertical' as const, ratio: 0.5,
- a: { type: 'leaf' as const, sessionId: 'worker' },
- b: { type: 'leaf' as const, sessionId: 'second' },
- } })),
- }
- const undo = mountUndoCloseAction(edited, harness.refs, vi.fn(async () => 'restored-root'))
- await act(async () => { await undo.actions.undoClose() })
- expect(undo.getState().tabs[0].root).toBe(edited.tabs[0].root)
- expect(undo.getState().detachedSessions['restored-root']).toMatchObject({ projectTabId: 'project' })
- expect(Object.keys(undo.getState().sessions).sort()).toEqual(['restored-root', 'second', 'worker'])
- undo.mounted.unmount()
- harness.mounted.unmount()
- })
-
- it('automation silentIfSoleTarget closes a root with a Dispatch sibling alone, with no dialog, promoting the sibling (m4)', async () => {
- // Behavior change for automation, called out in PR #887: this shape used to
- // expand to the whole tab and raise a dialog naming the requesting agent.
+ it('automation silentIfSoleTarget closes a session with siblings alone, with no dialog (m4)', async () => {
+ // Behavior change for automation, called out in PR #887: a tab's sole tile
+ // leaf used to expand to the whole tab and raise a dialog naming the
+ // requesting agent. It expands to exactly itself.
const harness = mountPaneActions(project())
let closing!: Promise
await act(async () => {
@@ -293,38 +268,45 @@ describe('root agent close scope (#153, #886)', () => {
expect(dialog).toBeNull()
expect(await closing).toBe(true)
expect(killed()).toEqual(['root'])
- expect(harness.getState().tabs[0].root).toEqual({ type: 'leaf', sessionId: 'worker' })
expect(harness.getState().sessions.worker).toBeDefined()
harness.mounted.unmount()
})
+
+ // Deleted with the tile tree (#992), each because its subject no longer exists:
+ // - "offers agent versus tab from the focused Dispatch row" — the scope
+ // choice (closing a tab's sole leaf);
+ // - "closes only the root after Close Agent, preserving and PROMOTING the
+ // live worker" and "promotes the next displayed row even when detached
+ // records were inserted out of order" — row promotion into an emptied tree;
+ // - "undo preserves a later split instead of forcing the old root layout
+ // back" — `replacedRoot`, undo's conditional reversal of a promotion.
})
describe('Close Focused Session without a Dispatch target (#886 review finding 1)', () => {
function tiledProject(lane: { selectedSessionId?: string }): WorkspaceState {
return {
- tabs: [
- // An idle sole grid leaf with no Dispatch rows: the shape the blocker
- // killed instantly, taking its whole project with it.
- { id: 'project', title: 'Project', root: { type: 'leaf', sessionId: 'root' }, focusedSessionId: 'root' },
- { id: 'other', title: 'Other', root: { type: 'leaf', sessionId: 'other-root' }, focusedSessionId: 'other-root' },
- ],
+ // `root` is an idle agent that no lane shows: the shape the blocker
+ // killed instantly (it was the tab's sole tile leaf then), taking its
+ // whole project with it.
+ tabs: [{ id: 'project', title: 'Project' }, { id: 'other', title: 'Other' }],
activeTabId: 'project',
sessions: {
- root: { cwd: '/project', kind: 'claude' },
- 'other-root': { cwd: '/other', kind: 'claude' },
+ root: { cwd: '/project', kind: 'claude', projectId: 'project', joinedAt: 0 },
+ 'other-root': { cwd: '/other', kind: 'claude', projectId: 'other', joinedAt: 0 },
},
- detachedSessions: {},
- // Project scope, so the other project's session is outside the visible rows.
- dispatchMode: { scope: 'project', tiled: { lanes: [lane], focusedLane: 0 } },
- gridRelatedSelections: {}, buried: [], pinnedSessionIds: [],
+ stage: { lanes: [lane], focusedLane: 0 },
+ pinnedSessionIds: [],
}
}
it.each([
['an empty lane', {}],
['a lane holding a dead session id', { selectedSessionId: 'closed-long-ago' }],
- ['a lane holding a session outside the visible scope', { selectedSessionId: 'other-root' }],
- ])('closes nothing for %s, never the hidden grid session', async (_label, lane) => {
+ // A third row lived here until #992: "a lane holding a session outside the
+ // visible scope" (another project's agent under project scope). There is
+ // no scope now — that lane SHOWS the agent, so closing it is correct and
+ // is covered by the case below instead of being refused here.
+ ])('closes nothing for %s, never an agent the user cannot see', async (_label, lane) => {
const state = tiledProject(lane)
const harness = mountPaneActions(state)
await act(async () => { await harness.actions.closeFocused() })
@@ -335,26 +317,36 @@ describe('Close Focused Session without a Dispatch target (#886 review finding 1
expect(harness.getState()).toBe(state)
harness.mounted.unmount()
})
+
+ it('closes another project s agent when that is what the focused lane shows', async () => {
+ // The stage has no project scope (#992): a lane may show any project's
+ // agent, and the destructive target is what is highlighted. The active
+ // project is still `project`; the lane shows `other-root`; the lane wins.
+ const harness = mountPaneActions(tiledProject({ selectedSessionId: 'other-root' }))
+ await act(async () => { await harness.actions.closeFocused() })
+ // The target is captured from the lane — never the active project's own
+ // agent, whatever happens after (a sole idle leaf may confirm or close).
+ const targeted = currentCloseConfirmation()?.request.targets.map(target => target.sessionId)
+ ?? killOwnedSession.mock.calls.map(call => (call[0] as { sessionId: string }).sessionId)
+ expect(targeted).toContain('other-root')
+ expect(targeted).not.toContain('root')
+ harness.mounted.unmount()
+ })
})
describe('linked cascade revalidates each approved session at its own kill (#886 review finding 2)', () => {
function parentWithTwoChildren(): WorkspaceState {
return {
- // The parent is a split leaf, so no root-scope choice is involved: this is
- // the ordinary gated close of a session with linked children.
- tabs: [{ id: 'project', title: 'Project', focusedSessionId: 'parent', root: {
- type: 'split', direction: 'vertical', ratio: 0.5,
- a: { type: 'leaf', sessionId: 'anchor' }, b: { type: 'leaf', sessionId: 'parent' },
- } }],
+ tabs: [{ id: 'project', title: 'Project' }],
activeTabId: 'project',
sessions: {
- anchor: { cwd: '/project', kind: 'claude' },
- parent: { cwd: '/project', kind: 'claude', title: 'Parent' },
- first: { cwd: '/project', kind: 'codex', linkedParentId: 'parent' },
- second: { cwd: '/project', kind: 'codex', linkedParentId: 'parent' },
+ anchor: { cwd: '/project', kind: 'claude', projectId: 'project', joinedAt: 0 },
+ parent: { cwd: '/project', kind: 'claude', title: 'Parent', projectId: 'project', joinedAt: 1 },
+ first: { cwd: '/project', kind: 'codex', linkedParentId: 'parent', projectId: 'project', joinedAt: 2 },
+ second: { cwd: '/project', kind: 'codex', linkedParentId: 'parent', projectId: 'project', joinedAt: 3 },
},
- detachedSessions: { first: dispatchRow('first', 'project', 1), second: dispatchRow('second', 'project', 2) },
- dispatchMode: null, gridRelatedSelections: {}, buried: [], pinnedSessionIds: [],
+ stage: oneLaneStage('parent'),
+ pinnedSessionIds: [],
}
}
@@ -385,7 +377,6 @@ describe('linked cascade revalidates each approved session at its own kill (#886
await act(async () => { release(); expect(await closing).toBe(false) })
expect(killed()).toEqual(['first'])
expect(Object.keys(harness.getState().sessions).sort()).toEqual(['anchor', 'parent', 'second'])
- expect(harness.getState().tabs[0].root).toEqual(parentWithTwoChildren().tabs[0].root)
// #886 review round 2 (Codex 3, N4): not a silent refusal. The toast names
// what closed, why the parent stayed, and that the second child stayed open
// too; the closed child is recoverable.
@@ -393,8 +384,8 @@ describe('linked cascade revalidates each approved session at its own kill (#886
`Closed 1 of 3 listed sessions — kept “Parent” open because a linked session is still open; 1 other session stayed open because it changed or failed to close${UNDO_HINT}`,
)
expect(harness.refs.undoStackRef.current.length).toBe(1)
- expect(harness.refs.undoStackRef.current.peek()).toMatchObject({ type: 'detached', record: { sessionId: 'first' } })
- expectValidTabs(harness.getState())
+ expect(harness.refs.undoStackRef.current.peek()).toMatchObject({ type: 'session', sessionId: 'first' })
+ expectEverySessionOwned(harness.getState())
harness.mounted.unmount()
})
@@ -402,8 +393,10 @@ describe('linked cascade revalidates each approved session at its own kill (#886
const { harness, closing, release } = await approveWithFirstKillHeld()
act(() => harness.setState(prev => ({
...prev,
- sessions: { ...prev.sessions, late: { cwd: '/project', kind: 'codex', linkedParentId: 'parent' } },
- detachedSessions: { ...prev.detachedSessions, late: dispatchRow('late', 'project', 3) },
+ sessions: {
+ ...prev.sessions,
+ late: { cwd: '/project', kind: 'codex', linkedParentId: 'parent', projectId: 'project', joinedAt: 4 },
+ },
})))
await act(async () => { release(); expect(await closing).toBe(false) })
// Both approved children close; the unapproved late child is never touched
@@ -416,7 +409,7 @@ describe('linked cascade revalidates each approved session at its own kill (#886
expect(harness.refs.undoStackRef.current.length).toBe(1)
expect(harness.refs.undoStackRef.current.peek()).toMatchObject({
type: 'group',
- entries: [{ record: { sessionId: 'first' } }, { record: { sessionId: 'second' } }],
+ entries: [{ type: 'session', sessionId: 'first' }, { type: 'session', sessionId: 'second' }],
})
// A group replays last-first. A transient spawn failure before anything came
@@ -431,34 +424,39 @@ describe('linked cascade revalidates each approved session at its own kill (#886
expect(Object.keys(undo.getState().sessions).sort()).toEqual(['anchor', 'late', 'parent'])
await act(async () => { await undo.actions.undoClose() })
const restored = undo.getState()
- expect(restored.sessions['first-2']?.linkedParentId).toBe('parent')
- expect(restored.sessions['second-2']?.linkedParentId).toBe('parent')
- expect(restored.detachedSessions['first-2']).toMatchObject({ projectTabId: 'project', detachedAt: 1 })
- expect(restored.detachedSessions['second-2']).toMatchObject({ projectTabId: 'project', detachedAt: 2 })
+ expect(restored.sessions['first-2']).toMatchObject({ linkedParentId: 'parent', projectId: 'project', joinedAt: 2 })
+ expect(restored.sessions['second-2']).toMatchObject({ linkedParentId: 'parent', projectId: 'project', joinedAt: 3 })
+ // Back in their old places: between the parent and the child linked later.
+ expect(buildVisibleDispatchRows(restored).map(row => row.sessionId))
+ .toEqual(['anchor', 'parent', 'first-2', 'second-2', 'late'])
expect(harness.refs.undoStackRef.current.length).toBe(0)
undo.mounted.unmount()
harness.mounted.unmount()
})
})
-describe('a cascade never promotes a session it is about to close (#886 review finding 3)', () => {
- /** Parent P detached to Dispatch; its linked child C is the tab's sole grid
- * leaf. Reached by attaching C beside P and detaching P, both supported. */
- function detachedParentWithRootChild(withUnrelatedRow: boolean): WorkspaceState {
+describe('a project leaves only with its last session (#886 review finding 3, round 2 N1)', () => {
+ // The two #886 findings this describe block replaces were both about the
+ // tile tree's root. Finding 3: closing parent P whose linked child C was the
+ // tab's sole leaf PROMOTED P into the root while closing C, then deleted P —
+ // a tab rooted at a deleted session. Round 2 N1: the fix (never promote a
+ // pending member) removed the tab instead, stranding a member that was then
+ // KEPT under a deleted tab. Both were the same question — "what keeps this
+ // project alive?" — answered by a structure. The answer is data now: a
+ // project exists while a session names it, decided at each commit against
+ // the live store. These cases pin that both failure shapes stay closed.
+
+ function parentAndChild(extra: WorkspaceState['sessions'] = {}): WorkspaceState {
return {
- tabs: [{ id: 'project', title: 'Project', root: { type: 'leaf', sessionId: 'child' }, focusedSessionId: 'child' }],
+ tabs: [{ id: 'project', title: 'Project' }],
activeTabId: 'project',
sessions: {
- parent: { cwd: '/project', kind: 'claude', title: 'Parent' },
- child: { cwd: '/project', kind: 'codex', linkedParentId: 'parent' },
- ...(withUnrelatedRow ? { other: { cwd: '/project', kind: 'claude' as const } } : {}),
+ child: { cwd: '/project', kind: 'codex', linkedParentId: 'parent', projectId: 'project', joinedAt: 0 },
+ parent: { cwd: '/project', kind: 'claude', title: 'Parent', projectId: 'project', joinedAt: 1 },
+ ...extra,
},
- detachedSessions: {
- parent: dispatchRow('parent', 'project', 1),
- ...(withUnrelatedRow ? { other: dispatchRow('other', 'project', 2) } : {}),
- },
- dispatchMode: { scope: 'project', focusedSessionId: 'parent' },
- gridRelatedSelections: {}, buried: [], pinnedSessionIds: [],
+ stage: oneLaneStage('parent'),
+ pinnedSessionIds: [],
}
}
@@ -469,42 +467,38 @@ describe('a cascade never promotes a session it is about to close (#886 review f
expect(currentCloseConfirmation()?.request.targets.map(t => t.sessionId)).toEqual(['parent', 'child'])
await act(async () => { resolveCloseConfirmation(true); expect(await closing).toBe(true) })
expect(killed()).toEqual(['child', 'parent'])
- expectValidTabs(harness.getState())
+ expectEverySessionOwned(harness.getState())
return harness
}
- it('removes the emptied project when nothing unrelated survives, recording the project as it was for undo', async () => {
- const harness = await closeApprovedParent(detachedParentWithRootChild(false))
+ it('removes the emptied project when nothing unrelated survives, recording it for undo', async () => {
+ const state = parentAndChild()
+ const harness = await closeApprovedParent(state)
expect(harness.getState().tabs).toEqual([])
expect(harness.getState().sessions).toEqual({})
- expect(harness.getState().detachedSessions).toEqual({})
- // Built from the approval snapshot: the child was the root and the parent a
- // row, so undo brings the project back in that shape, not rooted at
- // whichever session happened to close last.
expect(harness.refs.undoStackRef.current.peek()).toMatchObject({
type: 'tab',
- tab: { id: 'project', root: { type: 'leaf', sessionId: 'child' }, focusedSessionId: 'child' },
- sessionMetas: { child: { linkedParentId: 'parent' } },
- detachedEntries: [{ sessionId: 'parent', detachedAt: 1 }],
+ tab: { id: 'project', title: 'Project' },
+ tabIndex: 0,
+ sessions: [
+ { sessionId: 'child', meta: { linkedParentId: 'parent', joinedAt: 0 } },
+ { sessionId: 'parent', meta: { joinedAt: 1 } },
+ ],
})
harness.mounted.unmount()
})
- it('promotes the unrelated row, never the closing parent', async () => {
- const harness = await closeApprovedParent(detachedParentWithRootChild(true))
- expect(harness.getState().tabs).toEqual([expect.objectContaining({
- id: 'project', root: { type: 'leaf', sessionId: 'other' }, focusedSessionId: 'other',
- })])
+ it('keeps the project when an unrelated session survives', async () => {
+ const state = parentAndChild({ other: { cwd: '/project', kind: 'claude', projectId: 'project', joinedAt: 2 } })
+ const harness = await closeApprovedParent(state)
+ expect(harness.getState().tabs).toEqual(state.tabs)
expect(Object.keys(harness.getState().sessions)).toEqual(['other'])
- expect(harness.getState().detachedSessions).toEqual({})
harness.mounted.unmount()
})
-})
-describe('a member kept after an earlier commit stays placed (#886 review round 2 N1)', () => {
- /** Hold the sole grid leaf's kill so a pending member can change before its
- * own verdict — after the leaf's commit has already decided the project. */
- async function closeParentWithRootKillHeld(state: WorkspaceState, listed: string[], change: string) {
+ /** Hold the child's kill so a pending member can change before its own
+ * verdict — after the child's commit has already run. */
+ async function closeParentWithChildKillHeld(state: WorkspaceState, listed: string[], change: string) {
let release: ((owned: boolean) => void) | undefined
killOwnedSession.mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
const refs = makeRefs(state)
@@ -521,59 +515,35 @@ describe('a member kept after an earlier commit stays placed (#886 review round
return { harness, result }
}
- it('keeps a parent that starts working during its root child\'s kill as the project root, visible in Dispatch', async () => {
- const state: WorkspaceState = {
- tabs: [{ id: 'project', title: 'Project', root: { type: 'leaf', sessionId: 'child' }, focusedSessionId: 'child' }],
- activeTabId: 'project',
- sessions: {
- parent: { cwd: '/project', kind: 'claude', title: 'Parent' },
- child: { cwd: '/project', kind: 'codex', linkedParentId: 'parent' },
- },
- detachedSessions: { parent: dispatchRow('parent', 'project', 1) },
- dispatchMode: { scope: 'project', focusedSessionId: 'parent' },
- gridRelatedSelections: {}, buried: [], pinnedSessionIds: [],
- }
- const { harness, result } = await closeParentWithRootKillHeld(state, ['parent', 'child'], 'parent')
+ it('keeps a parent that starts working during its child s kill filed under a living project', async () => {
+ const { harness, result } = await closeParentWithChildKillHeld(parentAndChild(), ['parent', 'child'], 'parent')
expect(result).toBe(false)
expect(killed()).toEqual(['child'])
- // Round 1 removed the project here and left the working parent as a row
- // under a deleted tab. The fallback promotion keeps its project alive.
- expect(harness.getState().tabs).toEqual([expect.objectContaining({
- id: 'project', root: { type: 'leaf', sessionId: 'parent' }, focusedSessionId: 'parent',
- })])
+ // Round 1 of #886 removed the project here and left the working parent
+ // under a deleted tab: invisible, pruned by the next autosave, backend
+ // still running. The project stays because the parent still names it.
+ expect(harness.getState().tabs.map(tab => tab.id)).toEqual(['project'])
expect(buildVisibleDispatchRows(harness.getState()).map(row => row.sessionId)).toEqual(['parent'])
- expectValidTabs(harness.getState())
+ expectEverySessionOwned(harness.getState())
expect(harness.showToast).toHaveBeenLastCalledWith(
`Closed 1 of 2 listed sessions — kept “Parent” open because it changed${UNDO_HINT}`,
)
- // The closed child is recoverable: undo restores it as root and returns the
- // parent to its Dispatch row.
- expect(harness.refs.undoStackRef.current.peek()).toMatchObject({
- type: 'detached', record: { sessionId: 'child' }, replacedRoot: { sessionId: 'parent', detachedAt: 1 },
- })
+ // The closed child is recoverable, as itself.
+ expect(harness.refs.undoStackRef.current.peek()).toMatchObject({ type: 'session', sessionId: 'child' })
harness.mounted.unmount()
})
- it('keeps both the parent and a second child that changes during the root child\'s kill placed and visible', async () => {
- const state: WorkspaceState = {
- tabs: [{ id: 'project', title: 'Project', root: { type: 'leaf', sessionId: 'child' }, focusedSessionId: 'child' }],
- activeTabId: 'project',
- sessions: {
- parent: { cwd: '/project', kind: 'claude', title: 'Parent' },
- child: { cwd: '/project', kind: 'codex', linkedParentId: 'parent' },
- second: { cwd: '/project', kind: 'codex', linkedParentId: 'parent' },
- },
- detachedSessions: { parent: dispatchRow('parent', 'project', 1), second: dispatchRow('second', 'project', 2) },
- dispatchMode: { scope: 'project', focusedSessionId: 'parent' },
- gridRelatedSelections: {}, buried: [], pinnedSessionIds: [],
- }
- const { harness, result } = await closeParentWithRootKillHeld(state, ['parent', 'child', 'second'], 'second')
+ it('keeps both the parent and a second child that changes during the first child s kill visible', async () => {
+ const state = parentAndChild({
+ second: { cwd: '/project', kind: 'codex', linkedParentId: 'parent', projectId: 'project', joinedAt: 2 },
+ })
+ const { harness, result } = await closeParentWithChildKillHeld(state, ['parent', 'child', 'second'], 'second')
expect(result).toBe(false)
expect(killed()).toEqual(['child'])
const after = harness.getState()
expect(after.tabs).toHaveLength(1)
expect(buildVisibleDispatchRows(after).map(row => row.sessionId).sort()).toEqual(['parent', 'second'])
- expectValidTabs(after)
+ expectEverySessionOwned(after)
expect(harness.showToast).toHaveBeenLastCalledWith(
`Closed 1 of 3 listed sessions — kept “Parent” open because a linked session is still open; 1 other session stayed open because it changed or failed to close${UNDO_HINT}`,
)
@@ -581,8 +551,8 @@ describe('a member kept after an earlier commit stays placed (#886 review round
})
})
-describe('undo keeps close lineage across promoted roots (#886 review finding 4)', () => {
- it('two agents: close A, close B, undo, undo restores A as root with B back as its row', async () => {
+describe('undo keeps close lineage across a removed project (#886 review finding 4)', () => {
+ it('two agents: close A, close B (the project goes), undo, undo brings back the project with both, in order', async () => {
const harness = mountPaneActions(project())
await act(async () => { await harness.actions.closeSession('root', { preConfirmed: true }) })
await act(async () => { await harness.actions.closeSession('worker', { preConfirmed: true }) })
@@ -590,29 +560,34 @@ describe('undo keeps close lineage across promoted roots (#886 review finding 4)
const spawn = vi.fn().mockResolvedValueOnce('worker-2').mockResolvedValueOnce('root-2')
const undo = mountUndoCloseAction(harness.getState(), harness.refs, spawn)
+ // First undo re-creates the project under a NEW id with the worker...
await act(async () => { await undo.actions.undoClose() })
+ const newProjectId = undo.getState().tabs[0]!.id
+ expect(newProjectId).not.toBe('project')
+ // ...and publishes project -> newProjectId, which is the only reason the
+ // older entry (A, anchored on the dead id) is still restorable.
await act(async () => { await undo.actions.undoClose() })
const restored = undo.getState()
expect(spawn).toHaveBeenCalledTimes(2)
- expect(restored.tabs).toHaveLength(1)
- expect(restored.tabs[0].root).toEqual({ type: 'leaf', sessionId: 'root-2' })
- // The worker regains its original record, detachedAt included.
- expect(restored.detachedSessions['worker-2']).toMatchObject({ projectTabId: restored.tabs[0].id, detachedAt: 1 })
+ expect(restored.tabs).toEqual([{ id: newProjectId, title: 'Project' }])
+ expect(restored.sessions['root-2']).toMatchObject({ projectId: newProjectId, joinedAt: 0 })
+ expect(restored.sessions['worker-2']).toMatchObject({ projectId: newProjectId, joinedAt: 1 })
expect(buildVisibleDispatchRows(restored).map(row => row.sessionId)).toEqual(['root-2', 'worker-2'])
expect(harness.refs.undoStackRef.current.length).toBe(0)
+ expectEverySessionOwned(restored)
undo.mounted.unmount()
harness.mounted.unmount()
})
- it('three agents: close A, close B, undo, undo restores the original root and row order', async () => {
+ it('three agents: close A, close B, undo, undo restores the original order around the survivor', async () => {
const state = project()
- state.sessions.second = { cwd: '/project', kind: 'codex' }
- state.detachedSessions.second = { ...state.detachedSessions.worker, sessionId: 'second', detachedAt: 2 }
+ state.sessions.second = { cwd: '/project', kind: 'codex', projectId: 'project', joinedAt: 2 }
const harness = mountPaneActions(state)
await act(async () => { await harness.actions.closeSession('root', { preConfirmed: true }) })
await act(async () => { await harness.actions.closeSession('worker', { preConfirmed: true }) })
- expect(harness.getState().tabs[0].root).toEqual({ type: 'leaf', sessionId: 'second' })
+ // The project survives both closes: `second` still names it.
+ expect(harness.getState().tabs.map(tab => tab.id)).toEqual(['project'])
const spawn = vi.fn().mockResolvedValueOnce('worker-2').mockResolvedValueOnce('root-2')
const undo = mountUndoCloseAction(harness.getState(), harness.refs, spawn)
@@ -621,18 +596,16 @@ describe('undo keeps close lineage across promoted roots (#886 review finding 4)
const restored = undo.getState()
expect(restored.tabs.map(tab => tab.id)).toEqual(['project'])
- expect(restored.tabs[0].root).toEqual({ type: 'leaf', sessionId: 'root-2' })
expect(buildVisibleDispatchRows(restored).map(row => row.sessionId)).toEqual(['root-2', 'worker-2', 'second'])
- expect(restored.detachedSessions['worker-2']).toMatchObject({ detachedAt: 1 })
- expect(restored.detachedSessions.second).toMatchObject({ detachedAt: 2 })
+ expect(restored.sessions.second).toBe(state.sessions.second)
undo.mounted.unmount()
harness.mounted.unmount()
})
it('still treats an entry whose project was merged away as stale instead of resurrecting it (#914)', async () => {
const state = project()
- state.tabs.push({ id: 'target', title: 'Target', root: { type: 'leaf', sessionId: 'target-root' }, focusedSessionId: 'target-root' })
- state.sessions['target-root'] = { cwd: '/target', kind: 'claude' }
+ state.tabs.push({ id: 'target', title: 'Target' })
+ state.sessions['target-root'] = { cwd: '/target', kind: 'claude', projectId: 'target', joinedAt: 0 }
const harness = mountPaneActions(state)
await act(async () => { await harness.actions.closeSession('root', { preConfirmed: true }) })
act(() => harness.setState(prev => {
@@ -654,51 +627,78 @@ describe('undo keeps close lineage across promoted roots (#886 review finding 4)
})
describe('Close Tab executes the plan the dialog listed (#886 review finding 5)', () => {
- it('closes a linked child attached in another project before its parent, and captures it for undo', async () => {
- const state: WorkspaceState = {
- tabs: [
- { id: 'a', title: 'A', root: { type: 'leaf', sessionId: 'parent' }, focusedSessionId: 'parent' },
- { id: 'b', title: 'B', focusedSessionId: 'anchor', root: {
- type: 'split', direction: 'vertical', ratio: 0.5,
- a: { type: 'leaf', sessionId: 'anchor' }, b: { type: 'leaf', sessionId: 'child' },
- } },
- ],
+ function twoProjects(): WorkspaceState {
+ return {
+ tabs: [{ id: 'a', title: 'A' }, { id: 'b', title: 'B' }],
activeTabId: 'a',
sessions: {
- parent: { cwd: '/a', kind: 'claude', title: 'Parent' },
- worker: { cwd: '/a', kind: 'codex' },
- // Attached beside a pane of project B; attachment keeps linkedParentId.
- child: { cwd: '/a', kind: 'codex', linkedParentId: 'parent' },
- anchor: { cwd: '/b', kind: 'claude' },
+ parent: { cwd: '/a', kind: 'claude', title: 'Parent', projectId: 'a', joinedAt: 0 },
+ worker: { cwd: '/a', kind: 'codex', projectId: 'a', joinedAt: 1 },
+ anchor: { cwd: '/b', kind: 'claude', projectId: 'b', joinedAt: 0 },
+ // Filed under project B, but linked to a parent in A: linkage is a
+ // lifecycle bond, membership is a label, and they need not agree.
+ child: { cwd: '/a', kind: 'codex', linkedParentId: 'parent', projectId: 'b', joinedAt: 1 },
},
- detachedSessions: { worker: dispatchRow('worker', 'a', 1) },
- dispatchMode: null, gridRelatedSelections: {}, buried: [], pinnedSessionIds: [],
+ stage: oneLaneStage('parent'),
+ pinnedSessionIds: [],
}
+ }
+
+ it('closes a linked child filed in another project before its parent, and captures it for undo', async () => {
+ const state = twoProjects()
const harness = mountPaneActions(state)
- let closing!: Promise
- await act(async () => { closing = harness.actions.closeSession('parent') })
+ let closing!: Promise
+ await act(async () => { closing = harness.actions.closeTab('a') })
const listed = currentCloseConfirmation()?.request.targets.map(t => t.sessionId)
expect(listed).toEqual(['parent', 'child', 'worker'])
- await act(async () => { resolveCloseConfirmation(true); expect(await closing).toBe(true) })
+ await act(async () => { resolveCloseConfirmation(true); await closing })
// Exactly the listed set, the linked child before its parent.
expect([...killed()].sort()).toEqual([...listed!].sort())
expect(killed().indexOf('child')).toBeLessThan(killed().indexOf('parent'))
- expect(harness.getState().tabs).toEqual([expect.objectContaining({
- id: 'b', root: { type: 'leaf', sessionId: 'anchor' }, focusedSessionId: 'anchor',
- })])
+ expect(harness.getState().tabs).toEqual([{ id: 'b', title: 'B' }])
expect(Object.keys(harness.getState().sessions)).toEqual(['anchor'])
expect(harness.getState().activeTabId).toBe('b')
- expectValidTabs(harness.getState())
- // One unit: the child's pane in B (so undo reinserts it where it was), then
- // project A with its row folded in.
+ expectEverySessionOwned(harness.getState())
+ // One unit: the child as a session of B (its project survived), then
+ // project A with its two sessions folded in.
expect(harness.refs.undoStackRef.current.peek()).toMatchObject({
type: 'group',
entries: [
- { type: 'pane', sessionId: 'child', tabId: 'b' },
- { type: 'tab', tab: { id: 'a' }, detachedEntries: [{ sessionId: 'worker' }] },
+ { type: 'session', sessionId: 'child', sessionMeta: { projectId: 'b' } },
+ { type: 'tab', tab: { id: 'a' }, sessions: [{ sessionId: 'parent' }, { sessionId: 'worker' }] },
],
})
harness.mounted.unmount()
})
+
+ it('undo re-nests the linked child under its restored parent', async () => {
+ // #886 review round 2 N3: the restore must re-point the child at its
+ // parent's NEW id, or it comes back un-nested and no longer closes with it.
+ const state = twoProjects()
+ state.sessions.child = { ...state.sessions.child!, projectId: 'a', joinedAt: 2 }
+ const harness = mountPaneActions(state)
+ let closing!: Promise
+ await act(async () => { closing = harness.actions.closeTab('a') })
+ await act(async () => { resolveCloseConfirmation(true); await closing })
+ expect(harness.refs.undoStackRef.current.peek()).toMatchObject({
+ type: 'tab', sessions: [{ sessionId: 'parent' }, { sessionId: 'worker' }, { sessionId: 'child' }],
+ })
+
+ const spawn = vi.fn()
+ .mockResolvedValueOnce('parent-2')
+ .mockResolvedValueOnce('worker-2')
+ .mockResolvedValueOnce('child-2')
+ const undo = mountUndoCloseAction(harness.getState(), harness.refs, spawn)
+ await act(async () => { await undo.actions.undoClose() })
+ const restored = undo.getState()
+ expect(restored.sessions['child-2']?.linkedParentId).toBe('parent-2')
+ const rows = buildVisibleDispatchRows(restored)
+ expect(rows.find(row => row.sessionId === 'child-2')?.depth).toBe(1)
+ expect(rows.find(row => row.sessionId === 'worker-2')?.depth).toBe(0)
+ // Re-inserted where it was: first, ahead of B.
+ expect(restored.tabs.map(tab => tab.title)).toEqual(['A', 'B'])
+ undo.mounted.unmount()
+ harness.mounted.unmount()
+ })
})
diff --git a/src/renderer/src/workspace/hook/actions/controlPlacement.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/controlPlacement.renderer.test.tsx
index b0718ce2b..9eef4d996 100644
--- a/src/renderer/src/workspace/hook/actions/controlPlacement.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/actions/controlPlacement.renderer.test.tsx
@@ -1,42 +1,60 @@
import { act } from '@testing-library/react'
import { expect, it } from 'vitest'
import { mountPaneActions } from './testing/paneActionsHarness'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
+import { resolveTabSessions } from '@renderer/workspace/queries'
import type { WorkspaceState } from '@renderer/workspace/types'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
function state(): WorkspaceState {
- return { activeTabId: 'project', dispatchMode: null, pinnedSessionIds: [], detachedSessions: {}, buried: [],
- tabs: [{ id: 'project', title: 'Project', root: { type: 'leaf', sessionId: 'anchor' }, focusedSessionId: 'anchor' }],
- sessions: { anchor: { kind: 'claude', cwd: '/project' } } }
+ return { activeTabId: 'project', stage: oneLaneStage('anchor'), pinnedSessionIds: [],
+ tabs: [{ id: 'project', title: 'Project' }],
+ sessions: { anchor: { kind: 'claude', cwd: '/project', projectId: 'project', joinedAt: 0 } } }
}
-it('returns the exact created ID with project affinity and leaves the grid intact', async () => {
+// "Leaves the grid intact" was this suite's recurring assertion: a control-plane
+// create parked the new agent in the detached bucket and must not have touched
+// the tab's tile tree. The tree is gone (#992), so that half has nothing left
+// to assert. What a create DOES touch is pinned instead, precisely: it files
+// the agent under the requested project, and — unless the caller passes
+// `selectCreated: false` — never moves a lane that already shows a session.
+//
+// The lane default was briefly "replace the focused lane's occupant" and was
+// pinned here as KNOWN NOT ENDORSED. Context-places (#992 §4.3, stage 4)
+// replaced it: an occupied lane is never displaced, so a control create lands
+// in the pool with nothing on screen moving. The fill case has its own test
+// below with an empty focused lane.
+it('returns the exact created ID, files it under the project, and moves nothing on screen', async () => {
const initial = state()
const harness = mountPaneActions(initial, { spawnSessionId: 'exact-created-agent' })
await act(async () => {
expect(await harness.actions.createDetachedDispatchAgent({ kind: 'codex' }, { tabId: 'project', anchorSessionId: 'anchor' }))
.toBe('exact-created-agent')
})
- expect(harness.getState().detachedSessions['exact-created-agent']).toMatchObject({ projectTabId: 'project' })
- expect(harness.getState().tabs[0].root).toEqual(initial.tabs[0].root)
+ expect(harness.getState().sessions['exact-created-agent']).toMatchObject({ projectId: 'project', joinedAt: expect.any(Number) })
+ // Appended: a new agent never jumps the queue in its project's index.
+ expect(resolveTabSessions(harness.getState(), 'project')).toEqual(['anchor', 'exact-created-agent'])
+ // The focused lane still shows `anchor`, by reference: not even a focus
+ // move. The caller places the returned ID with an explicit lane-select.
+ expect(harness.getState().stage).toBe(initial.stage)
expect(harness.spawn).toHaveBeenCalledTimes(1)
harness.mounted.unmount()
})
-it('restores a buried record under its existing ID without spawning another agent', async () => {
+it('fills the focused lane when it is EMPTY and selectCreated is not false', async () => {
const initial = state()
- initial.sessions.archived = { kind: 'codex', cwd: '/project' }
- initial.buried = [{ id: 'buried-record', sessionId: 'archived', sessionMeta: initial.sessions.archived,
- buriedAt: 1, sourceTabId: 'project', sourceTabTitle: 'Project', sourceTabIndex: 0 }]
- const harness = mountPaneActions(initial)
- await act(async () => { await harness.actions.reviveBuried('buried-record') })
- expect(harness.sessionActions.ensureSessionLive).toHaveBeenCalledWith('archived', 'pane.revive-buried')
- expect(harness.getState().buried).toEqual([])
- expect(collectLeaves(harness.getState().tabs[0].root).filter(id => id === 'archived')).toHaveLength(1)
- expect(harness.spawn).not.toHaveBeenCalled()
+ initial.stage = { focusedLane: 0, lanes: [{}, { selectedSessionId: 'anchor' }] }
+ const harness = mountPaneActions(initial, { spawnSessionId: 'exact-created-agent' })
+ await act(async () => {
+ expect(await harness.actions.createDetachedDispatchAgent({ kind: 'codex' }, { tabId: 'project', anchorSessionId: 'anchor' }))
+ .toBe('exact-created-agent')
+ })
+ // Lane 0 (focused, empty) fills; lane 1 keeps `anchor`.
+ expect(harness.getState().stage.lanes.map(lane => lane.selectedSessionId))
+ .toEqual(['exact-created-agent', 'anchor'])
harness.mounted.unmount()
})
+
it('keeps native continuation cwd and target project separate from focus', async () => {
const initial = state()
const harness = mountPaneActions(initial, { spawnSessionId: 'resumed-agent' })
@@ -46,21 +64,26 @@ it('keeps native continuation cwd and target project separate from focus', async
.toBe('resumed-agent')
})
expect(harness.spawn).toHaveBeenCalledExactlyOnceWith('/native-worktree', expect.objectContaining({ kind: 'opencode', providerRuntime: 'terminal', resumeSessionId: 'ses_native', builtInMcpOverrides: { orchestration: true } }))
- expect(harness.getState().detachedSessions['resumed-agent'].projectTabId).toBe('project')
- expect(harness.getState().tabs[0].root).toEqual(initial.tabs[0].root)
+ // Filed under the TARGET project even though its cwd is somewhere else
+ // entirely: a project is a label the caller chose, not a directory match.
+ expect(harness.getState().sessions['resumed-agent']).toMatchObject({ projectId: 'project', cwd: '/native-worktree' })
+ // Occupied focused lane ⇒ pooled, not displacing.
+ expect(harness.getState().stage).toBe(initial.stage)
harness.mounted.unmount()
})
// Reproduce the operator's two-lane creation observation through the real
-// placement owner: detached membership must not imply preserved selection.
-it.each([true, false])('creation selectCreated=%s preserves or replaces the captured lane explicitly', async selectCreated => {
+// placement owner. The focused lane is EMPTY here (the fill-eligible case):
+// `selectCreated` is the only thing that decides whether the new agent takes
+// it. With an occupied focused lane both values now pool — see the first case.
+it.each([true, false])('creation selectCreated=%s fills or preserves the focused EMPTY lane explicitly', async selectCreated => {
const initial = state()
- initial.sessions.hermes = { kind: 'codex', cwd: '/other' }
- initial.tabs.push({ id: 'other', title: 'Other', root: { type: 'leaf', sessionId: 'hermes' }, focusedSessionId: 'hermes' })
+ initial.sessions.hermes = { kind: 'codex', cwd: '/other', projectId: 'other', joinedAt: 0 }
+ initial.tabs.push({ id: 'other', title: 'Other' })
initial.activeTabId = 'other'
- initial.dispatchMode = { scope: 'global', focusedSessionId: 'anchor', tiled: {
- focusedLane: 1, lanes: [{ selectedSessionId: 'anchor' }, { selectedSessionId: 'hermes' }],
- } }
+ initial.stage = {
+ focusedLane: 1, lanes: [{ selectedSessionId: 'anchor' }, {}],
+ }
const harness = mountPaneActions(initial, { spawnSessionId: 'new-agent' })
await act(async () => {
await harness.actions.createDetachedSession({ kind: 'codex' },
@@ -68,10 +91,9 @@ it.each([true, false])('creation selectCreated=%s preserves or replaces the capt
})
const next = harness.getState()
expect(next.activeTabId).toBe(selectCreated ? 'project' : 'other')
- expect(next.dispatchMode?.tiled?.lanes.map(lane => lane.selectedSessionId))
- .toEqual(['anchor', selectCreated ? 'new-agent' : 'hermes'])
- expect(next.sessions.hermes).toEqual(initial.sessions.hermes)
- expect(next.detachedSessions['new-agent'].projectTabId).toBe('project')
+ expect(next.stage.lanes.map(lane => lane.selectedSessionId))
+ .toEqual(['anchor', selectCreated ? 'new-agent' : undefined])
+ expect(next.sessions['new-agent']?.projectId).toBe('project')
expect(harness.sessionActions.killSession).not.toHaveBeenCalled()
harness.mounted.unmount()
})
diff --git a/src/renderer/src/workspace/hook/actions/dispatch.ts b/src/renderer/src/workspace/hook/actions/dispatch.ts
index 7b190b1bf..1e9f49043 100644
--- a/src/renderer/src/workspace/hook/actions/dispatch.ts
+++ b/src/renderer/src/workspace/hook/actions/dispatch.ts
@@ -2,25 +2,16 @@ import { useCallback } from 'react'
import type {
DispatchGridRow,
- DispatchLane,
- DispatchModeState,
SessionId,
SessionMeta,
TabId,
WorkspaceState,
} from '@renderer/workspace/types'
-import {
- clampTileCount,
- dispatchEntrySeedSessionId,
- withLaneSession,
-} from '@renderer/workspace/dispatch/tiledDispatchSelectors'
+import { withLaneSession } from '@renderer/workspace/dispatch/tiledDispatchSelectors'
import type { GridShapeRow } from '@renderer/workspace/dispatch/gridShape'
import {
clampIndexFraction,
insertLaneRightIntoGrid,
- MAX_DISPATCH_LANES,
- MAX_DISPATCH_ROWS,
- MIN_DISPATCH_TILES,
insertRowBelowInGrid,
normalizeGridShape,
removeLaneFromGrid,
@@ -30,12 +21,13 @@ import {
setGridShape,
} from '@renderer/workspace/dispatch/gridShape'
import type {
+ WorkspaceSetRuntimes,
WorkspaceSetState,
- WorkspaceSetTileTabs,
} from '@renderer/workspace/hook/context'
import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
import type { SessionActions } from '@renderer/workspace/hook/actions/session'
import { isProcessSessionKind } from '@shared/types/providerKind'
+import { clearPooledSpawnBadge } from '@renderer/workspace/hook/actions/pooledSpawnBadge'
/**
* Write row METADATA without touching any row length.
@@ -55,60 +47,38 @@ function patchRow(
rowIndex: number,
patch: Partial>,
): WorkspaceState {
- const tiled = prev.dispatchMode?.tiled
- if (!tiled) return prev
+ const tiled = prev.stage
const grid = normalizeGridShape(tiled)
if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= grid.rows.length) {
return prev
}
return {
...prev,
- dispatchMode: {
- ...prev.dispatchMode!,
- tiled: {
- ...tiled,
- rows: grid.rows.map((row, i) => (i === rowIndex ? { ...row, ...patch } : row)),
- // Carried explicitly: normalizeGridShape may have just split a legacy
- // `ratios` array, and spreading `tiled` alone would put the stale one
- // back beside the fields it was split into.
- laneWeights: grid.laneWeights,
- ratios: undefined,
- },
+ stage: {
+ ...tiled,
+ rows: grid.rows.map((row, i) => (i === rowIndex ? { ...row, ...patch } : row)),
+ // Carried explicitly: normalizeGridShape may have just split a legacy
+ // `ratios` array, and spreading `tiled` alone would put the stale one
+ // back beside the fields it was split into.
+ laneWeights: grid.laneWeights,
+ ratios: undefined,
},
}
}
-/**
- * N blank lanes.
- *
- * Each lane is a fresh object rather than a shared literal: lanes are spread
- * and replaced individually by every writer, and a shared reference would make
- * two lanes alias one another the first time someone mutated instead of spread.
- */
-function emptyLanes(count: number): DispatchLane[] {
- return Array.from({ length: Math.max(0, count) }, () => ({}))
-}
-
export function useDispatchActions(
- state: { activeTabId: TabId; dispatchMode: DispatchModeState | null; sessions: Record },
setState: WorkspaceSetState,
- setTileTabs: WorkspaceSetTileTabs,
- closeNewAgentPlacement: () => void,
+ setRuntimes: WorkspaceSetRuntimes,
refs: WorkspaceRefs,
ensureSessionLive: SessionActions['ensureSessionLive'],
showToast: (message: string, durationMs?: number) => void,
): {
- enterDispatchMode: (scope?: DispatchModeState['scope']) => Promise
- exitDispatchMode: () => void
- setDispatchScope: (scope: DispatchModeState['scope']) => Promise
- focusDispatchSession: (tabId: TabId, sessionId: SessionId) => void
pinSession: (sessionId: SessionId) => void
unpinSession: (sessionId: SessionId) => void
setPinnedSessionIds: (ids: SessionId[]) => void
- // ---- Tiled Dispatch (issue #248) ----
- enterTiledDispatch: (rowLengths: number[]) => Promise
- exitTiledDispatch: () => void
+ // ---- Lanes (issue #248) ----
selectTiledLaneSession: (laneIndex: number, sessionId: SessionId) => Promise
+ clearTiledLane: (laneIndex: number) => void
insertTiledLaneRight: (laneIndex: number) => boolean
removeTiledLane: (laneIndex: number) => void
setTiledFocusedLane: (laneIndex: number) => void
@@ -123,191 +93,17 @@ export function useDispatchActions(
setDispatchRowCapChildren: (rowIndex: number, cap: boolean) => void
toggleDispatchRowExpandedParent: (rowIndex: number, sessionId: SessionId) => void
} {
- const enterDispatchMode = useCallback(
- // Global is the default scope (#973): entering Dispatch from a workspace
- // that never used it should show the whole fleet, matching what a fresh
- // install boots into. A persisted scope always wins over this fallback.
- async (scope: DispatchModeState['scope'] = state.dispatchMode?.scope ?? 'global') => {
- closeNewAgentPlacement()
- setState(prev => ({
- ...prev,
- dispatchMode: {
- scope,
- focusedSessionId: prev.dispatchMode?.focusedSessionId,
- },
- }))
- setTileTabs(null)
- },
- [closeNewAgentPlacement, setState, setTileTabs, state.dispatchMode?.scope],
- )
-
- const exitDispatchMode = useCallback(() => {
- setState(prev => ({
- ...prev,
- dispatchMode: null,
- }))
- }, [setState])
-
- const setDispatchScope = useCallback(
- async (scope: DispatchModeState['scope']) => {
- closeNewAgentPlacement()
- setState(prev => ({
- ...prev,
- dispatchMode: {
- scope,
- focusedSessionId: prev.dispatchMode?.focusedSessionId,
- },
- }))
- // Same rationale as enterDispatchMode: terminal mount is now the
- // DispatchLayout effect's responsibility, gated by the global
- // setting. Re-entering with a different scope must NOT spawn a
- // terminal behind the setting's back.
- },
- [closeNewAgentPlacement, setState],
- )
-
- const focusDispatchSession = useCallback(
- (tabId: TabId, sessionId: SessionId) => {
- setState(prev => {
- if (!prev.dispatchMode) return { ...prev, activeTabId: tabId }
- // WHY not update Tab.focusedSessionId here: Dispatch rows can now be
- // detached from the grid, while Tab.focusedSessionId is a tile-tree
- // invariant used by resize, reader, spotlight, and normal pane
- // commands. Dispatch focus is a mode-local selection; activeTabId still
- // follows it so project-scoped chrome and terminal selection stay in
- // sync with the visible command-center row.
- return {
- ...prev,
- activeTabId: tabId,
- dispatchMode: {
- ...prev.dispatchMode,
- focusedSessionId: sessionId,
- },
- }
- })
- },
- [setState],
- )
-
- // ---- Tiled Dispatch reducers (issue #248) ----
- //
- // These all read/write `dispatchMode.tiled`. The `tiled` block being
- // present is the single render fork (DispatchLayout renders the
- // multi-lane layout iff it exists). Every reducer is a no-op when there
- // is no dispatchMode/tiled, so a stray call from a stale keybind or
- // command can never corrupt classic Dispatch. Duplicates across lanes are
- // allowed (the views mirror — see DispatchLane), so these reducers no
- // longer reject a session that's open elsewhere.
-
- // Enter (or freshly build) a Tiled Dispatch layout. Enters Dispatch if it
- // wasn't already on and clears tiled-tabs (mutually exclusive top-level mode).
- //
- // The lanes other than lane 0 arrive EMPTY (#681). This used to auto-fill
- // from unclaimed visible agents on the theory that asking for N tiles means
- // wanting to see N agents. The cost of that convenience was a layout that
- // rearranges itself: the same helper ran on growth, and the render-time
- // healer ran on every unresolved lane, so killing an agent replaced it with
- // an unrelated one. Making entry the single exception would have left the
- // user unable to predict which of their slots the app feels entitled to
- // fill.
- //
- // Lane 0 is the one deliberate exception (#977): it is seeded with the
- // session the user was ALREADY focused on — classic Dispatch's focus, an
- // existing grid's focused lane, or the grid pane they left behind. That is
- // continuity with what they were commanding, not a prediction from the
- // index, so it does not reopen #681. A missing or buried focus id resolves
- // to null and lane 0 stays empty exactly like every other lane.
+ // enterDispatchMode, exitDispatchMode, setDispatchScope, focusDispatchSession,
+ // enterTiledDispatch and exitTiledDispatch lived here until #992. They
+ // turned the lane grid ON and OFF, switched a layout-wide project/global
+ // scope, and tracked a classic single-selection focus. The stage is a
+ // required field now: nothing is entered or exited, every index lists every
+ // project (a row's projectTabIds is the only filter), and the focused lane
+ // is the one focus. The entry seed (#977) survives only in the v2 migration.
//
- // A DETACHED seed is woken BEFORE the write (#690 parity): the seed is a
- // lane placement like any other, and a hibernated agent written into a lane
- // unwoken renders a pane that rejects the first prompt with "not a live
- // agent session". In an ordinary session every dispatch agent is detached,
- // so the wake is the COMMON path here, not an exception. Grid-placed seeds
- // skip it — same predicate selectTiledLaneSession uses — because rehydrate
- // already respawned those. A failed wake costs the seed, never the entry:
- // the user asked for a grid, and the toast reports what was declined.
- const enterTiledDispatch = useCallback(
- async (rowLengths: number[]) => {
- closeNewAgentPlacement()
- // Resolved from the live ref, not the hook's render snapshot: focus may
- // have moved since the command was admitted, and seeding an agent the
- // user is no longer commanding would be a guess.
- let candidate = dispatchEntrySeedSessionId(refs.stateRef.current)
- if (candidate && refs.stateRef.current.detachedSessions[candidate] !== undefined) {
- try {
- await ensureSessionLive(candidate, 'grid-dispatch.entry-seed')
- } catch (error) {
- showToast(
- error instanceof Error && error.message.length > 0
- ? error.message
- : 'Could not wake agent',
- )
- candidate = null
- }
- }
- setState(prev => {
- // Same default-scope rationale as enterDispatchMode above (#973):
- // whole fleet, matching a fresh install, unless a persisted scope wins.
- const scope = prev.dispatchMode?.scope ?? 'global'
- // Takes a length PER ROW rather than a single count, because the grid
- // is ragged by design and entering it should be able to express that
- // in one step. A count would force the user into a rectangle and then
- // make them edit their way out of it.
- const rows = rowLengths
- .slice(0, MAX_DISPATCH_ROWS)
- .map(length => ({ length: clampTileCount(length) }))
- const capped: { length: number }[] = []
- let total = 0
- for (const row of rows) {
- const length = Math.min(row.length, MAX_DISPATCH_LANES - total)
- if (length < MIN_DISPATCH_TILES) break
- capped.push({ length })
- total += length
- }
- const shape = capped.length > 0 ? capped : [{ length: clampTileCount(1) }]
- const lanes = emptyLanes(shape.reduce((sum, row) => sum + row.length, 0))
- // Re-resolved and IDENTITY-MATCHED against the validated candidate.
- // The wake window is up to 30s cold; if focus moved underneath it,
- // the new focus has been neither validated nor woken on this path,
- // and raw-writing it from inside this sync updater would reopen the
- // exact #690 gap the wake above closes. Dropping the seed mirrors
- // selectTiledLaneSession's membership-change drop: predictable over
- // clever.
- const resolved = dispatchEntrySeedSessionId(prev)
- if (candidate !== null && resolved === candidate) {
- lanes[0] = withLaneSession(lanes[0]!, candidate)
- }
- return {
- ...prev,
- dispatchMode: {
- scope,
- focusedSessionId: prev.dispatchMode?.focusedSessionId,
- tiled: {
- lanes,
- rows: shape,
- // Focus on the seeded lane: the agent the user was commanding
- // stays the agent every keyboard command targets. With no seed
- // this is simply the left edge of the grid, as before.
- focusedLane: 0,
- },
- },
- }
- })
- setTileTabs(null)
- },
- [closeNewAgentPlacement, ensureSessionLive, refs, setState, setTileTabs, showToast],
- )
-
- // Return to classic single-view Dispatch. Agents keep running — we only
- // drop the `tiled` block. (Exiting Dispatch entirely via exitDispatchMode
- // already drops it along with the rest of dispatchMode.)
- const exitTiledDispatch = useCallback(() => {
- setState(prev => {
- if (!prev.dispatchMode?.tiled) return prev
- const { tiled: _tiled, ...rest } = prev.dispatchMode
- return { ...prev, dispatchMode: { ...rest } }
- })
- }, [setState])
+ // Every reducer below reads and writes `stage` directly. They used to guard
+ // on `dispatchMode?.tiled` being present; that guard is gone because the
+ // state it protected against can no longer be represented.
// Assign a lane's agent. NOT exposed on the workspace: every caller must go
// through `selectTiledLaneSession` below, which wakes a hibernated agent
@@ -321,27 +117,31 @@ export function useDispatchActions(
// harmless, and a no-op when the lane already shows this session.
const setTiledLaneSession = useCallback(
(laneIndex: number, sessionId: SessionId) => {
+ let wrote = false
setState(prev => {
- const tiled = prev.dispatchMode?.tiled
- if (!tiled) return prev
+ const tiled = prev.stage
if (laneIndex < 0 || laneIndex >= tiled.lanes.length) return prev
if (tiled.lanes[laneIndex]?.selectedSessionId === sessionId) return prev
+ wrote = true
const lanes = tiled.lanes.map((lane, i) =>
i === laneIndex ? withLaneSession(lane, sessionId) : lane,
)
- return {
- ...prev,
- dispatchMode: { ...prev.dispatchMode!, tiled: { ...tiled, lanes } },
- }
+ return { ...prev, stage: { ...tiled, lanes } }
})
+ // Placing a session is the user ANSWERING the "new in the pool" badge
+ // (#992 §4.3). The index click, lane strip, ⌘N and the ⌥↑/↓ walk place
+ // through here; label navigation and every control-plane "show" place
+ // through agentIndexNavigation, which clears it the same way. Without
+ // that, the chip outlives its question and trains the user to ignore it.
+ if (wrote) clearPooledSpawnBadge(setRuntimes, sessionId)
},
- [setState],
+ [setRuntimes, setState],
)
/**
- * Put a session into a lane, WAKING it first when it is detached.
+ * Put a session into a lane, WAKING it first when it has no backend.
*
- * Rehydrate deliberately does not respawn detached sessions — they survive a
+ * Rehydrate deliberately does not respawn parked sessions — they survive a
* restart as metadata with no provider process (see rehydrate.ts). Something
* has to wake them before they are used, and agent-index navigation already
* says exactly why:
@@ -361,23 +161,26 @@ export function useDispatchActions(
* dead pane the user can type into during the gap, which is the very state
* this is fixing.
*
- * Be honest about the cost. `DetachedSessionRecord` means "live but not
- * grid-placed", so in an ordinary session EVERY dispatch agent is detached —
- * this is the common path, not the exception. `ensureSessionLive` joins an
- * in-flight wake and adopts rather than restarts a running agent, but it is
- * not free: one `session:recover` round-trip and a transient `spawning` flip
- * per gesture. Sub-frame in practice; not "nothing".
+ * The cost is paid once per session per app run: after the first wake its
+ * runtime reads 'started' and every later selection is the synchronous path.
+ * (Until #992 the fork was "is it a detached record", which EVERY lane agent
+ * was, so every gesture paid a `session:recover` round-trip and a transient
+ * `spawning` flip even for an agent that was already running.)
*/
const selectTiledLaneSession = useCallback(
async (laneIndex: number, sessionId: SessionId) => {
- const detached = refs.stateRef.current.detachedSessions[sessionId] !== undefined
- if (!detached) {
- // Grid-placed: stays synchronous, so no coordinate can shift underneath
- // it. NOT a guarantee that it is live — a tile leaf whose respawn failed
- // at rehydrate, or whose process died since, is still selectable here
- // and still needs the pane's own Retry. That gap is shared verbatim with
- // agent-index navigation, which uses the identical predicate; widening
- // both is its own change, not this one.
+ // Already has a backend: stays synchronous, so no coordinate can shift
+ // underneath it.
+ //
+ // The test is the RUNTIME. Until #992 it was "has no detachedSessions
+ // record" (i.e. is a tile leaf), which was a structural guess with a
+ // documented gap: a leaf whose respawn failed at rehydrate, or whose
+ // process died since, was written into the lane un-woken and needed the
+ // pane's own Retry. `processStatus` closes that gap — 'failed' and
+ // 'exited' now take the wake path below, which is also the retry path —
+ // and removes its mirror image, re-waking an agent that was already up.
+ // Agent-index navigation uses the identical predicate.
+ if (refs.latestRuntimesRef.current[sessionId]?.processStatus === 'started') {
setTiledLaneSession(laneIndex, sessionId)
return
}
@@ -412,9 +215,7 @@ export function useDispatchActions(
// The window is NOT narrow, which is why this matters: a cold wake allows
// up to 30s, and Remove Row / Close Agent sit on a confirmation dialog
// inside it.
- const before = normalizeGridShape(refs.stateRef.current.dispatchMode?.tiled ?? {
- lanes: [], focusedLane: 0,
- })
+ const before = normalizeGridShape(refs.stateRef.current.stage)
const rowIndex = rowIndexForLane(before.rows, laneIndex)
const column = rowIndex >= 0 ? laneIndex - rowStartIndex(before.rows, rowIndex) : -1
// Checked BEFORE the wake: an unresolvable coordinate can never produce a
@@ -434,9 +235,7 @@ export function useDispatchActions(
return
}
- const after = normalizeGridShape(refs.stateRef.current.dispatchMode?.tiled ?? {
- lanes: [], focusedLane: 0,
- })
+ const after = normalizeGridShape(refs.stateRef.current.stage)
const row = after.rows[rowIndex]
// Not the same row any more (removed, or displaced by an insert above),
// or it shrank past the column the user aimed at.
@@ -446,6 +245,37 @@ export function useDispatchActions(
[refs, ensureSessionLive, showToast, setTiledLaneSession],
)
+ /**
+ * Empty ONE lane without ending anything: the occupant returns to the pool
+ * alive (#992 §4.4, "Clear Lane"). The lane is NOT removed — Remove Lane
+ * owns that — and nothing refills it (#681): the user asked for the space
+ * back, not for a different agent in it.
+ *
+ * WHY this is an action and not just a command-local state write: it is the
+ * non-destructive half of a pair whose destructive half (Close Agent and
+ * Remove Lane) is an action, and closeAgentRemoveLane's suite pins their
+ * shared lane-index semantics. A command-local write would drift from
+ * whatever lane validation the close path settles on.
+ *
+ * No undo entry, deliberately: the undo stack is for CLOSES (things whose
+ * sessions are gone). Undoing a lane clear is just selecting the session
+ * back into the lane — one click in the index it never left.
+ */
+ const clearTiledLane = useCallback(
+ (laneIndex: number) => {
+ setState(prev => {
+ const tiled = prev.stage
+ if (laneIndex < 0 || laneIndex >= tiled.lanes.length) return prev
+ if (tiled.lanes[laneIndex]?.selectedSessionId === undefined) return prev
+ const lanes = tiled.lanes.map((lane, i) =>
+ i === laneIndex ? { ...lane, selectedSessionId: undefined } : lane,
+ )
+ return { ...prev, stage: { ...tiled, lanes } }
+ })
+ },
+ [setState],
+ )
+
/**
* Insert ONE lane beside an existing lane without changing command focus.
*
@@ -458,12 +288,11 @@ export function useDispatchActions(
(laneIndex: number) => {
let inserted = false
setState(prev => {
- const tiled = prev.dispatchMode?.tiled
- if (!tiled) return prev
+ const tiled = prev.stage
const next = insertLaneRightIntoGrid(tiled, laneIndex)
if (!next) return prev
inserted = true
- return { ...prev, dispatchMode: { ...prev.dispatchMode!, tiled: next } }
+ return { ...prev, stage: next }
})
// Zustand's workspace setter applies functional updaters synchronously,
// so this reports the reducer's ACTUAL admission rather than the command
@@ -487,11 +316,10 @@ export function useDispatchActions(
const removeTiledLane = useCallback(
(laneIndex: number) => {
setState(prev => {
- const tiled = prev.dispatchMode?.tiled
- if (!tiled) return prev
+ const tiled = prev.stage
const next = removeLaneFromGrid(tiled, laneIndex)
if (!next) return prev
- return { ...prev, dispatchMode: { ...prev.dispatchMode!, tiled: next } }
+ return { ...prev, stage: next }
})
},
[setState],
@@ -502,14 +330,10 @@ export function useDispatchActions(
const setTiledFocusedLane = useCallback(
(laneIndex: number) => {
setState(prev => {
- const tiled = prev.dispatchMode?.tiled
- if (!tiled) return prev
+ const tiled = prev.stage
const clamped = Math.max(0, Math.min(laneIndex, tiled.lanes.length - 1))
if (clamped === tiled.focusedLane) return prev
- return {
- ...prev,
- dispatchMode: { ...prev.dispatchMode!, tiled: { ...tiled, focusedLane: clamped } },
- }
+ return { ...prev, stage: { ...tiled, focusedLane: clamped } }
})
},
[setState],
@@ -527,12 +351,11 @@ export function useDispatchActions(
(rowIndex: number) => {
let inserted = false
setState(prev => {
- const tiled = prev.dispatchMode?.tiled
- if (!tiled) return prev
+ const tiled = prev.stage
const next = insertRowBelowInGrid(tiled, rowIndex)
if (!next) return prev
inserted = true
- return { ...prev, dispatchMode: { ...prev.dispatchMode!, tiled: next } }
+ return { ...prev, stage: next }
})
// Reports the reducer's ACTUAL admission rather than the palette's earlier
// render snapshot, so a stale invocation cannot announce a row that was
@@ -546,11 +369,10 @@ export function useDispatchActions(
const removeDispatchRow = useCallback(
(rowIndex: number) => {
setState(prev => {
- const tiled = prev.dispatchMode?.tiled
- if (!tiled) return prev
+ const tiled = prev.stage
const next = removeRowFromGrid(tiled, rowIndex)
if (!next) return prev
- return { ...prev, dispatchMode: { ...prev.dispatchMode!, tiled: next } }
+ return { ...prev, stage: next }
})
},
[setState],
@@ -560,12 +382,11 @@ export function useDispatchActions(
(rows: GridShapeRow[]) => {
let applied = false
setState(prev => {
- const tiled = prev.dispatchMode?.tiled
- if (!tiled) return prev
+ const tiled = prev.stage
const next = setGridShape(tiled, rows)
if (!next) return prev
applied = true
- return { ...prev, dispatchMode: { ...prev.dispatchMode!, tiled: next } }
+ return { ...prev, stage: next }
})
return applied
},
@@ -575,17 +396,13 @@ export function useDispatchActions(
const setDispatchLaneWeights = useCallback(
(weights: number[]) => {
setState(prev => {
- const tiled = prev.dispatchMode?.tiled
- if (!tiled) return prev
+ const tiled = prev.stage
// Length-checked here as well as on read: a weights array that does not
// describe every lane is dropped by normalizeGridShape anyway, and
// storing one would make the next drag start from a silently discarded
// value.
if (weights.length !== tiled.lanes.length) return prev
- return {
- ...prev,
- dispatchMode: { ...prev.dispatchMode!, tiled: { ...tiled, laneWeights: weights } },
- }
+ return { ...prev, stage: { ...tiled, laneWeights: weights } }
})
},
[setState],
@@ -601,18 +418,14 @@ export function useDispatchActions(
const setDispatchRowHeights = useCallback(
(heights: number[]) => {
setState(prev => {
- const tiled = prev.dispatchMode?.tiled
- if (!tiled) return prev
+ const tiled = prev.stage
const grid = normalizeGridShape(tiled)
if (heights.length !== grid.rows.length) return prev
return {
...prev,
- dispatchMode: {
- ...prev.dispatchMode!,
- tiled: {
- ...tiled,
- rows: grid.rows.map((row, i) => ({ ...row, height: heights[i] })),
- },
+ stage: {
+ ...tiled,
+ rows: grid.rows.map((row, i) => ({ ...row, height: heights[i] })),
},
}
})
@@ -626,24 +439,12 @@ export function useDispatchActions(
// Empty normalizes to ABSENT here, not to an empty array: "any project"
// must have exactly one representation or every reader needs to test
// for both.
- const patched = patchRow(prev, rowIndex, {
+ // (Binding used to PROMOTE a layout-wide scope to 'global' so a row
+ // bound to another project did not list nothing. The scope is gone —
+ // every index already lists every project — so this is the whole write.)
+ return patchRow(prev, rowIndex, {
projectTabIds: tabIds.length > 0 ? tabIds : undefined,
})
- if (patched === prev || !patched.dispatchMode) return patched
- // Binding PROMOTES scope to global. Project scope builds its row set
- // from activeTabId alone, so a row bound to any other project would
- // show an empty index and every lane in it would fail to resolve. The
- // same promotion, for the same reason, already happens in
- // agentIndexNavigation when a cross-project label is used.
- //
- // Unbinding deliberately does NOT demote: other rows may still be
- // bound, and silently narrowing the scope out from under them would
- // empty those rows.
- if (tabIds.length === 0 || patched.dispatchMode.scope === 'global') return patched
- return {
- ...patched,
- dispatchMode: { ...patched.dispatchMode, scope: 'global' },
- }
})
},
[setState],
@@ -662,8 +463,7 @@ export function useDispatchActions(
const toggleDispatchRowExpandedParent = useCallback(
(rowIndex: number, sessionId: SessionId) => {
setState(prev => {
- const tiled = prev.dispatchMode?.tiled
- if (!tiled) return prev
+ const tiled = prev.stage
const current = normalizeGridShape(tiled).rows[rowIndex]?.expandedParents ?? []
const next = current.includes(sessionId)
? current.filter(id => id !== sessionId)
@@ -751,16 +551,11 @@ export function useDispatchActions(
)
return {
- enterDispatchMode,
- exitDispatchMode,
- setDispatchScope,
- focusDispatchSession,
pinSession,
unpinSession,
setPinnedSessionIds,
- enterTiledDispatch,
- exitTiledDispatch,
selectTiledLaneSession,
+ clearTiledLane,
insertTiledLaneRight,
removeTiledLane,
setTiledFocusedLane,
diff --git a/src/renderer/src/workspace/hook/actions/extensionPlacement.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/extensionPlacement.renderer.test.tsx
index f6fb871cf..4efe5afa2 100644
--- a/src/renderer/src/workspace/hook/actions/extensionPlacement.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/actions/extensionPlacement.renderer.test.tsx
@@ -7,8 +7,9 @@ import {
mountPaneActions,
mountUndoCloseAction,
} from '@renderer/workspace/hook/actions/testing/paneActionsHarness'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
+import { resolveTabSessions } from '@renderer/workspace/queries'
import type { SessionMeta, WorkspaceState } from '@renderer/workspace/types'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
const extensionMeta: SessionMeta = {
kind: 'extension-view',
@@ -20,76 +21,89 @@ const extensionMeta: SessionMeta = {
function workspace(): WorkspaceState {
return {
tabs: [
- { id: 'tab-a', title: 'A', root: { type: 'leaf', sessionId: 'a' }, focusedSessionId: 'a' },
- { id: 'tab-b', title: 'B', root: { type: 'leaf', sessionId: 'b' }, focusedSessionId: 'b' },
+ { id: 'tab-a', title: 'A' },
+ { id: 'tab-b', title: 'B' },
],
activeTabId: 'tab-a',
sessions: {
- a: { kind: 'claude', cwd: '/projects/a' },
- b: { kind: 'terminal', cwd: '/projects/b' },
- detached: { kind: 'codex', cwd: '/projects/b/worktree' },
+ a: { kind: 'claude', cwd: '/projects/a', projectId: 'tab-a', joinedAt: 0 },
+ b: { kind: 'terminal', cwd: '/projects/b', projectId: 'tab-b', joinedAt: 0 },
+ // Named `detached` from when it sat in the detached bucket. It is an
+ // ordinary pool row of project B that no lane shows; the name is kept
+ // because it still says the one thing the cases below care about — this
+ // agent is NOT the one on screen when the file is first read.
+ detached: { kind: 'codex', cwd: '/projects/b/worktree', projectId: 'tab-b', joinedAt: 100 },
},
- detachedSessions: {
- detached: {
- sessionId: 'detached', surface: 'dispatch', projectTabId: 'tab-b',
- projectTabTitle: 'B', projectTabIndex: 1, detachedAt: 100,
- },
- },
- dispatchMode: null,
+ stage: oneLaneStage('a'),
pinnedSessionIds: [],
- buried: [],
} as WorkspaceState
}
describe('extension view placement follows the visible command target', () => {
- it('splits the grid and persists metadata without spawning a backend', () => {
- const harness = mountPaneActions(workspace())
- act(() => { harness.actions.openExtensionViewInPane('timer.main') })
- const state = harness.getState()
- const id = state.tabs[0]!.focusedSessionId
- expect(collectLeaves(state.tabs[0]!.root)).toEqual(['a', id])
- expect(state.sessions[id]).toEqual({ kind: 'extension-view', cwd: '/projects/a', extensionViewId: 'timer.main' })
- expect(harness.spawn).not.toHaveBeenCalled()
- harness.mounted.unmount()
- })
- it.each([false, true])('opens from a detached global Dispatch target (tiled=%s)', tiled => {
+ // Ran twice until #992 (`it.each([false, true])`, tiled or classic Dispatch),
+ // reading the new view's id back from the classic focus. There is one layout,
+ // so there is one case, and the id is read from the row it filed — the lane
+ // it lands in is context-places' to decide (#992 §4.3), not this test's.
+ it('opens from a target in another project, filed under that project, displacing nothing', () => {
const initial = workspace()
- initial.dispatchMode = {
- scope: 'global', focusedSessionId: 'detached',
- ...(tiled ? { tiled: {
- focusedLane: 1, lanes: [{ selectedSessionId: 'a' }, { selectedSessionId: 'detached' }],
- } } : {}),
+ initial.stage = {
+ focusedLane: 1, lanes: [{ selectedSessionId: 'a' }, { selectedSessionId: 'detached' }],
}
const harness = mountPaneActions(initial)
act(() => { harness.actions.openExtensionViewInPane('timer.main') })
const state = harness.getState()
- const id = state.dispatchMode!.focusedSessionId!
- expect(id).not.toBe('detached')
+ // The new row is found by WHAT it is, not where it shows: the focused lane
+ // is occupied by the target, and an occupied lane is never displaced.
+ const id = Object.keys(state.sessions)
+ .find(key => state.sessions[key]!.kind === 'extension-view')!
expect(state.activeTabId).toBe('tab-b')
- expect(state.detachedSessions[id]).toMatchObject({ projectTabId: 'tab-b', surface: 'dispatch' })
- expect(state.sessions[id]).toEqual({ kind: 'extension-view', cwd: '/projects/b/worktree', extensionViewId: 'timer.main' })
+ // Filed under the TARGET's project, not the active one: the view opened
+ // from an agent of project B, and U4 says projects are labels that follow
+ // the work. `joinedAt` is a wall-clock stamp, so only its presence and its
+ // order (after the row it opened from) are asserted.
+ expect(state.sessions[id]).toEqual({
+ kind: 'extension-view',
+ cwd: '/projects/b/worktree',
+ extensionViewId: 'timer.main',
+ projectId: 'tab-b',
+ joinedAt: expect.any(Number),
+ })
+ expect(resolveTabSessions(state, 'tab-b')).toEqual(['b', 'detached', id])
expect(state.tabs).toEqual(initial.tabs)
expect(buildVisibleDispatchRows(state).map(row => row.sessionId)).toContain(id)
- if (tiled) {
- expect(state.dispatchMode!.tiled!.lanes.map(lane => lane.selectedSessionId)).toEqual(['a', id])
- }
+ // Nothing on screen moved: the target keeps its lane, by reference.
+ expect(state.stage.lanes.map(lane => lane.selectedSessionId)).toEqual(['a', 'detached'])
expect(harness.spawn).not.toHaveBeenCalled()
harness.mounted.unmount()
})
+
+ it('fills the focused lane when it is empty', () => {
+ const initial = workspace()
+ initial.stage = { focusedLane: 1, lanes: [{ selectedSessionId: 'a' }, {}], rows: [{ length: 2 }] }
+ const harness = mountPaneActions(initial)
+ act(() => { harness.actions.openExtensionViewInPane('timer.main') })
+ const state = harness.getState()
+ const id = state.stage.lanes[1]!.selectedSessionId!
+ // With no occupant to derive from, the project is the ACTIVE one (the
+ // resolver's documented fallback) — the case is about the LANE, so that is
+ // what is asserted.
+ expect(state.sessions[id]).toMatchObject({ kind: 'extension-view', projectId: 'tab-a' })
+ expect(state.stage.lanes[0]!.selectedSessionId).toBe('a')
+ harness.mounted.unmount()
+ })
})
describe('extension undo restores UI identity without a provider process', () => {
- it('restores a detached view and consumes the undo entry', async () => {
+ it('restores a closed view to its place in its project and consumes the undo entry', async () => {
const initial = workspace()
- initial.dispatchMode = { scope: 'project', focusedSessionId: 'a' }
+ initial.stage = { lanes: [{ selectedSessionId: 'a' }], rows: [{ length: 1 }], focusedLane: 0 }
const refs = makeRefs(initial)
refs.undoStackRef.current.push({
- type: 'detached', closedAt: Date.now(), sessionMeta: extensionMeta,
- record: {
- sessionId: 'closed', surface: 'dispatch', projectTabId: 'tab-b',
- projectTabTitle: 'B', projectTabIndex: 1, detachedAt: 50,
- },
+ type: 'session', closedAt: Date.now(), sessionId: 'closed',
+ // Membership rides on the row (#992): the entry needs no separate record
+ // to say where the view lived or where in the list it sat.
+ sessionMeta: { ...extensionMeta, projectId: 'tab-b', joinedAt: 50 },
})
// A main-process spawn of extension-view really rejects. Resolving the
// mock would hide the poisoned-stack bug this scenario is meant to catch.
@@ -97,24 +111,37 @@ describe('extension undo restores UI identity without a provider process', () =>
const harness = mountUndoCloseAction(initial, refs, spawn)
await act(async () => { await harness.actions.undoClose() })
const state = harness.getState()
- const id = state.dispatchMode!.focusedSessionId!
+ // Found by ownership, not by focus: undo files the view back into the pool
+ // and deliberately does NOT re-aim a lane at it (undoClose.ts explains
+ // why), so there is no focus field that would name it. It used to be read
+ // from the classic-Dispatch focus, which #992 removed.
+ const id = Object.keys(state.sessions).find(key => !(key in initial.sessions))!
+ expect(id).toBeDefined()
+ // The user's lane is exactly as they left it.
+ expect(state.stage).toEqual(initial.stage)
expect(spawn).not.toHaveBeenCalled()
expect(refs.undoStackRef.current.length).toBe(0)
expect(state.activeTabId).toBe('tab-b')
- expect(state.sessions[id]).toEqual(extensionMeta)
- expect(state.detachedSessions[id]).toMatchObject({ sessionId: id, projectTabId: 'tab-b', detachedAt: 50 })
+ expect(state.sessions[id]).toEqual({ ...extensionMeta, projectId: 'tab-b', joinedAt: 50 })
+ // Back in its OLD position — between `b` (0) and `detached` (100) — not
+ // appended. That is the whole reason `joinedAt` is carried through undo:
+ // an index that reshuffles on Undo makes the restored row hard to find at
+ // exactly the moment the user is looking for it.
+ expect(resolveTabSessions(state, 'tab-b')).toEqual(['b', id, 'detached'])
expect(buildVisibleDispatchRows(state).map(row => row.sessionId)).toContain(id)
harness.mounted.unmount()
})
- it('restores a closed tab with both a grid view and a detached view', async () => {
+ it('restores a closed project with both of its views, in order', async () => {
const initial = workspace()
const refs = makeRefs(initial)
refs.undoStackRef.current.push({
type: 'tab', closedAt: Date.now(), tabIndex: 1,
- tab: { id: 'closed', title: 'Extensions', root: { type: 'leaf', sessionId: 'old-view' }, focusedSessionId: 'old-view' },
- sessionMetas: { 'old-view': extensionMeta },
- detachedEntries: [{ meta: { ...extensionMeta, extensionViewId: 'timer.history' }, detachedAt: 75 }],
+ tab: { id: 'closed', title: 'Extensions' },
+ sessions: [
+ { sessionId: 'old-view', meta: { ...extensionMeta, projectId: 'closed', joinedAt: 0 } },
+ { sessionId: 'old-history', meta: { ...extensionMeta, extensionViewId: 'timer.history', projectId: 'closed', joinedAt: 75 } },
+ ],
})
const spawn = vi.fn().mockRejectedValue(new Error('extension views have no process'))
const harness = mountUndoCloseAction(initial, refs, spawn)
@@ -123,11 +150,16 @@ describe('extension undo restores UI identity without a provider process', () =>
const tab = state.tabs[1]!
expect(spawn).not.toHaveBeenCalled()
expect(tab.title).toBe('Extensions')
- expect(state.sessions[tab.focusedSessionId]).toEqual(extensionMeta)
- const restored = Object.values(state.detachedSessions).filter(row => row.projectTabId === tab.id)
- expect(restored).toHaveLength(1)
- expect(restored[0]!.detachedAt).toBe(75)
- expect(state.sessions[restored[0]!.sessionId]).toEqual({ ...extensionMeta, extensionViewId: 'timer.history' })
+ // The project comes back under a NEW id (every restore mints ids), and
+ // both rows are re-filed under it — a row still naming `closed` would be
+ // unowned and dropped by the next autosave.
+ expect(tab.id).not.toBe('closed')
+ const restored = resolveTabSessions(state, tab.id)
+ expect(restored).toHaveLength(2)
+ expect(restored.map(id => state.sessions[id])).toEqual([
+ { ...extensionMeta, projectId: tab.id, joinedAt: 0 },
+ { ...extensionMeta, extensionViewId: 'timer.history', projectId: tab.id, joinedAt: 75 },
+ ])
expect(refs.undoStackRef.current.length).toBe(0)
harness.mounted.unmount()
})
diff --git a/src/renderer/src/workspace/hook/actions/focusSurfaceTarget.ts b/src/renderer/src/workspace/hook/actions/focusSurfaceTarget.ts
index a6ebd3e68..fad9931eb 100644
--- a/src/renderer/src/workspace/hook/actions/focusSurfaceTarget.ts
+++ b/src/renderer/src/workspace/hook/actions/focusSurfaceTarget.ts
@@ -14,21 +14,20 @@ export function resolveFocusSurfaceTarget(state: WorkspaceState, explicitSession
const sessionId = explicitSessionId ?? commandTargetSessionIdForState(state)
if (!sessionId || !state.sessions[sessionId]) return null
- if (state.dispatchMode) {
- const row = buildVisibleDispatchRows(state).find(item => item.sessionId === sessionId)
- if (row) {
- return { tabId: row.tabId, sessionId }
- }
+ // The index row is asked first because it is what the user sees: a row
+ // carries the project it is LISTED under. (Gated on "Dispatch is on" until
+ // the stage became a required field, #992.)
+ const row = buildVisibleDispatchRows(state).find(item => item.sessionId === sessionId)
+ if (row) {
+ return { tabId: row.tabId, sessionId }
}
- // WHY this does an ownership lookup instead of assuming activeTabId:
- // focus-takeover commands are wired to commandTargetSessionIdForState, which
- // can legitimately resolve a visible grid-related child instead of the
- // physical tab leaf. That child is usually detached and owned by the same
- // project tab via projectTabId. Reader/Spotlight store the owner tab id so
- // their pill lists use the same membership model as the normal workspace
- // surfaces; activeTabId is only a layout pointer and is stale in several
- // Dispatch/Tiled Dispatch flows.
+ // WHY this does an ownership lookup instead of assuming activeTabId: an
+ // explicit target (an MCP caller, a Performance Monitor row) can name an
+ // agent the index does not list, such as a pinned one. Reader/Spotlight
+ // store the OWNING project so their pill lists use the same membership as
+ // the index. activeTabId is only a label (U4) and need not name the project
+ // the target belongs to.
const owner = state.tabs.find(tab => resolveTabSessions(state, tab.id).includes(sessionId))
return owner ? { tabId: owner.id, sessionId } : null
}
diff --git a/src/renderer/src/workspace/hook/actions/mcpDomainContinuity.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/mcpDomainContinuity.renderer.test.tsx
index 8593566b9..c33b64e18 100644
--- a/src/renderer/src/workspace/hook/actions/mcpDomainContinuity.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/actions/mcpDomainContinuity.renderer.test.tsx
@@ -9,58 +9,31 @@ import {
sessionActionsWithSpawn,
stateWriter,
} from '@renderer/workspace/hook/actions/testing/paneActionsHarness'
-import type { DispatchModeState, WorkspaceState } from '@renderer/workspace/types'
+import type { WorkspaceState, TiledDispatchState } from '@renderer/workspace/types'
+import { freshStage } from '@renderer/workspace/dispatch/gridShape'
-function makeState(dispatchMode: DispatchModeState | null): WorkspaceState {
+function makeState(stage: TiledDispatchState = freshStage()): WorkspaceState {
return {
tabs: [{
id: 'tab-parent',
title: 'parent',
- root: { type: 'leaf', sessionId: 'parent' },
- focusedSessionId: 'parent',
}],
activeTabId: 'tab-parent',
- dispatchMode,
+ stage,
sessions: {
- parent: { cwd: '/projects/parent', kind: 'codex' },
+ parent: { cwd: '/projects/parent', kind: 'codex', projectId: 'tab-parent', joinedAt: 0 },
},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
} as WorkspaceState
}
describe('built-in MCP continuity at session resurrection boundaries', () => {
- it('scopes a normal split clone to the selected source cwd, not its physical parent', async () => {
- const harness = mountPaneActions(makeState(null))
-
- await act(async () => {
- await harness.actions.splitFocused('vertical', 'codex', {
- resumeSessionId: 'provider-clone',
- builtInMcpOverrides: { workflows: true },
- cwd: '/projects/related-child',
- })
- })
-
- // WHY this deliberately disagrees with the parent fixture cwd: related agents can render as
- // tabs inside a parent pane while running in another worktree. The spawn boundary is where an
- // incorrect fallback would become a valid-but-wrong project-scoped bearer credential.
- expect(harness.spawn).toHaveBeenCalledWith('/projects/related-child', {
- kind: 'codex',
- resumeSessionId: 'provider-clone',
- builtInMcpOverrides: { workflows: true },
- })
- harness.mounted.unmount()
- })
it('keeps the explicit source cwd when Dispatch turns a split into a detached clone', async () => {
- const harness = mountPaneActions(makeState({
- scope: 'project',
- focusedSessionId: 'parent',
- }))
+ const harness = mountPaneActions(makeState({ lanes: [{ selectedSessionId: 'parent' }], rows: [{ length: 1 }], focusedLane: 0 }))
await act(async () => {
- await harness.actions.splitFocused('vertical', 'codex', {
+ await harness.actions.splitFocused('codex', {
resumeSessionId: 'provider-clone',
builtInMcpOverrides: { workflows: true },
cwd: '/projects/related-child',
@@ -76,10 +49,13 @@ describe('built-in MCP continuity at session resurrection boundaries', () => {
})
it('keeps the OpenCode terminal runtime when a transcript clone is spawned', async () => {
- const harness = mountPaneActions(makeState(null))
+ // Re-based onto the stage (#992): cloning outside Dispatch used to split
+ // the tile tree, and that branch no longer exists. The contract under test
+ // is the spawn boundary, which is identical on the surviving path.
+ const harness = mountPaneActions(makeState({ lanes: [{ selectedSessionId: 'parent' }], rows: [{ length: 1 }], focusedLane: 0 }))
await act(async () => {
- await harness.actions.splitFocused('vertical', 'opencode', {
+ await harness.actions.splitFocused('opencode', {
resumeSessionId: 'ses_clone',
builtInMcpOverrides: { orchestration: true },
providerRuntime: 'terminal',
@@ -96,25 +72,23 @@ describe('built-in MCP continuity at session resurrection boundaries', () => {
harness.mounted.unmount()
})
- it('restores a closed pane with fresh credentials derived from its captured domains', async () => {
- const state = makeState(null)
+ it('restores a closed session with fresh credentials derived from its captured domains', async () => {
+ const state = makeState()
const refs = makeRefs(state)
const writer = stateWriter(state, refs)
const spawn = vi.fn().mockResolvedValue('restored-pane')
refs.undoStackRef.current.push({
- type: 'pane',
+ type: 'session',
closedAt: Date.now(),
- tabId: 'tab-parent',
+ sessionId: 'closed-pane',
sessionMeta: {
cwd: '/projects/related-child',
kind: 'codex',
providerSessionId: 'provider-old',
builtInMcpDomains: ['workflows'],
+ projectId: 'tab-parent',
+ joinedAt: 1,
},
- direction: 'vertical',
- ratio: 0.5,
- side: 'a',
- siblingLeafId: 'parent',
})
let actions!: ReturnType
@@ -139,26 +113,24 @@ describe('built-in MCP continuity at session resurrection boundaries', () => {
})
it('restores an explicit all-off MCP selection instead of treating it as missing', async () => {
- const state = makeState(null)
+ const state = makeState()
const refs = makeRefs(state)
refs.defaultBuiltInMcpDomainsRef.current = ['orchestration']
const writer = stateWriter(state, refs)
const spawn = vi.fn().mockResolvedValue('restored-pane')
refs.undoStackRef.current.push({
- type: 'pane',
+ type: 'session',
closedAt: Date.now(),
- tabId: 'tab-parent',
+ sessionId: 'closed-pane',
sessionMeta: {
cwd: '/projects/related-child',
kind: 'codex',
providerSessionId: 'provider-old',
builtInMcpDomains: [],
builtInMcpOverrides: { orchestration: false },
+ projectId: 'tab-parent',
+ joinedAt: 1,
},
- direction: 'vertical',
- ratio: 0.5,
- side: 'a',
- siblingLeafId: 'parent',
})
let actions!: ReturnType
@@ -182,8 +154,8 @@ describe('built-in MCP continuity at session resurrection boundaries', () => {
mounted.unmount()
})
- it('restores both grid and detached tab agents with their own domain metadata', async () => {
- const state = { ...makeState(null), tabs: [], sessions: {} } as WorkspaceState
+ it('restores every agent of a closed project with its own domain metadata', async () => {
+ const state = { ...makeState(), tabs: [], sessions: {} } as WorkspaceState
const refs = makeRefs(state)
const writer = stateWriter(state, refs)
const spawn = vi.fn()
@@ -192,30 +164,32 @@ describe('built-in MCP continuity at session resurrection boundaries', () => {
refs.undoStackRef.current.push({
type: 'tab',
closedAt: Date.now(),
- tab: {
- id: 'closed-tab',
- title: 'closed',
- root: { type: 'leaf', sessionId: 'old-grid' },
- focusedSessionId: 'old-grid',
- },
+ tab: { id: 'closed-tab', title: 'closed' },
tabIndex: 0,
- sessionMetas: {
- 'old-grid': {
- cwd: '/projects/grid',
- kind: 'codex',
- providerSessionId: 'provider-grid',
- builtInMcpDomains: ['workflows'],
+ sessions: [
+ {
+ sessionId: 'old-grid',
+ meta: {
+ cwd: '/projects/grid',
+ kind: 'codex',
+ providerSessionId: 'provider-grid',
+ builtInMcpDomains: ['workflows'],
+ projectId: 'closed-tab',
+ joinedAt: 0,
+ },
},
- },
- detachedEntries: [{
- meta: {
- cwd: '/projects/detached',
- kind: 'claude',
- providerSessionId: 'provider-detached',
- builtInMcpDomains: ['workflows'],
+ {
+ sessionId: 'old-detached',
+ meta: {
+ cwd: '/projects/detached',
+ kind: 'claude',
+ providerSessionId: 'provider-detached',
+ builtInMcpDomains: ['workflows'],
+ projectId: 'closed-tab',
+ joinedAt: 10,
+ },
},
- detachedAt: 10,
- }],
+ ],
})
let actions!: ReturnType
diff --git a/src/renderer/src/workspace/hook/actions/mcpPreferences.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/mcpPreferences.renderer.test.tsx
index 752142e1c..5136b1ce5 100644
--- a/src/renderer/src/workspace/hook/actions/mcpPreferences.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/actions/mcpPreferences.renderer.test.tsx
@@ -9,6 +9,7 @@ import { useSessionActions } from './session'
import { useProviderActions } from './provider'
import { makeRefs, stateWriter } from './testing/paneActionsHarness'
import type { SessionMeta, WorkspaceState } from '@renderer/workspace/types'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
vi.mock('./initialHistory', () => ({ loadInitialHistoryForSession: vi.fn(async () => undefined) }))
const originalApi = window.api
@@ -17,12 +18,17 @@ afterEach(() => { cleanup(); window.api = originalApi; vi.useRealTimers() })
function setup(meta: Partial = { builtInMcpDomains: [], builtInMcpOverrides: {} }) {
vi.useFakeTimers()
const state = {
- tabs: [{ id: 'project', title: 'Project', root: { type: 'leaf', sessionId: 'original' }, focusedSessionId: 'original' }],
- activeTabId: 'project', sessions: { original: { cwd: '/project', kind: 'codex', providerSessionId: 'native-original', ...meta } },
- detachedSessions: {}, buried: [], pinnedSessionIds: [], dispatchMode: null,
+ tabs: [{ id: 'project', title: 'Project' }],
+ activeTabId: 'project', sessions: { original: { cwd: '/project', kind: 'codex', providerSessionId: 'native-original', ...meta, projectId: 'project', joinedAt: 0 } },
+ pinnedSessionIds: [], stage: oneLaneStage('original'),
} as WorkspaceState
const refs = makeRefs(state), writer = stateWriter(state, refs)
- refs.latestRuntimesRef.current = { original: emptyRuntime() }
+ // `started`: the agent HAS a backend. Reload-all restarts only sessions with
+ // one (#992) — it asks the runtime, where it used to ask "is this a tile
+ // leaf". An `idle` runtime is a parked agent, which a reload deliberately
+ // leaves parked (that is the #258 fork-bomb guard), so with the default
+ // runtime the bulk-reload case below spawned nothing and read `undefined`.
+ refs.latestRuntimesRef.current = { original: { ...emptyRuntime(), processStatus: 'started' } }
const setRuntimes = (update: Record | ((prev: Record) => Record)) => {
refs.latestRuntimesRef.current = typeof update === 'function' ? update(refs.latestRuntimesRef.current) : update
}
@@ -33,7 +39,8 @@ function setup(meta: Partial = { builtInMcpDomains: [], builtInMcpO
const sessions = useSessionActions(state, writer.setState, setRuntimes, refs)
return { sessions, provider: useProviderActions(refs, setRuntimes, vi.fn(), sessions) }
})
- const focused = () => writer.getState().tabs[0]!.focusedSessionId!
+ // The commanded session: the focused lane's occupant. (Tree era: the tab's focus.)
+ const focused = () => writer.getState().stage.lanes[writer.getState().stage.focusedLane]!.selectedSessionId!
const command = async (id: string) => {
const run = sessionCommands.find(item => item.id === id)!.run
await run({ workspace: {
diff --git a/src/renderer/src/workspace/hook/actions/newTabPlacement.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/newTabPlacement.renderer.test.tsx
new file mode 100644
index 000000000..25d51027a
--- /dev/null
+++ b/src/renderer/src/workspace/hook/actions/newTabPlacement.renderer.test.tsx
@@ -0,0 +1,122 @@
+import { act, renderHook } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+
+import { freshStage } from '@renderer/workspace/dispatch/gridShape'
+import { useTabActions } from '@renderer/workspace/hook/actions/tab'
+import {
+ makeRefs,
+ sessionActionsWithSpawn,
+ stateWriter,
+} from '@renderer/workspace/hook/actions/testing/paneActionsHarness'
+import type { SessionRuntime } from '@renderer/session-runtime/state'
+import type { TiledDispatchState, WorkspaceState } from '@renderer/workspace/types'
+
+// Where a new project's first agent appears (#992).
+//
+// This is the rule that lets a fresh install show its one agent without
+// bootstrap knowing that lanes exist. Until the stage became a required field,
+// bootstrap entered Tiled Dispatch after creating the first tab, and THAT
+// action seeded lane 0. With the action gone the seed had to live somewhere,
+// and the honest home is the spawn itself: "the agent you just asked for
+// appears where you are looking — unless something is already there".
+//
+// It is mounted through the real hook rather than asserted on a reducer copy,
+// because the bootstrap suite's own newTab is a stand-in: if this rule broke,
+// that suite would keep passing.
+
+function workspace(stage: TiledDispatchState): WorkspaceState {
+ return {
+ tabs: [{
+ id: 'tab-a', title: 'app',
+ }],
+ activeTabId: 'tab-a',
+ stage,
+ sessions: { a1: { cwd: '/work/app', kind: 'claude', projectId: 'tab-a', joinedAt: 0 } },
+ pinnedSessionIds: [],
+ }
+}
+
+function mount(initial: WorkspaceState) {
+ const refs = makeRefs(initial)
+ const writer = stateWriter(initial, refs)
+ const spawn = vi.fn().mockResolvedValue('new-session')
+ let runtimes: Record = {}
+ const setRuntimes = (next: Record | ((prev: Record) => Record)) => {
+ runtimes = typeof next === 'function' ? next(runtimes) : next
+ }
+ const hook = renderHook(() => useTabActions(
+ initial,
+ writer.setState,
+ setRuntimes,
+ vi.fn(),
+ vi.fn(),
+ refs,
+ vi.fn(),
+ sessionActionsWithSpawn(spawn),
+ ))
+ return { hook, getState: writer.getState, runtimes: () => runtimes }
+}
+
+describe('newTab places the first agent of a new project', () => {
+ it('fills the fresh stage s single empty lane — the fresh-install shape', async () => {
+ const initial = { ...workspace(freshStage()), tabs: [], sessions: {}, activeTabId: '' }
+ const { hook, getState } = mount(initial)
+
+ await act(async () => { await hook.result.current.newTab('/work/first') })
+
+ expect(getState().stage.lanes).toEqual([{ selectedSessionId: 'new-session' }])
+ expect(getState().stage.rows).toEqual([{ length: 1 }])
+ expect(getState().stage.focusedLane).toBe(0)
+ })
+
+ it('takes the FOCUSED lane when it is empty, not the first empty lane', async () => {
+ const { hook, getState } = mount(workspace({
+ lanes: [{}, { selectedSessionId: 'a1' }, {}],
+ rows: [{ length: 3 }],
+ focusedLane: 2,
+ }))
+
+ await act(async () => { await hook.result.current.newTab('/work/second') })
+
+ expect(getState().stage.lanes).toEqual([{}, { selectedSessionId: 'a1' }, { selectedSessionId: 'new-session' }])
+ })
+
+ it('never displaces an occupied focused lane', async () => {
+ // The agent the user is commanding stays exactly where it is. The new
+ // project is active and its agent is in the pool, at the top of its index.
+ const stage: TiledDispatchState = {
+ lanes: [{ selectedSessionId: 'a1' }, {}],
+ rows: [{ length: 2 }],
+ focusedLane: 0,
+ }
+ const { hook, getState } = mount(workspace(stage))
+
+ const created = await act(async () => hook.result.current.newTab('/work/second'))
+
+ // Same reference: not rebuilt, so lane memos do not churn on ⌘T either.
+ expect(getState().stage).toBe(stage)
+ expect(getState().activeTabId).toBe(created.tabId)
+ })
+
+ it('badges the first agent it could not place, like every other pooled spawn', async () => {
+ // #1013 review B: ⌘T never marked the badge, so with an occupied lane
+ // nothing on screen changed and nothing said where the agent went.
+ const { hook, runtimes } = mount(workspace({ lanes: [{ selectedSessionId: 'a1' }], rows: [{ length: 1 }], focusedLane: 0 }))
+ await act(async () => { await hook.result.current.newTab('/work/second') })
+ expect(runtimes()['new-session']?.pooledSpawnAt).toEqual(expect.any(Number))
+ })
+
+ it('does not badge an agent that filled the lane', async () => {
+ const { hook, runtimes } = mount(workspace({ lanes: [{}], rows: [{ length: 1 }], focusedLane: 0 }))
+ await act(async () => { await hook.result.current.newTab('/work/second') })
+ expect(runtimes()['new-session']?.pooledSpawnAt ?? null).toBeNull()
+ })
+
+ it('fills a lane whose occupant was closed, as every other spawn does', async () => {
+ // A lane pointing at a gone session reads empty. ⌘T refused it as
+ // occupied, unlike applyDispatchSpawnFocus (#1013 review B).
+ const { hook, getState } = mount(workspace({ lanes: [{ selectedSessionId: 'closed' }], rows: [{ length: 1 }], focusedLane: 0 }))
+ await act(async () => { await hook.result.current.newTab('/work/second') })
+ expect(getState().stage.lanes).toEqual([{ selectedSessionId: 'new-session' }])
+ })
+})
diff --git a/src/renderer/src/workspace/hook/actions/opencodeTerminalRestore.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/opencodeTerminalRestore.renderer.test.tsx
index 560acf234..4a21bd498 100644
--- a/src/renderer/src/workspace/hook/actions/opencodeTerminalRestore.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/actions/opencodeTerminalRestore.renderer.test.tsx
@@ -15,6 +15,7 @@ import type { SessionId, SessionMeta, WorkspaceState } from '@renderer/workspace
import { useSessionActions } from './session'
import { makeRefs, stateWriter } from './testing/paneActionsHarness'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
// The two restore paths that bring an OpenCode Terminal pane back without a
// rehydrate: adopting a closed window's workspace, and "restart every agent"
@@ -84,12 +85,10 @@ describe('an OpenCode Terminal pane restored without a rehydrate', () => {
refuseWorkspaceAdoption: vi.fn(async () => undefined),
})
const survivor: WorkspaceState = {
- tabs: [{ id: 'own-tab', title: 'own', root: { type: 'leaf', sessionId: 'own-agent' }, focusedSessionId: 'own-agent' }],
+ tabs: [{ id: 'own-tab', title: 'own' }],
activeTabId: 'own-tab',
- dispatchMode: null,
- sessions: { 'own-agent': { cwd: '/own', kind: 'claude' } },
- detachedSessions: {},
- buried: [],
+ stage: oneLaneStage('own-agent'),
+ sessions: { 'own-agent': { cwd: '/own', kind: 'claude', projectId: 'own-tab', joinedAt: 0 } },
pinnedSessionIds: [],
}
const refs = makeRefs(survivor)
@@ -98,12 +97,10 @@ describe('an OpenCode Terminal pane restored without a rehydrate', () => {
renderHook(() => useWorkspaceAdoption(refs, writer.setState, runtimes.set, true))
const closedWindow = {
- tabs: [{ id: 'closed-tab', title: 'closed', root: { type: 'leaf', sessionId: SESSION_ID }, focusedSessionId: SESSION_ID }],
+ tabs: [{ id: 'closed-tab', title: 'closed' }],
activeTabId: 'closed-tab',
- dispatchMode: null,
- sessions: { [SESSION_ID]: terminalMeta(fixture.meta.sessionID) },
- detachedSessions: {},
- buried: [],
+ stage: oneLaneStage(SESSION_ID),
+ sessions: { [SESSION_ID]: { ...terminalMeta(fixture.meta.sessionID), projectId: 'closed-tab', joinedAt: 0 }},
tileTabs: null,
}
await act(async () => {
@@ -131,10 +128,10 @@ describe('an OpenCode Terminal pane restored without a rehydrate', () => {
const ghostRead = vi.fn(async () => [])
scope.extendApi({ spawnSession, killOwnedSession, ghostRead })
const state: WorkspaceState = {
- tabs: [{ id: 'project', title: 'project', root: { type: 'leaf', sessionId: SESSION_ID }, focusedSessionId: SESSION_ID }],
- activeTabId: 'project', dispatchMode: null,
- sessions: { [SESSION_ID]: terminalMeta('ses_previous') },
- detachedSessions: {}, buried: [], pinnedSessionIds: [],
+ tabs: [{ id: 'project', title: 'project' }],
+ activeTabId: 'project', stage: oneLaneStage(SESSION_ID),
+ sessions: { [SESSION_ID]: { ...terminalMeta('ses_previous'), projectId: 'project', joinedAt: 0 }},
+ pinnedSessionIds: [],
}
const refs = makeRefs(state)
const writer = stateWriter(state, refs)
@@ -161,10 +158,10 @@ describe('an OpenCode Terminal pane restored without a rehydrate', () => {
it('soft-reloading the view preserves a stopped channel after readable history loads', async () => {
const { fixture } = recordedSession()
const state: WorkspaceState = {
- tabs: [{ id: 'project', title: 'project', root: { type: 'leaf', sessionId: SESSION_ID }, focusedSessionId: SESSION_ID }],
- activeTabId: 'project', dispatchMode: null,
- sessions: { [SESSION_ID]: terminalMeta(fixture.meta.sessionID) },
- detachedSessions: {}, buried: [], pinnedSessionIds: [],
+ tabs: [{ id: 'project', title: 'project' }],
+ activeTabId: 'project', stage: oneLaneStage(SESSION_ID),
+ sessions: { [SESSION_ID]: { ...terminalMeta(fixture.meta.sessionID), projectId: 'project', joinedAt: 0 }},
+ pinnedSessionIds: [],
}
const refs = makeRefs(state)
const writer = stateWriter(state, refs)
@@ -188,17 +185,20 @@ describe('an OpenCode Terminal pane restored without a rehydrate', () => {
scope.extendApi({ spawnSession, killOwnedSession })
const meta = terminalMeta(fixture.meta.sessionID)
const state: WorkspaceState = {
- tabs: [{ id: 'project', title: 'project', root: { type: 'leaf', sessionId: SESSION_ID }, focusedSessionId: SESSION_ID }],
+ tabs: [{ id: 'project', title: 'project' }],
activeTabId: 'project',
- dispatchMode: null,
- sessions: { [SESSION_ID]: meta },
- detachedSessions: {},
- buried: [],
+ stage: oneLaneStage(SESSION_ID),
+ sessions: { [SESSION_ID]: { ...meta, projectId: 'project', joinedAt: 0 }},
pinnedSessionIds: [],
}
const refs = makeRefs(state)
const writer = stateWriter(state, refs)
- const runtimes = runtimeStore({ [SESSION_ID]: emptyRuntime() }, refs)
+ // `started`: reload-all restarts sessions that HAVE a backend (#992: it
+ // reads the runtime, where it used to read tile-leaf membership). The pane
+ // under test is a running TUI; seeded `idle` it would be a parked agent,
+ // which a reload leaves parked on purpose — and this case would wait
+ // forever for a respawn that was correctly never attempted.
+ const runtimes = runtimeStore({ [SESSION_ID]: { ...emptyRuntime(), processStatus: 'started' } }, refs)
const { result } = renderHook(() => useSessionActions(
{ activeTabId: state.activeTabId, sessions: state.sessions, tabs: state.tabs },
writer.setState,
@@ -217,7 +217,9 @@ describe('an OpenCode Terminal pane restored without a rehydrate', () => {
kind: 'opencode', providerRuntime: 'terminal', cwd: PANE_CWD, resumeSessionId: fixture.meta.sessionID, dangerousMode: false,
}))
expect(writer.getState().sessions['reloaded-pane']).toMatchObject({ providerRuntime: 'terminal', providerSessionId: fixture.meta.sessionID })
- expect(writer.getState().tabs[0]!.root).toEqual({ type: 'leaf', sessionId: 'reloaded-pane' })
+ // The reloaded session takes over the row and the lane. (Tree era: the
+ // tab's tile leaf was remapped to it.)
+ expect(writer.getState().stage.lanes[0]?.selectedSessionId).toBe('reloaded-pane')
expect(history.loadInitialHistory).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ kind: 'opencode', providerSessionId: fixture.meta.sessionID }))
const runtime = runtimes.get()['reloaded-pane']!
diff --git a/src/renderer/src/workspace/hook/actions/pane.ts b/src/renderer/src/workspace/hook/actions/pane.ts
index 6eda6b65b..6ea7f45e3 100644
--- a/src/renderer/src/workspace/hook/actions/pane.ts
+++ b/src/renderer/src/workspace/hook/actions/pane.ts
@@ -11,17 +11,13 @@ import type { SessionRuntime } from '@renderer/session-runtime/state'
import type { CloseExpansionRuntimes, CloseTargetSnapshot } from '@renderer/workspace/closeConfirmation'
import {
clearRemovedTabTakeovers,
- dispatchModeAfterSessionRemovals,
workspaceWithoutTab,
} from '@renderer/workspace/hook/actions/tabRemoval'
-import { requestCloseConfirmation, requestRootCloseConfirmation } from '@renderer/workspace/closeConfirmationBroker'
+import { requestCloseConfirmation } from '@renderer/workspace/closeConfirmationBroker'
import { sessionDisplayTitle } from '@renderer/workspace/sessionDisplayTitle'
import { useCallback, useRef } from 'react'
import type {
- BuriedPaneRecord,
- DetachedSessionRecord,
- DispatchModeState,
SessionId,
SessionKind,
SessionMeta,
@@ -29,36 +25,20 @@ import type {
SplitDirection,
Tab,
TabId,
- TileNode,
+ TiledDispatchState,
WorkspaceState,
} from '@renderer/workspace/types'
import type { AgentProviderRuntime } from '@shared/types/providerKind'
-import { RATIO_DEFAULT } from '@renderer/workspace/types'
-import {
- closeLeaf,
- collectLeaves,
- insertBesideLeaf,
- normalizeTree,
- splitLeaf,
- wrapRootWithLeaf,
- wrapRootWithNode,
-} from '@renderer/workspace/tile-tree/treeOps'
-import { findBestRemainingFocus, findDirectionalNeighbor } from '@renderer/workspace/tile-tree/geometry'
-import { findParentSplitInfo } from '@renderer/lib/undoClose'
-import type { ClosedTab, ClosedTabDetachedEntry, SingleClosedEntry, UndoCloseStack } from '@renderer/lib/undoClose'
-import { titleFromCwd } from '@renderer/workspace/layout/helpers'
-import {
- buildVisibleDispatchRows,
- detachedDispatchSessionIdsForTab,
- resolveDispatchSpawnTarget,
-} from '@renderer/workspace/dispatch/dispatchSelectors'
+import type { ClosedTab, SingleClosedEntry, UndoCloseStack } from '@renderer/lib/undoClose'
+import { resolveDispatchSpawnTarget } from '@renderer/workspace/dispatch/dispatchSelectors'
+import { fileSessionInProject, workspaceWithoutSessions } from '@renderer/workspace/pool'
+import { projectIdOf, resolveTabSessions } from '@renderer/workspace/queries'
import type { DispatchAgentRow } from '@renderer/workspace/dispatch/dispatchSelectors'
import {
clearTiledLaneSessions,
withLaneSession,
} from '@renderer/workspace/dispatch/tiledDispatchSelectors'
import { commandTargetSessionIdForState } from '@renderer/workspace/hook/selectors/commandTargetSessionId'
-import type { PlacementTarget } from '@renderer/features/workspace/lib/newAgentPlacement'
import type { BuiltInMcpDomain, BuiltInMcpOverrides } from '@mcp/shared/types'
import type {
OrchestrationAgentKind,
@@ -72,7 +52,6 @@ import type {
WorkspaceSetRuntimes,
WorkspaceSetSpotlight,
WorkspaceSetState,
- WorkspaceSetTileTabs,
} from '@renderer/workspace/hook/context'
import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
import {
@@ -80,13 +59,15 @@ import {
type SessionActions,
} from '@renderer/workspace/hook/actions/session'
import type { AgentProviderKind } from '@shared/types/providerKind'
+import { clearPooledSpawnBadge, markPooledSpawn } from '@renderer/workspace/hook/actions/pooledSpawnBadge'
// -----------------------------------------------------------------------------
// Pane / focus / navigation actions.
//
-// Covers: splitFocused, startNewAgentPlacement, commitNewAgentPlacement,
-// closeFocused, closeSession, requestBuryFocused, buryFocused,
-// reviveBuried, killBuried, focusSession, focusSessionInTab, navigate.
+// Covers: splitFocused, startNewAgentPlacement, the pool creators
+// (createDetachedDispatchAgent / createLinkedAgent / createOrchestrationAgent),
+// closeFocused, closeSession, closeTab, focusSession, focusSessionInTab,
+// openExtensionViewInPane.
// -----------------------------------------------------------------------------
function forgetClosedSessionDebugState(refs: WorkspaceRefs, sessionId: SessionId): void {
@@ -234,70 +215,33 @@ export type CloseSessionOptions = {
requireConfirmation?: { headline: string }
}
-type DetachedTabChildren = {
- records: DetachedSessionRecord[]
- ids: SessionId[]
-}
-
-function detachedTabChildren(state: WorkspaceState, tabId: string): DetachedTabChildren {
- // WHY projectTabId is authoritative here: detached sessions deliberately
- // have no tile-tree leaf. Their persisted projectTabId is the only ownership
- // edge tying them to the tab whose final visible pane is being removed.
- // Ignoring that edge creates an invisible orphan; the save-time ownership
- // sanitizer then correctly prunes it, turning a UI action into data loss.
- const records = Object.values(state.detachedSessions)
- .filter(entry => entry.projectTabId === tabId)
-
- return {
- records,
- ids: records.map(entry => entry.sessionId),
- }
-}
-
/**
- * The next displayed Dispatch row becomes the grid root. Object insertion
- * order is not row order after persistence/undo; reuse the list's ordering so
- * closing the first agent does not shuffle the remaining project.
+ * Where a session lives right now: the project its row names, if that project
+ * exists. A session whose project is gone is unowned metadata, not something
+ * a close may act on — it resolves to null exactly as an unknown id does.
*
- * `excluded` must be EVERY session the current close operation has approved
- * and not yet finished — not just the session whose leaf is being removed.
- * #886 review finding 3: parent P (detached) with linked child C as the tab's
- * sole grid leaf. Closing P closes C first; C's promotion used to exclude only
- * C, so it promoted P into the root, and P's own close then deleted P's
- * metadata while the tab's root and focus still named it — a tab rooted at a
- * deleted session. A session the operation is about to kill can never be the
- * survivor that keeps a project alive.
- */
-function detachedRootReplacement(
- state: WorkspaceState,
- tabId: TabId,
- excluded: ReadonlySet,
-): DetachedSessionRecord | undefined {
- const id = detachedDispatchSessionIdsForTab(state, tabId).find(id => !excluded.has(id))
- return id === undefined ? undefined : state.detachedSessions[id]
-}
-
-/**
- * Where a session lives right now. Buried sessions resolve to null on purpose:
- * closeSession never ends them (Kill Buried owns that irreversible act).
+ * Until #992 this had two arms ('grid': the tab whose tile tree held its leaf;
+ * 'detached': its detachedSessions record), a third state it deliberately
+ * returned null for (buried), and two helpers beside it that existed only to
+ * keep the tree valid while closing: `detachedTabChildren` (the rows a tab
+ * owned outside its tree) and `detachedRootReplacement` (which Dispatch row
+ * to PROMOTE into the tree when its last leaf closed, because a tab's root
+ * could not be empty). With ownership on the row there is nothing to promote:
+ * a project with sessions left simply still has them.
*/
-type SessionPlacement =
- | { kind: 'grid'; tab: Tab; tabIndex: number }
- | { kind: 'detached'; record: DetachedSessionRecord }
+type SessionPlacement = { tab: Tab; tabIndex: number }
function sessionPlacement(state: WorkspaceState, sessionId: SessionId): SessionPlacement | null {
- const tabIndex = state.tabs.findIndex(tab => collectLeaves(tab.root).includes(sessionId))
- if (tabIndex >= 0) return { kind: 'grid', tab: state.tabs[tabIndex], tabIndex }
- const record = state.detachedSessions[sessionId]
- return record ? { kind: 'detached', record } : null
+ const projectId = projectIdOf(state, sessionId)
+ if (projectId === undefined) return null
+ const tabIndex = state.tabs.findIndex(tab => tab.id === projectId)
+ return tabIndex >= 0 ? { tab: state.tabs[tabIndex], tabIndex } : null
}
-/** A session's project: the tab owning its grid leaf, or its Dispatch row's
- * projectTabId. The approved plan records this so a session moved to another
- * project under the dialog (attach, merge) is refused rather than killed. */
+/** A session's project. The approved plan records this so a session moved to
+ * another project under the dialog (a merge) is refused rather than killed. */
function placementProjectTabId(placement: SessionPlacement | null): TabId | null {
- if (!placement) return null
- return placement.kind === 'grid' ? placement.tab.id : placement.record.projectTabId
+ return placement ? placement.tab.id : null
}
function linkedChildIds(state: WorkspaceState, parentId: SessionId): SessionId[] {
@@ -320,16 +264,6 @@ function linkedDepth(state: WorkspaceState, sessionId: SessionId): number {
return seen.size
}
-function closeNoun(meta: SessionMeta | undefined): 'agent' | 'terminal' {
- return meta?.kind === 'terminal' ? 'terminal' : 'agent'
-}
-
-function sameTargetIds(a: readonly CloseTargetSnapshot[], b: readonly CloseTargetSnapshot[]): boolean {
- if (a.length !== b.length) return false
- const ids = new Set(b.map(target => target.sessionId))
- return a.every(target => ids.has(target.sessionId))
-}
-
type ApprovedCloseTarget = {
/** Activity the approver saw. Idle then + working now = refused. */
live: boolean
@@ -337,9 +271,9 @@ type ApprovedCloseTarget = {
projectTabId: TabId | null
/** Captured at approval for the operation's Undo Close entry: by the time the
* top-level target records undo, each member's own close has already deleted
- * its metadata and Dispatch record from state. */
+ * its row from state. The row carries its own membership (`projectId`,
+ * `joinedAt`), which is everything undo needs to put it back. */
meta: SessionMeta | undefined
- record: DetachedSessionRecord | undefined
}
/**
@@ -363,16 +297,15 @@ type ApprovedCloseTarget = {
* - Linked children close before their parent, and a parent is KEPT while any
* linked child still exists afterwards — refused, failed, never approved,
* or linked after the snapshot. Never orphan a lifecycle-bound child.
- * - Root promotion PREFERS a Dispatch row the operation is not about to
- * close, but falls back to a pending member rather than remove a tab that
- * still files one; a tab is removed only when no Dispatch row is filed
- * under it. Every member re-resolves its placement from the live store at
- * commit, so a promoted pending member that then closes promotes the next
- * row or removes the tab itself, and one that is then kept simply stays
- * placed. (#886 review round 2 N1: round 1 excluded pending members
- * outright. A member kept AFTER that removal — it changed, a sibling kept
- * it, its kill threw — was left filed under a deleted tab: invisible in
- * Dispatch, dropped by the next autosave, backend still running.)
+ * - A project is removed only by the commit that takes its LAST session,
+ * decided against the live store at that commit (workspaceWithoutSessions).
+ * So a member kept after its siblings closed — it changed, a sibling kept
+ * it, its kill threw — always still has a project to be filed under.
+ * (#886 review round 2 N1 was the v2 form of this: a member kept after an
+ * eager tab removal was left filed under a deleted tab — invisible,
+ * dropped by the next autosave, backend still running. v2 defended against
+ * it by PROMOTING a pending member into the tab's tile tree; with
+ * ownership on the row the defect cannot be constructed.)
* - Undo and the outcome toast describe `commits`, i.e. what actually
* happened, not what was approved: a partial operation records and reports
* exactly the members that closed (see recordOperationUndo).
@@ -384,10 +317,9 @@ type CloseOperation = {
* names a project. Never re-entered as someone's child. */
rootId: SessionId | null
approved: ReadonlyMap
- /** Each approved session's project tab as it stood at approval. A tab this
- * operation removes is recorded for undo from THIS snapshot — its whole tile
- * tree and position — because by the time the removing commit happens the
- * earlier members' commits have already reshaped it. */
+ /** Each approved session's project as it stood at approval. A project this
+ * operation removes is recorded for undo from THIS snapshot — its title and
+ * position — because by the removing commit the earlier members are gone. */
approvalTabs: ReadonlyMap
admit?: CloseSessionOptions['onlyIf']
visited: Set
@@ -420,7 +352,6 @@ function beginCloseOperation(
live: target.live,
projectTabId,
meta: state.sessions[target.sessionId],
- record: state.detachedSessions[target.sessionId],
})
const tabIndex = projectTabId ? state.tabs.findIndex(tab => tab.id === projectTabId) : -1
if (tabIndex >= 0) approvalTabs.set(state.tabs[tabIndex].id, { tab: state.tabs[tabIndex], tabIndex })
@@ -461,11 +392,17 @@ function closeRefusal(
return null
}
+// What one member's commit did to the workspace.
+//
+// Until #992 there were five outcomes, three of them about the tile tree:
+// 'pane' (a split collapsed — undo needed the split's direction/ratio/side),
+// 'detached' (a Dispatch row's record was deleted) and 'promoted' (the last
+// leaf closed and a detached survivor was moved into the tree to keep the tab
+// valid). A session is one kind of thing now, so a close either removed a
+// session or removed a session AND the project it emptied.
type CommittedClose =
| { kind: 'gone' }
- | { kind: 'detached'; record: DetachedSessionRecord }
- | { kind: 'pane'; tabId: TabId; parentInfo: NonNullable> }
- | { kind: 'promoted'; tab: Tab; tabIndex: number; survivor: DetachedSessionRecord }
+ | { kind: 'session' }
| { kind: 'tab-removed'; tab: Tab; tabIndex: number }
const UNDO_HINT = ' — ⌘⇧T Undo Close; repeat for earlier closes'
@@ -483,58 +420,36 @@ function withShownLiveness(
}
/**
- * The ClosedTab for a project this operation removed, built from the approval
- * snapshot: the original tile tree pruned to the leaves that closed, plus every
- * closed member filed under that project as a Dispatch row.
+ * The ClosedTab for a project this operation removed: its title and original
+ * position from the approval snapshot, plus every member this operation closed
+ * that was filed under it, in index order.
*
- * WHY the approval snapshot and not the removing commit's tab: members close
- * one at a time, so by the time the last leaf removes the tab the earlier
- * commits have already collapsed its splits and promoted rows. Recording that
- * final one-leaf tab (round 1's shape) made undo bring back whichever session
- * happened to close last as the root; recording the approval tree brings back
- * the project the user closed.
+ * WHY the approval snapshot and not the removing commit's tab: they are the
+ * same title, but only the snapshot's INDEX is meaningful — it is where the
+ * project sat when the user decided, before anything in the operation ran.
*/
function removedTabUndo(
operation: CloseOperation,
removed: { tab: Tab; tabIndex: number },
- removerId: SessionId,
closedAt: number,
): ClosedTab | null {
- const closed = new Set(operation.commits.map(commit => commit.sessionId))
const snapshot = operation.approvalTabs.get(removed.tab.id) ?? removed
- let root: TileNode | null = snapshot.tab.root
- for (const leaf of collectLeaves(snapshot.tab.root)) {
- if (root && !closed.has(leaf)) root = closeLeaf(root, leaf)
- }
- const tree: TileNode = root ?? { type: 'leaf', sessionId: removerId }
- const leaves = collectLeaves(tree)
- const sessionMetas: Record = {}
- for (const leaf of leaves) {
- const meta = operation.approved.get(leaf)?.meta
- // A leaf with no captured metadata cannot be respawned; the entry would be
- // judged stale at undo time anyway, so do not record a lie.
- if (!meta) return null
- sessionMetas[leaf] = meta
- }
- const detachedEntries: ClosedTabDetachedEntry[] = operation.commits.flatMap(commit => {
+ const sessions = operation.commits.flatMap(commit => {
const approved = operation.approved.get(commit.sessionId)
- if (leaves.includes(commit.sessionId) || approved?.projectTabId !== removed.tab.id || !approved.meta) return []
- // A row keeps its detachedAt so it returns to its position; a session that
- // was a grid leaf elsewhere at approval sorts after the project's own rows,
- // where createLinkedAgent files a new child.
- return [{ sessionId: commit.sessionId, meta: approved.meta, detachedAt: approved.record?.detachedAt ?? operation.startedAt }]
+ // A member with no captured metadata cannot be respawned; leave it out
+ // rather than record a lie.
+ if (approved?.projectTabId !== removed.tab.id || !approved.meta) return []
+ return [{ sessionId: commit.sessionId, meta: approved.meta }]
})
+ if (sessions.length === 0) return null
+ // Index order, so the restored project lists its sessions as it used to.
+ sessions.sort((a, b) => (a.meta.joinedAt ?? 0) - (b.meta.joinedAt ?? 0))
return {
type: 'tab',
closedAt,
- tab: {
- ...snapshot.tab,
- root: tree,
- focusedSessionId: leaves.includes(snapshot.tab.focusedSessionId) ? snapshot.tab.focusedSessionId : leaves[0],
- },
+ tab: { id: snapshot.tab.id, title: snapshot.tab.title },
tabIndex: snapshot.tabIndex,
- sessionMetas,
- detachedEntries: detachedEntries.length > 0 ? detachedEntries : undefined,
+ sessions,
}
}
@@ -550,21 +465,18 @@ function removedTabUndo(
* Shape rules:
* - A project the operation REMOVED becomes one ClosedTab (removedTabUndo),
* emitted at the commit that removed it; the members filed under it fold in.
- * - Every other commit becomes the entry of its own shape: a split pane, a
- * Dispatch row, or a root replaced by a promoted row. Each keeps its
- * session id / anchors so group undo can re-anchor it through lineage.
+ * - Every other commit becomes a ClosedSession carrying its old id (so group
+ * undo can re-anchor linked children through lineage) and its row.
* - One unit is pushed as itself; several as one ClosedGroup in commit order.
* That is what makes a PARTIAL operation recoverable (#886 review round 2):
* a parent kept because a child changed while its other children already
* closed used to leave those children with no entry at all.
*
- * WHY this does not WHY-duplicate the per-shape rationale: the detached shape
- * exists because a closed Dispatch TERMINAL's tmux session survives and the
- * next launch's reconcile would kill it as an orphan without an entry carrying
- * `tmuxName` (#671, src/main/tmux/tmuxRecovery.ts); the record is stored
- * verbatim so `detachedAt` — the only thing ordering rows inside a project
- * group — survives. A promoted root's row keeps its approval-time detachedAt
- * when it had one, for the same reason.
+ * WHY every session gets an entry, terminals above all: a closed terminal's
+ * tmux session survives, and the next launch's reconcile would kill it as an
+ * orphan without an entry carrying `tmuxName` (#671,
+ * src/main/tmux/tmuxRecovery.ts). The row is stored verbatim so `joinedAt` —
+ * the only thing ordering rows inside a project — survives.
*/
function recordOperationUndo(stack: UndoCloseStack, operation: CloseOperation): boolean {
const closedAt = Date.now()
@@ -574,43 +486,16 @@ function recordOperationUndo(stack: UndoCloseStack, operation: CloseOperation):
for (const { sessionId, meta, outcome } of operation.commits) {
const approved = operation.approved.get(sessionId)
if (outcome.kind === 'tab-removed') {
- const unit = removedTabUndo(operation, outcome, sessionId, closedAt)
+ const unit = removedTabUndo(operation, outcome, closedAt)
if (unit) units.push(unit)
continue
}
- // Filed under a project this operation removed: folded into that tab's
- // entry. Placement checks guarantee nothing filed there commits after the
- // removal (a tab is removed only when no row is left under it).
+ // Filed under a project this operation removed: folded into that project's
+ // entry. Nothing filed there can commit after the removal — a project is
+ // removed only by the commit that takes its last session.
if (approved?.projectTabId && removedTabIds.has(approved.projectTabId)) continue
- if (!meta) continue
- if (outcome.kind === 'pane') {
- units.push({
- type: 'pane',
- closedAt,
- tabId: outcome.tabId,
- sessionId,
- sessionMeta: meta,
- direction: outcome.parentInfo.direction,
- ratio: outcome.parentInfo.ratio,
- side: outcome.parentInfo.side,
- siblingLeafId: outcome.parentInfo.siblingLeafId,
- })
- } else if (outcome.kind === 'detached') {
- units.push({ type: 'detached', closedAt, sessionMeta: meta, record: outcome.record })
- } else if (outcome.kind === 'promoted') {
- // The project survives. Undo restores this session within that project
- // and never respawns the promoted survivor or duplicates the whole tab.
- units.push({
- type: 'detached',
- closedAt,
- sessionMeta: meta,
- record: {
- ...detachedDispatchRecord(sessionId, outcome.tab, outcome.tabIndex),
- ...(approved?.record ? { detachedAt: approved.record.detachedAt } : {}),
- },
- replacedRoot: outcome.survivor,
- })
- }
+ if (!meta || outcome.kind !== 'session') continue
+ units.push({ type: 'session', closedAt, sessionId, sessionMeta: meta })
}
if (units.length === 0) return false
stack.push(units.length === 1 ? units[0] : { type: 'group', closedAt, entries: units })
@@ -618,17 +503,16 @@ function recordOperationUndo(stack: UndoCloseStack, operation: CloseOperation):
}
/** The toast for one committed unit, when the operation closed everything it
- * approved. Without a recorded undo entry only a Dispatch row still toasts —
- * the long-standing behavior for bulk and fleet closes of other shapes. */
+ * approved. A closed session toasts with or without a recorded undo entry
+ * (what a Dispatch row always did — and every session is one now); a removed
+ * project toasts only when there is an entry to point at. */
function describeCommittedClose(commit: CommittedMember, undoRecorded: boolean): string | null {
const { meta, outcome } = commit
const kindLabel = meta?.kind ?? DEFAULT_PROVIDER
const cwdBase = meta?.cwd.split('/').filter(Boolean).pop() ?? meta?.cwd ?? 'session'
const hint = undoRecorded ? UNDO_HINT : ''
- if (outcome.kind === 'detached') return `Closed detached ${kindLabel} session (${cwdBase})${hint}`
+ if (outcome.kind === 'session') return `Closed ${kindLabel} session (${cwdBase})${hint}`
if (!undoRecorded) return null
- if (outcome.kind === 'pane') return `Closed ${kindLabel} pane (${cwdBase})${hint}`
- if (outcome.kind === 'promoted') return `Closed ${closeNoun(meta)}${hint}`
if (outcome.kind === 'tab-removed') return `Closed “${outcome.tab.title}”${hint}`
return null
}
@@ -677,30 +561,23 @@ function describeCloseOperation(
}
/**
- * The confirmation and the mutation share an explicit scope. Session is the
- * default even for the final grid leaf: layout ownership does not authorize
- * killing its detached siblings. Only the human root-scope dialog can choose
- * tab scope. Keeping that distinction here prevents the old preview-one /
- * terminate-project mismatch from reappearing in another close entry point.
+ * Every session closing `targetId` would end: itself and its linked cascade.
+ *
+ * A close is SESSION-scoped, always. Until #992 this took a `scope` and could
+ * return a whole tab, because closing a tab's final tile leaf was offered as a
+ * choice between "close this agent" and "close the tab" (the tree could not be
+ * empty, so the alternative was promoting a Dispatch row into it). No session
+ * is structurally special now; ending a whole project is the Close Tab
+ * command's job and nobody else's. Keeping the expansion here prevents the old
+ * preview-one / terminate-project mismatch from reappearing in another close
+ * entry point.
*/
function paneCloseTargets(
state: WorkspaceState,
runtimes: CloseExpansionRuntimes,
targetId: SessionId,
- scope: 'session' | 'tab' = 'session',
): CloseTargetSnapshot[] {
- const owningTab = state.tabs.find(tab => collectLeaves(tab.root).includes(targetId))
- // A detached target, or a pane inside a split: the tab survives, so only the
- // linked cascade dies.
- if (scope === 'session' || !owningTab || findParentSplitInfo(owningTab.root, targetId)) {
- return expandSessionCloseTargets(state, runtimes, targetId)
- }
- return expandTabCloseTargets(
- state,
- runtimes,
- [targetId],
- detachedTabChildren(state, owningTab.id).ids,
- )
+ return expandSessionCloseTargets(state, runtimes, targetId)
}
/**
@@ -708,31 +585,24 @@ function paneCloseTargets(
* carries that id through any dialog rather than rereading whichever row
* gains focus later.
*
- * WHY Dispatch Mode never falls back to grid focus (#886 review finding 1,
- * a blocker): `Tab.focusedSessionId` is grid-only, and in Tiled Dispatch the
- * grid is hidden. `commandTargetSessionIdForState` deliberately returns null
- * for an empty lane, a lane holding a dead id, or a lane holding a session
- * outside the visible scope — visually "no agent is selected here". The first
- * version of this helper then fell through to the active tab's grid focus, so
- * pressing Close Focused Session on an empty lane killed the hidden grid agent
- * (silently when idle, taking the project with it when it was the sole leaf).
- * Main's old closeFocused had an `if (snapshot.dispatchMode) return` guard;
- * this is that guard, stated where the target is chosen.
+ * WHY this is strict and has NO fallback (#886 review finding 1, a blocker):
+ * `commandTargetSessionIdForState` deliberately returns null for an empty
+ * lane or a lane holding a dead id — visually "no agent is selected here".
+ * The first version of this helper then fell through to the active tab's
+ * tree focus, so pressing Close Focused Session on an empty lane killed an
+ * agent the user could not see (silently when idle, taking the project with
+ * it when it was the sole leaf). A destructive command must target what is
+ * highlighted or nothing.
*
- * In CLASSIC Dispatch the strict resolver still yields a row when
- * `dispatchMode.focusedSessionId` is stale (after a scope switch, rehydrate
- * miss or rapid close): it applies the same fallback DispatchLayout uses to
- * highlight a row — classic focus, then grid focus, then the first visible
- * row — so the highlighted row and the destructive target cannot diverge. Only
- * Tiled Dispatch's lanes are strict, because an empty lane is a real visual
- * state there. Outside Dispatch the command target already includes a
- * visibly selected related child; the grid focus is its own fallback.
+ * Two fallbacks used to live below the strict read and are gone with #992:
+ * classic Dispatch's "classic focus, then grid focus, then first visible row"
+ * ladder, and the grid's own `Tab.focusedSessionId`. Neither surface exists.
+ * The function is kept, rather than inlined at its one call site, because
+ * this comment is the record of why "just fall back to something sensible"
+ * is the wrong instinct here.
*/
function resolveFocusedCloseTarget(state: WorkspaceState): SessionId | undefined {
- const commandTarget = commandTargetSessionIdForState(state)
- if (commandTarget) return commandTarget
- if (state.dispatchMode) return undefined
- return state.tabs.find(tab => tab.id === state.activeTabId)?.focusedSessionId
+ return commandTargetSessionIdForState(state) ?? undefined
}
/**
@@ -744,64 +614,111 @@ function resolveFocusedCloseTarget(state: WorkspaceState): SessionId | undefined
const CLOSE_CHANGED_TOAST =
'Close cancelled — these sessions changed while the dialog was open. Try again.'
-// Update dispatchMode after a new dispatch agent is spawned. In Tiled
-// Dispatch the new agent takes over the lane the user is commanding
-// (target.laneIndex) so it appears where they were looking; in classic
-// Dispatch it becomes the single focus. Setting focusedSessionId in both
-// cases keeps classic focus coherent if the user later exits the tiled view.
-// The lane index is re-validated here (a stale resolution could outrun a
-// concurrent count change), falling back to a plain focus update.
+// Put a newly spawned session on the lane the user was commanding
+// (target.laneIndex) — but ONLY if that lane is EMPTY. This is the
+// context-places rule (#992 §4.3, the option the operator chose by name):
+// spawning from an empty focused lane fills it, which is one of the two
+// continuity writes U2 allows besides the user naming an occupant. An
+// OCCUPIED lane is never displaced — that would be the #681 healer wearing a
+// spawn costume. The session is still in the pool and reachable from every
+// index; placement is one click.
+//
+// The emptiness test happens HERE, at commit time, not in the spawn-target
+// resolver: the lane index was resolved BEFORE an awaited spawn, and a lane
+// that was empty when the chord was struck may have been filled by the time
+// the backend answers. Displacing that later occupant would be a surprise
+// ordered before it existed. (The reverse race — lane emptied while the
+// spawn was in flight — is fine: the fill was the intent all along.)
+//
+// The lane index is also re-validated for the same reason as before: an index
+// that no longer exists leaves the stage untouched rather than being clamped
+// onto whichever lane now sits at the edge. When the lane is refused, focus
+// does NOT move either — "nothing on screen moves" is half the rule; moving
+// the cursor to advertise the pool row would be the healer again, in a
+// cheaper costume.
+//
+// (This also set a classic-Dispatch `focusedSessionId` until #992, "to keep
+// classic focus coherent if the user later exits the tiled view". There is no
+// other view to exit to.)
function applyDispatchSpawnFocus(
- dispatchMode: DispatchModeState | null,
+ state: WorkspaceState,
sessionId: SessionId,
laneIndex: number | null,
-): DispatchModeState | null {
- if (!dispatchMode) return dispatchMode
- const tiled = dispatchMode.tiled
- if (laneIndex !== null && tiled && laneIndex >= 0 && laneIndex < tiled.lanes.length) {
- const lanes = tiled.lanes.map((lane, i) =>
- i === laneIndex ? withLaneSession(lane, sessionId) : lane,
- )
- return {
- ...dispatchMode,
- focusedSessionId: sessionId,
- tiled: { ...tiled, lanes, focusedLane: laneIndex },
- }
- }
- return { ...dispatchMode, focusedSessionId: sessionId }
+): TiledDispatchState {
+ const stage = state.stage
+ if (laneIndex === null || laneIndex < 0 || laneIndex >= stage.lanes.length) return stage
+ // Occupied means `selectedSessionId` is set and resolves. A lane pointing
+ // at a GONE session reads empty to the user and is treated as fillable:
+ // that is exactly the shape a mid-operation close leaves behind, and the
+ // stale pointer is dropped by the same write that fills the lane.
+ const occupant = stage.lanes[laneIndex]?.selectedSessionId
+ if (occupant !== undefined && state.sessions[occupant] !== undefined) return stage
+ const lanes = stage.lanes.map((lane, i) =>
+ i === laneIndex ? withLaneSession(lane, sessionId) : lane,
+ )
+ return { ...stage, lanes, focusedLane: laneIndex }
+}
+
+// markPooledSpawn moved to pooledSpawnBadge.ts, beside the clear it pairs
+// with (#1013 review B).
+
+export type OpenExtensionViewOptions = {
+ /** Put the view on screen even when the focused lane is occupied, reusing a
+ * view of the same id that already exists. See revealExtensionView. */
+ reveal?: boolean
}
/**
- * The durable record that files a session as a Dispatch row for a project.
- *
- * WHY this is one helper instead of the literal being written at each spawn
- * site: `splitFocused`'s Dispatch branch and `createDetachedDispatchAgent`
- * built byte-identical objects, and the shape is a persistence contract —
- * `projectTabId` drives Dispatch grouping, cwd defaults, and attach targeting,
- * while `detachedAt` is the ONLY thing that orders rows inside a project group
- * (see buildDispatchGroups). Two hand-written copies of a durable shape is how
- * one of them silently drifts.
+ * Bring an EXISTING session of `viewId` on screen, or return null when there
+ * is none. A view already in a lane just takes focus. A pooled one goes into
+ * the focused lane, whose occupant returns to the pool alive, exactly as an
+ * index click does (selectTiledLaneSession). An extension view has no process,
+ * so there is nothing to wake first.
*
- * `projectTabIndex` is a display ordinal that buildDispatchGroups recomputes
- * from `state.tabs` on every render; it is seeded here only so a record read
- * before the next render has something sane, which is why a missing tab
- * collapses to 0 rather than refusing to build the record.
+ * WHY reuse instead of opening another (#1013 parity review, MAJOR): a legacy
+ * action command opens the view only to get a frame to run in. Minting a
+ * fresh session per press piled up identical views, and under
+ * context-places every one of them landed in the pool, invisible.
*/
-function detachedDispatchRecord(
- sessionId: SessionId,
- tab: Tab,
- tabIndex: number,
-): DetachedSessionRecord {
+function revealExtensionView(state: WorkspaceState, viewId: string): WorkspaceState | null {
+ const stage = state.stage
+ const existing = Object.entries(state.sessions)
+ .filter(([, meta]) => meta.kind === 'extension-view' && meta.extensionViewId === viewId)
+ .map(([id]) => id as SessionId)
+ if (existing.length === 0) return null
+ const laneIndex = stage.lanes.findIndex(lane => lane.selectedSessionId !== undefined && existing.includes(lane.selectedSessionId))
+ if (laneIndex >= 0) {
+ return stage.focusedLane === laneIndex ? state : { ...state, stage: { ...stage, focusedLane: laneIndex } }
+ }
+ const sessionId = existing[0]!
+ const focusedLane = stage.focusedLane
+ if (!stage.lanes[focusedLane]) return null
+ const projectId = state.sessions[sessionId]?.projectId
return {
- sessionId,
- surface: 'dispatch',
- projectTabId: tab.id,
- projectTabTitle: tab.title,
- projectTabIndex: tabIndex >= 0 ? tabIndex : 0,
- detachedAt: Date.now(),
+ ...state,
+ activeTabId: projectId ?? state.activeTabId,
+ stage: { ...stage, lanes: stage.lanes.map((lane, i) => (i === focusedLane ? withLaneSession(lane, sessionId) : lane)) },
}
}
+// `detachedDispatchRecord` lived here until #992: the one helper that built the
+// durable record filing a session under a project (`projectTabId`,
+// `detachedAt`, and two display copies of the tab's title and index). Its
+// reason for being one helper — two hand-written copies of a durable shape is
+// how one of them drifts — carries over to `fileSessionInProject` (pool.ts),
+// which stamps the same two facts onto the row itself.
+
+/**
+ * A directory to start a new session in when nothing under the cursor offers
+ * one: the first session of the project that has a cwd. A project has no
+ * directory of its own, so its sessions are the only source.
+ */
+function projectCwd(state: WorkspaceState, tabId: TabId): string | undefined {
+ return resolveTabSessions(state, tabId)
+ .map(id => state.sessions[id]?.cwd)
+ .find((cwd): cwd is string => Boolean(cwd))
+}
+
type SplitFocusedContinuation = {
// WHY cwd and resumeSessionId are required together: this object represents a provider
// continuation, not generic split preferences. Making the scope cwd optional recreates the exact
@@ -819,34 +736,27 @@ type SplitFocusedContinuation = {
export function usePaneActions(
state: {
activeTabId: string
- detachedSessions: Record
- dispatchMode: DispatchModeState | null
sessions: Record
tabs: Tab[]
},
setState: WorkspaceSetState,
setRuntimes: WorkspaceSetRuntimes,
setSpotlight: WorkspaceSetSpotlight,
- setTileTabs: WorkspaceSetTileTabs,
// Reader Mode joins the other takeover setters so an emptied tab is cleaned
// up by the same tail as the Close Tab command (tabRemoval.ts), instead of
// leaving a Reader takeover on a removed tab for an effect to heal later.
setReaderMode: WorkspaceSetReaderMode,
refs: WorkspaceRefs,
showToast: (message: string, durationMs?: number) => void,
- openBuryPrompt: (sessionId: SessionId) => void,
- closeBuryPrompt: () => void,
openNewAgentPlacement: () => void,
closeNewAgentPlacement: () => void,
sessionActions: SessionActions,
): {
splitFocused: (
- direction: SplitDirection,
kind?: SessionKind,
continuation?: SplitFocusedContinuation,
) => Promise
startNewAgentPlacement: () => void
- commitNewAgentPlacement: (selection: SessionSpawnSelection, target: PlacementTarget) => Promise
createDetachedSession: (selection: SessionSpawnSelection, projectOverride?: { tabId: TabId; anchorSessionId: SessionId }, continuation?: SplitFocusedContinuation, placement?: { selectCreated: boolean }) => Promise
// WHY `kind` is the full SessionKind here (unlike createLinkedAgent right
// below, which stays narrowed to agent providers): Dispatch's "New Agent…"
@@ -880,10 +790,6 @@ export function usePaneActions(
runId?: string
builtInMcpDomains?: BuiltInMcpDomain[]
}) => Promise
- attachDetachedToGrid: (sessionId: SessionId, targetTabId: string, target: PlacementTarget) => Promise
- attachAllDetachedForTab: (tabId: string) => Promise
- detachSessionToDispatch: (sessionId: SessionId) => void
- detachFocusedToDispatch: () => void
closeFocused: () => Promise
/**
* Resolves true exactly when the NAMED session's close committed.
@@ -907,14 +813,8 @@ export function usePaneActions(
/** The Close Tab command: end the project's approved plan through the same
* executor as closeSession (see its implementation's WHY). */
closeTab: (tabId: TabId) => Promise
- requestBuryFocused: () => void
- buryFocused: (note?: string, targetSessionId?: SessionId) => void
- reviveBuried: (buriedId: string) => Promise
- killBuried: (buriedId: string) => Promise
- focusSession: (sessionId: SessionId) => void
focusSessionInTab: (tabId: string, sessionId: SessionId) => void
- navigate: (direction: 'left' | 'right' | 'up' | 'down') => void
- openExtensionViewInPane: (viewId: string, direction?: SplitDirection) => void
+ openExtensionViewInPane: (viewId: string, options?: OpenExtensionViewOptions) => void
} {
const closeSessionRef = useRef<
((targetId: SessionId, options?: CloseSessionOptions) => Promise) | null
@@ -924,7 +824,6 @@ export function usePaneActions(
// leaf under a fresh split node, makes the new pane focused.
const splitFocused = useCallback(
async (
- direction: SplitDirection,
kind: SessionKind = 'claude',
continuation?: SplitFocusedContinuation,
) => {
@@ -962,156 +861,59 @@ export function usePaneActions(
//
// Terminals created OUTSIDE Dispatch still split the grid — see the
// normal-mode path below. Only the Dispatch creation surface changed.
- if (dispatchSnapshot.dispatchMode) {
- // Same target resolution as createDetachedDispatchAgent: follow the
- // focused lane in Tiled Dispatch so cwd and projectTab agree on the
- // project the user is commanding (issue #266 / #248). Routing terminals
- // through this same resolver is what preserves #366 — project tab and
- // cwd come from the focused lane, never from a stale activeTabId —
- // without needing a terminal-specific resolver to keep in sync.
- const target = resolveDispatchSpawnTarget(dispatchSnapshot)
- const tab = dispatchSnapshot.tabs.find(t => t.id === target.tabId)
- if (!tab) return
-
- const leafIds = collectLeaves(tab.root)
- // WHY a caller may override the visually focused project cwd: lifecycle commands can
- // target a selected related/orchestration child that is rendered inside a physical parent
- // pane but intentionally runs in another worktree. Its transcript id, enabled MCP domains,
- // and cwd are one continuation identity. Mixing the child's domains with the parent's cwd
- // would mint a fresh token for the wrong project scope.
- const cwd =
- continuation?.cwd ??
- (target.cwdSessionId ? dispatchSnapshot.sessions[target.cwdSessionId]?.cwd : null) ??
- dispatchSnapshot.sessions[tab.focusedSessionId]?.cwd ??
- leafIds.map(id => dispatchSnapshot.sessions[id]?.cwd).find(Boolean)
- if (!cwd) {
- showToast(
- kind === 'terminal'
- ? 'Could not create dispatch terminal: no project directory found'
- : 'Could not create dispatch agent: no project directory found',
- )
- return
- }
-
- let sessionId: SessionId
- try {
- // Resume identity, runtime flavor, and built-in MCP domains are
- // passed through unguarded, but they are NOT symmetric and it is
- // worth being precise about which is which:
- //
- // - `builtInMcpOverrides` really is dropped for a terminal —
- // `sessionActions.spawn` gates the resolved capability list it
- // produces behind `isAgentProviderKind`.
- // - `resumeSessionId` is NOT dropped. It is forwarded to
- // `window.api.spawnSession` for every kind; only the value written
- // back into the durable `SessionMeta` is kind-gated. It is inert
- // for a terminal because main re-gates on kind before resolving a
- // transcript, not because anything here filtered it.
- // - `providerRuntime` is validated by main against the chosen
- // provider factory. It is present when a transcript clone must
- // remain OpenCode Terminal instead of reverting to rendered
- // OpenCode.
- //
- // Neither can be reached today regardless: `continuation` is only
- // supplied by agent-gated callers, so no terminal spawn carries one.
- // Adding a local guard would state a rule this call site does not
- // actually own, which is exactly the kind of comment that outlives
- // the code it describes.
- sessionId = await sessionActions.spawn(cwd, {
- kind,
- ...(providerRuntime ? { providerRuntime } : {}),
- resumeSessionId,
- builtInMcpOverrides,
- })
- } catch (err) {
- showToast(
- err instanceof Error && err.message.length > 0
- ? err.message
- : kind === 'terminal'
- ? 'Failed to create dispatch terminal'
- : 'Failed to create dispatch agent',
- )
- return
- }
-
- // Did the record actually get written? The resolved tab can be closed
- // between the awaited spawn and this commit, in which case `spawn` has
- // already registered a live backend that would then belong to no tile
- // tree and no detached record — an unreachable session leaking in
- // renderer and main state. The terminal branch used to guard this and
- // the agent branch did not; merging keeps the guard and extends it to
- // both kinds rather than preserving the leak in the shared path.
- //
- // Reading a flag set inside the updater is only sound because setState
- // is the zustand store setter, which applies the updater synchronously.
- // If this ever becomes a React useState setter the flag would still be
- // false here and every spawn would be killed on the spot.
- let filed = false
- setState(prev => {
- const latestTab = prev.tabs.find(t => t.id === tab.id)
- if (!latestTab) return prev
- const projectTabIndex = prev.tabs.findIndex(t => t.id === tab.id)
- filed = true
-
- // WHY splitFocused owns this Dispatch detour instead of making every
- // keybinding and command-palette entry remember Dispatch Mode:
- // `splitFocused` is the old "make me a new session" primitive. Before
- // detached sessions, routing that through the tile tree was correct.
- // In Dispatch Mode it is now wrong: the command-center surface can
- // create many sessions, and those must not mutate the normal grid
- // just because the user used the familiar Option-D/Option-C/Option-T
- // grammar. Keeping the rule here makes all callers agree: normal mode
- // splits the grid; Dispatch Mode creates a detached dispatch row and
- // focuses it immediately.
- return {
- ...prev,
- activeTabId: latestTab.id,
- detachedSessions: {
- ...prev.detachedSessions,
- [sessionId]: detachedDispatchRecord(sessionId, latestTab, projectTabIndex),
- },
- dispatchMode: applyDispatchSpawnFocus(prev.dispatchMode, sessionId, target.laneIndex),
- }
- })
+ // Same target resolution as createDetachedDispatchAgent: follow the
+ // focused lane in Tiled Dispatch so cwd and projectTab agree on the
+ // project the user is commanding (issue #266 / #248). Routing terminals
+ // through this same resolver is what preserves #366 — project tab and
+ // cwd come from the focused lane, never from a stale activeTabId —
+ // without needing a terminal-specific resolver to keep in sync.
+ const target = resolveDispatchSpawnTarget(dispatchSnapshot)
+ const tab = dispatchSnapshot.tabs.find(t => t.id === target.tabId)
+ if (!tab) return
- if (!filed) {
- // Kill the backend with the kind/cwd THIS call already resolved
- // rather than leaving it to killSession's ownership proof, which
- // re-reads them from `refs.stateRef`.
- //
- // History: that ref used to be a RENDER-BODY mirror (assigned while
- // the workspace hook rendered). Immediately after an awaited spawn
- // React had not re-rendered, so the ref lacked the new session, the
- // proof bailed on missing metadata, and the kill silently no-opped —
- // this guard never actually reclaimed anything. #886 subscribed
- // stateRef to the store synchronously, so the proof would now see the
- // session; the explicit owner stays as defense in depth, because this
- // reclaim must not depend on how the ref happens to be wired.
- // killSession still runs for the renderer-side cleanup; its own
- // ownership check may then find the backend already gone, harmlessly.
- await window.api.killOwnedSession({
- sessionId,
- kind,
- ...(providerRuntime ? { providerRuntime } : {}),
- cwd,
- })
- .catch(() => undefined)
- await sessionActions.killSession(sessionId)
- return
- }
- closeNewAgentPlacement()
+ // WHY a caller may override the visually focused project cwd: lifecycle commands can
+ // target a selected related/orchestration child that is rendered inside a physical parent
+ // pane but intentionally runs in another worktree. Its transcript id, enabled MCP domains,
+ // and cwd are one continuation identity. Mixing the child's domains with the parent's cwd
+ // would mint a fresh token for the wrong project scope.
+ const cwd =
+ continuation?.cwd ??
+ (target.cwdSessionId ? dispatchSnapshot.sessions[target.cwdSessionId]?.cwd : null) ??
+ projectCwd(dispatchSnapshot, tab.id)
+ if (!cwd) {
+ showToast(
+ kind === 'terminal'
+ ? 'Could not create dispatch terminal: no project directory found'
+ : 'Could not create dispatch agent: no project directory found',
+ )
return
}
- const tab = state.tabs.find(t => t.id === state.activeTabId)
- if (!tab) return
- const parentSessionId = tab.focusedSessionId
- const spawnCwd = continuation?.cwd ?? state.sessions[parentSessionId]?.cwd
- if (!spawnCwd) return
-
- let newSessionId: SessionId
+ let sessionId: SessionId
try {
- newSessionId = await sessionActions.spawn(spawnCwd, {
+ // Resume identity, runtime flavor, and built-in MCP domains are
+ // passed through unguarded, but they are NOT symmetric and it is
+ // worth being precise about which is which:
+ //
+ // - `builtInMcpOverrides` really is dropped for a terminal —
+ // `sessionActions.spawn` gates the resolved capability list it
+ // produces behind `isAgentProviderKind`.
+ // - `resumeSessionId` is NOT dropped. It is forwarded to
+ // `window.api.spawnSession` for every kind; only the value written
+ // back into the durable `SessionMeta` is kind-gated. It is inert
+ // for a terminal because main re-gates on kind before resolving a
+ // transcript, not because anything here filtered it.
+ // - `providerRuntime` is validated by main against the chosen
+ // provider factory. It is present when a transcript clone must
+ // remain OpenCode Terminal instead of reverting to rendered
+ // OpenCode.
+ //
+ // Neither can be reached today regardless: `continuation` is only
+ // supplied by agent-gated callers, so no terminal spawn carries one.
+ // Adding a local guard would state a rule this call site does not
+ // actually own, which is exactly the kind of comment that outlives
+ // the code it describes.
+ sessionId = await sessionActions.spawn(cwd, {
kind,
...(providerRuntime ? { providerRuntime } : {}),
resumeSessionId,
@@ -1121,22 +923,81 @@ export function usePaneActions(
showToast(
err instanceof Error && err.message.length > 0
? err.message
- : 'Failed to split pane',
+ : kind === 'terminal'
+ ? 'Failed to create dispatch terminal'
+ : 'Failed to create dispatch agent',
)
return
}
- setState(prev => ({
- ...prev,
- tabs: prev.tabs.map(t => {
- if (t.id !== prev.activeTabId) return t
- return {
- ...t,
- root: splitLeaf(t.root, parentSessionId, direction, newSessionId),
- focusedSessionId: newSessionId,
- }
- }),
- }))
+ // Did the session actually get FILED? The resolved project can be closed
+ // between the awaited spawn and this commit, in which case `spawn` has
+ // already registered a live backend whose row names no project — an
+ // unreachable session leaking in renderer and main state. The terminal branch used to guard this and
+ // the agent branch did not; merging keeps the guard and extends it to
+ // both kinds rather than preserving the leak in the shared path.
+ //
+ // Reading a flag set inside the updater is only sound because setState
+ // is the zustand store setter, which applies the updater synchronously.
+ // If this ever becomes a React useState setter the flag would still be
+ // false here and every spawn would be killed on the spot.
+ let filed = false
+ // Whether the spawn took a lane. Decided INSIDE the updater so it reads
+ // the same `prev` the placement read — a lane freed during the awaited
+ // spawn is fillable, one filled since is not — and readable outside
+ // because the zustand setter applies updaters synchronously (the same
+ // contract the `filed` flag relies on).
+ let pooled = false
+ setState(prev => {
+ const latestTab = prev.tabs.find(t => t.id === tab.id)
+ if (!latestTab) return prev
+ filed = true
+ const stage = applyDispatchSpawnFocus(prev, sessionId, target.laneIndex)
+ // Refused placement returns the stage by reference; that reference
+ // identity IS the fill/refuse answer (see applyDispatchSpawnFocus).
+ pooled = stage === prev.stage
+
+ return {
+ ...prev,
+ activeTabId: latestTab.id,
+ // Filing IS ownership (pool.ts): the row `spawn` wrote becomes a
+ // member of this project, last in its index.
+ sessions: fileSessionInProject(prev.sessions, sessionId, latestTab.id),
+ stage,
+ }
+ })
+
+ if (filed && pooled) markPooledSpawn(setRuntimes, sessionId)
+ if (!filed) {
+ // Kill the backend with the kind/cwd THIS call already resolved
+ // rather than leaving it to killSession's ownership proof, which
+ // re-reads them from `refs.stateRef`.
+ //
+ // History: that ref used to be a RENDER-BODY mirror (assigned while
+ // the workspace hook rendered). Immediately after an awaited spawn
+ // React had not re-rendered, so the ref lacked the new session, the
+ // proof bailed on missing metadata, and the kill silently no-opped —
+ // this guard never actually reclaimed anything. #886 subscribed
+ // stateRef to the store synchronously, so the proof would now see the
+ // session; the explicit owner stays as defense in depth, because this
+ // reclaim must not depend on how the ref happens to be wired.
+ // killSession still runs for the renderer-side cleanup; its own
+ // ownership check may then find the backend already gone, harmlessly.
+ await window.api.killOwnedSession({
+ sessionId,
+ kind,
+ ...(providerRuntime ? { providerRuntime } : {}),
+ cwd,
+ })
+ .catch(() => undefined)
+ await sessionActions.killSession(sessionId)
+ return
+ }
+ closeNewAgentPlacement()
+ // A tile-tree branch followed this one until #992, and `direction`
+ // parameterized it. The tree is gone, the stage is required, and the
+ // argument went with it (#992 stage 4): every spawn is one flow — fill
+ // the focused lane when it is empty, else pool.
},
[
closeNewAgentPlacement,
@@ -1144,9 +1005,6 @@ export function usePaneActions(
sessionActions,
setState,
showToast,
- state.activeTabId,
- state.sessions,
- state.tabs,
],
)
@@ -1190,16 +1048,13 @@ export function usePaneActions(
const tab = snapshot.tabs.find(t => t.id === target.tabId)
if (!tab) return null
- const leafIds = collectLeaves(tab.root)
// A native continuation owns its cwd; the project only owns placement.
// Reusing the anchor cwd here can resume a transcript in another repo.
const cwd = continuation?.cwd ??
(target.cwdSessionId ? snapshot.sessions[target.cwdSessionId]?.cwd : null) ??
- // Do NOT fall back to tab.focusedSessionId: in Tiled Dispatch that's
- // stale grid focus (the focused lane's session is already
- // target.cwdSessionId via resolveDispatchSpawnTarget). Fall back to any
- // leaf cwd of the resolved tab — all are valid project dirs for it.
- leafIds.map(id => snapshot.sessions[id]?.cwd).find(Boolean)
+ // No agent under the cursor to borrow from: any session of the
+ // resolved project will do — all are valid directories for it.
+ projectCwd(snapshot, tab.id)
if (!cwd) {
showToast('Could not create dispatch agent: no project directory found')
return null
@@ -1218,30 +1073,39 @@ export function usePaneActions(
}
let placed = false
+ // Pooled = the spawn took no lane (context-places). Same synchronous-
+ // updater trick as `placed`: readable after setState, decided against
+ // the exact `prev` the placement read. A selectCreated:false caller
+ // asked for no view change, so its spawn is pooled BY REQUEST and still
+ // badges — the caller places the returned ID itself, and until it does
+ // the badge is the honest state of that row.
+ let pooled = placement?.selectCreated === false
setState(prev => {
const latestTab = prev.tabs.find(t => t.id === tab.id)
- const projectTabIndex = prev.tabs.findIndex(t => t.id === tab.id)
if (!latestTab) return prev
placed = true
- // Detached sessions are live workspace sessions with project affinity,
- // not children of Dispatch Mode. We deliberately do not insert this id
- // into latestTab.root, because the whole point is that creating ten
- // command-center agents must not explode the normal grid when Dispatch
- // Mode is turned off.
+ if (!pooled) {
+ const stage = applyDispatchSpawnFocus(prev, sessionId, target.laneIndex)
+ pooled = stage === prev.stage
+ return {
+ ...prev,
+ // Filing is membership, not focus. UI creation has always selected
+ // the captured lane; external operators can preserve the entire
+ // current view, then explicitly assign the returned ID to a chosen
+ // lane using a fresh layout revision.
+ activeTabId: latestTab.id,
+ sessions: fileSessionInProject(prev.sessions, sessionId, latestTab.id),
+ stage,
+ }
+ }
return {
...prev,
- // Detached describes grid membership, not focus. UI creation has
- // always selected the captured lane; external operators can preserve
- // the entire current view, then explicitly assign the returned ID to
- // a chosen lane using a fresh layout revision.
- activeTabId: placement?.selectCreated === false ? prev.activeTabId : latestTab.id,
- detachedSessions: {
- ...prev.detachedSessions,
- [sessionId]: detachedDispatchRecord(sessionId, latestTab, projectTabIndex),
- },
- dispatchMode: placement?.selectCreated === false ? prev.dispatchMode : applyDispatchSpawnFocus(prev.dispatchMode, sessionId, target.laneIndex),
+ activeTabId: prev.activeTabId,
+ sessions: fileSessionInProject(prev.sessions, sessionId, latestTab.id),
+ stage: prev.stage,
}
})
+ if (placed && pooled) markPooledSpawn(setRuntimes, sessionId)
// A caller needs the exact spawned ID; comparing a before/after census
// could accidentally claim an agent created concurrently by the UI.
// If the owning project disappeared during spawn, retire only this new
@@ -1253,7 +1117,7 @@ export function usePaneActions(
if (placement?.selectCreated !== false) closeNewAgentPlacement()
return sessionId
},
- [closeNewAgentPlacement, refs.stateRef, sessionActions, setState, showToast],
+ [closeNewAgentPlacement, refs.stateRef, sessionActions, setState, setRuntimes, showToast],
)
// Spawn a "linked agent" — a normal detached dispatch agent that
@@ -1287,21 +1151,16 @@ export function usePaneActions(
// note on SessionMeta.linkedParentId).
const rootParentId = parentMeta.linkedParentId ?? parentId
const rootParentMeta = snapshot.sessions[rootParentId] ?? parentMeta
- const tiled = snapshot.dispatchMode?.tiled
- const focusedLane = tiled?.focusedLane ?? null
- const targetLaneIndex =
- focusedLane !== null &&
- tiled?.lanes[focusedLane]?.selectedSessionId === parentId
- ? focusedLane
- : null
-
- // Resolve the parent's tab: a detached parent carries its tab
- // id on the detachedSessions record; a grid parent is found by
- // the tab whose tile tree contains its leaf.
- const parentDetached = snapshot.detachedSessions[rootParentId]
- const parentTab = parentDetached
- ? snapshot.tabs.find(t => t.id === parentDetached.projectTabId)
- : snapshot.tabs.find(t => collectLeaves(t.root).includes(rootParentId))
+ // No lane capture. This used to aim the child at the focused lane WHEN
+ // that lane showed the parent — "spawned to immediately hand it a
+ // prompt". Under context-places (#992 §4.3) a lane showing the parent is
+ // an OCCUPIED lane, and an occupied lane is never displaced; a lane not
+ // showing the parent was never a target. Both branches of the capture
+ // are dead, so it is gone. The child lands in the pool, nested under
+ // its parent in every index that lists it.
+
+ // The child is filed in its parent's project.
+ const parentTab = sessionPlacement(snapshot, rootParentId)?.tab
if (!parentTab) {
showToast('Could not create linked agent: parent tab not found')
return
@@ -1319,10 +1178,11 @@ export function usePaneActions(
return
}
+ let filed = false
setState(prev => {
const latestTab = prev.tabs.find(t => t.id === parentTab.id)
- const projectTabIndex = prev.tabs.findIndex(t => t.id === parentTab.id)
if (!latestTab) return prev
+ filed = true
return {
...prev,
activeTabId: latestTab.id,
@@ -1338,29 +1198,22 @@ export function usePaneActions(
...(providerRuntime ? { providerRuntime } : {}),
}),
linkedParentId: rootParentId,
+ // Filed in the PARENT's project rather than the active one, last
+ // in its index (the index then nests it under its parent).
+ projectId: latestTab.id,
+ joinedAt: Date.now(),
},
},
- // A linked agent is a detached dispatch agent — same record
- // shape as createDetachedDispatchAgent, just anchored to the
- // parent's tab instead of the active one.
- detachedSessions: {
- ...prev.detachedSessions,
- [sessionId]: detachedDispatchRecord(sessionId, latestTab, projectTabIndex),
- },
- // Focus the new agent in dispatch — the user spawned it to
- // immediately hand it a prompt (typically a review prompt).
- //
- // WHY the lane index is captured before await:
- // spawn() crosses IPC and may take long enough for the user to move
- // focus. The command was initiated from a specific visual lane, so
- // that lane is the one that should flip to the child. Using the
- // latest focusedLane here would make an unrelated lane change race
- // with the child spawn and steal the next prompt target.
- dispatchMode: applyDispatchSpawnFocus(prev.dispatchMode, sessionId, targetLaneIndex),
+ // Pool-only placement (context-places): the child is filed under
+ // the parent's project and shown in NO lane. See the note above the
+ // spawn for why the old "focus the child in the parent's lane"
+ // behavior is gone; passing null keeps the stage byte-identical.
+ stage: applyDispatchSpawnFocus(prev, sessionId, null),
}
})
+ if (filed) markPooledSpawn(setRuntimes, sessionId)
},
- [refs.stateRef, sessionActions, setState, showToast],
+ [refs.stateRef, sessionActions, setState, setRuntimes, showToast],
)
const createOrchestrationAgent = useCallback(
@@ -1390,10 +1243,7 @@ export function usePaneActions(
const rootParentId = parentMeta.orchestrationRootId ?? params.parentId
const rootParentMeta = snapshot.sessions[rootParentId] ?? parentMeta
- const parentDetached = snapshot.detachedSessions[rootParentId]
- const parentTab = parentDetached
- ? snapshot.tabs.find(t => t.id === parentDetached.projectTabId)
- : snapshot.tabs.find(t => collectLeaves(t.root).includes(rootParentId))
+ const parentTab = sessionPlacement(snapshot, rootParentId)?.tab
if (!parentTab) {
throw new Error('Could not create orchestration agent: parent tab not found')
}
@@ -1458,10 +1308,11 @@ export function usePaneActions(
...(params.role ? { orchestrationRole: params.role } : {}),
}
+ let filed = false
setState(prev => {
const latestTab = prev.tabs.find(t => t.id === parentTab.id)
- const projectTabIndex = prev.tabs.findIndex(t => t.id === parentTab.id)
if (!latestTab) return prev
+ filed = true
return {
...prev,
sessions: {
@@ -1475,12 +1326,11 @@ export function usePaneActions(
orchestrationRootId: rootParentId,
...(params.runId ? { orchestrationRunId: params.runId } : {}),
...(params.role ? { orchestrationRole: params.role } : {}),
+ // Filed in the root parent's project; see createLinkedAgent.
+ projectId: latestTab.id,
+ joinedAt: Date.now(),
},
},
- detachedSessions: {
- ...prev.detachedSessions,
- [sessionId]: detachedDispatchRecord(sessionId, latestTab, projectTabIndex),
- },
// WHY orchestration agents intentionally do not steal focus:
// the MCP caller already gets `sessionId` back as the control handle,
// and the user may be reading or editing the parent while the new
@@ -1492,10 +1342,15 @@ export function usePaneActions(
// same project tree.
}
})
+ // Orchestrated children are the purest pooled spawn: many can arrive
+ // from one prompt, none of them takes a lane, and the parent's pane is
+ // the surface the user is reading (#992 §4.3 names orchestration
+ // explicitly). The badge is the only on-screen trace that they arrived.
+ if (filed) markPooledSpawn(setRuntimes, sessionId)
return agent
},
- [refs.stateRef, sessionActions, setState],
+ [refs.stateRef, sessionActions, setState, setRuntimes],
)
// Execute ONE member of an approved CloseOperation: its approved linked
@@ -1579,72 +1434,33 @@ export function usePaneActions(
setState(prev => {
// Placement is re-resolved from `prev`, never from the pre-kill
// snapshot: the kill was an await, and this member's own children may
- // have promoted a row or emptied the tab before it (finding 3).
+ // have emptied the project before it (finding 3).
const placement = sessionPlacement(prev, targetId)
- const sessions = { ...prev.sessions }
- delete sessions[targetId]
- if (!placement) return prev.sessions[targetId] ? { ...prev, sessions } : prev
- if (placement.kind === 'detached') {
- committed.value = { kind: 'detached', record: placement.record }
- const detachedSessions = { ...prev.detachedSessions }
- delete detachedSessions[targetId]
- const next = { ...prev, sessions, detachedSessions }
- return { ...next, dispatchMode: dispatchModeAfterSessionRemoval(prev, next, targetId) }
- }
- const { tab, tabIndex } = placement
- const tabs = [...prev.tabs]
- const nextRoot = closeLeaf(tab.root, targetId)
- if (nextRoot) {
- const parentInfo = findParentSplitInfo(tab.root, targetId)
- if (parentInfo) committed.value = { kind: 'pane', tabId: tab.id, parentInfo }
- tabs[tabIndex] = {
- ...tab,
- root: nextRoot,
- focusedSessionId: findBestRemainingFocus(tab.root, nextRoot, targetId) ?? collectLeaves(nextRoot)[0],
- }
- const next = { ...prev, tabs, sessions }
- return { ...next, dispatchMode: dispatchModeAfterSessionRemoval(prev, next, targetId) }
- }
- // A nonempty project must keep its identity. Promote an existing
- // detached backend into the mandatory grid leaf; never spawn a shell or
- // restart a working agent just to satisfy the layout type.
+ // One removal for the whole workspace (pool.ts): the row, its lanes,
+ // its pin, and its project IF this was the project's last session.
//
- // Prefer a row this operation is NOT about to close (finding 3: never
- // pick a session the operation will delete when a real survivor
- // exists). But fall back to a pending member before removing the tab:
- // that member's own verdict has not happened yet, and if it is then
- // kept — it changed, a sibling kept it, its kill threw — it must still
- // have a project to be filed under (#886 review round 2 N1). If it does
- // go on to close, it re-resolves itself as this grid leaf and promotes
- // the next row or removes the tab then.
- const survivor =
- detachedRootReplacement(prev, tab.id, new Set([targetId, ...operation.pending]))
- ?? detachedRootReplacement(prev, tab.id, new Set([targetId]))
- if (survivor) {
- committed.value = { kind: 'promoted', tab, tabIndex, survivor }
- tabs[tabIndex] = {
- ...tab,
- root: { type: 'leaf', sessionId: survivor.sessionId },
- focusedSessionId: survivor.sessionId,
- }
- const detachedSessions = { ...prev.detachedSessions }
- delete detachedSessions[survivor.sessionId]
- const next = { ...prev, tabs, sessions, detachedSessions }
- return { ...next, dispatchMode: dispatchModeAfterSessionRemoval(prev, next, targetId) }
+ // Until #992 this was four hand-written branches — delete a detached
+ // record; collapse a split; promote a Dispatch row into an emptied
+ // tree; or remove the tab — and the tab-removing one had to choose its
+ // survivor carefully enough to need two review rounds (#886 N1).
+ const next = workspaceWithoutSessions(prev, [targetId])
+ if (placement) {
+ committed.value = next.tabs.some(tab => tab.id === placement.tab.id)
+ ? { kind: 'session' }
+ : { kind: 'tab-removed', tab: placement.tab, tabIndex: placement.tabIndex }
}
- committed.value = { kind: 'tab-removed', tab, tabIndex }
- return workspaceWithoutTab(prev, tab.id, [targetId])
+ return next
})
operation.pending.delete(targetId)
const outcome = committed.value
operation.commits.push({ sessionId: targetId, meta: sessionMeta, outcome })
if (outcome.kind === 'tab-removed') {
- clearRemovedTabTakeovers({ setTileTabs, setSpotlight, setReaderMode }, outcome.tab.id)
+ clearRemovedTabTakeovers({ setSpotlight, setReaderMode }, outcome.tab.id)
}
return { closed: true }
},
- [refs, setReaderMode, setRuntimes, setSpotlight, setState, setTileTabs],
+ [refs, setReaderMode, setRuntimes, setSpotlight, setState],
)
// Close several approved members one at a time, in the order given. A member
@@ -1672,322 +1488,11 @@ export function usePaneActions(
[closeApprovedTarget],
)
- // Promote a detached dispatch session into the grid at a chosen placement
- // target.
- //
- // WHY this wakes before the state move:
- // Detached sessions are "live" only inside a single app process. After a full
- // Agent Code restart, rehydrate intentionally keeps their SessionMeta but
- // does not respawn their provider PTY; otherwise a workspace with dozens of
- // parked agents would fork-bomb on launch. Attaching one back to the grid is
- // the explicit user action that makes it live again. We wake under the same
- // SessionId before inserting the leaf so every relationship pointer
- // (orchestrationParentId/rootId, linkedParentId, tiled lanes, pins) remains
- // intact and the pane never becomes visibly commandable while main would drop
- // writes for a missing session.
- //
- // The target tab need not equal the detached record's projectTabId.
- // projectTabId was always *affinity* (cwd defaults / dispatch
- // grouping / terminal selection), never *ownership*. Letting the
- // user pin a project-A detached agent into project-B's grid is the
- // whole point of having a placement step.
- const attachDetachedToGrid = useCallback(
- async (sessionId: SessionId, targetTabId: string, target: PlacementTarget) => {
- try {
- await sessionActions.ensureSessionLive(sessionId, 'pane.attach-detached')
- } catch (err) {
- showToast(
- err instanceof Error && err.message.length > 0
- ? err.message
- : 'Could not wake detached session before attaching it.',
- )
- return
- }
- setState(prev => {
- const detached = prev.detachedSessions[sessionId]
- if (!detached) return prev
- const targetTab = prev.tabs.find(t => t.id === targetTabId)
- if (!targetTab) return prev
- // For a split-leaf target, the anchor must still exist in the
- // chosen tab's tree. The placement overlay computes targets from
- // a snapshot of the tree, so a stale target after a concurrent
- // tab close would silently no-op via insertBesideLeaf returning
- // the input. Bail with no state change so the user can re-open
- // the picker rather than getting a confusing "I clicked place
- // and nothing happened."
- if (target.kind === 'split-leaf') {
- const anchorStillThere = collectLeaves(targetTab.root).includes(target.targetSessionId)
- if (!anchorStillThere) return prev
- }
- const detachedSessions = { ...prev.detachedSessions }
- delete detachedSessions[sessionId]
- return {
- ...prev,
- // WHY activeTabId follows the explicit attach target:
- // attaching into a tab is a visible grid-focus change. Classic
- // Dispatch used to make this incidental because row focus synced
- // activeTabId before the overlay opened; Tiled Dispatch does not
- // touch activeTabId when a lane is selected. Capturing the tab in the
- // attach intent and committing it here keeps the grid focus context
- // aligned with the actual insertion tab instead of whatever tab was
- // active before the user entered global Tiled Dispatch.
- activeTabId: targetTabId,
- detachedSessions,
- tabs: prev.tabs.map(currentTab => {
- if (currentTab.id !== targetTabId) return currentTab
- return {
- ...currentTab,
- root:
- target.kind === 'wrap-root'
- ? wrapRootWithLeaf(
- currentTab.root,
- target.direction,
- target.side,
- sessionId,
- )
- : insertBesideLeaf(
- currentTab.root,
- target.targetSessionId,
- target.direction,
- RATIO_DEFAULT,
- target.side,
- sessionId,
- ),
- focusedSessionId: sessionId,
- }
- }),
- // Drop dispatch focus if it was pointing at this session —
- // the session now lives in the grid, and grid focus on the
- // active tab is what owns selection going forward. Leaving
- // the dispatch focus pointing at a now-grid-placed session
- // would make the dispatch list highlight a row that has
- // moved out of detachedSessions on the next render.
- dispatchMode:
- prev.dispatchMode?.focusedSessionId === sessionId
- ? { ...prev.dispatchMode, focusedSessionId: undefined }
- : prev.dispatchMode,
- }
- })
- },
- [sessionActions, setState, showToast],
- )
-
- const attachAllDetachedForTab = useCallback(
- async (tabId: string) => {
- let attachedCount = 0
- const snapshot = refs.stateRef.current
- const detachedIds = detachedDispatchSessionIdsForTab(snapshot, tabId)
- if (detachedIds.length === 0) return
- const liveIds: SessionId[] = []
- for (const sessionId of detachedIds) {
- try {
- await sessionActions.ensureSessionLive(sessionId, 'pane.attach-all-detached')
- liveIds.push(sessionId)
- } catch (err) {
- console.warn('[workspace] failed to wake detached session before bulk attach:', err)
- }
- }
- if (liveIds.length === 0) {
- showToast('Could not wake any detached sessions for this tab')
- return
- }
- setState(prev => {
- const tab = prev.tabs.find(t => t.id === tabId)
- if (!tab) return prev
- const attachableIds = liveIds.filter(sessionId => prev.detachedSessions[sessionId])
- if (attachableIds.length === 0) return prev
- attachedCount = attachableIds.length
-
- const detachedSessions = { ...prev.detachedSessions }
- for (const sessionId of attachableIds) {
- delete detachedSessions[sessionId]
- }
-
- // Bulk attach deliberately creates one new subtree for the
- // incoming Dispatch sessions and hard-normalizes ONLY that
- // subtree. The existing tab root is preserved byte-for-byte
- // below a single wrapper split; its internal ratios and pane
- // arrangement are not flattened. This gives users a predictable
- // "pin all background work beside my current grid" action
- // without punishing the layout they already curated.
- const attachedSubtree = normalizeTree(attachableIds)
- const nextRoot = wrapRootWithNode(
- tab.root,
- 'vertical',
- 'b',
- attachedSubtree,
- )
- const focusedSessionId = attachableIds[0]
-
- return {
- ...prev,
- activeTabId: tabId,
- detachedSessions,
- tabs: prev.tabs.map(currentTab =>
- currentTab.id === tabId
- ? {
- ...currentTab,
- root: nextRoot,
- focusedSessionId,
- }
- : currentTab,
- ),
- // The attached sessions stop being detached records, but the first
- // one is still the user's target for the bulk attach action. Keep
- // Dispatch focus explicit so the highlighted row and command target
- // do not depend on selectVisibleDispatchRow's grid-focus fallback.
- dispatchMode: prev.dispatchMode
- ? { ...prev.dispatchMode, focusedSessionId }
- : prev.dispatchMode,
- }
- })
- if (attachedCount > 0) {
- showToast(
- `Attached ${attachedCount} Dispatch ${attachedCount === 1 ? 'session' : 'sessions'} to grid`,
- )
- }
- },
- [refs.stateRef, sessionActions, setState, showToast],
- )
-
- // The reverse direction: take the focused grid pane out of the tile
- // tree without killing its session, and add it to the dispatch
- // detached bucket.
- //
- // Refuses in two cases, each surfaced as a toast so the user
- // understands why nothing happened:
- // 1. No focused session — nothing to detach.
- // 2. The focused pane is the only leaf in its tab — closeLeaf would
- // return null and the tab.root type cannot represent an empty
- // tree. We don't want to silently close the tab either, so we
- // refuse and ask the user to add another pane first.
- const detachSessionToDispatch = useCallback((sessionId: SessionId) => {
- const snapshot = refs.stateRef.current
- const meta = snapshot.sessions[sessionId]
- if (!meta) return
- const tab = snapshot.tabs.find(t => collectLeaves(t.root).includes(sessionId))
- if (!tab) {
- // WHY detached rows no-op here instead of re-detaching:
- // This action means "move the grid pane out to Dispatch." A detached
- // session is already there; treating it as success would hide a stale
- // command-target bug, while trying to mutate it would duplicate the
- // ownership record. The attach command owns the reverse direction.
- showToast('Session is already detached to Dispatch')
- return
- }
- const leaves = collectLeaves(tab.root)
- if (leaves.length <= 1) {
- showToast('Cannot detach the last pane in a tab — add another pane first')
- return
- }
- const tabIndex = snapshot.tabs.findIndex(t => t.id === tab.id)
-
- setState(prev => {
- const latestTab = prev.tabs.find(t => t.id === tab.id)
- if (!latestTab) return prev
- const nextRoot = closeLeaf(latestTab.root, sessionId)
- // Defensive guard: closeLeaf returning null here would mean a
- // race against a concurrent close emptied the tab between the
- // snapshot read and the setState. The leaves.length check above
- // already filtered the common case; this is for race-window
- // safety so the type stays sound.
- if (!nextRoot) return prev
- const nextLeafIds = collectLeaves(nextRoot)
- const nextFocus =
- latestTab.focusedSessionId === sessionId
- ? nextLeafIds[0] ?? ''
- : latestTab.focusedSessionId
-
- return {
- ...prev,
- tabs: prev.tabs.map(t =>
- t.id === tab.id
- ? { ...t, root: nextRoot, focusedSessionId: nextFocus }
- : t,
- ),
- detachedSessions: {
- ...prev.detachedSessions,
- [sessionId]: detachedDispatchRecord(sessionId, latestTab, tabIndex),
- },
- // If Dispatch is currently active, focus the freshly detached
- // session so the user sees the result of their action. If
- // Dispatch is not active, leave dispatchMode alone — toggling
- // into Dispatch later will pick this up via the existing
- // first-row fallback in selectActiveRow.
- dispatchMode: prev.dispatchMode
- ? { ...prev.dispatchMode, focusedSessionId: sessionId }
- : prev.dispatchMode,
- }
- })
- const cwdBase = meta.cwd.split('/').filter(Boolean).pop() ?? 'session'
- showToast(`Detached "${cwdBase}" to Dispatch`)
- }, [refs.stateRef, setState, showToast])
-
-
- const detachFocusedToDispatch = useCallback(() => {
- const id = commandTargetSessionIdForState(refs.stateRef.current)
- if (id) detachSessionToDispatch(id)
- else showToast('No focused session to detach')
- }, [refs.stateRef, detachSessionToDispatch, showToast])
-
- const commitNewAgentPlacement = useCallback(
- async (selection: SessionSpawnSelection, target: PlacementTarget) => {
- const { kind, providerRuntime } = selection
- const tab = state.tabs.find(t => t.id === state.activeTabId)
- if (!tab) return
- const anchorSessionId = tab.focusedSessionId
- const cwd = state.sessions[anchorSessionId]?.cwd
- if (!cwd) return
-
- let newSessionId: SessionId
- try {
- newSessionId = await sessionActions.spawn(cwd, { kind, providerRuntime })
- } catch (err) {
- showToast(
- err instanceof Error && err.message.length > 0
- ? err.message
- : 'Failed to create pane',
- )
- return
- }
- setState(prev => ({
- ...prev,
- tabs: prev.tabs.map(currentTab => {
- if (currentTab.id !== prev.activeTabId) return currentTab
- return {
- ...currentTab,
- root:
- target.kind === 'wrap-root'
- ? wrapRootWithLeaf(
- currentTab.root,
- target.direction,
- target.side,
- newSessionId,
- )
- : insertBesideLeaf(
- currentTab.root,
- target.targetSessionId,
- target.direction,
- RATIO_DEFAULT,
- target.side,
- newSessionId,
- ),
- focusedSessionId: newSessionId,
- }
- }),
- }))
- closeNewAgentPlacement()
- },
- [
- closeNewAgentPlacement,
- sessionActions,
- setState,
- showToast,
- state.activeTabId,
- state.sessions,
- state.tabs,
- ],
- )
+ // attachDetachedToGrid, attachAllDetachedForTab, detachSessionToDispatch,
+ // detachFocusedToDispatch and commitNewAgentPlacement lived here until the
+ // unified layout (#992). All five moved a session between a tile tree and
+ // the pool, or spawned one at a chosen split. There is no tree: every
+ // session is a pool member and is shown by selecting it into a lane.
// One close implementation owns both row buttons and the keyboard command.
// The former focused-grid copy silently made the sole leaf a tab close;
@@ -2039,43 +1544,14 @@ export function usePaneActions(
// Every branch below ends in ONE value: `approved`, the exact snapshot
// list the user (or the policy) authorized, with the activity they saw.
// The operation then executes that list and nothing else.
- let scope: 'session' | 'tab' = 'session'
let approved: readonly CloseTargetSnapshot[] | null = null
- const rootTab = initial.tabs.find(tab => tab.root.type === 'leaf' && tab.root.sessionId === targetId)
- if (rootTab && detachedTabChildren(initial, rootTab.id).ids.length > 0 &&
- !options?.preConfirmed && !options?.silentIfSoleTarget && !options?.requireConfirmation) {
- const agentTargets = paneCloseTargets(initial, refs.latestRuntimesRef.current, targetId)
- const tabTargets = paneCloseTargets(initial, refs.latestRuntimesRef.current, targetId, 'tab')
- // WHY the three-way choice is skipped when both scopes name the same
- // sessions (#886 review n4): a root whose only Dispatch rows are its own
- // linked children ends the identical set either way, and two buttons
- // that close the same sessions are noise. The ordinary gate below lists
- // them once. Undo is not worse for it: those children were the
- // project's last rows, so the session-scoped operation removes the tab
- // and folds them into one tab entry (recordOperationUndo).
- if (!sameTargetIds(agentTargets, tabTargets)) {
- const choice = await requestRootCloseConfirmation({
- required: true, reason: 'multi', targets: tabTargets,
- summary: `“${rootTab.title}” contains ${tabTargets.length} sessions.`,
- agentOnly: {
- title: agentTargets[0]?.title ?? targetId,
- targets: agentTargets,
- noun: closeNoun(initial.sessions[targetId]),
- },
- })
- if (!choice) return false
- scope = choice === 'tab' ? 'tab' : 'session'
- const shown = choice === 'tab' ? tabTargets : agentTargets
- const current = refs.stateRef.current
- // A changed root role/tab invalidates even an unchanged list of IDs.
- // Never let the scope choice transfer to another project under a dialog.
- if (!current.tabs.some(tab => tab.id === rootTab.id && tab.root.type === 'leaf' && tab.root.sessionId === targetId) ||
- !grantStillMatches(shown, paneCloseTargets(current, refs.latestRuntimesRef.current, targetId, scope))) {
- return refuse('changed')
- }
- approved = shown
- }
- }
+ // A three-way "Close agent / Close tab / Cancel" dialog opened here until
+ // #992, for ONE structural case: the target was its tab's sole tile leaf
+ // while the tab still held Dispatch rows. The tree could not be empty,
+ // so the honest options were "promote a row into the tree" or "end the
+ // whole project", and the user had to pick. No session is a root now:
+ // closing any session leaves the rest of its project exactly as it was,
+ // so there is no second scope to offer, and Close Tab is its own command.
// Resolve the automation modes HERE, where paneCloseTargets is in scope —
// it is the only code that computes the full set a close destroys, which
// is exactly what the caller cannot know from the outside.
@@ -2099,7 +1575,7 @@ export function usePaneActions(
let shown: readonly CloseTargetSnapshot[] = []
const gate = await runCloseConfirmationGate({
enumerate: () =>
- paneCloseTargets(refs.stateRef.current, refs.latestRuntimesRef.current, targetId, scope),
+ paneCloseTargets(refs.stateRef.current, refs.latestRuntimesRef.current, targetId),
ask: request => {
shown = request.targets
return requestCloseConfirmation(request)
@@ -2113,23 +1589,6 @@ export function usePaneActions(
// Built synchronously after approval, so the recorded project and meta of
// every approved session describe the workspace the user approved.
const operation = beginCloseOperation(refs.stateRef.current, targetId, approved, options?.onlyIf)
- if (scope === 'tab') {
- // Close Tab executes the SAME plan the dialog listed (#886 review
- // finding 5). The dialog expands every linked descendant transitively,
- // including a child attached into ANOTHER project's grid; the first
- // version of this branch killed only the root and this tab's Dispatch
- // rows, so that child was listed as ending and survived with a dead
- // parent. Members close deepest-first through the same executor, each
- // revalidated at its own kill boundary, and the root comes last: if
- // every member closed, its close removes the emptied tab; if one
- // changed or failed, the root promotes that survivor instead of
- // deleting a nonempty project.
- const approvalState = refs.stateRef.current
- await closeOperationMembers(operation, [...operation.approved.keys()]
- .filter(id => id !== targetId)
- .sort((a, b) => linkedDepth(approvalState, b) - linkedDepth(approvalState, a)))
- }
-
// The named session itself. A thrown kill is recorded like any member's
// and rethrown only AFTER the operation is recorded and reported, so bulk
// cleanup's `failed` bucket and orchestration's catch keep working while
@@ -2175,8 +1634,8 @@ export function usePaneActions(
closeSessionRef.current = closeSession
// The Close Tab command (⌘⇧W, the tab bar ×, the `close-tab` palette entry):
- // end every session the project owns — its grid leaves, its Dispatch rows, and
- // every linked descendant of either, wherever that descendant is attached.
+ // end every session the project owns, and every linked descendant of one,
+ // whichever project that descendant is filed under.
//
// WHY it lives here and runs the same operation as the root dialog's Close Tab
// (#886 review round 2, Codex majors 1 and 2, Claude N2): the command's own
@@ -2186,9 +1645,9 @@ export function usePaneActions(
// promised dead and survived with a dead parent, and one rejected kill left a
// tab whose tree named a deleted session plus an undo entry for a tab that
// was never removed. Here the approved list IS the kill list; members close
- // deepest-first with the tab's grid leaves last, each re-judged at its own
- // kill boundary; undo and the toast describe only what committed; and a
- // partial close leaves the tab rooted and focused on a survivor.
+ // deepest-first, each re-judged at its own kill boundary; undo and the toast
+ // describe only what committed; and a partial close leaves the project
+ // holding its survivors.
const closeTab = useCallback(
async (tabId: TabId) => {
if (!refs.stateRef.current.tabs.some(tab => tab.id === tabId)) return
@@ -2200,12 +1659,7 @@ export function usePaneActions(
const state = refs.stateRef.current
const tab = state.tabs.find(candidate => candidate.id === tabId)
return tab
- ? expandTabCloseTargets(
- state,
- refs.latestRuntimesRef.current,
- collectLeaves(tab.root),
- detachedTabChildren(state, tabId).ids,
- )
+ ? expandTabCloseTargets(state, refs.latestRuntimesRef.current, resolveTabSessions(state, tabId))
: []
},
ask: request => {
@@ -2223,14 +1677,15 @@ export function usePaneActions(
const tab = approvalState.tabs.find(candidate => candidate.id === tabId)
if (!tab || gate.targets.length === 0) return
const operation = beginCloseOperation(approvalState, null, withShownLiveness(gate.targets, shown), undefined)
- // Grid leaves last. While another leaf remains, closing one is a plain
- // split collapse; the final leaf's commit removes the tab only when no
- // Dispatch row is left under it, and otherwise promotes that survivor.
- const gridLeaves = collectLeaves(tab.root).filter(id => operation.approved.has(id))
+ // Deepest linked descendants first, so a child always closes before the
+ // parent that would otherwise be kept open for it. The project itself
+ // leaves with whichever commit takes its last session; if a member
+ // changed or failed, that commit never happens and the project stays,
+ // holding exactly the sessions that are still alive. (Until #992 the
+ // tab's tile leaves had to be ordered LAST so the tree stayed valid.)
const members = [...operation.approved.keys()]
- .filter(id => !gridLeaves.includes(id))
.sort((a, b) => linkedDepth(approvalState, b) - linkedDepth(approvalState, a))
- await closeOperationMembers(operation, [...members, ...gridLeaves])
+ await closeOperationMembers(operation, members)
const undoRecorded = recordOperationUndo(refs.undoStackRef.current, operation)
const message = describeCloseOperation(operation, null, undoRecorded)
if (message) {
@@ -2244,468 +1699,37 @@ export function usePaneActions(
[closeOperationMembers, refs.latestRuntimesRef, refs.stateRef, refs.undoStackRef, showToast],
)
- // Bury: remove the focused pane from the visible layout without
- // killing the underlying session. The session keeps running in
- // the background and remains eligible for revive.
- //
- // WHY commandTargetSessionIdForState instead of tab.focusedSessionId:
- // tab.focusedSessionId has a "must be a leaf in tab.root" invariant —
- // it's grid-only. In Dispatch Mode the user has a row selected, not
- // a grid focus, and reading tab.focusedSessionId silently opens the
- // bury prompt on whatever grid pane is focused underneath the
- // visible dispatch row — exactly the bug class issue #94 tracks.
- // Routing through commandTargetSessionIdForState makes Bury agree
- // with every other "act on the visible thing" command (close,
- // copy-assistant, scroll-to-latest, switch-provider, reload, rewind,
- // soft-reload-view — all already use this resolver).
- const requestBuryFocused = useCallback(() => {
- const snapshot = refs.stateRef.current
- const sessionId = commandTargetSessionIdForState(snapshot)
- if (!sessionId) return
- // Bury moves a GRID PANE out of the layout: `buryFocused` resolves the
- // owning tab, records the split position needed to revive it, and bails
- // when the target has no tab. A detached Dispatch row has none, so the
- // prompt would open, accept a note, and then silently do nothing.
- //
- // WHY the check is here rather than in `buryFocused`: failing at confirm
- // time means the user has already typed the note. Refusing before the
- // modal opens is the same judgement, made where it still costs nothing.
- //
- // WHY a toast rather than hiding the command: bury is a reasonable thing
- // to WANT for a Dispatch row, and detached sessions are already parked
- // out of the layout, so the honest answer is "this does not apply here" —
- // not a command that vanishes with no explanation. This became reachable
- // for terminals when Dispatch terminals stopped being grid leaves (#671);
- // detached agents always had it.
- if (!snapshot.tabs.some(tab => collectLeaves(tab.root).includes(sessionId))) {
- showToast('Bury applies to grid panes — this session is already parked in Dispatch')
- return
- }
- openBuryPrompt(sessionId)
- }, [openBuryPrompt, refs.stateRef, showToast])
-
- const buryFocused = useCallback(
- (note?: string, targetSessionId?: SessionId) => {
- // The bury prompt is modal on a specific session, not a
- // specific tab. It can outlive a tab switch: user opens the
- // prompt on pane X in tab A, switches to tab B, then hits
- // Enter. Earlier we resolved `tab` via `state.activeTabId`,
- // which meant that confirm-after-switch mutated tab B's tree
- // even though targetId still pointed at pane X in tab A.
- // Resolve the owning tab from the target session instead.
- const snapshot = refs.stateRef.current
- const activeTab = snapshot.tabs.find(t => t.id === snapshot.activeTabId)
- // The `?? activeTab?.focusedSessionId` fallback is intentionally
- // defensive belt-and-suspenders: every current caller passes an
- // explicit `targetSessionId` (the bury-prompt modal in App.tsx
- // owns the resolved id at confirm time; requestBuryFocused
- // resolves it via commandTargetSessionIdForState before opening
- // the prompt). The fallback exists so a future caller that
- // forgets to pass an id doesn't no-op silently — but it MUST
- // NOT become the primary path, because activeTab.focusedSessionId
- // is grid-only and would re-introduce the Dispatch-misses-target
- // bug from #94.
- const targetId = targetSessionId ?? activeTab?.focusedSessionId
- if (!targetId) return
-
- const owningTab = snapshot.tabs.find(t => collectLeaves(t.root).includes(targetId))
- if (!owningTab) return
-
- const sessionMeta = snapshot.sessions[targetId]
- if (!sessionMeta) return
-
- const parentInfo = findParentSplitInfo(owningTab.root, targetId)
- const tabIndex = snapshot.tabs.findIndex(t => t.id === owningTab.id)
- const buriedAt = Date.now()
- const buriedRecord: BuriedPaneRecord = {
- id: targetId,
- sessionId: targetId,
- sessionMeta,
- buriedAt,
- sourceTabId: owningTab.id,
- sourceTabTitle: owningTab.title,
- sourceTabIndex: tabIndex,
- direction: parentInfo?.direction,
- ratio: parentInfo?.ratio,
- side: parentInfo?.side,
- siblingLeafId: parentInfo?.siblingLeafId,
- note: note?.trim() ? note.trim() : undefined,
- }
- const detachedChildren = parentInfo
- ? { records: [], ids: [] }
- : detachedTabChildren(snapshot, owningTab.id)
- // WHY last-pane bury transfers detached children into the buried archive
- // instead of killing them: Bury is explicitly the non-destructive close.
- // Once the source tab disappears, leaving its dispatch children detached
- // would make them ownerless and the persistence sanitizer would discard
- // them on the next save. Giving every live child an archive record keeps
- // it discoverable and revivable while preserving its running backend.
- const detachedBuriedRecords: BuriedPaneRecord[] = detachedChildren.records
- .flatMap(entry => {
- const meta = snapshot.sessions[entry.sessionId]
- if (!meta) return []
- return [{
- id: entry.sessionId,
- sessionId: entry.sessionId,
- sessionMeta: meta,
- buriedAt,
- sourceTabId: owningTab.id,
- sourceTabTitle: owningTab.title,
- sourceTabIndex: tabIndex,
- }]
- })
-
- const kindLabel = sessionMeta.kind ?? DEFAULT_PROVIDER
- const cwdBase = sessionMeta.cwd.split('/').filter(Boolean).pop() ?? sessionMeta.cwd
- showToast(`Buried ${kindLabel} pane (${cwdBase})`)
-
- setState(prev => {
- const tabs = [...prev.tabs]
- const tabIdx = tabs.findIndex(t => t.id === owningTab.id)
- // Tab may have been closed between prompt-open and confirm.
- // Treat that as a no-op rather than mutating an unrelated tab.
- if (tabIdx === -1) return prev
-
- const currentTab = tabs[tabIdx]
- const nextRoot = closeLeaf(currentTab.root, targetId)
- if (nextRoot === null) {
- const remaining = tabs.filter((_, i) => i !== tabIdx)
- const detachedSessions = { ...prev.detachedSessions }
- for (const id of detachedChildren.ids) delete detachedSessions[id]
- const buriedSessionIds = new Set([
- targetId,
- ...detachedBuriedRecords.map(entry => entry.sessionId),
- ])
- const hiddenSessionIds = new Set([targetId, ...detachedChildren.ids])
- // Only retarget activeTabId if we just removed the active
- // tab. Burying a pane in a background tab must not yank
- // the user out of the tab they're currently looking at.
- const nextActiveTabId = prev.activeTabId === owningTab.id
- ? (remaining[Math.max(0, tabIdx - 1)]?.id ?? '')
- : prev.activeTabId
- return {
- ...prev,
- tabs: remaining,
- activeTabId: nextActiveTabId,
- detachedSessions,
- buried: [
- ...prev.buried.filter(entry => !buriedSessionIds.has(entry.sessionId)),
- buriedRecord,
- ...detachedBuriedRecords,
- ],
- // A buried session is hidden from the dispatch rows, so a tiled
- // lane still pointing at it would dangle; clear it so the lane
- // re-homes cleanly instead of bouncing to tile 0.
- dispatchMode: dispatchModeAfterSessionRemovals(
- prev.dispatchMode,
- hiddenSessionIds,
- ),
- }
- }
-
- const nextFocused =
- findBestRemainingFocus(currentTab.root, nextRoot, targetId) ??
- collectLeaves(nextRoot)[0]
- tabs[tabIdx] = {
- ...currentTab,
- root: nextRoot,
- focusedSessionId: nextFocused,
- }
- return {
- ...prev,
- tabs,
- buried: [
- ...prev.buried.filter(entry => entry.sessionId !== targetId),
- buriedRecord,
- ],
- // See above: clear any tiled lane pointing at the buried session.
- dispatchMode: clearTiledLaneSessions(prev.dispatchMode, targetId),
- }
- })
- setSpotlight(prev => (prev?.tabId === owningTab.id ? null : prev))
- closeBuryPrompt()
- },
- [closeBuryPrompt, refs.stateRef, setSpotlight, setState, showToast],
- )
-
- // Restores a buried session into the most plausible visible
- // location. First choice is the original sibling anchor, then the
- // original tab, then the best current tab by cwd/kind/title
- // affinity, and finally a fresh single-pane tab if no good target
- // exists.
- const reviveBuried = useCallback(
- async (buriedId: string) => {
- const initialEntry = refs.stateRef.current.buried.find(item => item.id === buriedId)
- if (!initialEntry) return
- try {
- await sessionActions.ensureSessionLive(initialEntry.sessionId, 'pane.revive-buried')
- } catch (err) {
- showToast(
- err instanceof Error && err.message.length > 0
- ? err.message
- : 'Could not wake buried session before reviving it.',
- )
- return
- }
-
- // WHY re-read after wake: ensureSessionLive can update runtime metadata,
- // clear stale backend errors, or lose a race to another revive/kill action.
- // Placement should be based on the workspace that actually exists after
- // the backend is live, not the pre-wake snapshot we used only to discover
- // which session needed waking.
- const current = refs.stateRef.current
- const entry = current.buried.find(item => item.id === buriedId)
- if (!entry) return
-
- const chooseFallbackTab = (): Tab | null => {
- const scored = current.tabs
- .map(tab => {
- let score = 0
- if (tab.id === entry.sourceTabId) score += 100
- if (tab.title === entry.sourceTabTitle) score += 20
- const leafIds = collectLeaves(tab.root)
- for (const leafId of leafIds) {
- const meta = current.sessions[leafId]
- if (!meta) continue
- if (meta.cwd === entry.sessionMeta.cwd) score += 15
- if ((meta.kind ?? DEFAULT_PROVIDER) === (entry.sessionMeta.kind ?? DEFAULT_PROVIDER)) score += 5
- }
- return { tab, score }
- })
- .filter(candidate => candidate.score > 0)
- .sort((a, b) => b.score - a.score)
- return scored[0]?.tab ?? current.tabs[0] ?? null
- }
-
- const anchorTab = entry.siblingLeafId
- ? current.tabs.find(tab => collectLeaves(tab.root).includes(entry.siblingLeafId!))
- : null
- const targetTab = anchorTab ?? chooseFallbackTab()
-
- setState(prev => {
- const nextBuried = prev.buried.filter(item => item.id !== buriedId)
-
- if (!targetTab) {
- const tabId = crypto.randomUUID()
- const title = titleFromCwd(entry.sessionMeta.cwd)
- const revivedTab: Tab = {
- id: tabId,
- title,
- root: { type: 'leaf', sessionId: entry.sessionId },
- focusedSessionId: entry.sessionId,
- }
- return {
- ...prev,
- tabs: [...prev.tabs, revivedTab],
- activeTabId: tabId,
- buried: nextBuried,
- }
- }
-
- const target = prev.tabs.find(tab => tab.id === targetTab.id)
- if (!target) {
- const tabId = crypto.randomUUID()
- const title = titleFromCwd(entry.sessionMeta.cwd)
- const revivedTab: Tab = {
- id: tabId,
- title,
- root: { type: 'leaf', sessionId: entry.sessionId },
- focusedSessionId: entry.sessionId,
- }
- return {
- ...prev,
- tabs: [...prev.tabs, revivedTab],
- activeTabId: tabId,
- buried: nextBuried,
- }
- }
-
- const leafIds = collectLeaves(target.root)
- const cwdLeaf =
- leafIds.find(leafId => prev.sessions[leafId]?.cwd === entry.sessionMeta.cwd) ?? null
- const anchorLeafId =
- (entry.siblingLeafId && leafIds.includes(entry.siblingLeafId))
- ? entry.siblingLeafId
- : (cwdLeaf ?? target.focusedSessionId ?? leafIds[0] ?? null)
-
- if (!anchorLeafId) {
- const tabId = crypto.randomUUID()
- const title = titleFromCwd(entry.sessionMeta.cwd)
- const revivedTab: Tab = {
- id: tabId,
- title,
- root: { type: 'leaf', sessionId: entry.sessionId },
- focusedSessionId: entry.sessionId,
- }
- return {
- ...prev,
- tabs: [...prev.tabs, revivedTab],
- activeTabId: tabId,
- buried: nextBuried,
- }
- }
-
- const revivedRoot = insertBesideLeaf(
- target.root,
- anchorLeafId,
- entry.direction ?? 'vertical',
- entry.ratio ?? RATIO_DEFAULT,
- entry.side ?? 'b',
- entry.sessionId,
- )
-
- return {
- ...prev,
- tabs: prev.tabs.map(tab =>
- tab.id === target.id
- ? {
- ...tab,
- root: revivedRoot,
- focusedSessionId: entry.sessionId,
- }
- : tab,
- ),
- activeTabId: target.id,
- buried: nextBuried,
- }
- })
- },
- [refs.stateRef, sessionActions, setState, showToast],
- )
-
- const killBuried = useCallback(
- async (buriedId: string) => {
- const snapshot = refs.stateRef.current
- const entry = snapshot.buried.find(item => item.id === buriedId)
- if (!entry) return
-
- // SECOND CONFIRMATION. The buried picker is already an explicit,
- // deliberate selection — but this is the one close in the app with NO
- // undo at all: a buried session is not on the undo-close stack, so the
- // kill is final. The picker's own selection is not consent to that.
- //
- // Confirmation is unconditional, unlike the ordinary close paths. There
- // is no cheap idle case to protect here, because there is no recovery
- // even when the session is idle.
- const buriedConfirmed = await requestCloseConfirmation({
- required: true,
- // Its OWN reason. Borrowing 'running' made the dialog title an idle
- // buried session "Close a working session?", contradicting both its
- // body and the actual state — on the one close with no undo, where the
- // dialog's credibility is the entire mechanism.
- reason: 'irreversible',
- targets: [{
- sessionId: entry.sessionId,
- // A buried entry always carries its own sessionMeta, even after the
- // session has left `sessions` entirely — so read from there rather
- // than the (possibly absent) live sessions record (#865).
- title: sessionDisplayTitle(entry.sessionMeta),
- live: isSessionLiveForClose(refs.latestRuntimesRef.current, entry.sessionId),
- }],
- summary: 'Killing a buried session is permanent — Undo Close cannot restore it.',
- })
- if (!buriedConfirmed) return
+ // requestBuryFocused / buryFocused / reviveBuried / killBuried lived here
+ // until #992. Bury took a pane out of the tree and kept it alive in an
+ // archive; in the pool-first workspace that is simply an unplaced session,
+ // so there is nothing to bury into, revive from, or kill separately.
+ // Persisted `buried` records become ordinary pool rows when an old file is
+ // migrated (legacyWorkspaceV2.ts legacyMemberships).
- // Buried panes are live sessions removed from every visible tab
- // tree. `closeSession` intentionally only handles visible panes
- // because it needs tree geometry and undo-close placement data;
- // using it here would no-op. Killing a buried pane is a different
- // operation: terminate the hidden backend and delete the buried
- // record directly, without briefly reviving or mutating layout.
- await killSessionBackendIfOwned(refs, entry.sessionId)
-
- setRuntimes(prev => {
- const next = { ...prev }
- delete next[entry.sessionId]
- return next
- })
- forgetClosedSessionDebugState(refs, entry.sessionId)
- const bootstrapTimer = refs.bootstrapTimersRef.current.get(entry.sessionId)
- if (bootstrapTimer) {
- clearTimeout(bootstrapTimer)
- refs.bootstrapTimersRef.current.delete(entry.sessionId)
- }
- const paneToastTimer = refs.paneToastTimers.current[entry.sessionId]
- if (paneToastTimer) {
- clearTimeout(paneToastTimer)
- delete refs.paneToastTimers.current[entry.sessionId]
- }
-
- setState(prev => {
- const sessions = { ...prev.sessions }
- delete sessions[entry.sessionId]
- return {
- ...prev,
- sessions,
- buried: prev.buried.filter(item => item.id !== buriedId),
- }
- })
-
- const kindLabel = entry.sessionMeta.kind ?? DEFAULT_PROVIDER
- const cwdBase = entry.sessionMeta.cwd.split('/').filter(Boolean).pop() ?? entry.sessionMeta.cwd
- showToast(`Killed buried ${kindLabel} pane (${cwdBase})`)
- },
- [
- refs.bootstrapTimersRef,
- refs.latestScreenRef,
- refs.paneToastTimers,
- refs.seenUuidsRef,
- refs.stateRef,
- setRuntimes,
- setState,
- showToast,
- ],
- )
-
- const focusSession = useCallback(
- (sessionId: SessionId) => {
- setState(prev => ({
- ...prev,
- tabs: prev.tabs.map(t =>
- t.id === prev.activeTabId ? { ...t, focusedSessionId: sessionId } : t,
- ),
- }))
- setSpotlight(prev => (
- prev && prev.tabId === refs.stateRef.current.activeTabId
- ? { ...prev, focusedSessionId: sessionId }
- : prev
- ))
- },
- [refs.stateRef, setSpotlight, setState],
- )
+ // `focusSession(sessionId)` lived here until #992: it wrote the active tab's
+ // tree focus. It had no caller left once the tree stopped rendering.
+ // Make a session's project the active one, and follow it inside Spotlight.
+ //
+ // This does NOT put the session on screen — it never did on the stage. Its
+ // job used to be "move the tile tree's focus to this leaf", which showed
+ // the agent because the tree rendered it. A caller that wants an agent
+ // SHOWN uses focusAgentBySessionId (existing lane, else the focused lane,
+ // waking it first). What is left here is what the two remaining callers
+ // need: Spotlight's leaf asking for focus, and keeping the active project —
+ // a label (U4) — pointed at where the user is working.
const focusSessionInTab = useCallback(
(tabId: string, sessionId: SessionId) => {
- setState(prev => ({
- ...prev,
- activeTabId: tabId,
- tabs: prev.tabs.map(t =>
- t.id === tabId ? { ...t, focusedSessionId: sessionId } : t,
- ),
- }))
+ setState(prev => (prev.activeTabId === tabId ? prev : { ...prev, activeTabId: tabId }))
setSpotlight(prev => (
prev && prev.tabId === tabId
? { ...prev, focusedSessionId: sessionId }
: prev
))
- setTileTabs(prev => (
- prev && prev.tabIds.includes(tabId)
- ? { ...prev, focusedTabId: tabId }
- : prev
- ))
},
- [setSpotlight, setState, setTileTabs],
+ [setSpotlight, setState],
)
- const navigate = useCallback(
- (direction: 'left' | 'right' | 'up' | 'down') => {
- const tab = state.tabs.find(t => t.id === state.activeTabId)
- if (!tab) return
- const next = findDirectionalNeighbor(tab.root, tab.focusedSessionId, direction)
- if (next) focusSession(next)
- },
- [focusSession, state.activeTabId, state.tabs],
- )
// Open a contributed extension view as a PANE (a tile leaf), not a modal.
//
@@ -2718,192 +1742,92 @@ export function usePaneActions(
// 'extension-view', so rehydrate reconstructs this leaf from metadata and never
// tries to recover a process for it.
const openExtensionViewInPane = useCallback(
- (viewId: string, direction: SplitDirection = 'vertical') => {
+ (viewId: string, options?: OpenExtensionViewOptions) => {
// Resolve placement INSIDE the synchronous workspace update. There is no
// process await here, so metadata, ownership and visible focus can land as
// one change rather than leaving a session whose split silently failed.
+ let openedId: SessionId | null = null
+ let revealedId: SessionId | null = null
+ let pooled = false
setState(prev => {
- const sessionId = crypto.randomUUID() as SessionId
- if (prev.dispatchMode) {
- // Dispatch may focus a detached row in a different project from the
- // active grid tab. Such a row is not a split anchor. Follow the same
- // placement contract as new terminals/agents: file a detached row under
- // the visible target's project and select it in the focused lane.
- const target = resolveDispatchSpawnTarget(prev)
- const tabIndex = prev.tabs.findIndex(t => t.id === target.tabId)
- const tab = prev.tabs[tabIndex]
- if (!tab) return prev
- const cwd = (target.cwdSessionId ? prev.sessions[target.cwdSessionId]?.cwd : undefined)
- ?? prev.sessions[tab.focusedSessionId]?.cwd
- ?? ''
- return {
- ...prev,
- activeTabId: tab.id,
- sessions: {
- ...prev.sessions,
- [sessionId]: { cwd, kind: 'extension-view', extensionViewId: viewId },
- },
- detachedSessions: {
- ...prev.detachedSessions,
- [sessionId]: detachedDispatchRecord(sessionId, tab, tabIndex),
- },
- dispatchMode: applyDispatchSpawnFocus(prev.dispatchMode, sessionId, target.laneIndex),
+ if (options?.reveal) {
+ const revealed = revealExtensionView(prev, viewId)
+ if (revealed) {
+ revealedId = revealed.stage.lanes[revealed.stage.focusedLane]?.selectedSessionId ?? null
+ return revealed
}
}
-
- const tab = prev.tabs.find(t => t.id === prev.activeTabId)
+ const sessionId = crypto.randomUUID() as SessionId
+ // (This block sat behind `if (dispatchMode)` until #992, with a
+ // tile-tree branch — split beside the focused leaf — after it.)
+ //
+ // The visible target may be a row of a different project from the
+ // active one. Follow the same placement contract as new
+ // terminals/agents: file the view under the visible target's project,
+ // and fill the focused lane only when it is empty (context-places).
+ const target = resolveDispatchSpawnTarget(prev)
+ const tab = prev.tabs.find(t => t.id === target.tabId)
if (!tab) return prev
- // Anchor on the PHYSICAL focused leaf, exactly like splitFocused. The
- // command target can be a related-agent mini-tab's session, which is a
- // detached child and never a leaf of tab.root, so splitLeaf returned the
- // same root and opening a view silently did nothing while such a tab was
- // selected.
- const parentSessionId = tab.focusedSessionId
- if (!parentSessionId) return prev
- const root = splitLeaf(tab.root, parentSessionId, direction, sessionId)
- // Only a real split owns metadata. This also guards stale tile focus.
- if (root === tab.root) return prev
+ openedId = sessionId
+ const cwd = (target.cwdSessionId ? prev.sessions[target.cwdSessionId]?.cwd : undefined)
+ ?? projectCwd(prev, tab.id)
+ ?? ''
+ // A plain "Open view" follows context-places: it fills an empty
+ // focused lane and otherwise waits in the pool with a "new" badge.
+ // A reveal is a caller that needs the view ON SCREEN (a legacy action
+ // command runs only inside a mounted frame), so it takes the focused
+ // lane. Its occupant returns to the pool alive, as with an index click.
+ const focusedLane = prev.stage.focusedLane
+ const stage = options?.reveal && prev.stage.lanes[focusedLane]
+ ? {
+ ...prev.stage,
+ lanes: prev.stage.lanes.map((lane, i) => (i === focusedLane ? withLaneSession(lane, sessionId) : lane)),
+ }
+ : applyDispatchSpawnFocus(prev, sessionId, target.laneIndex)
+ pooled = stage === prev.stage
return {
...prev,
- tabs: prev.tabs.map(t => t.id === tab.id ? { ...t, root, focusedSessionId: sessionId } : t),
+ activeTabId: tab.id,
sessions: {
...prev.sessions,
+ // Written already FILED: there is no spawn to write the row first.
[sessionId]: {
- cwd: prev.sessions[parentSessionId]?.cwd ?? '',
- kind: 'extension-view',
- extensionViewId: viewId,
+ cwd, kind: 'extension-view', extensionViewId: viewId,
+ projectId: tab.id, joinedAt: Date.now(),
},
},
+ stage,
}
})
+ if (openedId !== null && pooled) markPooledSpawn(setRuntimes, openedId)
+ if (revealedId !== null) clearPooledSpawnBadge(setRuntimes, revealedId)
},
- [setState],
+ [setRuntimes, setState],
)
return {
splitFocused,
startNewAgentPlacement,
- commitNewAgentPlacement,
// Shells and agents share detached placement and post-spawn ownership
// checks. Preserve the narrower agent entry point for existing pickers.
createDetachedSession: createDetachedDispatchAgent,
createDetachedDispatchAgent,
createLinkedAgent,
createOrchestrationAgent,
- attachDetachedToGrid,
- attachAllDetachedForTab,
- detachSessionToDispatch,
- detachFocusedToDispatch,
closeFocused,
closeSession,
closeTab,
- requestBuryFocused,
- buryFocused,
- reviveBuried,
- killBuried,
- focusSession,
focusSessionInTab,
- navigate,
openExtensionViewInPane,
}
}
-function dispatchModeAfterSessionRemoval(
- before: WorkspaceState,
- after: WorkspaceState,
- removedSessionId: SessionId,
-): DispatchModeState | null {
- // Always clear the removed session out of any TILED LANE first. A lane can
- // hold a session that is NOT the classic dispatch focus, so the
- // focusedSessionId short-circuit below must not skip lane cleanup — otherwise
- // the lane dangles at a dead id and the layout's auto-fill effect bounces it
- // to the first agent. clearTiledLaneSessions is a no-op (same ref) when there
- // is no tiled layout or no lane held the removed session.
- const cleared = clearTiledLaneSessions(after.dispatchMode, removedSessionId)
- if (!cleared || cleared.focusedSessionId !== removedSessionId) {
- // The user wasn't visibly commanding this row — leave Dispatch focus alone.
- //
- // This short-circuit matters because closeSession is also reached from
- // the Agent Activity modal, which kills *background* panes by id. Without
- // this branch, killing a stranger row would shuffle the user's visible
- // Dispatch selection on every removal.
- return cleared
- }
-
- // Row-by-index successor selection.
- //
- // The previous version of this helper picked "first row in the same project
- // tab, else first row globally," which made closing row 6 of a project jump
- // visibly to row 1 — there is no list-UI convention where a delete moves
- // the cursor to the start of the list. Native list pickers (Finder, mail
- // clients, IDE file lists) all keep the cursor at the same visual position
- // after delete, falling back to the previous row when the deleted row was
- // last. We mirror that here so close-and-keep-going feels predictable.
- //
- // Why diff against `before` instead of just picking afterRows[0]:
- // - The "same visual position" is only meaningful relative to where the
- // removed row USED to be. We need the index from the pre-removal list
- // to project it back into the post-removal list.
- // - When removedIndex is past the end of afterRows (closed the last
- // row), we fall back to afterRows[removedIndex - 1] so the cursor
- // trails behind the deletion instead of leaping to the top.
- //
- // When removedIndex is -1 (the closed session wasn't in the visible scope
- // — e.g. project-scope close that collapsed the active tab and switched
- // activeTabId to a different project) we deliberately clear focus instead
- // of inventing a row. The DispatchLayout fallback effect will pick a sane
- // first-row default on the next render in the new scope.
- const beforeRows = buildVisibleDispatchRows(before)
- const afterRows = buildVisibleDispatchRows(after)
- const removedIndex = beforeRows.findIndex(row => row.sessionId === removedSessionId)
-
- // Project-first successor selection (issue #261).
- //
- // In this codebase a "project" IS a tab — every Dispatch row carries a
- // `tabId`, and that is the ONLY reliable project key (cwd is not: two tabs
- // can share a directory, and a tab's cwd can change). The old logic picked
- // the successor purely by flat-list position
- // (`afterRows[removedIndex] ?? afterRows[removedIndex - 1]`). That is fine
- // mid-project, but when the closed row was its project's LAST row,
- // `afterRows[removedIndex]` is the FIRST row of the *next* project, so focus
- // silently jumped across the project boundary and the user lost the context
- // they were working in. We never want a single close to evict you from your
- // project unless the project itself is now gone.
- //
- // So: as long as the closed row's project still has any rows, keep the
- // cursor INSIDE that project — prefer the next pane down (first surviving
- // same-project row at or after the removed index, preserving the "cursor
- // trails the deletion" feel), and only when nothing survives below do we
- // fall back to the last same-project pane above (the bottom-of-project
- // close — the actual bug being fixed here).
- //
- // Only when the project is fully emptied (e.g. closing a single-pane
- // project) do we defer to the legacy GLOBAL fallback and let focus leave the
- // project — there is no in-project row left to land on, so the flat-list
- // neighbour is the sane "same visual position" choice.
- //
- // removedIndex < 0 stays unchanged: the closed session wasn't in the visible
- // scope, so we clear focus (undefined successor) and let DispatchLayout's
- // fallback effect pick a first-row default in the new scope.
- let successor: DispatchAgentRow | undefined
- if (removedIndex >= 0) {
- const removedTabId = beforeRows[removedIndex].tabId
- const sameProjectAfter = afterRows.filter(row => row.tabId === removedTabId)
- if (sameProjectAfter.length > 0) {
- // Project survives: never cross the boundary. Next pane down in-project,
- // else nearest pane up in-project.
- successor =
- sameProjectAfter.find(row => afterRows.indexOf(row) >= removedIndex) ??
- sameProjectAfter[sameProjectAfter.length - 1]
- } else {
- // Project is now empty: only NOW may focus leave the project. Legacy
- // global "same visual position, trailing on last-row close" rule.
- successor = afterRows[removedIndex] ?? afterRows[removedIndex - 1]
- }
- }
-
- return {
- ...cleared,
- focusedSessionId: successor?.sessionId,
- }
-}
+// `dispatchModeAfterSessionRemoval` lived here until #992. It did two jobs:
+// clear the closed session out of any lane, then pick a SUCCESSOR for the
+// classic-Dispatch single focus (project-first, same visual position; #261).
+// The second job existed because classic Dispatch showed exactly one agent, so
+// closing it had to show another. The stage has no such field: a lane that
+// loses its occupant goes EMPTY and stays empty until the user names a new one
+// (U2, #681) — refilling it with a neighbour is precisely the displacement
+// #681 removed. So the whole helper reduced to `clearTiledLaneSessions`, which
+// the three close commits now call directly.
diff --git a/src/renderer/src/workspace/hook/actions/paneRecoveryOwnership.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/paneRecoveryOwnership.renderer.test.tsx
index 4bf02f8c2..89707b5ef 100644
--- a/src/renderer/src/workspace/hook/actions/paneRecoveryOwnership.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/actions/paneRecoveryOwnership.renderer.test.tsx
@@ -15,6 +15,7 @@ import {
} from '@renderer/workspace/closeConfirmationBroker'
import { usePaneActions } from './pane'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
const originalApiDescriptor = Object.getOwnPropertyDescriptor(window, 'api')
@@ -75,13 +76,10 @@ function renderPaneActionsHarness(
setRuntimes,
vi.fn(),
vi.fn(),
- vi.fn(),
refs,
vi.fn(),
vi.fn(),
vi.fn(),
- vi.fn(),
- vi.fn(),
sessionActions,
))
@@ -101,17 +99,17 @@ describe('pane recovery ownership', () => {
tabs: [{
id: 'tab-1',
title: 'Project',
- focusedSessionId: sessionId,
- root: { type: 'leaf' as const, sessionId },
}],
activeTabId: 'tab-1',
sessions: {
- [sessionId]: { cwd: '/tmp/project', kind: 'claude' as const },
+ // Filed under the project: a close addresses a session through its
+ // own `projectId` (#992), so a row naming no project is unowned and
+ // closeSession has nothing to act on — the fixture would pass the type
+ // check and then silently test a no-op.
+ [sessionId]: { cwd: '/tmp/project', kind: 'claude' as const, projectId: 'tab-1', joinedAt: 0 },
},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
- dispatchMode: null,
+ stage: oneLaneStage(sessionId),
} as WorkspaceState
let runtimes: Record = {
[sessionId]: {
@@ -157,13 +155,10 @@ describe('pane recovery ownership', () => {
setRuntimes,
vi.fn(),
vi.fn(),
- vi.fn(),
refs,
vi.fn(),
vi.fn(),
vi.fn(),
- vi.fn(),
- vi.fn(),
sessionActions,
))
@@ -185,48 +180,41 @@ describe('pane recovery ownership', () => {
expect(runtimes[sessionId]).toBeUndefined()
})
- it('atomically closes detached children when their last owning pane closes', async () => {
+ it('closes every session of a project, parked ones included, as one transaction with one undo entry', async () => {
const paneId = 'visible-pane'
const detachedId = 'detached-child'
const state = {
tabs: [{
id: 'tab-1',
title: 'Project',
- focusedSessionId: paneId,
- root: { type: 'leaf' as const, sessionId: paneId },
}],
activeTabId: 'tab-1',
sessions: {
- [paneId]: { cwd: '/tmp/project', kind: 'claude' as const },
- [detachedId]: { cwd: '/tmp/project', kind: 'codex' as const },
+ [paneId]: { cwd: '/tmp/project', kind: 'claude' as const, projectId: 'tab-1', joinedAt: 0 },
+ [detachedId]: { cwd: '/tmp/project', kind: 'codex' as const, projectId: 'tab-1', joinedAt: 123 },
},
- detachedSessions: {
- [detachedId]: {
- sessionId: detachedId,
- surface: 'dispatch' as const,
- projectTabId: 'tab-1',
- projectTabTitle: 'Project',
- projectTabIndex: 0,
- detachedAt: 123,
- },
- },
- buried: [],
pinnedSessionIds: [],
- dispatchMode: null,
+ stage: oneLaneStage(paneId),
} as WorkspaceState
const harness = renderPaneActionsHarness(state, {
[paneId]: emptyRuntime(),
[detachedId]: emptyRuntime(),
})
- // Closing the tab's last pane also kills its detached child, so this is a
- // two-session close and the gate must ask. Answering it here is not test
- // ceremony — it is the assertion that the dialog names BOTH sessions.
- // Before the gate counted detached children, this close reported one target
- // and silently took two.
- let closing: Promise | undefined
+ // Close Tab takes the parked agent no lane shows along with the one on
+ // screen, so this is a two-session close and the gate must ask. Answering
+ // it here is not test ceremony — it is the assertion that the dialog names
+ // BOTH sessions. Before the gate counted parked sessions, a tab close
+ // reported one target and silently took two.
+ //
+ // Re-based with #992. This used to be reached by closing the tab's LAST
+ // TILE LEAF, which took the tab's detached rows with it because the tile
+ // tree could not be left empty. A session close is session-scoped now —
+ // closing `visible-pane` alone would leave the project holding its parked
+ // agent — so the whole-project transaction is Close Tab's, and only its.
+ let closing: Promise | undefined
await act(async () => {
- closing = harness.result.current.closeSession(paneId)
+ closing = harness.result.current.closeTab('tab-1')
await Promise.resolve()
})
expect(currentCloseConfirmation()?.request.targets.map(t => t.sessionId).sort())
@@ -236,10 +224,10 @@ describe('pane recovery ownership', () => {
await closing
})
- // WHY this assertion covers more than renderer cleanup: once the final tab
- // disappears, a detached child has no valid projectTabId. The save-time
- // sanitizer is right to reject it, so the close action must first make the
- // child part of the same destructive transaction and Undo Close snapshot.
+ // WHY this assertion covers more than renderer cleanup: once the project
+ // disappears, a session still naming it is unowned. The save-time prune is
+ // right to drop it, so the close must first make it part of the same
+ // destructive transaction and Undo Close snapshot.
expect(harness.killOwnedSession).toHaveBeenCalledTimes(2)
expect(harness.killOwnedSession).toHaveBeenCalledWith({
sessionId: detachedId,
@@ -248,70 +236,19 @@ describe('pane recovery ownership', () => {
})
expect(harness.getState().tabs).toEqual([])
expect(harness.getState().sessions).toEqual({})
- expect(harness.getState().detachedSessions).toEqual({})
expect(harness.getRuntimes()).toEqual({})
const undoEntry = harness.refs.undoStackRef.current.pop()
expect(undoEntry?.type).toBe('tab')
if (undoEntry?.type === 'tab') {
- // sessionId is the lineage anchor undo publishes when this row is
+ // sessionId is the lineage anchor undo publishes when a session is
// restored, so older entries naming it keep resolving (#886 finding 4).
- expect(undoEntry.detachedEntries).toEqual([{
- sessionId: detachedId,
- meta: state.sessions[detachedId],
- detachedAt: 123,
- }])
+ // The row carries its own place (`joinedAt: 123`), so it returns to it.
+ expect(undoEntry.sessions).toEqual([
+ { sessionId: paneId, meta: state.sessions[paneId] },
+ { sessionId: detachedId, meta: state.sessions[detachedId] },
+ ])
}
})
- it('moves detached children into the buried archive when bury removes their tab', () => {
- const paneId = 'visible-pane'
- const detachedId = 'detached-child'
- const state = {
- tabs: [{
- id: 'tab-1',
- title: 'Project',
- focusedSessionId: paneId,
- root: { type: 'leaf' as const, sessionId: paneId },
- }],
- activeTabId: 'tab-1',
- sessions: {
- [paneId]: { cwd: '/tmp/project', kind: 'claude' as const },
- [detachedId]: { cwd: '/tmp/project', kind: 'codex' as const },
- },
- detachedSessions: {
- [detachedId]: {
- sessionId: detachedId,
- surface: 'dispatch' as const,
- projectTabId: 'tab-1',
- projectTabTitle: 'Project',
- projectTabIndex: 0,
- detachedAt: 123,
- },
- },
- buried: [],
- pinnedSessionIds: [],
- dispatchMode: null,
- } as WorkspaceState
- const harness = renderPaneActionsHarness(state, {
- [paneId]: emptyRuntime(),
- [detachedId]: emptyRuntime(),
- })
-
- act(() => {
- harness.result.current.buryFocused('keep this work', paneId)
- })
-
- // Bury is a non-destructive visibility operation. Both sessions remain
- // live, but both acquire durable archive ownership before the tab vanishes.
- expect(harness.killOwnedSession).not.toHaveBeenCalled()
- expect(harness.getState().tabs).toEqual([])
- expect(harness.getState().detachedSessions).toEqual({})
- expect(harness.getState().buried.map(entry => entry.sessionId)).toEqual([
- paneId,
- detachedId,
- ])
- expect(harness.getState().sessions).toEqual(state.sessions)
- expect(Object.keys(harness.getRuntimes())).toEqual([paneId, detachedId])
- })
})
diff --git a/src/renderer/src/workspace/hook/actions/pooledSpawnBadge.ts b/src/renderer/src/workspace/hook/actions/pooledSpawnBadge.ts
new file mode 100644
index 000000000..80aed89c8
--- /dev/null
+++ b/src/renderer/src/workspace/hook/actions/pooledSpawnBadge.ts
@@ -0,0 +1,41 @@
+import { emptyRuntime } from '@renderer/session-runtime/state'
+import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context'
+import type { SessionId } from '@renderer/workspace/types'
+
+// The "new" chip on an index row (#992 §4.3), both halves in one place.
+//
+// Under context-places, a spawn from an occupied lane, the palette, ⌘N, ⌘T,
+// MCP or orchestration moves nothing on screen. A spawn that cost a real
+// backend boot would then look exactly like a command that did nothing. The
+// index row wears a small "new" chip (SessionRuntime.pooledSpawnAt) until the
+// session is placed into any lane, so "where did my agent go?" is answered by
+// the next thing the user was going to look at anyway.
+//
+// WHY one module for both halves (#1013 review B): the mark lived in pane.ts
+// and the clear in dispatch.ts's setTiledLaneSession, with a comment claiming
+// every placement funnels through there. Label navigation, agents.show,
+// views.agentSet, Agent Activity's Focus and the Performance Monitor all
+// place through agentIndexNavigation instead, so the chip stayed on an agent
+// that was on screen for the rest of the run. ⌘T, for its part, never marked
+// at all. Every placement or spawn site now imports the same two functions.
+
+/** Badge a session that landed in the pool. The "guard the row exists" dance
+ * (`prev[id] ?? emptyRuntime()`) is exactly the kind of thing one site gets
+ * subtly wrong, and a spawn whose badge write throws would report a creation
+ * failure after the backend already booted. */
+export function markPooledSpawn(setRuntimes: WorkspaceSetRuntimes, sessionId: SessionId): void {
+ setRuntimes(prev => {
+ const runtime = prev[sessionId] ?? emptyRuntime()
+ return { ...prev, [sessionId]: { ...runtime, pooledSpawnAt: Date.now() } }
+ })
+}
+
+/** Placing a session into a lane is the user answering the badge. Returns the
+ * same map when there is nothing to clear, so it costs no render. */
+export function clearPooledSpawnBadge(setRuntimes: WorkspaceSetRuntimes, sessionId: SessionId): void {
+ setRuntimes(prev => {
+ const runtime = prev[sessionId]
+ if (!runtime?.pooledSpawnAt) return prev
+ return { ...prev, [sessionId]: { ...runtime, pooledSpawnAt: null } }
+ })
+}
diff --git a/src/renderer/src/workspace/hook/actions/provider.cyberPolicy.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/provider.cyberPolicy.renderer.test.tsx
index 12c1089dd..da94a05aa 100644
--- a/src/renderer/src/workspace/hook/actions/provider.cyberPolicy.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/actions/provider.cyberPolicy.renderer.test.tsx
@@ -28,8 +28,6 @@ function setup(options?: {
tabs: [{
id: 'project',
title: 'Project',
- root: { type: 'leaf', sessionId: 'source' },
- focusedSessionId: 'source',
}],
sessions: {
source: {
@@ -37,9 +35,10 @@ function setup(options?: {
cwd: '/source',
providerSessionId,
builtInMcpDomains: ['orchestration'],
+ projectId: 'project',
+ joinedAt: 0,
},
},
- buried: [],
},
workspaceRuntimes: {
source: {
diff --git a/src/renderer/src/workspace/hook/actions/reader.ts b/src/renderer/src/workspace/hook/actions/reader.ts
index 87b476f69..aa5fddd18 100644
--- a/src/renderer/src/workspace/hook/actions/reader.ts
+++ b/src/renderer/src/workspace/hook/actions/reader.ts
@@ -2,7 +2,6 @@ import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKin
import { useCallback } from 'react'
import type { SessionId } from '@renderer/workspace/types'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
import {
buildVisibleDispatchRows,
} from '@renderer/workspace/dispatch/dispatchSelectors'
@@ -70,12 +69,11 @@ export function useReaderActions(
// Switch which session is being read inside ReaderMode.
//
- // WHY Dispatch mode is special here: detached sessions are not tile-tree
- // leaves, and Tab.focusedSessionId is a grid-only invariant. The original
- // Reader implementation wrote every selected reader session into
- // Tab.focusedSessionId, which corrupts the tab whenever the selected row is
- // detached. In Dispatch, keep focus on dispatchMode.focusedSessionId and
- // activeTabId instead; outside Dispatch, preserve the older grid behavior.
+ // WHY this never writes a lane or a tree focus: Reader is a takeover with its
+ // own focusedSessionId. The original implementation mirrored every selection
+ // into Tab.focusedSessionId, which corrupted the tab whenever the selected
+ // row was not a tree leaf; the Dispatch-era fix moved the mirror to a classic
+ // focus field. Both fields are gone (#992) and nothing replaces them.
const setReaderModeSession = useCallback(
(sessionId: SessionId) => {
const snapshot = refs.stateRef.current
@@ -104,9 +102,7 @@ export function useReaderActions(
// reason; this guard must agree with its own command's own visibility
// rule.
if (!sessionHasTranscript(snapshot.sessions[sessionId])) return
- const rows = snapshot.dispatchMode
- ? buildVisibleDispatchRows(snapshot)
- : []
+ const rows = buildVisibleDispatchRows(snapshot)
const dispatchRow = rows.find(row => row.sessionId === sessionId) ?? null
setReaderMode(prev => (
prev
@@ -117,34 +113,14 @@ export function useReaderActions(
}
: prev
))
+ // Only the active PROJECT follows the Reader selection; see the matching
+ // note in spotlight.ts. Reader holds its own focusedSessionId, and
+ // paging through transcripts is not the user naming a lane occupant
+ // (U2, #681), so no lane — and no tree focus, which no longer exists —
+ // is written here.
setState(prev => {
- // Tab.focusedSessionId is a grid-only field (its invariant:
- // must be a leaf in `tab.root`). Non-Dispatch Reader now
- // surfaces detached agents in its session list (via
- // resolveTabSessions), so a detached id can reach this
- // handler. The pre-existing comment above already explained
- // the Dispatch case; the same reasoning applies to detached
- // sessions clicked from a non-Dispatch Reader view — only
- // mirror to focusedSessionId when the id is actually a leaf.
- // For a detached selection, Reader's own focusedSessionId
- // holds the choice; we don't need to (and must not) mirror
- // it to the grid-only field.
- const activeTab = prev.tabs.find(t => t.id === prev.activeTabId) ?? null
- const isGridLeaf = activeTab ? collectLeaves(activeTab.root).includes(sessionId) : false
- return {
- ...prev,
- activeTabId: dispatchRow?.tabId ?? prev.activeTabId,
- dispatchMode: prev.dispatchMode && dispatchRow
- ? { ...prev.dispatchMode, focusedSessionId: sessionId }
- : prev.dispatchMode,
- tabs: prev.dispatchMode
- ? prev.tabs
- : prev.tabs.map(t =>
- t.id === prev.activeTabId && isGridLeaf
- ? { ...t, focusedSessionId: sessionId }
- : t,
- ),
- }
+ const activeTabId = dispatchRow?.tabId ?? prev.activeTabId
+ return activeTabId === prev.activeTabId ? prev : { ...prev, activeTabId }
})
},
[refs.stateRef, setReaderMode, setState],
diff --git a/src/renderer/src/workspace/hook/actions/replacementOwnership.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/replacementOwnership.renderer.test.tsx
index ef8deec91..5cfd29262 100644
--- a/src/renderer/src/workspace/hook/actions/replacementOwnership.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/actions/replacementOwnership.renderer.test.tsx
@@ -15,8 +15,8 @@ afterEach(() => { cleanup(); useAppStore.setState(original, true); window.api =
it.each(['spawn', 'retirement'] as const)('retires an uncommittable successor when source closes during %s', async stage => {
vi.useFakeTimers()
useAppStore.setState({ workspaceState: { ...original.workspaceState, activeTabId: 'project',
- tabs: [{ id: 'project', title: 'Project', root: { type: 'leaf', sessionId: 'source' }, focusedSessionId: 'source' }],
- sessions: { source: { kind: 'claude', cwd: '/recorded/project', providerSessionId: 'native-source' } }, detachedSessions: {}, buried: [],
+ tabs: [{ id: 'project', title: 'Project' }],
+ sessions: { source: { kind: 'claude', cwd: '/recorded/project', providerSessionId: 'native-source', projectId: 'project', joinedAt: 0 } },
}, workspaceRuntimes: { source: { ...emptyRuntime(), draftInput: 'human draft' } } })
const state = useAppStore.getState().workspaceState
const refs = makeRefs(state)
diff --git a/src/renderer/src/workspace/hook/actions/resize.ts b/src/renderer/src/workspace/hook/actions/resize.ts
deleted file mode 100644
index 8d5f013e5..000000000
--- a/src/renderer/src/workspace/hook/actions/resize.ts
+++ /dev/null
@@ -1,171 +0,0 @@
-import { useCallback } from 'react'
-
-import type { SessionId, TabId } from '@renderer/workspace/types'
-import {
- adjustNearestSplitRatio,
- collectLeaves,
- equalizeRatios,
- normalizeTree,
- resizeInDirection,
- rotateTree,
-} from '@renderer/workspace/tile-tree/treeOps'
-import { setRatioBetween } from '@renderer/workspace/layout/helpers'
-
-import type {
- WorkspaceSetState,
- WorkspaceSetTileTabs,
-} from '@renderer/workspace/hook/context'
-
-// Resize + layout normalization actions.
-//
-// resizeFocused — adjust the nearest split ratio
-// resizeFocusedDirectional — grow focused pane toward a direction (tmux-style)
-// setSplitRatio — set a specific split's ratio directly (drag)
-// setSplitRatioInTab — same, but targets a specific tab by id
-// normalizeLayout — soft: every split ratio = 0.5, tree unchanged
-// hardNormalizeLayout — flatten & rebuild as a rows×cols grid
-// rotateLayout — flip every split direction
-
-export function useResizeActions(
- setState: WorkspaceSetState,
- setTileTabs: WorkspaceSetTileTabs,
-): {
- resizeFocused: (delta: number) => void
- resizeFocusedDirectional: (direction: 'left' | 'right' | 'up' | 'down', delta: number) => void
- setSplitRatio: (fromSessionId: SessionId, toSessionId: SessionId, ratio: number) => void
- setSplitRatioInTab: (tabId: TabId, fromSessionId: SessionId, toSessionId: SessionId, ratio: number) => void
- normalizeLayout: (tabId?: TabId) => void
- hardNormalizeLayout: (tabId?: TabId) => void
- rotateLayout: (tabId?: TabId) => void
-} {
- const resizeFocused = useCallback(
- (delta: number) => {
- setState(prev => ({
- ...prev,
- tabs: prev.tabs.map(t => {
- if (t.id !== prev.activeTabId) return t
- return {
- ...t,
- root: adjustNearestSplitRatio(t.root, t.focusedSessionId, delta),
- }
- }),
- }))
- },
- [setState],
- )
-
- // Grows the focused pane toward the given direction by `delta`. See
- // resizeInDirection in treeOps.ts for the full tmux-style semantics.
- const resizeFocusedDirectional = useCallback(
- (direction: 'left' | 'right' | 'up' | 'down', delta: number) => {
- setState(prev => ({
- ...prev,
- tabs: prev.tabs.map(t => {
- if (t.id !== prev.activeTabId) return t
- return {
- ...t,
- root: resizeInDirection(t.root, t.focusedSessionId, direction, delta),
- }
- }),
- }))
- },
- [setState],
- )
-
- // Walks the tree and finds the split whose `a` side contains fromId
- // and whose `b` side contains toId, then sets its ratio directly.
- const setSplitRatio = useCallback(
- (fromSessionId: SessionId, toSessionId: SessionId, ratio: number) => {
- setState(prev => ({
- ...prev,
- tabs: prev.tabs.map(t => {
- if (t.id !== prev.activeTabId) return t
- return { ...t, root: setRatioBetween(t.root, fromSessionId, toSessionId, ratio) }
- }),
- }))
- },
- [setState],
- )
-
- const setSplitRatioInTab = useCallback(
- (tabId: TabId, fromSessionId: SessionId, toSessionId: SessionId, ratio: number) => {
- setState(prev => {
- let changed = prev.activeTabId !== tabId
- const tabs = prev.tabs.map(t => {
- if (t.id !== tabId) return t
- const root = setRatioBetween(t.root, fromSessionId, toSessionId, ratio)
- if (root === t.root) return t
- changed = true
- return { ...t, root }
- })
- return changed ? { ...prev, activeTabId: tabId, tabs } : prev
- })
- setTileTabs(prev => (
- prev && prev.focusedTabId !== tabId && prev.tabIds.includes(tabId)
- ? { ...prev, focusedTabId: tabId }
- : prev
- ))
- },
- [setState, setTileTabs],
- )
-
- // Optional tab IDs let callers act on an observed project without changing
- // selection first. Human commands omit it and retain their active-tab scope;
- // no SDK or MCP dependency belongs in these ordinary domain operations.
- // Keep the existing tree structure but set every split ratio to
- // 0.5. Equalizes spacing without rearranging panes — if you have
- // three vertical panes on the left and one on the right, they stay
- // that way but all dividers move to the midpoint.
- const normalizeLayout = useCallback((tabId?: TabId) => {
- setState(prev => ({
- ...prev,
- tabs: prev.tabs.map(t =>
- t.id === (tabId ?? prev.activeTabId)
- ? { ...t, root: equalizeRatios(t.root) }
- : t,
- ),
- }))
- }, [setState])
-
- // Flatten the tree and rebuild as a balanced grid where every pane
- // gets equal space. Changes the arrangement — all panes end up in
- // a rows × cols grid. No sessions are spawned or killed.
- const hardNormalizeLayout = useCallback((tabId?: TabId) => {
- setState(prev => {
- const tab = prev.tabs.find(t => t.id === (tabId ?? prev.activeTabId))
- if (!tab) return prev
- const leaves = collectLeaves(tab.root)
- if (leaves.length <= 1) return prev
- const newRoot = normalizeTree(leaves)
- return {
- ...prev,
- tabs: prev.tabs.map(t =>
- t.id === (tabId ?? prev.activeTabId) ? { ...t, root: newRoot } : t,
- ),
- }
- })
- }, [setState])
-
- // Flip every split direction in the active tab's tree: vertical
- // becomes horizontal and vice versa. Turns rows into columns.
- const rotateLayout = useCallback((tabId?: TabId) => {
- setState(prev => ({
- ...prev,
- tabs: prev.tabs.map(t =>
- t.id === (tabId ?? prev.activeTabId)
- ? { ...t, root: rotateTree(t.root) }
- : t,
- ),
- }))
- }, [setState])
-
- return {
- resizeFocused,
- resizeFocusedDirectional,
- setSplitRatio,
- setSplitRatioInTab,
- normalizeLayout,
- hardNormalizeLayout,
- rotateLayout,
- }
-}
diff --git a/src/renderer/src/workspace/hook/actions/session.ts b/src/renderer/src/workspace/hook/actions/session.ts
index af64decbc..d4cfcba9e 100644
--- a/src/renderer/src/workspace/hook/actions/session.ts
+++ b/src/renderer/src/workspace/hook/actions/session.ts
@@ -14,7 +14,7 @@ import { useCallback, useRef } from 'react'
import { emptyRuntime } from '@renderer/session-runtime/state'
import type { SessionRuntime } from '@renderer/session-runtime/state'
import { clearLiveEntryWindowSession } from '@renderer/session-runtime/liveEntryWindow'
-import type { SessionId, SessionKind, SessionMeta, TileNode, WorkspaceState } from '@renderer/workspace/types'
+import type { SessionId, SessionKind, SessionMeta, WorkspaceState } from '@renderer/workspace/types'
import type { BuiltInMcpDomain, BuiltInMcpOverrides } from '@mcp/shared/types'
import { resolveSessionBuiltInMcpDomains, sessionMcpOverrides, spawnMcpOverrides } from '@renderer/workspace/mcpDomains'
import {
@@ -22,11 +22,10 @@ import {
remapTiledLanes,
} from '@renderer/workspace/dispatch/tiledDispatchSelectors'
import {
- remapGridRelatedSelections,
remapPinnedSessionIds,
remapSessionsRelationships,
} from '@renderer/workspace/idRemap'
-import { closeLeaf, collectLeaves, remapTileTreeSessionIds } from '@renderer/workspace/tile-tree/treeOps'
+import { inheritedMembership, workspaceWithoutSessions } from '@renderer/workspace/pool'
import type { Tab } from '@renderer/workspace/types'
import {
releaseIdentityCarry,
@@ -53,7 +52,6 @@ import {
withoutProvisionalProviderSession,
} from '@renderer/workspace/providerSessionIdentity'
import {
- collectLiveProcessIds,
collectOwnedSessionIds,
collectUnownedSessionIds,
pickOwnedSessions,
@@ -1130,27 +1128,13 @@ export function useSessionActions(
delete next[sessionId]
return next
})
- setState(prev => {
- const nextSessions = { ...prev.sessions }
- delete nextSessions[sessionId]
- const detachedSessions = { ...prev.detachedSessions }
- delete detachedSessions[sessionId]
- // Clear the killed session out of any tiled lane FIRST (a lane can
- // hold a session that isn't the classic dispatch focus), then clear
- // the classic focus if it pointed here. Otherwise the lane dangles at
- // a dead id and the layout's auto-fill effect bounces it to tile 0.
- const clearedDispatch = clearTiledLaneSessions(prev.dispatchMode, sessionId)
- const dispatchMode =
- clearedDispatch?.focusedSessionId === sessionId
- ? { ...clearedDispatch, focusedSessionId: undefined }
- : clearedDispatch
- return {
- ...prev,
- sessions: nextSessions,
- detachedSessions,
- dispatchMode,
- }
- })
+ // One removal for the whole workspace (pool.ts): the row leaves the pool,
+ // every lane that showed it goes EMPTY and stays empty (U2, #681), its
+ // pin is dropped, and a project it leaves with no sessions goes with it.
+ // Until #992 this site deleted the row and the detachedSessions record
+ // and left the tree to its callers, which is how a killed leaf could
+ // outlive its metadata.
+ setState(prev => workspaceWithoutSessions(prev, [sessionId]))
delete refs.seenUuidsRef.current[sessionId]
// Live-window bookkeeping follows the seen-uuid lifecycle (see
// liveEntryWindow.ts: trimmed ⊆ ever-seen must hold).
@@ -1192,14 +1176,11 @@ export function useSessionActions(
): Promise => {
const snapshot = refs.stateRef.current
const { targetSessionId: _targetSessionId, preserveTldr, restoreTldrIdentity, ...spawnOpts } = opts ?? {}
- // WHY this reads Dispatch focus before tab focus:
- //
// `replaceSession` powers resume, reload, provider-switch, and rewind.
- // Those commands target the thing the user is visibly commanding. In
- // Dispatch Mode that can be a detached row, or a grid row that did not
- // mutate Tab.focusedSessionId. Remapping by the old grid-only focus would
- // make the palette labels talk about one agent while the destructive
- // replacement happened to another.
+ // Those commands target the thing the user is visibly commanding — the
+ // focused lane's occupant. Targeting anything else would make the
+ // palette labels talk about one agent while the destructive replacement
+ // happened to another.
// WHY callers may pin the target:
// Most command actions should follow the *current* command target at the
// moment replacement begins. Rewind is different: main may spend time
@@ -1251,7 +1232,8 @@ export function useSessionActions(
const current = state.sessions[oldId]
return Boolean(current && current.cwd === oldMeta.cwd && current.kind === oldMeta.kind
&& current.providerRuntime === oldMeta.providerRuntime
- && !state.buried.some(row => row.sessionId === oldId)
+ // Still owned: its project still exists. (A `buried` check sat here
+ // too until #992 folded burial into the pool.)
&& collectOwnedSessionIds(state).has(oldId))
}
if (!canCommit(snapshot)) return
@@ -1297,9 +1279,8 @@ export function useSessionActions(
if (!mainHandledPredecessor) {
await killSessionBackendIfOwned(refs, oldId, oldMeta)
}
- // Swap the sessionId wherever this live session is placed. Grid sessions
- // live in one tile-tree leaf; detached Dispatch sessions live in
- // detachedSessions with no leaf at all.
+ // Swap the sessionId everywhere it is referenced: the pool row, the
+ // lanes, the pins and other rows' relationship pointers.
const idMap = new Map([[oldId, newId]])
let committed = false
@@ -1319,6 +1300,12 @@ export function useSessionActions(
// `prev.sessions[oldId]` is still readable here: only the local
// `sessions` copy has had oldId deleted.
const carriedAgentNameId = prev.sessions[oldId]?.agentNameId
+ // The successor IS the same row in the same project at the same
+ // place in its index — only the backend changed. Until #992 this was
+ // implicit: the id was swapped inside the tile leaf or the detached
+ // record that owned it, so position came for free. Ownership lives
+ // on the row now, so it has to be carried like the title is.
+ const carriedMembership = inheritedMembership(prev.sessions[oldId])
delete sessions[oldId]
// Persist the replacement provider metadata immediately
// instead of waiting for the first transcript line to
@@ -1360,24 +1347,12 @@ export function useSessionActions(
// successor under its own id, which is the same outcome by the one
// rule the feature has.
...(carriedAgentNameId !== undefined ? { agentNameId: carriedAgentNameId } : {}),
- }
- const detachedSessions = { ...prev.detachedSessions }
- const detached = detachedSessions[oldId]
- if (detached) {
- delete detachedSessions[oldId]
- detachedSessions[newId] = { ...detached, sessionId: newId }
+ // After the spread for the same reason: the successor's own row
+ // was written by `spawn` and is un-filed.
+ ...carriedMembership,
}
return {
...prev,
- tabs: prev.tabs.map(t => {
- if (!collectLeaves(t.root).includes(oldId)) return t
- return {
- ...t,
- root: remapTileTreeSessionIds(t.root, idMap),
- focusedSessionId:
- t.focusedSessionId === oldId ? newId : t.focusedSessionId,
- }
- }),
// Remap relationship pointers across ALL sessions: a linked /
// orchestration CHILD of the swapped session carries oldId in its
// linkedParentId/orchestrationParentId/orchestrationRootId, so the
@@ -1388,21 +1363,14 @@ export function useSessionActions(
// A pinned agent that gets a fresh id on reload/switch must follow
// to the new id instead of silently dropping out of the Pinned list.
pinnedSessionIds: remapPinnedSessionIds(prev.pinnedSessionIds, idMap),
- gridRelatedSelections: remapGridRelatedSelections(prev.gridRelatedSelections, idMap),
- detachedSessions,
- // Remap the swapped session id everywhere Dispatch holds it: the
- // classic single-view focus AND every Tiled Dispatch lane selection
- // (dispatchMode.tiled.lanes[].selectedSessionId). reload /
- // provider-switch / resume / rewind all funnel through here; before
- // this, the focused lane kept pointing at the now-dead oldId and the
- // layout's auto-fill effect re-homed it to the first tile. Same
- // tiled-vs-grid divergence as #266/#267/#271, fixed at the swap.
- dispatchMode: remapTiledLanes(
- prev.dispatchMode?.focusedSessionId === oldId
- ? { ...prev.dispatchMode, focusedSessionId: newId }
- : prev.dispatchMode,
- idMap,
- ),
+ // Remap the swapped session id in every lane that shows it
+ // (stage.lanes[].selectedSessionId). reload / provider-switch /
+ // resume / rewind all funnel through here; before this, the
+ // focused lane kept pointing at the now-dead oldId and went blank
+ // under the user mid-conversation. Same lane-vs-owner divergence
+ // as #266/#267/#271, fixed at the swap. (A classic-Dispatch focus
+ // was remapped beside the lanes until #992 removed the field.)
+ stage: remapTiledLanes(prev.stage, idMap),
}
})
if (!committed) {
@@ -1458,7 +1426,7 @@ export function useSessionActions(
const reloadAgentSessions = useCallback(
async (dangerousMode = refs.dangerousAgentsRef.current) => {
const current = refs.stateRef.current
- const liveProcessIds = collectLiveProcessIds(current)
+ const ownedIds = collectOwnedSessionIds(current)
const staleIds = collectUnownedSessionIds(current)
if (staleIds.length > 0) {
// WHY reload prunes but does not kill unowned ids directly:
@@ -1473,20 +1441,26 @@ export function useSessionActions(
// eslint-disable-next-line no-console
console.warn('[workspace] dropping unowned sessions during agent reload:', staleIds)
}
- // WHY filter by liveProcessIds, not ownedIds (mirrors the rehydrate fix):
+ // WHY only sessions that HAVE a backend, not every owned agent:
//
- // After the rehydrate live-vs-owned split, hibernated dispatch agents
- // (entries in `state.sessions` whose ids are NOT in any tile leaf) have
- // no PTY, no mitmdump, and no provider process to reload. Toggling
- // dangerous mode while parked agents exist used to call killSession +
- // spawnSession on every one of them, which re-introduced the original
- // fork-bomb in a different code path: a single mode toggle would
- // resurrect N hibernated agents as live processes. liveProcessIds
- // restricts the reload to tile-leaf sessions actually exposed to the
- // user; hibernated agents pick up the new dangerous-mode setting when
- // the wake-on-attach UI later spawns them.
+ // A parked agent has no PTY, no mitmdump, and no provider process to
+ // reload. Toggling dangerous mode while parked agents exist once called
+ // killSession + spawnSession on every one of them, which re-introduced
+ // the #258 fork bomb in a different code path: a single mode toggle
+ // resurrected N hibernated agents as live processes. A parked agent
+ // picks up the new setting when it is next woken.
+ //
+ // The test is the RUNTIME, not a structure. Until #992 it was "is a tile
+ // leaf" (collectLiveProcessIds), which was only ever a proxy for "was
+ // spawned at boot" — and a wrong one, since an agent woken from a lane
+ // had a backend and was skipped. `processStatus` is "does a writable
+ // backend exist for this session": everything but 'idle' has (or had,
+ // and visibly lost) one, which is exactly what a reload should restart.
+ const runtimesNow = refs.latestRuntimesRef.current
const agentEntries = Object.entries(current.sessions).filter(([id, meta]) => {
- if (!liveProcessIds.has(id)) return false
+ if (!ownedIds.has(id)) return false
+ const processStatus = runtimesNow[id]?.processStatus
+ if (processStatus === undefined || processStatus === 'idle') return false
const kind = meta.kind ?? DEFAULT_PROVIDER
return isAgentProviderKind(kind)
})
@@ -1589,84 +1563,38 @@ export function useSessionActions(
nextSessions[newId] = meta
}
- const nextTabs = prev.tabs
- .map(tab => {
- let root: TileNode | null = remapTileTreeSessionIds(tab.root, idMap)
- for (const failedId of failedIds) {
- root = closeLeaf(root!, failedId)
- if (root === null) break
- }
- if (root === null) return null
- const leaves = collectLeaves(root)
- if (leaves.length === 0) return null
- const focusedSessionId = idMap.get(tab.focusedSessionId)
- ?? (failedIds.has(tab.focusedSessionId) ? leaves[0] : tab.focusedSessionId)
- return {
- ...tab,
- root,
- focusedSessionId,
- } satisfies Tab
- })
- .filter((tab): tab is Tab => tab !== null)
-
- const activeTabId = nextTabs.some(tab => tab.id === prev.activeTabId)
- ? prev.activeTabId
- : (nextTabs[0]?.id ?? '')
-
- const nextBuried = prev.buried
- .filter(entry => !failedIds.has(entry.sessionId))
- .map(entry => ({
- ...entry,
- id: idMap.get(entry.id) ?? entry.id,
- sessionId: idMap.get(entry.sessionId) ?? entry.sessionId,
- siblingLeafId: entry.siblingLeafId
- ? (idMap.get(entry.siblingLeafId) ?? entry.siblingLeafId)
- : undefined,
- }))
-
- const nextDetachedSessions = Object.fromEntries(
- Object.entries(prev.detachedSessions)
- .filter(([sessionId]) => !failedIds.has(sessionId))
- .map(([sessionId, entry]) => {
- const mapped = idMap.get(sessionId)
- if (!mapped) return [sessionId, entry]
- return [mapped, { ...entry, sessionId: mapped }]
- }),
- )
-
- const focusedDispatchSessionId = prev.dispatchMode?.focusedSessionId
- // Remap tiled lanes through the same old->new idMap (every reloaded
- // agent got a fresh sessionId), then clear any lane whose session
- // failed to respawn. Without this, "reload all" would point every lane
- // at a dead id and the auto-fill effect would collapse them to tile 0.
- const remappedDispatch = clearTiledLaneSessions(
- remapTiledLanes(prev.dispatchMode, idMap),
+ // A successor carries its predecessor's pool membership through
+ // `...restoredMeta` above, so it keeps its project and its place. An
+ // agent that FAILED to respawn was deleted a few lines up; the removal
+ // helper below then takes any project that leaves empty. (Until #992
+ // this rewrote every tile tree, the buried list and the detached
+ // bucket by hand to follow the new ids.)
+ // Remap lanes through the same old->new idMap (every reloaded agent
+ // got a fresh sessionId), then clear any lane whose session failed to
+ // respawn. Without this, "reload all" would point every lane at a dead
+ // id. Order matters: remap first, because `failedIds` are OLD ids that
+ // have no entry in idMap and so survive the remap to be cleared.
+ const nextStage = clearTiledLaneSessions(
+ remapTiledLanes(prev.stage, idMap),
failedIds,
)
- const nextDispatchMode = remappedDispatch
- ? {
- ...remappedDispatch,
- focusedSessionId: focusedDispatchSessionId
- ? idMap.get(focusedDispatchSessionId) ??
- (failedIds.has(focusedDispatchSessionId) ? undefined : focusedDispatchSessionId)
- : undefined,
- }
- : null
- return {
+ // `workspaceWithoutSessions` is handed the FAILED ids against a state
+ // whose sessions map still holds them, so it can see which projects
+ // they belonged to and remove the ones left empty.
+ return workspaceWithoutSessions({
...prev,
- tabs: nextTabs,
- activeTabId,
// Reload-all gives every agent a fresh id; remap relationship
// pointers across all sessions (children keep pointing at the right
// parent) and remap the pinned list (pins follow to the new ids).
- sessions: remapSessionsRelationships(nextSessions, idMap),
+ sessions: {
+ ...remapSessionsRelationships(nextSessions, idMap),
+ ...Object.fromEntries([...failedIds].flatMap(id =>
+ prev.sessions[id] ? [[id, prev.sessions[id]!] as const] : [])),
+ },
pinnedSessionIds: remapPinnedSessionIds(prev.pinnedSessionIds, idMap),
- gridRelatedSelections: remapGridRelatedSelections(prev.gridRelatedSelections, idMap),
- detachedSessions: nextDetachedSessions,
- buried: nextBuried,
- dispatchMode: nextDispatchMode,
- }
+ stage: nextStage,
+ }, failedIds)
})
for (const [newId, meta] of Object.entries(freshSessions)) {
if (!hasDurableProviderSession(meta)) continue
diff --git a/src/renderer/src/workspace/hook/actions/sessionRecovery.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/sessionRecovery.renderer.test.tsx
index 53c054db1..5c7740544 100644
--- a/src/renderer/src/workspace/hook/actions/sessionRecovery.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/actions/sessionRecovery.renderer.test.tsx
@@ -8,6 +8,8 @@ import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
import type { SessionId, WorkspaceState } from '@renderer/workspace/types'
import { killSessionBackendIfOwned, useSessionActions } from './session'
+import { freshStage } from '@renderer/workspace/dispatch/gridShape'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
const originalApiDescriptor = Object.getOwnPropertyDescriptor(window, 'api')
@@ -39,10 +41,8 @@ describe('useSessionActions recovery retry', () => {
tabs: [],
activeTabId: '',
sessions: {},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
- dispatchMode: null,
+ stage: freshStage(),
} as unknown as WorkspaceState
let runtimes: Record = {}
const refs = {
@@ -146,17 +146,13 @@ describe('useSessionActions recovery retry', () => {
tabs: [{
id: 'tab-1',
title: 'Project',
- focusedSessionId: sessionId,
- root: { type: 'leaf' as const, sessionId },
}],
activeTabId: 'tab-1',
sessions: {
[sessionId]: { cwd: '/tmp/project', kind: 'claude' as const },
},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
- dispatchMode: null,
+ stage: oneLaneStage(sessionId),
} as WorkspaceState
let runtimes: Record = {
[sessionId]: {
@@ -253,8 +249,6 @@ describe('useSessionActions recovery retry', () => {
tabs: [{
id: 'tab-1',
title: 'Project',
- focusedSessionId: sessionId,
- root: { type: 'leaf' as const, sessionId },
}],
activeTabId: 'tab-1',
sessions: {
@@ -264,10 +258,8 @@ describe('useSessionActions recovery retry', () => {
...(providerRuntime ? { providerRuntime } : {}),
},
},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
- dispatchMode: null,
+ stage: oneLaneStage(sessionId),
} as WorkspaceState
let runtimes: Record = {
[sessionId]: { ...emptyRuntime(), processStatus: 'spawning', inputReady: false },
@@ -376,8 +368,6 @@ describe('useSessionActions recovery retry', () => {
tabs: [{
id: 'tab-1',
title: 'Project',
- focusedSessionId: sessionId,
- root: { type: 'leaf' as const, sessionId },
}],
activeTabId: 'tab-1',
sessions: {
@@ -385,13 +375,12 @@ describe('useSessionActions recovery retry', () => {
cwd: '/tmp/project',
kind: 'claude' as const,
title: 'Initial title',
+ projectId: 'tab-1',
+ joinedAt: 0,
},
},
- detachedSessions: {},
- gridRelatedSelections: {},
- buried: [],
pinnedSessionIds: [],
- dispatchMode: null,
+ stage: oneLaneStage(sessionId),
} as WorkspaceState
let runtimes: Record = {
[sessionId]: emptyRuntime(),
diff --git a/src/renderer/src/workspace/hook/actions/sessionReplacementHandoff.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/sessionReplacementHandoff.renderer.test.tsx
index 1d4a00246..8e0f5298d 100644
--- a/src/renderer/src/workspace/hook/actions/sessionReplacementHandoff.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/actions/sessionReplacementHandoff.renderer.test.tsx
@@ -9,6 +9,7 @@ import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
import type { SessionId, WorkspaceState } from '@renderer/workspace/types'
import { useSessionActions } from './session'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
vi.mock('@renderer/workspace/hook/actions/initialHistory', () => ({
loadInitialHistoryForSession: vi.fn(async () => undefined),
@@ -38,8 +39,6 @@ describe('renderer session replacement handoff', () => {
tabs: [{
id: 'tab-a',
title: 'recorded',
- root: { type: 'leaf' as const, sessionId: predecessorId },
- focusedSessionId: predecessorId,
}],
activeTabId: 'tab-a',
sessions: {
@@ -49,12 +48,12 @@ describe('renderer session replacement handoff', () => {
providerSessionId: 'recorded-provider-session',
providerSessionIdSource: 'resume-request' as const,
builtInMcpDomains: [],
+ projectId: 'tab-a',
+ joinedAt: 0,
},
},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
- dispatchMode: null,
+ stage: oneLaneStage(predecessorId),
} as WorkspaceState
let runtimes: Record = {
[predecessorId]: {
@@ -66,7 +65,6 @@ describe('renderer session replacement handoff', () => {
stateRef: ref(state),
latestStateRef: ref(state),
latestRuntimesRef: ref(runtimes),
- latestTileTabsRef: ref(null),
dangerousAgentsRef: ref(false),
useProxyStreamingRef: ref(true),
defaultBuiltInMcpDomainsRef: ref([]),
@@ -148,10 +146,15 @@ describe('renderer session replacement handoff', () => {
// the legacy cleanup here is indistinguishable from an explicit close and
// would correctly cancel the hidden successor before the remap can persist.
expect(killOwnedSession).not.toHaveBeenCalled()
- expect(state.tabs[0]).toMatchObject({
- root: { type: 'leaf', sessionId: 'local-successor' },
- focusedSessionId: 'local-successor',
- })
+ // The successor stands exactly where the predecessor stood: same project,
+ // same position in its index (`joinedAt` is INHERITED, not re-stamped — a
+ // provider switch must not send the agent to the bottom of the list), and
+ // the lane that showed the predecessor now shows it. Until #992 this was
+ // one fact, "the tile leaf was swapped in place"; membership and the lane
+ // are separate writes now, so each is asserted.
+ expect(state.sessions[predecessorId]).toBeUndefined()
+ expect(state.sessions['local-successor']).toMatchObject({ projectId: 'tab-a', joinedAt: 0 })
+ expect(state.stage.lanes).toEqual([{ selectedSessionId: 'local-successor' }])
expect(runtimes['local-successor']?.draftInput).toBe('edited while spawning')
expect(runtimes['local-successor']?.draftImages).toEqual(destination === 'claude' ? [image] : [])
diff --git a/src/renderer/src/workspace/hook/actions/spotlight.ts b/src/renderer/src/workspace/hook/actions/spotlight.ts
index 0a54d1d99..6c7c32aa9 100644
--- a/src/renderer/src/workspace/hook/actions/spotlight.ts
+++ b/src/renderer/src/workspace/hook/actions/spotlight.ts
@@ -1,7 +1,6 @@
import { useCallback } from 'react'
import type { SessionId } from '@renderer/workspace/types'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
import {
buildVisibleDispatchRows,
} from '@renderer/workspace/dispatch/dispatchSelectors'
@@ -13,12 +12,12 @@ import type {
} from '@renderer/workspace/hook/context'
import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
-// Spotlight mode — focused-pane zoom for the current command target.
-// toggleSpotlight exits whenever Spotlight is already open; otherwise it uses
-// the same command target selector as lifecycle commands so Tiled Dispatch,
-// pinned rows, and grid-related children all enter the session the user is
-// actually commanding. setSpotlightSession switches which session is showing
-// inside Spotlight.
+// Spotlight mode — a full-window takeover of the current command target.
+// toggleSpotlight exits whenever Spotlight is already open; otherwise it enters
+// on the same command target lifecycle commands use (the focused lane's
+// agent). setSpotlightSession switches which session is showing inside
+// Spotlight, and while Spotlight is open that session IS the command target
+// (commandTargetSessionIdForState reads the takeover first).
export function useSpotlightActions(
setSpotlight: WorkspaceSetSpotlight,
@@ -58,9 +57,7 @@ export function useSpotlightActions(
const setSpotlightSession = useCallback(
(sessionId: SessionId) => {
const snapshot = refs.stateRef.current
- const rows = snapshot.dispatchMode
- ? buildVisibleDispatchRows(snapshot)
- : []
+ const rows = buildVisibleDispatchRows(snapshot)
const dispatchRow = rows.find(row => row.sessionId === sessionId) ?? null
setSpotlight(prev => (
prev
@@ -71,35 +68,20 @@ export function useSpotlightActions(
}
: prev
))
+ // Only the active PROJECT follows the Spotlight selection — it is a label
+ // (U4) that decides where the next agent defaults and which header is
+ // highlighted, so it should name the project the user is looking at.
+ //
+ // WHY no lane is written, although this used to mirror the selection
+ // into a classic-Dispatch focus (and, outside Dispatch, into the tree's
+ // Tab.focusedSessionId): both of those fields are gone (#992), and the
+ // stage has no equivalent on purpose. Spotlight is a takeover that holds
+ // its own focusedSessionId; browsing agents inside it is not the user
+ // naming a lane occupant (U2, #681), so leaving Spotlight returns to the
+ // stage exactly as it was left.
setState(prev => {
- // Tab.focusedSessionId has a hard invariant: it must be a
- // leaf in `tab.root`. The non-Dispatch Spotlight view now
- // surfaces detached agents (via resolveTabSessions), so a
- // detached id can land here. Writing it into focusedSessionId
- // would corrupt the tab — every downstream surface that
- // reads tab.focusedSessionId (resize, split, bury, command
- // target fallback) assumes it points at an actual tile. So
- // we only mirror to focusedSessionId when the id is provably
- // a grid leaf for the active tab. The Spotlight surface
- // itself already holds the chosen id; the grid-focus mirror
- // is just a convenience for the "Spotlight off → land on
- // this pane" handoff, which is moot for a detached session.
- const activeTab = prev.tabs.find(t => t.id === prev.activeTabId) ?? null
- const isGridLeaf = activeTab ? collectLeaves(activeTab.root).includes(sessionId) : false
- return {
- ...prev,
- activeTabId: dispatchRow?.tabId ?? prev.activeTabId,
- dispatchMode: prev.dispatchMode && dispatchRow
- ? { ...prev.dispatchMode, focusedSessionId: sessionId }
- : prev.dispatchMode,
- tabs: prev.dispatchMode
- ? prev.tabs
- : prev.tabs.map(t =>
- t.id === prev.activeTabId && isGridLeaf
- ? { ...t, focusedSessionId: sessionId }
- : t,
- ),
- }
+ const activeTabId = dispatchRow?.tabId ?? prev.activeTabId
+ return activeTabId === prev.activeTabId ? prev : { ...prev, activeTabId }
})
},
[refs.stateRef, setSpotlight, setState],
diff --git a/src/renderer/src/workspace/hook/actions/spotlightCommandTarget.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/spotlightCommandTarget.renderer.test.tsx
new file mode 100644
index 000000000..0d26615e8
--- /dev/null
+++ b/src/renderer/src/workspace/hook/actions/spotlightCommandTarget.renderer.test.tsx
@@ -0,0 +1,86 @@
+import { act, cleanup, renderHook } from '@testing-library/react'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { useAppStore } from '@renderer/app-state/hooks'
+import { emptyRuntime } from '@renderer/session-runtime/state'
+import { paneCommands } from '@renderer/features/workspace/commands/paneCommands'
+import type { CommandContext } from '@renderer/features/command-palette/types'
+import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/commandTargetSessionId'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
+import { useWorkspace } from '../index'
+
+// #1013 parity review, MAJOR: picking another agent INSIDE Spotlight must move
+// the command target with it.
+//
+// On main the Spotlight pick was mirrored into the tree/Dispatch focus that
+// command targeting read. The unified stage deliberately does not write the
+// pick into a lane (browsing inside Spotlight is not naming a lane occupant),
+// and targeting read only the focused lane, so every command run from inside
+// Spotlight after a switch acted on the lane agent hidden behind it.
+//
+// This drives the real workspace hook and the real registered Tail command.
+// Only the process/IPC ingress is suppressed, as in the orchestration runtime
+// test, because this is about targeting, not spawning.
+vi.mock('../ipc/useIpcSubscriptions', () => ({ useIpcSubscriptions: () => undefined }))
+vi.mock('../ipc/useWorkspaceAdoption', () => ({ useWorkspaceAdoption: () => undefined }))
+vi.mock('../persistence/useBootstrap', () => ({ useBootstrap: () => undefined }))
+vi.mock('@renderer/features/sessionFeed/SessionFeedContext', () => ({ useSessionFeed: () => ({}) }))
+
+const originalStore = useAppStore.getState()
+const originalApi = Object.getOwnPropertyDescriptor(window, 'api')
+
+beforeEach(() => {
+ useAppStore.setState({
+ workspaceState: {
+ ...originalStore.workspaceState,
+ activeTabId: 'project', stage: oneLaneStage('lane-agent'), pinnedSessionIds: [],
+ tabs: [{ id: 'project', title: 'Project' }],
+ sessions: {
+ 'lane-agent': { kind: 'claude', cwd: '/repo', projectId: 'project', joinedAt: 0 },
+ 'pooled-agent': { kind: 'claude', cwd: '/repo', projectId: 'project', joinedAt: 1 },
+ },
+ },
+ workspaceRuntimes: { 'lane-agent': emptyRuntime(), 'pooled-agent': emptyRuntime() },
+ workspaceSpotlight: null,
+ workspaceReaderMode: null,
+ })
+ Object.defineProperty(window, 'api', { configurable: true, value: {
+ onOrchestrationRequest: () => () => undefined,
+ onAgentManagementRequest: () => () => undefined,
+ ghostRead: async () => [],
+ reportSessionLifecycle: vi.fn(),
+ appendFeedDebugLog: async () => undefined,
+ } })
+})
+afterEach(() => {
+ cleanup()
+ useAppStore.setState(originalStore, true)
+ if (originalApi) Object.defineProperty(window, 'api', originalApi)
+ else Reflect.deleteProperty(window, 'api')
+})
+
+const runCommand = (id: string, workspace: ReturnType) => {
+ const command = paneCommands.find(entry => entry.id === id)
+ if (!command?.run) throw new Error(`command ${id} is not registered`)
+ // Tail reads only the workspace from its context.
+ return command.run({ workspace } as unknown as CommandContext)
+}
+
+it('commands run from Spotlight act on the agent picked in Spotlight, not the hidden lane agent', () => {
+ const hook = renderHook(() => useWorkspace())
+ act(() => { hook.result.current.toggleSpotlight() })
+ expect(useAppStore.getState().workspaceSpotlight?.focusedSessionId).toBe('lane-agent')
+
+ act(() => { hook.result.current.setSpotlightSession('pooled-agent') })
+ // The pick stays inside Spotlight: the lane still holds the agent it held.
+ expect(useAppStore.getState().workspaceState.stage.lanes[0]?.selectedSessionId).toBe('lane-agent')
+ expect(commandTargetSessionId(hook.result.current)).toBe('pooled-agent')
+
+ act(() => { void runCommand('toggle-tail', hook.result.current) })
+ const runtimes = useAppStore.getState().workspaceRuntimes
+ expect(runtimes['pooled-agent']?.tailMode).toBe(true)
+ expect(runtimes['lane-agent']?.tailMode).toBeFalsy()
+
+ // Leaving Spotlight returns to the stage as it was, and so does the target.
+ act(() => { hook.result.current.toggleSpotlight() })
+ expect(commandTargetSessionId(hook.result.current)).toBe('lane-agent')
+})
diff --git a/src/renderer/src/workspace/hook/actions/tab.ts b/src/renderer/src/workspace/hook/actions/tab.ts
index 87a0c4e09..654eef18a 100644
--- a/src/renderer/src/workspace/hook/actions/tab.ts
+++ b/src/renderer/src/workspace/hook/actions/tab.ts
@@ -1,32 +1,33 @@
import { useCallback } from 'react'
+import { withLaneSession } from '@renderer/workspace/dispatch/tiledDispatchSelectors'
-import type { DetachedSessionRecord, SessionId, SessionKind, SessionMeta, Tab, TabId } from '@renderer/workspace/types'
+import type { SessionId, SessionKind, SessionMeta, Tab, TabId } from '@renderer/workspace/types'
import { titleFromCwd } from '@renderer/workspace/layout/helpers'
-import { mergeProjectTabs, retargetTileTabsAfterMerge } from '@renderer/workspace/mergeProjectTabs'
+import { mergeProjectTabs } from '@renderer/workspace/mergeProjectTabs'
+import { fileSessionInProject } from '@renderer/workspace/pool'
import type { MergeProjectTabsResult } from '@renderer/workspace/mergeProjectTabs'
import { tabIndexLabel } from '@renderer/workspace/tile-tree/paneLabelFormat'
import type {
WorkspaceSetReaderMode,
+ WorkspaceSetRuntimes,
WorkspaceSetSpotlight,
WorkspaceSetState,
- WorkspaceSetTileTabs,
} from '@renderer/workspace/hook/context'
import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
import type { SessionActions } from '@renderer/workspace/hook/actions/session'
+import { markPooledSpawn } from '@renderer/workspace/hook/actions/pooledSpawnBadge'
// Tab actions — open/close + tab-navigation keybinds.
export function useTabActions(
state: {
activeTabId: string
- detachedSessions: Record
sessions: Record
tabs: Tab[]
},
- tileTabs: { tabIds: TabId[]; focusedTabId: TabId } | null,
setState: WorkspaceSetState,
- setTileTabs: WorkspaceSetTileTabs,
+ setRuntimes: WorkspaceSetRuntimes,
setSpotlight: WorkspaceSetSpotlight,
setReaderMode: WorkspaceSetReaderMode,
refs: WorkspaceRefs,
@@ -60,22 +61,59 @@ export function useTabActions(
}
const tabId = crypto.randomUUID()
const title = titleFromCwd(cwd)
+ let placed = false
setState(prev => {
- const tab: Tab = {
- id: tabId,
- title,
- root: { type: 'leaf', sessionId },
- focusedSessionId: sessionId,
- }
+ // A project is a title and a position. Its first session belongs to
+ // it because the session SAYS so (fileSessionInProject below) — until
+ // #992 the tab was created holding a one-leaf tile tree instead.
+ const tab: Tab = { id: tabId, title }
+ // Context-places (#992, U2's second continuity write): the agent the
+ // user just asked for appears where they are looking — but ONLY when
+ // that lane is empty. An occupied lane is never displaced; the new
+ // agent is then in the pool, at the top of its project's index, one
+ // keystroke away. (Opening a lane beside an occupied one is stage 4's
+ // job, together with the other spawn paths.)
+ //
+ // WHY this lives in newTab rather than in bootstrap: a fresh install,
+ // the persisted-fallback recovery shell and an ordinary ⌘T are the
+ // same event — "first agent of a new project" — and bootstrap used to
+ // special-case the first two by entering Tiled Dispatch afterwards.
+ // One rule here means a fresh install's single lane shows its single
+ // agent without boot knowing anything about lanes.
+ //
+ // No wake is needed (#690): this session was spawned a few lines up.
+ //
+ // "Empty" means what it means for every other spawn
+ // (applyDispatchSpawnFocus in pane.ts): no occupant, OR an occupant
+ // whose session is gone. A lane pointing at a closed session reads
+ // empty to the user, and ⌘T used to refuse it as occupied (#1013
+ // review B).
+ const focusedLane = prev.stage.lanes[prev.stage.focusedLane]
+ const occupant = focusedLane?.selectedSessionId
+ const stage = focusedLane && (occupant === undefined || prev.sessions[occupant] === undefined)
+ ? {
+ ...prev.stage,
+ lanes: prev.stage.lanes.map((lane, index) =>
+ index === prev.stage.focusedLane ? withLaneSession(lane, sessionId) : lane,
+ ),
+ }
+ : prev.stage
+ placed = stage !== prev.stage
return {
...prev,
tabs: [...prev.tabs, tab],
activeTabId: tabId,
+ sessions: fileSessionInProject(prev.sessions, sessionId, tabId),
+ stage,
}
})
+ // A first agent that could not take the lane is in the pool, and it
+ // wears the same "new" badge as every other pooled spawn. Without it,
+ // ⌘T with an occupied lane looked like it did nothing (#1013 review B).
+ if (!placed) markPooledSpawn(setRuntimes, sessionId)
return { tabId, sessionId }
},
- [sessionActions, setState, showToast],
+ [sessionActions, setRuntimes, setState, showToast],
)
// WHY Close Tab no longer lives here (#886 review round 2): the command used
@@ -91,22 +129,8 @@ export function useTabActions(
(tabId: TabId) => {
setState(prev => ({ ...prev, activeTabId: tabId }))
setSpotlight(null)
- // Preserve tile-tabs mode when the activated tab is part of
- // the tiled set — just shift the focused tile. If it's NOT
- // part of the set, leave tile-tabs ALONE rather than nuking
- // the mode. The previous behavior (setting null) caused the
- // tile layout to silently collapse whenever the user clicked
- // any other tab in the bar, which read as a phantom
- // "auto-deselect."
- setTileTabs(prev => {
- if (!prev) return prev
- if (prev.tabIds.includes(tabId)) {
- return { ...prev, focusedTabId: tabId }
- }
- return prev
- })
},
- [setSpotlight, setState, setTileTabs],
+ [setSpotlight, setState],
)
const activateTabByIndex = useCallback(
@@ -116,18 +140,8 @@ export function useTabActions(
return t ? { ...prev, activeTabId: t.id } : prev
})
setSpotlight(null)
- // Same preservation rule as activateTab — see comment there.
- setTileTabs(prev => {
- const target = refs.stateRef.current.tabs[index]
- if (!prev) return prev
- if (!target) return prev
- if (prev.tabIds.includes(target.id)) {
- return { ...prev, focusedTabId: target.id }
- }
- return prev
- })
},
- [refs.stateRef, setSpotlight, setState, setTileTabs],
+ [setSpotlight, setState],
)
const mergeTabs = useCallback(
@@ -164,23 +178,25 @@ export function useTabActions(
)
return result
}
- setTileTabs(prev => retargetTileTabsAfterMerge(prev, sourceTabIds, targetTabId))
- // Spotlight and Reader zoom a GRID pane of a tab; the pane they named
- // is now a Dispatch agent of another tab, so the takeover has nothing
- // to frame.
+ // A takeover framing a merged-away project FOLLOWS its session into the
+ // target: the session it shows is exactly as alive as before, only its
+ // project label changed, and a takeover's `tabId` is just which
+ // project's sessions its switcher lists. (Until #992 the takeover was
+ // dismissed here, because it framed a GRID PANE of the removed tab and
+ // that pane had stopped being one.)
const removed = new Set(sourceTabIds)
- setSpotlight(prev => (prev && removed.has(prev.tabId) ? null : prev))
- setReaderMode(prev => (prev && removed.has(prev.tabId) ? null : prev))
+ setSpotlight(prev => (prev && removed.has(prev.tabId) ? { ...prev, tabId: targetTabId } : prev))
+ setReaderMode(prev => (prev && removed.has(prev.tabId) ? { ...prev, tabId: targetTabId } : prev))
const { summary } = result
- const moved = summary.detachedFromGrid.length + summary.repointedDetached.length
+ const moved = summary.movedSessionIds.length
showToast(
`Merged ${summary.removedTabIds.length} tab${summary.removedTabIds.length === 1 ? '' : 's'} into `
+ `${tabIndexLabel(summary.targetIndex)} · ${summary.targetTitle} — `
- + `${moved} agent${moved === 1 ? '' : 's'} now in its Dispatch list`,
+ + `${moved} agent${moved === 1 ? '' : 's'} now listed under it`,
)
return result
},
- [setReaderMode, setSpotlight, setState, setTileTabs, showToast],
+ [setReaderMode, setSpotlight, setState, showToast],
)
const reorderTabs = useCallback(
@@ -210,14 +226,6 @@ export function useTabActions(
)
const nextTab = useCallback(() => {
- const tiled = tileTabs
- if (tiled && tiled.tabIds.length > 1) {
- const idx = tiled.tabIds.indexOf(tiled.focusedTabId)
- const nextId = tiled.tabIds[(idx + 1 + tiled.tabIds.length) % tiled.tabIds.length]
- setState(prev => ({ ...prev, activeTabId: nextId }))
- setTileTabs(prev => (prev ? { ...prev, focusedTabId: nextId } : prev))
- return
- }
setState(prev => {
const idx = prev.tabs.findIndex(t => t.id === prev.activeTabId)
if (idx === -1) return prev
@@ -225,18 +233,9 @@ export function useTabActions(
return { ...prev, activeTabId: next.id }
})
setSpotlight(null)
- }, [setSpotlight, setState, setTileTabs, tileTabs])
+ }, [setSpotlight, setState])
const prevTab = useCallback(() => {
- const tiled = tileTabs
- if (tiled && tiled.tabIds.length > 1) {
- const idx = tiled.tabIds.indexOf(tiled.focusedTabId)
- const nextId =
- tiled.tabIds[(idx - 1 + tiled.tabIds.length) % tiled.tabIds.length]
- setState(prev => ({ ...prev, activeTabId: nextId }))
- setTileTabs(prev => (prev ? { ...prev, focusedTabId: nextId } : prev))
- return
- }
setState(prev => {
const idx = prev.tabs.findIndex(t => t.id === prev.activeTabId)
if (idx === -1) return prev
@@ -244,7 +243,7 @@ export function useTabActions(
return { ...prev, activeTabId: next.id }
})
setSpotlight(null)
- }, [setSpotlight, setState, setTileTabs, tileTabs])
+ }, [setSpotlight, setState])
return {
newTab,
diff --git a/src/renderer/src/workspace/hook/actions/tabRemoval.test.ts b/src/renderer/src/workspace/hook/actions/tabRemoval.test.ts
index f49e7ef26..62d3692f3 100644
--- a/src/renderer/src/workspace/hook/actions/tabRemoval.test.ts
+++ b/src/renderer/src/workspace/hook/actions/tabRemoval.test.ts
@@ -4,13 +4,11 @@ import { clearRemovedTabTakeovers, workspaceWithoutTab } from './tabRemoval'
import type {
WorkspaceSetReaderMode,
WorkspaceSetSpotlight,
- WorkspaceSetTileTabs,
} from '@renderer/workspace/hook/context'
import type {
ReaderModeState,
SpotlightState,
Tab,
- TileTabsState,
WorkspaceState,
} from '@renderer/workspace/types'
@@ -20,7 +18,7 @@ import type {
// per caller.
const tab = (id: string): Tab => ({
- id, title: id.toUpperCase(), root: { type: 'leaf', sessionId: `${id}-root` }, focusedSessionId: `${id}-root`,
+ id, title: id.toUpperCase(),
})
function workspace(activeTabId: string): WorkspaceState {
@@ -30,19 +28,12 @@ function workspace(activeTabId: string): WorkspaceState {
sessions: {
'a-root': { cwd: '/a', kind: 'claude' },
'b-root': { cwd: '/b', kind: 'claude' },
- 'b-row': { cwd: '/b', kind: 'codex' },
+ 'b-row': { cwd: '/b', kind: 'codex', projectId: 'b', joinedAt: 1 },
'c-root': { cwd: '/c', kind: 'claude' },
'd-root': { cwd: '/d', kind: 'claude' },
},
- detachedSessions: {
- 'b-row': { sessionId: 'b-row', surface: 'dispatch', projectTabId: 'b', projectTabTitle: 'B', projectTabIndex: 1, detachedAt: 1 },
- },
- dispatchMode: {
- scope: 'global',
- focusedSessionId: 'b-row',
- tiled: { lanes: [{ selectedSessionId: 'b-row' }, { selectedSessionId: 'a-root' }], focusedLane: 0 },
- },
- gridRelatedSelections: {}, buried: [], pinnedSessionIds: [],
+ stage: { lanes: [{ selectedSessionId: 'b-row' }, { selectedSessionId: 'a-root' }], focusedLane: 0 },
+ pinnedSessionIds: [],
}
}
@@ -65,53 +56,48 @@ describe('workspaceWithoutTab', () => {
expect(workspaceWithoutTab(workspace('c'), 'b', ['b-root', 'b-row']).activeTabId).toBe('c')
})
- it('removes the sessions and rows it is given and clears their Dispatch lanes and focus', () => {
+ it('removes the sessions and rows it is given and empties the lanes that showed them', () => {
const next = workspaceWithoutTab(workspace('b'), 'b', ['b-root', 'b-row'])
expect(Object.keys(next.sessions).sort()).toEqual(['a-root', 'c-root', 'd-root'])
- expect(next.detachedSessions).toEqual({})
- expect(next.dispatchMode?.focusedSessionId).toBeUndefined()
- expect(next.dispatchMode?.tiled?.lanes.map(lane => lane.selectedSessionId)).toEqual([undefined, 'a-root'])
+ // The lane goes EMPTY — it is neither refilled with a neighbour (#681) nor
+ // removed, and the lane beside it is untouched. The user shaped the stage;
+ // closing a project must not reshape it. (A classic-Dispatch focus was
+ // cleared here too until #992 removed the field.)
+ expect(next.stage.lanes.map(lane => lane.selectedSessionId)).toEqual([undefined, 'a-root'])
+ expect(next.stage.focusedLane).toBe(0)
})
})
describe('clearRemovedTabTakeovers', () => {
function takeovers(initial: {
- tileTabs: TileTabsState | null
spotlight: SpotlightState | null
readerMode: ReaderModeState | null
}) {
const current = { ...initial }
const apply = (value: T | ((prev: T) => T), prev: T): T =>
typeof value === 'function' ? (value as (prev: T) => T)(prev) : value
- const setTileTabs: WorkspaceSetTileTabs = next => { current.tileTabs = apply(next, current.tileTabs) }
const setSpotlight: WorkspaceSetSpotlight = next => { current.spotlight = apply(next, current.spotlight) }
const setReaderMode: WorkspaceSetReaderMode = next => { current.readerMode = apply(next, current.readerMode) }
- return { current, setters: { setTileTabs, setSpotlight, setReaderMode } }
+ return { current, setters: { setSpotlight, setReaderMode } }
}
- it('drops the removed tab from Tiled Tabs and clears Spotlight and Reader that framed it', () => {
+ // Tile Tabs was the third takeover cleared here until #992 deleted it.
+
+ it('clears Spotlight and Reader that framed the removed tab', () => {
const { current, setters } = takeovers({
- tileTabs: { tabIds: ['a', 'b', 'c'], focusedTabId: 'b', direction: 'vertical', ratios: [1, 1, 1] },
spotlight: { tabId: 'b', focusedSessionId: 'b-root' },
readerMode: { tabId: 'b', focusedSessionId: 'b-root' },
})
clearRemovedTabTakeovers(setters, 'b')
- expect(current.tileTabs).toMatchObject({ tabIds: ['a', 'c'], focusedTabId: 'a' })
- expect(current.tileTabs?.ratios).toHaveLength(2)
expect(current.spotlight).toBeNull()
expect(current.readerMode).toBeNull()
})
- it('exits Tiled Tabs below two tabs and leaves takeovers of other tabs alone', () => {
+ it('leaves takeovers of other tabs alone', () => {
const spotlight = { tabId: 'a', focusedSessionId: 'a-root' }
const readerMode = { tabId: 'c', focusedSessionId: 'c-root' }
- const { current, setters } = takeovers({
- tileTabs: { tabIds: ['a', 'b'], focusedTabId: 'a', direction: 'horizontal', ratios: [1, 1] },
- spotlight,
- readerMode,
- })
+ const { current, setters } = takeovers({ spotlight, readerMode })
clearRemovedTabTakeovers(setters, 'b')
- expect(current.tileTabs).toBeNull()
expect(current.spotlight).toBe(spotlight)
expect(current.readerMode).toBe(readerMode)
})
diff --git a/src/renderer/src/workspace/hook/actions/tabRemoval.ts b/src/renderer/src/workspace/hook/actions/tabRemoval.ts
index 7f6dd3214..6f95a5e0b 100644
--- a/src/renderer/src/workspace/hook/actions/tabRemoval.ts
+++ b/src/renderer/src/workspace/hook/actions/tabRemoval.ts
@@ -1,25 +1,20 @@
import type {
- DispatchModeState,
SessionId,
TabId,
- TileTabsState,
WorkspaceState,
} from '@renderer/workspace/types'
-import { clearTiledLaneSessions } from '@renderer/workspace/dispatch/tiledDispatchSelectors'
-import { sanitizeTileTabsState } from '@renderer/workspace/layout/helpers'
+import { workspaceWithoutSessions } from '@renderer/workspace/pool'
import type {
WorkspaceSetReaderMode,
WorkspaceSetSpotlight,
- WorkspaceSetTileTabs,
} from '@renderer/workspace/hook/context'
// -----------------------------------------------------------------------------
// The ONE tab-removal tail (#153 acceptance: "root-pane close and tab close have
// consistent, documented semantics").
//
-// WHY this exists: a project tab disappears in exactly one place — when a close
-// operation commits the removal of the tab's last grid leaf and no Dispatch row
-// is left to promote (`closeApprovedTarget` in pane.ts). Both "Close Tab" entry
+// WHY this exists: a project disappears in exactly one way — a close operation
+// commits the removal of its last session (`closeApprovedTarget` in pane.ts). Both "Close Tab" entry
// points reach that commit through the SAME executor since #886 review round 2:
// the Close Tab command (⌘⇧W, the tab bar ×) and the root dialog's "Close Tab"
// button. Before that the command had a hand-written copy, and its removal
@@ -44,24 +39,11 @@ import type {
// place doing it.
// -----------------------------------------------------------------------------
-/**
- * Dispatch focus/lanes after a batch of sessions disappeared.
- *
- * Lanes are cleared first because a lane can hold a session that is not the
- * classic focus; a dangling lane id gets bounced to tile 0 by the auto-fill
- * effect. Classic focus is then cleared (not re-picked) so DispatchLayout's
- * fallback chooses a row in whatever scope remains.
- */
-export function dispatchModeAfterSessionRemovals(
- dispatchMode: DispatchModeState | null,
- removedSessionIds: ReadonlySet,
-): DispatchModeState | null {
- const cleared = clearTiledLaneSessions(dispatchMode, removedSessionIds)
- if (!cleared?.focusedSessionId || !removedSessionIds.has(cleared.focusedSessionId)) {
- return cleared
- }
- return { ...cleared, focusedSessionId: undefined }
-}
+// `dispatchModeAfterSessionRemovals` lived here until #992. It cleared lanes
+// and THEN cleared a classic-Dispatch focus that pointed at a removed session.
+// With the classic focus gone the whole job is `clearTiledLaneSessions`, so
+// the wrapper was deleted rather than kept as a one-line alias — a second name
+// for the same operation is how the two close paths drifted apart before.
/**
* Remove `tabId` and the given sessions from workspace state.
@@ -80,53 +62,30 @@ export function workspaceWithoutTab(
tabId: TabId,
removedSessionIds: Iterable,
): WorkspaceState {
- const tabIdx = prev.tabs.findIndex(tab => tab.id === tabId)
- // A tab already gone (a concurrent close won the race) still has its killed
- // sessions' metadata removed: those backends are dead either way, and leaving
- // their SessionMeta behind would describe agents nothing can show or close.
- const tabs = tabIdx < 0 ? prev.tabs : prev.tabs.filter((_, index) => index !== tabIdx)
- const removed = new Set(removedSessionIds)
- const sessions = { ...prev.sessions }
- const detachedSessions = { ...prev.detachedSessions }
- for (const id of removed) {
- delete sessions[id]
- delete detachedSessions[id]
- }
- return {
- ...prev,
- tabs,
- activeTabId: prev.activeTabId === tabId
- ? (tabs[Math.max(0, tabIdx - 1)]?.id ?? '')
- : prev.activeTabId,
- sessions,
- detachedSessions,
- dispatchMode: dispatchModeAfterSessionRemovals(prev.dispatchMode, removed),
- }
-}
-
-/** Tiled Tabs without a removed tab; sanitize exits the mode below two tabs. */
-export function tileTabsWithoutTab(prev: TileTabsState | null, tabId: TabId): TileTabsState | null {
- if (!prev) return prev
- return sanitizeTileTabsState({
- ...prev,
- tabIds: prev.tabIds.filter(id => id !== tabId),
- focusedTabId: prev.focusedTabId === tabId
- ? (prev.tabIds.find(id => id !== tabId) ?? prev.focusedTabId)
- : prev.focusedTabId,
- })
+ // The pool's one removal (pool.ts) does all of it: rows, lanes, pins, and
+ // the project — which goes because it is EMPTY, not because it was named.
+ // `tabId` is passed as "also remove if empty" so a project whose last
+ // session was already gone (a concurrent close won the race) still leaves.
+ //
+ // Until #992 this function filtered the tab out unconditionally and deleted
+ // the given rows from `sessions` and `detachedSessions` by hand. The
+ // unconditional half was safe only because its one caller had already proved
+ // the tab's tree was empty and no Dispatch row was left to promote. With
+ // ownership on the row that proof is a property of the data, so the helper
+ // checks it instead of trusting the caller: a project that still holds a
+ // session survives, because deleting it would orphan that session's backend.
+ return workspaceWithoutSessions(prev, removedSessionIds, [tabId])
}
/** Drop view takeovers that framed the removed tab. Called after the state
* commit so a refused removal never clears a takeover the user still has. */
export function clearRemovedTabTakeovers(
setters: {
- setTileTabs: WorkspaceSetTileTabs
setSpotlight: WorkspaceSetSpotlight
setReaderMode: WorkspaceSetReaderMode
},
tabId: TabId,
): void {
- setters.setTileTabs(prev => tileTabsWithoutTab(prev, tabId))
setters.setSpotlight(prev => (prev?.tabId === tabId ? null : prev))
setters.setReaderMode(prev => (prev?.tabId === tabId ? null : prev))
}
diff --git a/src/renderer/src/workspace/hook/actions/testing/paneActionsHarness.tsx b/src/renderer/src/workspace/hook/actions/testing/paneActionsHarness.tsx
index 824244d22..d4d640ea0 100644
--- a/src/renderer/src/workspace/hook/actions/testing/paneActionsHarness.tsx
+++ b/src/renderer/src/workspace/hook/actions/testing/paneActionsHarness.tsx
@@ -1,6 +1,7 @@
import type { MutableRefObject } from 'react'
import { render } from '@testing-library/react'
import { vi } from 'vitest'
+import type { SessionRuntime } from '@renderer/session-runtime/state'
import { UndoCloseStack } from '@renderer/lib/undoClose'
import type { SessionActions } from '@renderer/workspace/hook/actions/session'
@@ -11,7 +12,6 @@ import type {
WorkspaceSetRuntimes,
WorkspaceSetSpotlight,
WorkspaceSetState,
- WorkspaceSetTileTabs,
} from '@renderer/workspace/hook/context'
import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
import type { WorkspaceState } from '@renderer/workspace/types'
@@ -37,7 +37,6 @@ export function makeRefs(state: WorkspaceState): WorkspaceRefs {
stateRef: ref(state),
latestStateRef: ref(state),
latestRuntimesRef: ref({}),
- latestTileTabsRef: ref(null),
dangerousAgentsRef: ref(false),
useProxyStreamingRef: ref(false),
defaultBuiltInMcpDomainsRef: ref([]),
@@ -124,20 +123,27 @@ export function mountPaneActions(
const sessionActions = sessionActionsWithSpawn(spawn)
let actions!: ReturnType
+ // A REAL runtime store, not a no-op: spawn paths write the pooled-spawn
+ // badge (#992 §4.3) through setRuntimes, and a harness that swallowed the
+ // updater would let a spec assert "no badge" for a reason that is the
+ // harness, not the action. Same synchronous-apply contract as the state
+ // writer.
+ let runtimes: Record = {}
+ const setRuntimes: WorkspaceSetRuntimes = next => {
+ runtimes = typeof next === 'function' ? next(runtimes) : next
+ }
+
function Harness(): React.JSX.Element {
actions = usePaneActions(
initialState,
writer.setState,
- (() => undefined) as WorkspaceSetRuntimes,
+ setRuntimes,
(() => undefined) as WorkspaceSetSpotlight,
- (() => undefined) as WorkspaceSetTileTabs,
(() => undefined) as WorkspaceSetReaderMode,
refs,
showToast,
vi.fn(),
vi.fn(),
- vi.fn(),
- vi.fn(),
sessionActions,
)
return
@@ -156,6 +162,7 @@ export function mountPaneActions(
sessionActions,
getState: writer.getState,
setState: writer.setState,
+ runtimes: () => runtimes,
}
}
diff --git a/src/renderer/src/workspace/hook/actions/tileTabs.ts b/src/renderer/src/workspace/hook/actions/tileTabs.ts
deleted file mode 100644
index 3a4804e8b..000000000
--- a/src/renderer/src/workspace/hook/actions/tileTabs.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { useCallback } from 'react'
-
-import type { SplitDirection, TabId } from '@renderer/workspace/types'
-import { equalRatios, normalizeRatios } from '@renderer/workspace/layout/helpers'
-
-import type {
- WorkspaceSetSpotlight,
- WorkspaceSetState,
- WorkspaceSetTileTabs,
-} from '@renderer/workspace/hook/context'
-import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
-
-// Tile-tabs actions. Opening a tile-tab set tiles multiple tabs side
-// by side inside a single tab view (meta-tabs). Focus + resize here
-// operates on the tile-tabs slice, NOT the regular tab bar.
-
-export function useTileTabsActions(
- setTileTabs: WorkspaceSetTileTabs,
- setSpotlight: WorkspaceSetSpotlight,
- setState: WorkspaceSetState,
- refs: WorkspaceRefs,
-): {
- openTileTabs: (tabIds: TabId[], direction?: SplitDirection) => void
- closeTileTabs: () => void
- focusTiledTab: (tabId: TabId) => void
- focusTiledTabByIndex: (index: number) => void
- resizeFocusedTiledTab: (delta: number) => void
- resizeTiledTabByIndex: (index: number, delta: number) => void
-} {
- const openTileTabs = useCallback(
- (tabIds: TabId[], direction: SplitDirection = 'vertical') => {
- const current = refs.stateRef.current
- const valid = tabIds.filter(id => current.tabs.some(t => t.id === id))
- if (valid.length < 2) return
- const focusedTabId = valid.includes(current.activeTabId)
- ? current.activeTabId
- : valid[0]
- setSpotlight(null)
- // Tile-tabs and Dispatch Mode are both top-level alternatives to
- // the normal grid. Letting both stay active made Dispatch persist
- // invisibly behind TileTabs, so entering one mode explicitly clears
- // the other.
- setTileTabs({
- tabIds: valid,
- focusedTabId,
- direction,
- ratios: equalRatios(valid.length),
- })
- setState(prev => ({
- ...prev,
- activeTabId: focusedTabId,
- dispatchMode: null,
- }))
- },
- [refs.stateRef, setSpotlight, setState, setTileTabs],
- )
-
- const closeTileTabs = useCallback(() => {
- setTileTabs(null)
- }, [setTileTabs])
-
- const focusTiledTab = useCallback(
- (tabId: TabId) => {
- setTileTabs(prev => (
- prev && prev.tabIds.includes(tabId)
- ? { ...prev, focusedTabId: tabId }
- : prev
- ))
- setState(prev => ({ ...prev, activeTabId: tabId }))
- },
- [setState, setTileTabs],
- )
-
- const focusTiledTabByIndex = useCallback(
- (index: number) => {
- setTileTabs(prev => {
- if (!prev) return prev
- const tabId = prev.tabIds[index]
- if (!tabId) return prev
- setState(statePrev => ({ ...statePrev, activeTabId: tabId }))
- return { ...prev, focusedTabId: tabId }
- })
- },
- [setState, setTileTabs],
- )
-
- const resizeFocusedTiledTab = useCallback(
- (delta: number) => {
- setTileTabs(prev => {
- if (!prev || prev.tabIds.length < 2) return prev
- const idx = prev.tabIds.indexOf(prev.focusedTabId)
- if (idx === -1) return prev
-
- const leftIndex = idx === prev.tabIds.length - 1 ? idx - 1 : idx
- const rightIndex = idx === prev.tabIds.length - 1 ? idx : idx + 1
- if (leftIndex < 0 || rightIndex >= prev.ratios.length) return prev
-
- const nextRatios = [...prev.ratios]
- const signedDelta = idx === prev.tabIds.length - 1 ? -delta : delta
- const nextLeft = nextRatios[leftIndex] + signedDelta
- const nextRight = nextRatios[rightIndex] - signedDelta
- const minRatio = 0.12
- if (nextLeft < minRatio || nextRight < minRatio) return prev
-
- nextRatios[leftIndex] = nextLeft
- nextRatios[rightIndex] = nextRight
- return {
- ...prev,
- ratios: normalizeRatios(nextRatios),
- }
- })
- },
- [setTileTabs],
- )
-
- const resizeTiledTabByIndex = useCallback(
- (index: number, delta: number) => {
- setTileTabs(prev => {
- if (!prev || prev.tabIds.length < 2) return prev
- if (index < 0 || index >= prev.tabIds.length) return prev
-
- const leftIndex = index === prev.tabIds.length - 1 ? index - 1 : index
- const rightIndex = index === prev.tabIds.length - 1 ? index : index + 1
- if (leftIndex < 0 || rightIndex >= prev.ratios.length) return prev
-
- const nextRatios = [...prev.ratios]
- const signedDelta = index === prev.tabIds.length - 1 ? -delta : delta
- const nextLeft = nextRatios[leftIndex] + signedDelta
- const nextRight = nextRatios[rightIndex] - signedDelta
- const minRatio = 0.12
- if (nextLeft < minRatio || nextRight < minRatio) return prev
-
- nextRatios[leftIndex] = nextLeft
- nextRatios[rightIndex] = nextRight
- return {
- ...prev,
- ratios: normalizeRatios(nextRatios),
- }
- })
- },
- [setTileTabs],
- )
-
- return {
- openTileTabs,
- closeTileTabs,
- focusTiledTab,
- focusTiledTabByIndex,
- resizeFocusedTiledTab,
- resizeTiledTabByIndex,
- }
-}
diff --git a/src/renderer/src/workspace/hook/actions/undoClose.ts b/src/renderer/src/workspace/hook/actions/undoClose.ts
index 5bfdd98b5..0625c9cb4 100644
--- a/src/renderer/src/workspace/hook/actions/undoClose.ts
+++ b/src/renderer/src/workspace/hook/actions/undoClose.ts
@@ -3,26 +3,20 @@ import { DEFAULT_PROVIDER, isAgentSessionKind } from '@shared/types/providerKind
import { useCallback, useState } from 'react'
import type {
- DetachedSessionRecord,
SessionId,
SessionKind,
SessionMeta,
Tab,
- TabId,
} from '@renderer/workspace/types'
-import { collectLeaves, remapTileTreeSessionIds } from '@renderer/workspace/tile-tree/treeOps'
import { remapTiledLanes } from '@renderer/workspace/dispatch/tiledDispatchSelectors'
import {
- missingClosedTabLeafMetaIds,
- reinsertPane,
remapMetaLineage,
remapSingleEntryLineage,
} from '@renderer/lib/undoClose'
import type {
- ClosedDetached,
ClosedEntry,
ClosedGroup,
- ClosedPane,
+ ClosedSession,
ClosedTab,
SingleClosedEntry,
UndoLineage,
@@ -43,7 +37,7 @@ type PublishLineage = (lineage: UndoLineage) => void
/**
* The durable metadata a respawn cannot rebuild, carried onto the new session ID.
*
- * WHY all three restore paths must share one answer: `sessionActions.spawn`
+ * WHY every restore path must share one answer: `sessionActions.spawn`
* builds SessionMeta from {cwd, kind, providerRuntime, tmuxName,
* providerSessionId, builtInMcpDomains} only (session.ts:339-345). Everything
* else a session owned — its user-authored
@@ -96,24 +90,30 @@ function carryDurableMeta(spawned: SessionMeta | undefined, closed: SessionMeta)
// sets it, so without carrying it an undone orchestration child would
// re-run its bootstrap handoff.
...(closed.orchestrationBootstrapPromptDelivered ? { orchestrationBootstrapPromptDelivered: true } : {}),
+ // Pool membership (#992). `spawn` writes an UN-FILED row — no project, no
+ // position — so without these the restored session would be unowned
+ // metadata that no index lists and the next autosave drops. `joinedAt` is
+ // carried verbatim for the reason the v2 entries carried `detachedAt`: it
+ // alone orders rows inside a project, and undo puts things BACK.
+ ...(closed.projectId !== undefined ? { projectId: closed.projectId } : {}),
+ ...(closed.joinedAt !== undefined ? { joinedAt: closed.joinedAt } : {}),
}
}
// Undo-close action. Pops the most recent entry from the undo stack
// and restores it.
//
-// For panes: finds the surviving sibling in the current tree by its
-// anchor leaf, re-wraps it in a split with the restored session on
-// the correct side, and respawns the session (with --resume for
-// Claude sessions so the conversation comes back).
+// For a session: respawns it (with --resume for an agent so the conversation
+// comes back, `recoverTmuxName` for a terminal so its still-alive tmux session
+// is re-attached) and files it back under its project at its old position.
+// The only anchor that has to still exist is the project.
//
-// For tabs: respawns every session in the tab, remaps the session
-// ids in the tree (since the new spawn produces new ids), and
-// re-inserts the tab at its original index (clamped to bounds).
+// For a project: re-creates it at its original index (clamped to bounds) under
+// a NEW id, and respawns the sessions that closed with it.
//
-// For detached Dispatch rows: respawns the session and re-files its
-// `detachedSessions` record. There is no tree work to do — the record IS the
-// placement — so the only anchor that has to still exist is the project tab.
+// (Until #992 there were three paths — a split pane re-inserted beside its
+// surviving sibling, a Dispatch row re-filed from its record, and a tab whose
+// tree was remapped leaf by leaf. See lib/undoClose.ts.)
export function useUndoCloseAction(
_state: { tabs: Tab[] },
@@ -126,300 +126,172 @@ export function useUndoCloseAction(
} {
const [, bumpUndoCloseVersion] = useState(0)
- const restorePaneEntry = useCallback(
- async (entry: ClosedPane, publish: PublishLineage): Promise => {
- // Find which tab the sibling leaf is in now.
- const targetTab = refs.stateRef.current.tabs.find(t =>
- collectLeaves(t.root).includes(entry.siblingLeafId),
- )
- if (!targetTab) return 'stale' // sibling was also closed — stale undo
-
- // Probe before spawning because a pane entry's only trustworthy
- // placement is its sibling anchor. If that anchor cannot produce
- // a new tree, spawning first would create a replacement session
- // with nowhere visible to attach it. We still re-run reinsert in
- // the state updater below because the tree can change between
- // this event handler and React applying the update; the probe is
- // the cheap guard that handles the normal stale-entry case.
- const probeRoot = reinsertPane(
- targetTab.root,
- entry.siblingLeafId,
- '__undo_close_probe__' as SessionId,
- entry.direction,
- entry.ratio,
- entry.side,
- )
- if (!probeRoot) return 'stale'
+ // Respawn one closed session, or mint an id for a process-less one.
+ //
+ // ── A PROCESS-LESS SESSION IS RESTORED, NOT SPAWNED ──
+ // Undo once called spawn() for every kind. Main rejects an extension-view
+ // spawn outright, the caller turned that into 'retryable-failure', and
+ // undoClose PUSHES A FAILED ENTRY BACK — so the stack head became permanently
+ // poisoned: every later Cmd+Shift+T popped the same entry, failed, re-pushed,
+ // and returned, and all older undo history was unreachable for the rest of
+ // the session. Minting the id mirrors openExtensionViewInPane; there is
+ // nothing to recover because there was never a process.
+ //
+ // Returns null on a spawn failure. `spawned` says whether a backend now
+ // exists that a bail-out must kill.
+ const respawn = useCallback(
+ async (meta: SessionMeta): Promise<{ sessionId: SessionId; spawned: boolean } | null> => {
+ const kind: SessionKind = meta.kind ?? DEFAULT_PROVIDER
+ if (kind === 'extension-view') {
+ return { sessionId: crypto.randomUUID() as SessionId, spawned: false }
+ }
+ try {
+ const sessionId = await sessionActions.spawn(meta.cwd, {
+ kind,
+ ...(meta.providerRuntime ? { providerRuntime: meta.providerRuntime } : {}),
+ // - an agent with a durable transcript resumes it;
+ // - a terminal with tmuxName re-attaches the same tmux session,
+ // preserving scrollback and any running process. Without this,
+ // "undo close" on a terminal would respawn an empty shell —
+ // defeating the point of having a tmux backing.
+ resumeSessionId: isAgentSessionKind(kind) ? resumableProviderSessionId(meta) : undefined,
+ recoverTmuxName: kind === 'terminal' ? meta.tmuxName : undefined,
+ // WHY capability intent is restored but credentials are not: closing
+ // a session revokes its token. Undo must ask main to mint a fresh one
+ // from the session's durable choices; dropping them makes an
+ // undo-restored transcript silently lose tools. The effective list is
+ // deliberately not restored — the restored session is a NEW provider
+ // process, so it resolves those choices against current Settings.
+ tldrIdentity: meta.tldrIdentity,
+ builtInMcpOverrides: sessionMcpOverrides(meta),
+ })
+ return { sessionId, spawned: true }
+ } catch {
+ return null
+ }
+ },
+ [sessionActions],
+ )
- // Respawn the session.
- // - Claude/Codex with providerSessionId → pass --resume so
- // the conversation history replays via JSONL.
- // - Terminal with tmuxName → pass recoverTmuxName so the
- // same tmux session is re-attached, preserving scrollback
- // and any running process. Without this, "undo close" on
- // a terminal would respawn an empty shell — defeating the
- // point of having a tmux backing.
+ const restoreSessionEntry = useCallback(
+ async (entry: ClosedSession, publish: PublishLineage): Promise => {
+ // The project is a session's only anchor: the index files each row under
+ // its `projectId`, so a row whose project is gone renders nowhere —
+ // restoring it would spawn a live backend the user can never see or
+ // close. Treat that as stale.
+ //
+ // We deliberately do NOT re-home the session into some surviving
+ // project: a close that REMOVED the project recorded a 'tab' entry, so a
+ // missing project here means the user closed or merged the project
+ // itself afterwards, and undoing THAT is the correct recovery.
const meta = entry.sessionMeta
- let newSessionId: SessionId
- if (meta.kind === 'extension-view') {
- // ── A PROCESS-LESS LEAF IS RESTORED, NOT SPAWNED ──
- // Undo previously called spawn() for every kind. Main rejects an
- // extension-view spawn outright, the catch below turned that into
- // 'retryable-failure', and undoClose PUSHES A FAILED ENTRY BACK — so the
- // stack head became permanently poisoned. Every later Cmd+Shift+T popped the
- // same entry, failed, re-pushed, and returned: all older undo history became
- // unreachable for the rest of the session.
- //
- // Minting the id mirrors openExtensionViewInPane; there is nothing to
- // recover because there was never a process. The METADATA is written in the
- // setState below — see the note there, because getting that half wrong is
- // worse than the bug this branch was added to fix.
- newSessionId = crypto.randomUUID() as SessionId
- } else {
- try {
- newSessionId = await sessionActions.spawn(meta.cwd, {
- kind: meta.kind ?? DEFAULT_PROVIDER,
- ...(meta.providerRuntime ? { providerRuntime: meta.providerRuntime } : {}),
- resumeSessionId: resumableProviderSessionId(meta),
- recoverTmuxName: meta.kind === 'terminal' ? meta.tmuxName : undefined,
- // WHY capability intent is restored but credentials are not: closing a pane revokes its
- // session token. Undo must ask main to mint a fresh token from the pane's durable
- // choices; dropping them makes an undo-restored transcript silently lose tools. The
- // effective list is deliberately not restored — the restored pane is a NEW provider
- // process, so it resolves those choices against current Settings like any other launch.
- tldrIdentity: meta.tldrIdentity,
- builtInMcpOverrides: sessionMcpOverrides(meta),
- })
- } catch {
- return 'retryable-failure'
- }
- }
+ const projectId = meta.projectId
+ if (projectId === undefined) return 'stale'
+ if (!refs.stateRef.current.tabs.some(tab => tab.id === projectId)) return 'stale'
+
+ const respawned = await respawn(meta)
+ if (!respawned) return 'retryable-failure'
+ const newSessionId = respawned.sessionId
- let inserted = false
+ // Set inside the updater and read after. Sound because setState is the
+ // zustand store setter, which applies updaters synchronously — NOT a
+ // React useState setter, whose updater would still be pending here.
+ let refiled = false
setState(prev => {
- const tabs = prev.tabs.map(t => {
- if (t.id !== targetTab.id) return t
- const newRoot = reinsertPane(
- t.root,
- entry.siblingLeafId,
- newSessionId,
- entry.direction,
- entry.ratio,
- entry.side,
- )
- if (!newRoot) return t // anchor not found — bail
- inserted = true
- return {
- ...t,
- root: newRoot,
- focusedSessionId: newSessionId,
- }
- })
- // Only patch metadata for a placement that actually happened: the
- // `!inserted` branch below kills the session, and writing durable
- // fields for an id we are about to remove would leave the workspace
- // describing an agent nothing points at.
- if (!inserted) return { ...prev, tabs }
+ // Re-check inside the updater: the project can be closed between the
+ // awaited spawn and here. Returning `prev` unchanged would strand the
+ // session, so fall through to the kill below instead.
+ if (!prev.tabs.some(tab => tab.id === projectId)) return prev
+ refiled = true
return {
...prev,
- tabs,
- // This path used to touch `tabs` and nothing else, so a restored
- // pane came back with only what `spawn` could rebuild — losing its
- // title and, once names existed, its spoken identity. See
- // carryDurableMeta for why that re-addresses a live agent.
+ // The project the row came back into becomes the active one: that is
+ // where the user's attention now is, and it is what every path that
+ // files a session has always done.
+ activeTabId: projectId,
sessions: {
...prev.sessions,
+ // carryDurableMeta restores membership too — `projectId` and,
+ // critically, `joinedAt`, so the row returns to its old position
+ // in the index rather than jumping to the bottom.
[newSessionId]: carryDurableMeta(prev.sessions[newSessionId], meta),
},
+ // The stage is deliberately NOT written. The close emptied the lane
+ // that showed this session, so the visible result of an undo is the
+ // row reappearing at its old position in the index — back in the
+ // pool, one keystroke from any lane — not a lane takeover.
+ // Restoring the lane would need the lane index captured at close
+ // time; deliberately not done, because a lane the user has since
+ // re-aimed should not be yanked back by an undo (U2).
}
})
- if (!inserted) {
- // WHY we kill the just-spawned session here:
+ if (!refiled) {
+ // No backend or renderer metadata was created for an unfiled view.
+ if (!respawned.spawned) return 'stale'
+ // The spawn already registered a live backend, so a filing that did
+ // not happen must not leave it running with no row pointing at it.
//
- // The preflight probe above catches the normal stale-anchor case, but
- // React/Zustand state can still change between the probe and the
- // updater. If the anchor vanishes in that window, the session was
- // successfully created but has no visible tile-tree owner. Leaving it
- // alive would produce a hidden process that cannot be focused or
- // closed from the UI, so failed insertion must undo the spawn before
- // the undo loop walks to older entries.
- if (meta.kind !== 'extension-view') {
- await sessionActions.killSession(newSessionId).catch(() => undefined)
- }
+ // Kill with the kind/cwd resolved above rather than leaving it to
+ // killSession's ownership proof, which re-reads them from
+ // `refs.stateRef`. History: that ref used to be a render-body mirror,
+ // so immediately after an awaited spawn it did not contain the new
+ // session, the proof failed, and the kill silently no-opped —
+ // stranding exactly the backend this bail exists to reclaim. #886 made
+ // stateRef a synchronous store subscription; the explicit owner stays
+ // as defense in depth.
+ await window.api
+ .killOwnedSession({ sessionId: newSessionId, kind: meta.kind ?? DEFAULT_PROVIDER, cwd: meta.cwd })
+ .catch(() => undefined)
+ // `.catch`: killSession reaches an IPC invoke that can reject, and this
+ // bail runs with the entry already POPPED — a throw here would lose the
+ // entry, skip bumpUndoCloseVersion so the palette's count stays stale,
+ // and escape into the keybinding handler.
+ await sessionActions.killSession(newSessionId).catch(() => undefined)
return 'stale'
}
-
- // Older entries may anchor on this pane (a sibling split's
- // siblingLeafId, a linked child's parent). Publish old -> new so they
- // keep resolving; see UndoLineage.
- if (entry.sessionId) {
- publish({
- sessions: new Map([[entry.sessionId, newSessionId]]),
- })
- }
+ // The closed session is back under a new id. An older entry may point at
+ // it — a linked child closed earlier names it as `linkedParentId` — so
+ // publish old -> new; see UndoLineage.
+ publish({ sessions: new Map([[entry.sessionId, newSessionId]]) })
return 'restored'
},
- [refs.stateRef, refs.undoStackRef, sessionActions, setState],
+ [refs.stateRef, respawn, sessionActions, setState],
)
const restoreTabEntry = useCallback(
async (entry: ClosedTab, publish: PublishLineage): Promise => {
- // Tab undo: respawn every session and remap the tree.
+ if (entry.sessions.length === 0) return 'stale'
+ const restoredTab: Tab = { id: crypto.randomUUID(), title: entry.tab.title }
+
+ // Old -> new for EVERY session this restore brings back. It does two
+ // jobs: (1) a linked child restored beside its parent must point at the
+ // parent's NEW id — carryDurableMeta copies the closed `linkedParentId`
+ // verbatim, which used to leave restored children un-nested and no
+ // longer cascading; (2) it is published to the stack so older entries
+ // anchored on this project or its sessions still resolve.
const idMap = new Map()
- // New session ID → the meta the closed session actually had, for both
- // the tile-tree loop and the detached loop below. Applied through
- // carryDurableMeta in the commit at the end.
- //
- // This REPLACES a `freshSessions: Record` that
- // was populated here and never read by anything — so a tab undo lost the
- // same durable fields the pane undo did, and the map that looked like it
- // was preventing that was dead code.
+ // New session id -> the meta the closed session actually had; applied
+ // through carryDurableMeta in the commit at the end.
const carried = new Map()
- const spawnedIds: SessionId[] = []
- const requiredLeafIds = collectLeaves(entry.tab.root)
- if (missingClosedTabLeafMetaIds(entry).length > 0) {
- return 'stale'
- }
-
- for (const oldId of requiredLeafIds) {
- const meta = entry.sessionMetas[oldId]
- if (!meta) {
- return 'stale'
- }
- // Same process-less restore as the pane branch, and it matters MORE here:
- // this loop spawns leaves in order, so hitting an extension-view leaf used to
- // throw partway through, and the catch below then killed every sibling it had
- // just spawned. Undoing a tab that contained one extension pane started N real
- // claude/codex processes and their proxies, killed them all, restored nothing,
- // and poisoned the undo stack.
- if (meta.kind === 'extension-view') {
- const newId = crypto.randomUUID() as SessionId
- idMap.set(oldId, newId)
- carried.set(newId, meta)
- // Deliberately NOT pushed to spawnedIds: there is no process to kill on
- // rollback, and adding it would make the failure path try to terminate one.
- continue
- }
- try {
- const kind: SessionKind = meta.kind ?? DEFAULT_PROVIDER
- // Same per-kind recover hint as the pane-undo branch
- // above: tmuxName for terminals, providerSessionId for
- // agents.
- const newId = await sessionActions.spawn(meta.cwd, {
- kind,
- ...(meta.providerRuntime ? { providerRuntime: meta.providerRuntime } : {}),
- resumeSessionId: isAgentSessionKind(kind) ? resumableProviderSessionId(meta) : undefined,
- recoverTmuxName: kind === 'terminal' ? meta.tmuxName : undefined,
- tldrIdentity: meta.tldrIdentity,
- builtInMcpOverrides: sessionMcpOverrides(meta),
- })
- idMap.set(oldId, newId)
- carried.set(newId, meta)
- spawnedIds.push(newId)
- } catch {
- // WHY grid leaves are all-or-nothing while detached entries below
- // remain best-effort:
- //
- // A tab's tile tree cannot contain missing leaves. Leaving an old
- // session id in the restored tree creates a phantom pane with no
- // runtime, which is worse than not restoring. Detached dispatch
- // sessions are outside the tree, so they can still be restored
- // opportunistically after the tab itself is valid.
- for (const spawnedId of spawnedIds) {
- await sessionActions.killSession(spawnedId).catch(() => undefined)
- }
- return 'retryable-failure'
- }
- }
-
- if (idMap.size === 0) return 'retryable-failure' // nothing survived
-
- const restoredRoot = remapTileTreeSessionIds(entry.tab.root, idMap)
- const leaves = collectLeaves(restoredRoot)
- if (leaves.length === 0) return 'retryable-failure'
-
- const restoredFocused =
- idMap.get(entry.tab.focusedSessionId) ?? leaves[0]
- const restoredTab: Tab = {
- id: crypto.randomUUID(),
- title: entry.tab.title,
- root: restoredRoot,
- focusedSessionId: restoredFocused,
- }
- // Re-spawn any detached dispatch agents that were associated
- // with this tab at close time. Done AFTER the tile-tree spawn
- // loop so the restored tab id is already known — DetachedSessionRecord
- // carries projectTabId, which has to point at the NEW tab id
- // (the old one is gone). Failed spawns are skipped for the same
- // reason as the tile-tree loop above: restore what we can.
+ // Restore what we can. A project is just a group of sessions, so one
+ // that fails to respawn (a provider hiccup on one agent out of nine)
+ // must not cost the user the other eight.
//
- // Note: sessionActions.spawn registers SessionMeta into
- // state.sessions itself, so the metas land there through the spawn
- // call's own setState, before our setState below runs. What it CANNOT
- // rebuild is tracked in `carried` and re-applied over the top — see
- // carryDurableMeta.
- const restoredDetached: Record = {}
- // Old -> new for EVERY session this restore brings back, grid and
- // detached. It does two jobs: (1) a linked child restored beside its
- // parent must point at the parent's NEW id — carryDurableMeta copies the
- // closed `linkedParentId` verbatim, which used to leave restored children
- // un-nested and no longer cascading; (2) it is published to the stack so
- // older entries anchored on this tab or its sessions still resolve.
- const lineageSessions = new Map(idMap)
- for (const detached of entry.detachedEntries ?? []) {
- try {
- const kind: SessionKind = detached.meta.kind ?? DEFAULT_PROVIDER
- // Detached dispatch sessions CAN be terminals: Dispatch terminal
- // creation files a detached row like any other kind (#671), and
- // `detachFocusedToDispatch` has always allowed a grid terminal to be
- // parked in Dispatch. Gating the recover hint on kind is what lets a
- // restored terminal re-attach its tmux session instead of coming back
- // as a fresh shell with the user's scrollback gone.
- // A detached extension has the same processless identity as a grid
- // extension. The two loops must agree: otherwise undo restores the
- // tab but silently drops every extension parked in Dispatch.
- const newId = kind === 'extension-view'
- ? crypto.randomUUID() as SessionId
- : await sessionActions.spawn(detached.meta.cwd, {
- kind,
- ...(detached.meta.providerRuntime
- ? { providerRuntime: detached.meta.providerRuntime }
- : {}),
- resumeSessionId: isAgentSessionKind(kind)
- ? resumableProviderSessionId(detached.meta)
- : undefined,
- recoverTmuxName: kind === 'terminal' ? detached.meta.tmuxName : undefined,
- tldrIdentity: detached.meta.tldrIdentity,
- builtInMcpOverrides: sessionMcpOverrides(detached.meta),
- })
- // A detached child restored with its tab is the same population
- // restoreDetachedEntry covers on its own, so it gets the same
- // durable metadata; the two routes back to one row must not
- // disagree about whether the agent keeps its name.
- carried.set(newId, detached.meta)
- if (detached.sessionId) lineageSessions.set(detached.sessionId, newId)
- restoredDetached[newId] = {
- sessionId: newId,
- surface: 'dispatch',
- projectTabId: restoredTab.id,
- projectTabTitle: restoredTab.title,
- // projectTabIndex is a display ordinal recomputed at render
- // time by buildDispatchGroups (state.tabs.findIndex(...)),
- // so any value here gets overwritten on next render. Use
- // entry.tabIndex as the seed; it's correct as long as no
- // other tabs were inserted before our splice index.
- projectTabIndex: entry.tabIndex,
- // Preserve the original detachedAt so the dispatch row's age
- // display (e.g. "4h" since detached) doesn't snap back to
- // "just now" on undo.
- detachedAt: detached.detachedAt,
- }
- } catch {
- // Same restore-what-we-can policy as the tile-tree spawn loop above.
- }
+ // Until #992 the tab's tile-tree leaves were ALL-OR-NOTHING here (a tree
+ // cannot contain a missing leaf, so one failed spawn killed every
+ // sibling it had just started and pushed the entry back) while its
+ // detached rows were best-effort. With no tree there is nothing a
+ // missing session could corrupt, so the gentler rule covers everyone.
+ for (const member of entry.sessions) {
+ const respawned = await respawn(member.meta)
+ if (!respawned) continue
+ idMap.set(member.sessionId, respawned.sessionId)
+ carried.set(respawned.sessionId, member.meta)
}
+ // Nothing came back: the entry is still good, the provider is not.
+ if (idMap.size === 0) return 'retryable-failure'
setState(prev => {
const insertIdx = Math.min(entry.tabIndex, prev.tabs.length)
@@ -427,243 +299,55 @@ export function useUndoCloseAction(
tabs.splice(insertIdx, 0, restoredTab)
const sessions = { ...prev.sessions }
for (const [newId, closed] of carried) {
- // Relationship pointers follow the restore: a linked child restored
- // with this tab points at its parent's NEW id, so it renders nested
- // and cascades again (see lineageSessions above).
- sessions[newId] = remapMetaLineage(carryDurableMeta(sessions[newId], closed), lineageSessions)
+ sessions[newId] = {
+ // Relationship pointers follow the restore: a linked child
+ // restored with this project points at its parent's NEW id.
+ ...remapMetaLineage(carryDurableMeta(sessions[newId], closed), idMap),
+ // The old project id is dead; membership moves to the new one.
+ // `joinedAt` rides through carryDurableMeta, so the restored
+ // project lists its sessions in the order it used to.
+ projectId: restoredTab.id,
+ }
}
return {
...prev,
tabs,
sessions,
activeTabId: restoredTab.id,
- detachedSessions: { ...prev.detachedSessions, ...restoredDetached },
- // Restored sessions get fresh ids (idMap); remap any tiled lane that
- // pointed at the closed tab's sessions so the lane follows the
- // restored agent instead of dangling at a dead id.
- dispatchMode: remapTiledLanes(prev.dispatchMode, idMap),
+ // Restored sessions get fresh ids; remap any lane that still named
+ // one of the closed ids so the lane follows the restored agent
+ // instead of dangling. (The close normally emptied those lanes, so
+ // this is a no-op unless something re-aimed a lane at a dead id.)
+ stage: remapTiledLanes(prev.stage, idMap),
}
})
- // The closed tab is back as a NEW tab id with NEW session ids. An older
- // entry anchored on it — typically the Close Agent entry of the root this
- // tab's root had replaced — must now name the restored ids, or the next
- // undo judges it stale and the original root is lost (#886 finding 4).
+ // The project is back under a NEW id with NEW session ids. An older
+ // entry anchored on it — a session closed from this project before the
+ // project itself went — must now name the restored ids, or the next undo
+ // judges it stale and that session is lost (#886 finding 4).
publish({
- sessions: lineageSessions,
+ sessions: idMap,
tabs: new Map([[entry.tab.id, restoredTab.id]]),
})
return 'restored'
},
- [refs.undoStackRef, sessionActions, setState],
- )
-
- const restoreDetachedEntry = useCallback(
- async (entry: ClosedDetached, publish: PublishLineage): Promise => {
- // The project tab is a detached row's only anchor. `buildDispatchGroups`
- // walks `state.tabs` and files each detached record under its
- // `projectTabId`, so a record whose tab is gone renders in no group at
- // all — restoring it would spawn a live backend the user can never see
- // or close. Treat that as stale, the same judgement restorePaneEntry
- // makes when its sibling anchor is gone.
- //
- // We deliberately do NOT re-home the row into some surviving tab: the
- // tab-close undo path already restores detached children as part of
- // restoring their tab, so a missing tab here means the user closed the
- // project itself and undoing THAT is the correct recovery.
- const targetTab = refs.stateRef.current.tabs.find(
- tab => tab.id === entry.record.projectTabId,
- )
- if (!targetTab) return 'stale'
-
- const meta = entry.sessionMeta
- const kind: SessionKind = meta.kind ?? DEFAULT_PROVIDER
- let newSessionId: SessionId
- if (kind === 'extension-view') {
- // Main correctly rejects spawning an extension. Treating that as a
- // retryable provider failure puts this entry back forever, hiding all
- // older undo history. Restoring its saved metadata is the whole wake.
- newSessionId = crypto.randomUUID() as SessionId
- } else {
- try {
- // Same respawn contract as restorePaneEntry: --resume for an agent with
- // a durable transcript, `recoverTmuxName` for a terminal so the still
- // alive tmux session is re-attached rather than replaced by an empty
- // shell. The latter is the whole reason this entry type exists.
- newSessionId = await sessionActions.spawn(meta.cwd, {
- kind,
- ...(meta.providerRuntime ? { providerRuntime: meta.providerRuntime } : {}),
- resumeSessionId: isAgentSessionKind(kind)
- ? resumableProviderSessionId(meta)
- : undefined,
- recoverTmuxName: kind === 'terminal' ? meta.tmuxName : undefined,
- tldrIdentity: meta.tldrIdentity,
- builtInMcpOverrides: sessionMcpOverrides(meta),
- })
- } catch {
- return 'retryable-failure'
- }
- }
-
- // Set inside the updater and read after. Sound because setState is the
- // zustand store setter, which applies updaters synchronously — NOT a
- // React useState setter, whose updater would still be pending here.
- //
- // History worth keeping: this was deliberately not a `refs.stateRef`
- // read because that ref used to be a render-body mirror that lagged an
- // awaited continuation, so it reported a successful restore as a failure
- // and killed the session just revived. #886 subscribed stateRef to the
- // store synchronously, so it no longer lags; the updater-local flag stays
- // because it is correct by construction however the ref is wired (the
- // test harnesses drive refs by hand).
- let refiled = false
- setState(prev => {
- // Re-check inside the updater: the tab can be closed between the
- // awaited spawn and here. Returning `prev` unchanged would strand the
- // session, so fall through to the kill below instead.
- const tabIndex = prev.tabs.findIndex(tab => tab.id === entry.record.projectTabId)
- if (tabIndex < 0) return prev
- refiled = true
- const tab = prev.tabs[tabIndex]
- const promoted = entry.replacedRoot
- const restoreRoot = promoted && tab.root.type === 'leaf' &&
- tab.root.sessionId === promoted.sessionId && prev.sessions[promoted.sessionId]
- // Undo changes the root role back only if nobody has rearranged it.
- // The survivor stays the same live backend and regains its old
- // Dispatch record. A later split wins; then restore just the row.
- const detachedSessions = { ...prev.detachedSessions }
- if (restoreRoot) {
- detachedSessions[promoted.sessionId] = {
- // Verbatim for `detachedAt`: it alone orders rows inside a project
- // group, so the survivor returns to its old position rather than
- // the bottom of the list.
- ...promoted,
- // projectTabTitle/Index are display copies recomputed on render.
- // Refresh them exactly like the branch below: the tab may have been
- // renamed or moved while this entry waited on the stack, and a
- // record read before the next render must not carry close-time
- // values (#886 review m10).
- projectTabTitle: tab.title,
- projectTabIndex: tabIndex,
- }
- } else {
- detachedSessions[newSessionId] = {
- // The spawn minted a new local id; everything else about the
- // record — project affinity and, critically, `detachedAt` — is
- // restored verbatim so the row returns to its old position in the
- // Dispatch list rather than jumping to the bottom.
- ...entry.record,
- sessionId: newSessionId,
- // Display copies, refreshed for the reason given above.
- projectTabTitle: tab.title,
- projectTabIndex: tabIndex,
- }
- }
- return {
- ...prev,
- // Every other path that files a detached row makes its tab active in
- // the same updater — splitFocused's Dispatch branch,
- // createDetachedDispatchAgent, and restoreTabEntry above — because
- // buildDispatchGroups filters `sourceTabs` to `activeTabId` outside
- // global scope. Without this, undoing a row that belongs to a
- // different project tab spawns a live backend that renders NOWHERE:
- // the row is filed correctly but filtered out of the list, and
- // ClassicDispatchLayout's focus effect immediately overwrites the
- // focus we set below with whatever row is actually visible. There is
- // no toast on this path, so the user sees undo do nothing while an
- // agent (or a re-attached tmux session) runs invisibly.
- activeTabId: entry.record.projectTabId,
- // The fields that make a session a LINKED or ORCHESTRATION child —
- // and any user-authored title, and its spoken identity — are durable
- // metadata `spawn` never sees. Those children are always detached, so
- // they are precisely the population this undo path covers: without
- // this patch an undone linked child returns un-nested
- // (buildDispatchGroups reads `linkedParentId` to indent it) and stops
- // cascading when its parent closes, and a titled row silently
- // relabels to its cwd basename. This used to be a hand-written
- // allowlist here; it is now the shared carryDurableMeta, so the pane
- // and tab undo paths cannot drift from it again.
- sessions: {
- ...prev.sessions,
- [newSessionId]: carryDurableMeta(prev.sessions[newSessionId], meta),
- },
- detachedSessions,
- tabs: restoreRoot ? prev.tabs.map(current => current.id === tab.id
- ? { ...current, root: { type: 'leaf' as const, sessionId: newSessionId }, focusedSessionId: newSessionId }
- : current) : prev.tabs,
- // Focus the restored row when Dispatch is up. NOTE this is the
- // classic focus only: in Tiled Dispatch `dispatchFocusedSessionId`
- // reads the focused LANE first, and the close already cleared that
- // lane (dispatchModeAfterSessionRemoval) and the heal effect refilled
- // it with another agent. So the visible result there is the row
- // reappearing at its old position in the index, not a lane takeover.
- // Restoring the lane would need the lane index captured on the entry
- // at close time; deliberately not done, because a lane the user has
- // since re-aimed should not be yanked back by an undo.
- dispatchMode: prev.dispatchMode
- ? { ...prev.dispatchMode, focusedSessionId: newSessionId }
- : prev.dispatchMode,
- }
- })
-
- if (!refiled) {
- // No backend or renderer metadata was created for an unfiled view.
- if (kind === 'extension-view') return 'stale'
- // Mirror restorePaneEntry's bail: the spawn already registered a live
- // backend, so a placement that did not happen must not leave it
- // running with no row pointing at it.
- //
- // Kill with the kind/cwd resolved above rather than leaving it to
- // killSession's ownership proof, which re-reads them from
- // `refs.stateRef`.
- //
- // History: that ref used to be a render-body mirror, so immediately
- // after an awaited spawn it did not contain the new session, the proof
- // failed, and the kill silently no-opped — stranding exactly the
- // backend this bail exists to reclaim. #886 made stateRef a synchronous
- // store subscription, so the proof would now succeed; the explicit
- // owner stays as defense in depth, because this bail must reclaim the
- // process however the ref happens to be wired. killSession still runs
- // for the renderer-side cleanup.
- await window.api
- .killOwnedSession({ sessionId: newSessionId, kind, cwd: meta.cwd })
- .catch(() => undefined)
- // `.catch` mirrors restorePaneEntry's guard. killSession reaches an IPC
- // invoke that can reject, and this bail runs with the entry already
- // POPPED — a throw here would lose the entry (the push-back only
- // happens for 'retryable-failure'), skip bumpUndoCloseVersion so the
- // palette's count stays stale, and escape into the keybinding handler.
- await sessionActions.killSession(newSessionId).catch(() => undefined)
- return 'stale'
- }
- // The closed session is back under a new id. An older entry may anchor
- // on it: the three-agent case is "close A (B promoted), close B (C
- // promoted), undo, undo" — B's restore must tell A's entry that its
- // promoted survivor is now B′, or A comes back as a trailing row instead
- // of the original root (#886 review finding 4).
- publish({
- sessions: new Map([[entry.record.sessionId, newSessionId]]),
- })
- return 'restored'
- },
- [refs.stateRef, refs.undoStackRef, sessionActions, setState],
+ [respawn, setState],
)
const restoreSingleEntry = useCallback(
(entry: SingleClosedEntry, publish: PublishLineage): Promise =>
- entry.type === 'pane'
- ? restorePaneEntry(entry, publish)
- : entry.type === 'detached'
- ? restoreDetachedEntry(entry, publish)
- : restoreTabEntry(entry, publish),
- [restoreDetachedEntry, restorePaneEntry, restoreTabEntry],
+ entry.type === 'session'
+ ? restoreSessionEntry(entry, publish)
+ : restoreTabEntry(entry, publish),
+ [restoreSessionEntry, restoreTabEntry],
)
// Replay one close OPERATION's units last-first (see ClosedGroup).
//
// WHY last-first with lineage threaded through the members not yet replayed:
// the last commit is the outermost change — the parent, the tab removal — and
- // older units anchor on what it restores: a child's linkedParentId, a pane's
- // siblingLeafId, a row's projectTabId. Every restore mints new ids and
+ // older units anchor on what it restores: a child's linkedParentId, a
+ // session's projectId. Every restore mints new ids and
// publishes them, so each older member is re-anchored before it runs, and the
// rest of the stack is re-anchored as for any other restore.
//
@@ -702,12 +386,12 @@ export function useUndoCloseAction(
const undoClose = useCallback(async () => {
// Undo Close is a small LIFO recovery history, not a one-shot
- // toast action. Pane entries can go stale during normal cleanup
- // because their only safe placement anchor is the surviving sibling
- // leaf; if that sibling was also closed, the entry is no longer
- // restorable in-place. We deliberately skip such entries and keep
+ // toast action. A session entry can go stale during normal cleanup
+ // because its only anchor is its project; if that project was closed
+ // or merged away since, the entry is no longer restorable in place.
+ // We deliberately skip such entries and keep
// walking backward so one stale close does not block an older valid
- // tab/pane restore. We still pop stale entries because retaining
+ // restore. We still pop stale entries because retaining
// an entry we already know cannot restore would trap the user on
// the same failure every time they press Cmd+Shift+T. Transient spawn
// failures are different: those keep the entry by pushing it back so a
diff --git a/src/renderer/src/workspace/hook/context.ts b/src/renderer/src/workspace/hook/context.ts
index c08da61cc..c83f87d03 100644
--- a/src/renderer/src/workspace/hook/context.ts
+++ b/src/renderer/src/workspace/hook/context.ts
@@ -4,7 +4,6 @@ import type { SessionRuntime } from '@renderer/session-runtime/state'
import type {
ReaderModeState,
SpotlightState,
- TileTabsState,
} from '@renderer/workspace/types'
import type { SessionId, WorkspaceState } from '@renderer/workspace/types'
@@ -25,13 +24,6 @@ export type WorkspaceSetSpotlight = (
| ((prev: SpotlightState | null) => SpotlightState | null),
) => void
-export type WorkspaceSetTileTabs = (
- next:
- | TileTabsState
- | null
- | ((prev: TileTabsState | null) => TileTabsState | null),
-) => void
-
export type WorkspaceSetReaderMode = (
next:
| ReaderModeState
diff --git a/src/renderer/src/workspace/hook/index.ts b/src/renderer/src/workspace/hook/index.ts
index dc2d3b12b..4fa36beda 100644
--- a/src/renderer/src/workspace/hook/index.ts
+++ b/src/renderer/src/workspace/hook/index.ts
@@ -2,7 +2,6 @@ import { createElement, useCallback, useEffect, useLayoutEffect, useMemo, useRef
import { useAppStore } from '@renderer/app-state/hooks'
import { useGlobalToast } from '@renderer/ui/GlobalToast'
-import type { WorkspaceModeId } from '@renderer/app-state/settings/types'
import type { ConfigurableBuiltInMcpDomain } from '@mcp/shared/types'
import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind'
import type { AgentViewModeOverride, SessionId } from '@renderer/workspace/types'
@@ -14,8 +13,6 @@ import { useStreamingActions } from '@renderer/workspace/hook/actions/streaming'
import { usePickerActions } from '@renderer/workspace/hook/actions/picker'
import { useSpotlightActions } from '@renderer/workspace/hook/actions/spotlight'
import { useReaderActions } from '@renderer/workspace/hook/actions/reader'
-import { useTileTabsActions } from '@renderer/workspace/hook/actions/tileTabs'
-import { useResizeActions } from '@renderer/workspace/hook/actions/resize'
import { useSessionActions } from '@renderer/workspace/hook/actions/session'
import { useTabActions } from '@renderer/workspace/hook/actions/tab'
import { usePaneActions } from '@renderer/workspace/hook/actions/pane'
@@ -34,7 +31,6 @@ import {
usePinnedSessionIdsSanity,
useReaderModeSanity,
useSpotlightSanity,
- useTileTabsSanity,
} from '@renderer/workspace/hook/invalidation/effects'
import { useIpcSubscriptions } from '@renderer/workspace/hook/ipc/useIpcSubscriptions'
import { useTerminalForeground } from '@renderer/workspace/hook/ipc/useTerminalForeground'
@@ -82,17 +78,10 @@ export type Workspace = ReturnType
export function useWorkspace(
dangerousAgentsEnabled = false,
useProxyStreaming = false,
- // Read once at mount via useBootstrap's useEffect closure. Live
- // changes to this preference do not retro-trigger bootstrap — that
- // is intentional, the setting only seeds initial state on a fresh
- // install (no workspace.json yet).
- defaultWorkspaceMode: WorkspaceModeId = 'grid',
defaultBuiltInMcpDomains: ConfigurableBuiltInMcpDomain[] = [],
) {
// ---- Zustand subscriptions (these drive re-renders) ----
const { showToast } = useGlobalToast()
- const openBuryPrompt = useAppStore(store => store.openBuryPrompt)
- const closeBuryPrompt = useAppStore(store => store.closeBuryPrompt)
const openNewAgentPlacement = useAppStore(store => store.openNewAgentPlacement)
const closeNewAgentPlacement = useAppStore(store => store.closeNewAgentPlacement)
@@ -105,8 +94,6 @@ export function useWorkspace(
const setRuntimes = useAppStore(store => store.setWorkspaceRuntimes)
const spotlight = useAppStore(store => store.workspaceSpotlight)
const setSpotlight = useAppStore(store => store.setWorkspaceSpotlight)
- const tileTabs = useAppStore(store => store.workspaceTileTabs)
- const setTileTabs = useAppStore(store => store.setWorkspaceTileTabs)
const readerMode = useAppStore(store => store.workspaceReaderMode)
const setReaderMode = useAppStore(store => store.setWorkspaceReaderMode)
@@ -114,7 +101,6 @@ export function useWorkspace(
const refs = useWorkspaceRefs(
state,
runtimes,
- tileTabs,
dangerousAgentsEnabled,
useProxyStreaming,
defaultBuiltInMcpDomains,
@@ -141,7 +127,6 @@ export function useWorkspace(
})
return () => { unsubscribeRuntime(); unsubscribeState() }
}, [refs])
- refs.latestTileTabsRef.current = tileTabs
refs.dangerousAgentsRef.current = dangerousAgentsEnabled
refs.useProxyStreamingRef.current = useProxyStreaming
refs.defaultBuiltInMcpDomainsRef.current = defaultBuiltInMcpDomains
@@ -156,21 +141,6 @@ export function useWorkspace(
// says "are we past the once-only effect", not "is the on-disk state
// intact". See useBootstrap for the four possible terminal values.
const [restoreStatus, setRestoreStatus] = useState('pending')
- const selectGridRelatedSession = useCallback((ownerSessionId: string, selectedSessionId: string) => {
- setState(prev => {
- const nextSelections = { ...(prev.gridRelatedSelections ?? {}) }
- if (ownerSessionId === selectedSessionId) {
- delete nextSelections[ownerSessionId]
- } else {
- nextSelections[ownerSessionId] = selectedSessionId
- }
- return {
- ...prev,
- gridRelatedSelections: nextSelections,
- }
- })
- }, [setState])
-
const setSessionAgentViewModeOverride = useCallback((
sessionId: SessionId,
override: AgentViewModeOverride | null,
@@ -305,23 +275,9 @@ export function useWorkspace(
setState,
refs,
)
- const {
- openTileTabs,
- closeTileTabs,
- focusTiledTab,
- focusTiledTabByIndex,
- resizeFocusedTiledTab,
- resizeTiledTabByIndex,
- } = useTileTabsActions(setTileTabs, setSpotlight, setState, refs)
- const {
- resizeFocused,
- resizeFocusedDirectional,
- setSplitRatio,
- setSplitRatioInTab,
- normalizeLayout,
- hardNormalizeLayout,
- rotateLayout,
- } = useResizeActions(setState, setTileTabs)
+ // useTileTabsActions and useResizeActions were composed here until the
+ // unified layout (#992): Tile Tabs and split resizing both died with the
+ // tile tree. Lane/row sizing lives in useDispatchActions.
// Session lifecycle + derivatives that depend on it
const sessionActions = useSessionActions(state, setState, setRuntimes, refs)
@@ -332,7 +288,7 @@ export function useWorkspace(
const { focusAgentByPaneLabel, focusAgentBySessionId } = useAgentIndexNavigationActions(
setState,
- setTileTabs,
+ setRuntimes,
refs,
sessionActions,
showToast,
@@ -340,9 +296,8 @@ export function useWorkspace(
const tabActions = useTabActions(
state,
- tileTabs,
setState,
- setTileTabs,
+ setRuntimes,
setSpotlight,
setReaderMode,
refs,
@@ -355,12 +310,9 @@ export function useWorkspace(
setState,
setRuntimes,
setSpotlight,
- setTileTabs,
setReaderMode,
refs,
showToast,
- openBuryPrompt,
- closeBuryPrompt,
openNewAgentPlacement,
closeNewAgentPlacement,
sessionActions,
@@ -369,8 +321,6 @@ export function useWorkspace(
createOrchestrationAgentRef.current = paneActions.createOrchestrationAgent
const closeOrchestrationSessionRef = useRef(paneActions.closeSession)
closeOrchestrationSessionRef.current = paneActions.closeSession
- const killBuriedSessionRef = useRef(paneActions.killBuried)
- killBuriedSessionRef.current = paneActions.killBuried
useEffect(() => {
const off = window.api.onOrchestrationRequest(async request => {
@@ -788,11 +738,15 @@ export function useWorkspace(
callerSessionId: request.callerSessionId,
sessionId: request.sessionId,
})
- if (placement.placement === 'buried') {
- const buried = current.buried.find(item => item.sessionId === request.sessionId)
- if (!buried) throw new Error('agent_not_found')
- await killBuriedSessionRef.current(buried.id)
- } else {
+ // A 'buried' placement used to branch to Kill Buried here. Buried
+ // records become ordinary pool rows when an old file is migrated
+ // (#992, legacyWorkspaceV2.ts legacyMemberships) and live state has no
+ // `buried` field at all, so nothing can report one and every
+ // managed close takes the one authorized path below. `placement` is
+ // still resolved for its side effect: assertManagedTarget throws when
+ // the caller does not manage this target.
+ void placement
+ {
// THE authorization check for the Agent Management close tool.
//
// The tool's rule used to be prose in its description ("never close
@@ -885,10 +839,8 @@ export function useWorkspace(
)
const dispatchActions = useDispatchActions(
- state,
setState,
- setTileTabs,
- closeNewAgentPlacement,
+ setRuntimes,
refs,
sessionActions.ensureSessionLive,
showToast,
@@ -908,12 +860,9 @@ export function useWorkspace(
refs,
setState,
setRuntimes,
- setTileTabs,
tabActions.newTab,
setBootstrapComplete,
setRestoreStatus,
- defaultWorkspaceMode,
- dispatchActions.enterDispatchMode,
)
// The persist effect reads current refs on its own timer, so it needs no
// render-time snapshot — passing `runtimes` here would suggest a reactivity
@@ -921,7 +870,6 @@ export function useWorkspace(
useFeedDebugPersist(refs)
useSpotlightSanity(spotlight, state, setSpotlight)
useReaderModeSanity(readerMode, state, setReaderMode)
- useTileTabsSanity(tileTabs, state.tabs, setTileTabs)
usePinnedSessionIdsSanity(state, setState)
// Beside the sanity hooks because it is the same kind of thing: a
// membership-driven correction that keeps an orthogonal slice consistent
@@ -950,9 +898,11 @@ export function useWorkspace(
}),
activeTab,
spotlight,
- tileTabs,
readerMode,
- dispatchMode: state.dispatchMode,
+ // The lane grid. Exposed as `stage`, replacing the nullable `dispatchMode`
+ // envelope (#992): consumers used to branch on "is Dispatch on?" and then
+ // on "is it tiled?"; both questions are gone, so the field is the grid.
+ stage: state.stage,
restoreStatus,
setReaderModeTarget,
toggleReaderMode,
@@ -978,40 +928,24 @@ export function useWorkspace(
splitFocused: paneActions.splitFocused,
openExtensionViewInPane: paneActions.openExtensionViewInPane,
startNewAgentPlacement: paneActions.startNewAgentPlacement,
- commitNewAgentPlacement: paneActions.commitNewAgentPlacement,
createDetachedDispatchAgent: paneActions.createDetachedDispatchAgent,
createDetachedSession: paneActions.createDetachedSession,
createLinkedAgent: paneActions.createLinkedAgent,
createOrchestrationAgent: paneActions.createOrchestrationAgent,
- attachDetachedToGrid: paneActions.attachDetachedToGrid,
- attachAllDetachedForTab: paneActions.attachAllDetachedForTab,
- detachSessionToDispatch: paneActions.detachSessionToDispatch,
- detachFocusedToDispatch: paneActions.detachFocusedToDispatch,
closeFocused: paneActions.closeFocused,
closeSession: paneActions.closeSession,
closeIdleOrchestrationAgents,
- requestBuryFocused: paneActions.requestBuryFocused,
- buryFocused: paneActions.buryFocused,
- reviveBuried: paneActions.reviveBuried,
- killBuried: paneActions.killBuried,
- focusSession: paneActions.focusSession,
focusSessionInTab: paneActions.focusSessionInTab,
focusAgentByPaneLabel,
focusAgentBySessionId,
setAgentTitle,
setSessionAgentViewModeOverride,
- selectGridRelatedSession,
- navigate: paneActions.navigate,
activateTab: tabActions.activateTab,
activateTabByIndex: tabActions.activateTabByIndex,
reorderTabs: tabActions.reorderTabs,
mergeTabs: tabActions.mergeTabs,
nextTab: tabActions.nextTab,
prevTab: tabActions.prevTab,
- resizeFocused,
- resizeFocusedDirectional,
- setSplitRatio,
- setSplitRatioInTab,
beginOptimisticSubmit,
unwindOptimisticSubmit,
settleQueuedSubmit,
@@ -1028,9 +962,6 @@ export function useWorkspace(
showPaneToast,
undoClose,
undoCloseCount,
- normalizeLayout,
- hardNormalizeLayout,
- rotateLayout,
replaceSession,
reloadFocusedAgent,
softReloadAgentView,
@@ -1048,12 +979,6 @@ export function useWorkspace(
setSpotlightTarget,
toggleSpotlight,
setSpotlightSession,
- openTileTabs,
- closeTileTabs,
- focusTiledTab,
- focusTiledTabByIndex,
- resizeFocusedTiledTab,
- resizeTiledTabByIndex,
toggleTailMode,
acquireRenderedViewLease,
releaseRenderedViewLease,
@@ -1064,16 +989,11 @@ export function useWorkspace(
pickerConfirm,
pickerCancel,
setCodeBlockPicker,
- enterDispatchMode: dispatchActions.enterDispatchMode,
- exitDispatchMode: dispatchActions.exitDispatchMode,
- setDispatchScope: dispatchActions.setDispatchScope,
- focusDispatchSession: dispatchActions.focusDispatchSession,
pinSession: dispatchActions.pinSession,
unpinSession: dispatchActions.unpinSession,
setPinnedSessionIds: dispatchActions.setPinnedSessionIds,
- enterTiledDispatch: dispatchActions.enterTiledDispatch,
- exitTiledDispatch: dispatchActions.exitTiledDispatch,
selectTiledLaneSession: dispatchActions.selectTiledLaneSession,
+ clearTiledLane: dispatchActions.clearTiledLane,
insertTiledLaneRight: dispatchActions.insertTiledLaneRight,
removeTiledLane: dispatchActions.removeTiledLane,
setTiledFocusedLane: dispatchActions.setTiledFocusedLane,
diff --git a/src/renderer/src/workspace/hook/invalidation/effects.ts b/src/renderer/src/workspace/hook/invalidation/effects.ts
index 01652f36e..e19ae68a3 100644
--- a/src/renderer/src/workspace/hook/invalidation/effects.ts
+++ b/src/renderer/src/workspace/hook/invalidation/effects.ts
@@ -4,24 +4,20 @@ import type { SessionRuntime } from '@renderer/session-runtime/state'
import type {
ReaderModeState,
SpotlightState,
- TileTabsState,
} from '@renderer/workspace/types'
import type { SessionId, Tab, TabId, WorkspaceState } from '@renderer/workspace/types'
-import { resolveTabSessions } from '@renderer/workspace/queries'
import {
buildVisibleDispatchRows,
} from '@renderer/workspace/dispatch/dispatchSelectors'
import {
assistantUuidsWithText,
} from '@renderer/lib/copyAssistant'
-import { ratiosEqual, sanitizeTileTabsState } from '@renderer/workspace/layout/helpers'
import { isAgentSessionKind, isProcessSessionKind } from '@shared/types/providerKind'
import type {
WorkspaceSetReaderMode,
WorkspaceSetSpotlight,
- WorkspaceSetTileTabs,
} from '@renderer/workspace/hook/context'
// Invalidation effects — these fire when state changes and adjust
@@ -109,11 +105,13 @@ function validFocusSessionIdsForMode(
// user clicks a detached row in non-Dispatch Reader → validator
// sees the id isn't a grid leaf → forces focus back to the first
// grid pane → user's selection silently disappears.
- const sessionIds = state.dispatchMode
- ? buildVisibleDispatchRows(state)
- .filter(row => row.tabId === tabId)
- .map(row => row.sessionId)
- : resolveTabSessions(state, tabId)
+ //
+ // - Then the stage became the only layout (#992) and the views dropped
+ // their non-Dispatch branch, so this did too. The rule is unchanged:
+ // whatever ReaderView/SpotlightView list, this lists.
+ const sessionIds = buildVisibleDispatchRows(state)
+ .filter(row => row.tabId === tabId)
+ .map(row => row.sessionId)
return options.agentOnly
? sessionIds.filter(sessionId => isAgentSessionKind(state.sessions[sessionId]?.kind))
@@ -193,34 +191,3 @@ export function usePinnedSessionIdsSanity(
}, [setState, state])
}
-export function useTileTabsSanity(
- tileTabs: TileTabsState | null,
- tabs: Tab[],
- setTileTabs: WorkspaceSetTileTabs,
-): void {
- useEffect(() => {
- if (!tileTabs) return
- const nextTileTabs = sanitizeTileTabsState(tileTabs)
- if (!nextTileTabs) {
- setTileTabs(null)
- return
- }
- const validTabIds = nextTileTabs.tabIds.filter(id => tabs.some(t => t.id === id))
- const sanitized = sanitizeTileTabsState({
- ...nextTileTabs,
- tabIds: validTabIds,
- })
- if (!sanitized) {
- setTileTabs(null)
- return
- }
- if (
- sanitized.tabIds.length !== tileTabs.tabIds.length ||
- sanitized.focusedTabId !== tileTabs.focusedTabId ||
- sanitized.direction !== tileTabs.direction ||
- !ratiosEqual(sanitized.ratios, tileTabs.ratios)
- ) {
- setTileTabs(sanitized)
- }
- }, [setTileTabs, tabs, tileTabs])
-}
diff --git a/src/renderer/src/workspace/hook/ipc/testing/opencodeTerminalScope.ts b/src/renderer/src/workspace/hook/ipc/testing/opencodeTerminalScope.ts
index c1441c80e..b21645e94 100644
--- a/src/renderer/src/workspace/hook/ipc/testing/opencodeTerminalScope.ts
+++ b/src/renderer/src/workspace/hook/ipc/testing/opencodeTerminalScope.ts
@@ -9,6 +9,7 @@ import { ensureAbortSignalTimeout } from 'opencode-terminal-headless/testing'
import { createOpencodeDatabase } from '@providers/opencode/runtime/opencodeDatabase'
import { createOpencodeHistorySource } from '@providers/opencode/runtime/opencodeHistory'
+import { freshStage } from '@renderer/workspace/dispatch/gridShape'
import type { SessionRuntime } from '@renderer/session-runtime/state'
import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context'
import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
@@ -69,20 +70,10 @@ export function paneWorkspace(meta: SessionMeta): WorkspaceState {
tabs: [{
id: 'project',
title: 'project',
- focusedSessionId: PARENT_ID,
- root: {
- type: 'split',
- direction: 'vertical',
- ratio: 0.5,
- a: { type: 'leaf', sessionId: PARENT_ID },
- b: { type: 'leaf', sessionId: SESSION_ID },
- },
}],
activeTabId: 'project',
- dispatchMode: null,
- sessions: { [PARENT_ID]: { cwd: PANE_CWD, kind: 'claude' }, [SESSION_ID]: meta },
- detachedSessions: {},
- buried: [],
+ stage: freshStage(),
+ sessions: { [PARENT_ID]: { cwd: PANE_CWD, kind: 'claude', projectId: 'project', joinedAt: 0 }, [SESSION_ID]: { ...meta, projectId: 'project', joinedAt: 1 }},
pinnedSessionIds: [],
}
}
diff --git a/src/renderer/src/workspace/hook/ipc/testing/workspaceRefsForTest.ts b/src/renderer/src/workspace/hook/ipc/testing/workspaceRefsForTest.ts
index cdca8cd6e..947f28186 100644
--- a/src/renderer/src/workspace/hook/ipc/testing/workspaceRefsForTest.ts
+++ b/src/renderer/src/workspace/hook/ipc/testing/workspaceRefsForTest.ts
@@ -16,7 +16,6 @@ export function makeWorkspaceRefsForTest(state: WorkspaceState): WorkspaceRefs {
stateRef: ref(state),
latestStateRef: ref(state),
latestRuntimesRef: ref({}),
- latestTileTabsRef: ref(null),
dangerousAgentsRef: ref(false),
useProxyStreamingRef: ref(false),
defaultBuiltInMcpDomainsRef: ref([]),
diff --git a/src/renderer/src/workspace/hook/ipc/useWorkspaceAdoption.renderer.test.tsx b/src/renderer/src/workspace/hook/ipc/useWorkspaceAdoption.renderer.test.tsx
index a908ab3dc..e80e4a83f 100644
--- a/src/renderer/src/workspace/hook/ipc/useWorkspaceAdoption.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/ipc/useWorkspaceAdoption.renderer.test.tsx
@@ -6,6 +6,7 @@ import { emptyRuntime } from '@renderer/session-runtime/state'
import type { SessionRuntime } from '@renderer/session-runtime/state'
import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
import type { SessionId, SessionMeta, WorkspaceState } from '@renderer/workspace/types'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
// The renderer half of the workspace handoff.
//
@@ -46,42 +47,32 @@ function survivorState(): WorkspaceState {
tabs: [{
id: 'tab-own',
title: 'own',
- root: { type: 'leaf', sessionId: 'own-agent' },
- focusedSessionId: 'own-agent',
}],
activeTabId: 'tab-own',
- dispatchMode: null,
- sessions: { 'own-agent': meta('/own') },
- detachedSessions: {},
- buried: [],
+ stage: oneLaneStage('own-agent'),
+ sessions: { 'own-agent': { ...meta('/own'), projectId: 'tab-own', joinedAt: 0 }},
pinnedSessionIds: [],
}
}
+/**
+ * What a closed window's autosave actually wrote: the v3 shape, and only it
+ * (useAutoSave.ts). `grid-a` is the agent its one lane showed; `parked` is a
+ * pool row no lane showed. The names are from when those were a tile leaf and
+ * a detached record — two owner structures that are one thing now.
+ *
+ * The payload was briefly a hybrid (v2 `tabs` with no tile `root`, v3 rows).
+ * No build ever wrote that, and adoption's input is a FILE, so it is exactly
+ * the place a fixture must not invent a shape.
+ */
function closedWindowPayload(): string {
return JSON.stringify({
workspace: {
- tabs: [{
- id: 'tab-closed',
- title: 'closed',
- root: { type: 'leaf', sessionId: 'grid-a' },
- focusedSessionId: 'grid-a',
- }],
- activeTabId: 'tab-closed',
- dispatchMode: null,
- sessions: { 'grid-a': meta('/closed'), parked: meta('/closed') },
- detachedSessions: {
- parked: {
- sessionId: 'parked',
- surface: 'dispatch',
- projectTabId: 'tab-closed',
- projectTabTitle: 'closed',
- projectTabIndex: 0,
- detachedAt: 1,
- },
- },
- buried: [],
- tileTabs: null,
+ projects: [{ id: 'tab-closed', title: 'closed' }],
+ activeProjectId: 'tab-closed',
+ stage: oneLaneStage('grid-a'),
+ sessions: { 'grid-a': { ...meta('/closed'), projectId: 'tab-closed', joinedAt: 0 }, parked: { ...meta('/closed'), projectId: 'tab-closed', joinedAt: 1 }},
+ pinnedSessionIds: [],
},
})
}
@@ -214,7 +205,18 @@ describe('adopting a closed window', () => {
expect(h.refs.latestRuntimesRef.current['grid-a']?.inputReadinessRevision).toBe(7)
})
- it('loads history for adopted leaves only', async () => {
+ it('loads history only for adopted sessions that have a live backend', async () => {
+ // Re-based with #992. The rule was "tile leaves load, detached rows do
+ // not" — a structural stand-in for "has a backend", because the closed
+ // window had spawned exactly its leaves. It asks main directly now: a
+ // session main still holds a live backend for is one the user can read and
+ // type into the moment it is adopted, so its transcript is loaded; every
+ // other row is parked and loads when it is woken.
+ getBackendSnapshot.mockImplementation(async (sessionId: string) => (
+ sessionId === 'grid-a'
+ ? { sessionId, kind: 'claude', cwd: '/closed', lifecycle: 'live', input: { ready: true, reason: null, revision: 1 } }
+ : null
+ ))
const h = harness(true)
h.fire({ windowId: 'closed-window', workspace: closedWindowPayload() })
@@ -225,6 +227,16 @@ describe('adopting a closed window', () => {
.toEqual(['grid-a'])
})
+ it('loads no history when the closed window left no live backend behind', async () => {
+ // The default mock: main knows none of these sessions. Everything adopted
+ // is parked, so nothing is paged in — and the adoption still lands.
+ const h = harness(true)
+ h.fire({ windowId: 'closed-window', workspace: closedWindowPayload() })
+
+ await waitFor(() => expect(h.refs.latestRuntimesRef.current.parked).toBeDefined())
+ expect(loadInitialHistoryForSession).not.toHaveBeenCalled()
+ })
+
it('refuses an unreadable payload without confirming', async () => {
const h = harness(true)
h.fire({ windowId: 'closed-window', workspace: 'not json' })
@@ -240,7 +252,10 @@ describe('adopting a closed window', () => {
const colliding = JSON.parse(closedWindowPayload()) as {
workspace: { sessions: Record }
}
- colliding.workspace.sessions['own-agent'] = meta('/collision')
+ // Filed under the closed window's project: an UNFILED row would be dropped
+ // as unowned before the merge ever compared ids, and the adoption would
+ // (correctly) succeed — testing the ownership prune, not the collision.
+ colliding.workspace.sessions['own-agent'] = { ...meta('/collision'), projectId: 'tab-closed', joinedAt: 2 }
h.fire({ windowId: 'closed-window', workspace: JSON.stringify(colliding) })
await waitFor(() => expect(refuseWorkspaceAdoption).toHaveBeenCalledWith('closed-window'))
diff --git a/src/renderer/src/workspace/hook/ipc/useWorkspaceAdoption.ts b/src/renderer/src/workspace/hook/ipc/useWorkspaceAdoption.ts
index 72e6a4975..c4de66b13 100644
--- a/src/renderer/src/workspace/hook/ipc/useWorkspaceAdoption.ts
+++ b/src/renderer/src/workspace/hook/ipc/useWorkspaceAdoption.ts
@@ -146,13 +146,13 @@ export function useWorkspaceAdoption(
}
}))
- // Runtimes first, state second. A tile leaf whose runtime does not exist
- // yet renders through `emptyRuntime()` as an idle pane with a `?` label
- // (see repairPersistedTabs' note on orphan leaves); seeding before the
- // tabs are visible means the adopted panes never paint in that state.
+ // Runtimes first, state second. A session whose runtime does not exist yet
+ // renders through `emptyRuntime()` as an idle pane with a `?` label;
+ // seeding before the rows are visible means an adopted agent selected into
+ // a lane never paints in that state.
//
- // WHY EVERY adopted session gets a runtime and not just the tile leaves:
- // `ensureSessionLive` — the wake path behind Attach to Grid and revive —
+ // WHY EVERY adopted session gets a runtime, not just the ones with a
+ // backend: `ensureSessionLive` — the wake path behind lane selection —
// gates on `latestRuntimesRef.current[sessionId]` being present, and every
// one of its `setRuntimes` writes no-ops when it is missing. A parked agent
// adopted without a runtime therefore wakes into an empty feed with no
@@ -190,9 +190,12 @@ export function useWorkspaceAdoption(
// History is loaded per session and not awaited as a batch: each pane fills
// in as its transcript arrives, which is the same progressive behavior
- // bootstrap has. Only tile leaves are loaded eagerly — a parked agent's
- // transcript is fetched by `ensureSessionLive` when it is actually woken.
- for (const sessionId of adoption.adoptedLeafSessionIds) {
+ // bootstrap has. Only sessions that arrived WITH A LIVE BACKEND are loaded
+ // eagerly — those are the ones the closed window was actively running. A
+ // parked agent's transcript is fetched by `ensureSessionLive` when it is
+ // actually woken. (Until #992 the eager set was "the adopted tile leaves",
+ // a structural stand-in for the same idea.)
+ for (const sessionId of adoption.adoptedSessionIds.filter(id => snapshots.get(id) != null)) {
void loadInitialHistoryForSession({
sessionId: sessionId as SessionId,
refs,
diff --git a/src/renderer/src/workspace/hook/orchestrationRuntime.renderer.test.tsx b/src/renderer/src/workspace/hook/orchestrationRuntime.renderer.test.tsx
index 4970c879c..e13167cb5 100644
--- a/src/renderer/src/workspace/hook/orchestrationRuntime.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/orchestrationRuntime.renderer.test.tsx
@@ -4,6 +4,7 @@ import { useAppStore } from '@renderer/app-state/hooks'
import { emptyRuntime } from '@renderer/session-runtime/state'
import type { OrchestrationRendererRequest, OrchestrationRendererResponse } from '@mcp/shared/orchestrationTypes'
import { useWorkspace } from './index'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
// Mount the real renderer create handler, pane action, session spawn action,
// and workspace store. Boot/history subscriptions are unrelated ingress;
@@ -28,13 +29,12 @@ beforeEach(() => {
useAppStore.setState({
workspaceState: {
...originalStore.workspaceState,
- activeTabId: 'project', dispatchMode: null, pinnedSessionIds: [], buried: [],
- tabs: [{ id: 'project', title: 'Project', focusedSessionId: 'root', root: { type: 'leaf', sessionId: 'root' } }],
+ activeTabId: 'project', stage: oneLaneStage('root'), pinnedSessionIds: [],
+ tabs: [{ id: 'project', title: 'Project' }],
sessions: {
- root: { kind: 'claude', cwd: '/repo' },
- parent: { kind: 'opencode', providerRuntime: 'terminal', cwd: '/repo/subdir', orchestrationParentId: 'root', orchestrationRootId: 'root' },
+ root: { kind: 'claude', cwd: '/repo', projectId: 'project', joinedAt: 0 },
+ parent: { kind: 'opencode', providerRuntime: 'terminal', cwd: '/repo/subdir', orchestrationParentId: 'root', orchestrationRootId: 'root', projectId: 'project', joinedAt: 1 },
},
- detachedSessions: { parent: { sessionId: 'parent', surface: 'dispatch', projectTabId: 'project', projectTabTitle: 'Project', projectTabIndex: 0, detachedAt: 1 } },
},
workspaceRuntimes: { root: emptyRuntime(), parent: emptyRuntime() },
})
@@ -68,6 +68,7 @@ async function dispatch(request: OrchestrationRendererRequest): Promise {
describe('renderer orchestration runtime creation', () => {
it.each([true, false])('carries the selected runtime and ownership through real spawn; terminal=%s', async terminal => {
renderHook(() => useWorkspace())
+ const stageBefore = useAppStore.getState().workspaceState.stage
await dispatch({
requestId: 'create', type: 'create-agent', parentSessionId: 'parent', kind: 'opencode',
...(terminal ? { providerRuntime: 'terminal' as const } : {}),
@@ -82,8 +83,10 @@ describe('renderer orchestration runtime creation', () => {
}
const state = useAppStore.getState().workspaceState
expect(state.sessions.child).toMatchObject({ kind: 'opencode', providerRuntime: terminal ? 'terminal' : undefined, cwd: '/repo/child', title: 'Parser review', ...ownership })
- expect(state.detachedSessions.child).toMatchObject({ sessionId: 'child', surface: 'dispatch', projectTabId: 'project' })
- expect(state.tabs[0]!.focusedSessionId).toBe('root')
+ // Filed in the root parent's project, and it does NOT steal the stage:
+ // one prompt can create many workers, so no lane is re-aimed at it.
+ expect(state.sessions.child).toMatchObject({ projectId: 'project', joinedAt: expect.any(Number) })
+ expect(state.stage).toBe(stageBefore)
expect(resolved).toHaveBeenCalledWith({ requestId: 'create', ok: true, type: 'create-agent', agent: {
sessionId: 'child', kind: 'opencode', cwd: '/repo/child', title: 'Parser review', ...ownership,
} })
diff --git a/src/renderer/src/workspace/hook/persistence/codexLiveContinuity.renderer.test.tsx b/src/renderer/src/workspace/hook/persistence/codexLiveContinuity.renderer.test.tsx
index 600e2a269..baaf759fd 100644
--- a/src/renderer/src/workspace/hook/persistence/codexLiveContinuity.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/persistence/codexLiveContinuity.renderer.test.tsx
@@ -34,6 +34,8 @@ import type { SessionId, WorkspaceState } from '@renderer/workspace/types'
import { forwardCodexRolloutEntries } from '@providers/codex/runtime/codexHeadlessForwarding'
import { useAutoSave } from './useAutoSave'
+import { freshStage } from '@renderer/workspace/dispatch/gridShape'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
const { createSession, loadInitialHistoryForSession } = vi.hoisted(() => ({
createSession: vi.fn(),
@@ -205,7 +207,6 @@ function makeRefs(state: WorkspaceState, runtimes: Record = {}
const refs = makeRefs(state, runtimes)
@@ -276,7 +275,6 @@ function makeReloadHarness() {
runtimes = typeof next === 'function' ? next(runtimes) : next
refs.latestRuntimesRef.current = runtimes
},
- setTileTabs: vi.fn(),
}
}
@@ -310,17 +308,13 @@ describe('recorded Codex 0.151 live continuity across app layers', () => {
tabs: [{
id: 'tab-1',
title: 'Recorded Codex',
- focusedSessionId: localSessionId,
- root: { type: 'leaf', sessionId: localSessionId },
}],
activeTabId: 'tab-1',
sessions: {
- [localSessionId]: { cwd: '/fixture/project-1', kind: 'codex' },
+ [localSessionId]: { cwd: '/fixture/project-1', kind: 'codex', projectId: 'tab-1', joinedAt: 0 },
},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
- dispatchMode: null,
+ stage: oneLaneStage(localSessionId),
} as WorkspaceState
const initialRuntimes = {
[localSessionId]: {
@@ -442,7 +436,6 @@ describe('recorded Codex 0.151 live continuity across app layers', () => {
reload.refs,
reload.setState,
reload.setRuntimes,
- reload.setTileTabs,
vi.fn(),
{
recoverSession: restartedManager.recover.bind(restartedManager),
diff --git a/src/renderer/src/workspace/hook/persistence/rehydrate.renderer.test.ts b/src/renderer/src/workspace/hook/persistence/rehydrate.renderer.test.ts
index 6d4c9d781..cf669f9c3 100644
--- a/src/renderer/src/workspace/hook/persistence/rehydrate.renderer.test.ts
+++ b/src/renderer/src/workspace/hook/persistence/rehydrate.renderer.test.ts
@@ -13,6 +13,8 @@ import type {
} from '@shared/types/session'
import { rehydrateWorkspace } from './rehydrate'
+import { freshStage } from '@renderer/workspace/dispatch/gridShape'
+import { resolveTabSessions } from '@renderer/workspace/queries'
const originalApiDescriptor = Object.getOwnPropertyDescriptor(window, 'api')
@@ -59,18 +61,33 @@ function makePersisted(): PersistedWorkspace {
}
}
+/**
+ * What "the pane survived" means now that there is no tile tree (#992).
+ *
+ * These cases used to assert `tabs[0].root` — the leaf was still in the tree,
+ * the split still had both children. A session's place is two independent facts
+ * now, and a boot bug can break either one without the other: whether its
+ * PROJECT still lists it (ownership; lose this and autosave drops the row), and
+ * whether a LANE still shows it (a pointer; lose this and the user's screen
+ * rearranged itself). Asserting both in one value keeps each case's intent —
+ * "recovery did not take this away" — readable at the call site.
+ */
+function placement(state: WorkspaceState, tabId = 'tab-1') {
+ return {
+ listed: resolveTabSessions(state, tabId),
+ lanes: state.stage.lanes.map(lane => lane.selectedSessionId ?? null),
+ }
+}
+
function makeHarness() {
let state = {
tabs: [],
activeTabId: 'tab-1',
sessions: {},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
- dispatchMode: null,
- } as unknown as WorkspaceState
+ stage: freshStage(),
+ } satisfies WorkspaceState as WorkspaceState
let runtimes: Record = {}
- let tileTabs: null = null
const refs = {
dangerousAgentsRef: ref(false),
useProxyStreamingRef: ref(false),
@@ -98,11 +115,6 @@ function makeHarness() {
runtimes = typeof next === 'function' ? next(runtimes) : next
refs.latestRuntimesRef.current = runtimes
},
- setTileTabs: (next: unknown) => {
- tileTabs = typeof next === 'function'
- ? (next as (prev: null) => null)(tileTabs)
- : next as null
- },
}
}
@@ -121,7 +133,7 @@ describe('rehydrateWorkspace backend reconciliation', () => {
},
})),
} })
- await rehydrateWorkspace(persisted, harness.refs, harness.setState, harness.setRuntimes, harness.setTileTabs, vi.fn())
+ await rehydrateWorkspace(persisted, harness.refs, harness.setState, harness.setRuntimes, vi.fn())
expect(harness.state().sessions['stable-session']?.tldrIdentity).toBe('main-summary')
})
@@ -181,7 +193,6 @@ describe('rehydrateWorkspace backend reconciliation', () => {
harness.refs,
harness.setState,
harness.setRuntimes,
- harness.setTileTabs,
vi.fn(),
)
@@ -244,7 +255,6 @@ describe('rehydrateWorkspace backend reconciliation', () => {
harness.refs,
harness.setState,
harness.setRuntimes,
- harness.setTileTabs,
vi.fn(),
)
@@ -267,7 +277,7 @@ describe('rehydrateWorkspace backend reconciliation', () => {
},
}))
Object.defineProperty(window, 'api', { configurable: true, value: { recoverSession, defaultCwd: vi.fn() } })
- await rehydrateWorkspace(persisted, harness.refs, harness.setState, harness.setRuntimes, harness.setTileTabs, vi.fn())
+ await rehydrateWorkspace(persisted, harness.refs, harness.setState, harness.setRuntimes, vi.fn())
// Recovery may adopt an existing process. Settings describe the next
// launch; they cannot change the tools that process already started with.
expect(recoverSession).toHaveBeenCalledWith(expect.objectContaining({ builtInMcpDomains: ['tldr'] }))
@@ -305,7 +315,6 @@ describe('rehydrateWorkspace backend reconciliation', () => {
harness.refs,
harness.setState,
harness.setRuntimes,
- harness.setTileTabs,
vi.fn(),
)
@@ -317,9 +326,11 @@ describe('rehydrateWorkspace backend reconciliation', () => {
builtInMcpDomains: [],
}))
expect(spawnSession).not.toHaveBeenCalled()
- expect(harness.state().tabs[0].root).toEqual({
- type: 'leaf',
- sessionId: 'stable-session',
+ // A v2 file with no lane grid boots onto the migration default: the pane
+ // the user was commanding in lane 0, beside one empty lane (plan §6.4).
+ expect(placement(harness.state())).toEqual({
+ listed: ['stable-session'],
+ lanes: ['stable-session', null],
})
expect(harness.state().pinnedSessionIds).toEqual(['stable-session'])
expect(harness.state().sessions['stable-session']?.builtInMcpDomains).toEqual([
@@ -352,7 +363,6 @@ describe('rehydrateWorkspace backend reconciliation', () => {
harness.refs,
harness.setState,
harness.setRuntimes,
- harness.setTileTabs,
vi.fn(),
)
await vi.waitFor(() => expect(recoverSession).toHaveBeenCalledTimes(1))
@@ -408,7 +418,6 @@ describe('rehydrateWorkspace backend reconciliation', () => {
harness.refs,
harness.setState,
harness.setRuntimes,
- harness.setTileTabs,
newTab,
)
@@ -418,9 +427,11 @@ describe('rehydrateWorkspace backend reconciliation', () => {
cwd: '/tmp/project',
kind: 'claude',
})
- expect(harness.state().tabs[0].root).toEqual({
- type: 'leaf',
- sessionId: 'stable-session',
+ // A v2 file with no lane grid boots onto the migration default: the pane
+ // the user was commanding in lane 0, beside one empty lane (plan §6.4).
+ expect(placement(harness.state())).toEqual({
+ listed: ['stable-session'],
+ lanes: ['stable-session', null],
})
expect(harness.runtimes()['stable-session']).toMatchObject({
draftInput: 'unfinished prompt',
@@ -465,7 +476,6 @@ describe('rehydrateWorkspace backend reconciliation', () => {
harness.refs,
harness.setState,
harness.setRuntimes,
- harness.setTileTabs,
vi.fn(),
)
@@ -475,10 +485,19 @@ describe('rehydrateWorkspace backend reconciliation', () => {
})
})
- it('keeps failed siblings in a split after every leaf has a resolved outcome', async () => {
+ it('keeps a failed session and its parked sibling listed once every outcome is resolved', async () => {
+ // Re-based with #992. This was "keeps failed siblings in a SPLIT": two tile
+ // leaves, both spawned at boot, one succeeding and one refused, and the
+ // assertion that the refused leaf stayed in the tree. Two things changed
+ // under it. There is no tree to fall out of — the failure that matters now
+ // is the ROW being dropped, which takes the project's listing with it. And
+ // boot spawns only the focused lane's occupant (sessionOwnership.ts), so
+ // the sibling is never asked to start at all. What is still worth pinning
+ // is the pair: a refused recovery must not cost the refused session its
+ // place, and must not disturb the sibling that was never part of it.
const persisted = makePersisted()
persisted.sessions['second-session'] = { cwd: '/tmp/project', kind: 'codex' }
- persisted.tabs[0].root = {
+ persisted.tabs![0]!.root = {
type: 'split',
direction: 'vertical',
ratio: 0.5,
@@ -486,26 +505,12 @@ describe('rehydrateWorkspace backend reconciliation', () => {
b: { type: 'leaf', sessionId: 'second-session' },
}
const harness = makeHarness()
- const recoverSession = vi.fn(async ({ sessionId }: { sessionId: string }) =>
- sessionId === 'stable-session'
- ? {
- ok: true as const,
- disposition: 'spawned' as const,
- snapshot: {
- sessionId,
- kind: 'claude' as const,
- cwd: '/tmp/project',
- lifecycle: 'live' as const,
- input: { ready: false, revision: 0, reason: 'starting' as const },
- },
- }
- : {
- ok: false as const,
- code: 'ownership-conflict' as const,
- retryable: false,
- message: 'Owned by another project',
- },
- )
+ const recoverSession = vi.fn(async () => ({
+ ok: false as const,
+ code: 'ownership-conflict' as const,
+ retryable: false,
+ message: 'Owned by another project',
+ }))
Object.defineProperty(window, 'api', {
configurable: true,
value: { recoverSession, spawnSession: vi.fn(), defaultCwd: vi.fn() },
@@ -516,45 +521,59 @@ describe('rehydrateWorkspace backend reconciliation', () => {
harness.refs,
harness.setState,
harness.setRuntimes,
- harness.setTileTabs,
vi.fn(),
)
- expect(result).toEqual({ restoredSessions: 1, expectedSessions: 2, complete: true })
- expect(harness.state().tabs[0].root).toMatchObject({
- type: 'split',
- a: { sessionId: 'stable-session' },
- b: { sessionId: 'second-session' },
+ // `complete` with zero restored: a refusal is a RESOLVED outcome, which is
+ // what lets autosave unlock instead of waiting forever on a backend that
+ // will never come.
+ expect(result).toEqual({ restoredSessions: 0, expectedSessions: 1, complete: true })
+ expect(recoverSession).toHaveBeenCalledTimes(1)
+ expect(recoverSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'stable-session' }))
+ // Tree order became pool order: the split's depth-first leaves.
+ expect(placement(harness.state())).toEqual({
+ listed: ['stable-session', 'second-session'],
+ lanes: ['stable-session', null],
})
- expect(harness.runtimes()['second-session']).toMatchObject({
+ expect(harness.runtimes()['stable-session']).toMatchObject({
processStatus: 'failed',
processError: 'Owned by another project',
recoveryFailureCode: 'ownership-conflict',
inputReady: false,
})
+ // Parked, not failed: nothing tried to start it, so nothing about it can
+ // have gone wrong. `idle` is also what makes the first selection wake it.
+ expect(harness.runtimes()['second-session']).toMatchObject({
+ processStatus: 'idle',
+ processError: null,
+ recoveryFailureCode: null,
+ })
})
it('never replays persisted layout or runtime state after the initial shell is published', async () => {
+ // Re-based with #992: the old case raced TWO boot recoveries against each
+ // other and removed the first leaf while the second was pending. One
+ // backend spawns at boot now, so the race is that recovery against the
+ // USER — which was always the point. Everything the user can do to a
+ // published shell while a provider is still starting is done below, and
+ // the late outcome must own none of it.
const persisted = makePersisted()
persisted.sessions['second-session'] = {
cwd: '/tmp/project',
kind: 'codex',
title: 'Persisted title',
}
- persisted.tabs[0].root = {
+ persisted.tabs![0]!.root = {
type: 'split',
direction: 'vertical',
ratio: 0.5,
a: { type: 'leaf', sessionId: 'stable-session' },
b: { type: 'leaf', sessionId: 'second-session' },
}
- const first = deferred>>()
- const second = deferred>>()
+ const pending = deferred>>()
const harness = makeHarness()
const recoveryApi = {
- recoverSession: vi.fn(({ sessionId }: { sessionId: string }) =>
- sessionId === 'stable-session' ? first.promise : second.promise,
- ),
+ recoverSession: vi.fn(() => pending.promise),
cancelSessionRecovery: vi.fn(async () => true),
defaultCwd: vi.fn(async () => '/tmp/fallback'),
}
@@ -564,84 +583,72 @@ describe('rehydrateWorkspace backend reconciliation', () => {
harness.refs,
harness.setState,
harness.setRuntimes,
- harness.setTileTabs,
vi.fn(),
recoveryApi,
)
- expect(harness.state().tabs[0].root).toMatchObject({
- type: 'split',
- a: { sessionId: 'stable-session' },
- b: { sessionId: 'second-session' },
+ // The whole durable workspace is on screen BEFORE any provider answers.
+ expect(placement(harness.state())).toEqual({
+ listed: ['stable-session', 'second-session'],
+ lanes: ['stable-session', null],
})
- expect(harness.runtimes()['stable-session'].processStatus).toBe('spawning')
- expect(harness.runtimes()['second-session'].processStatus).toBe('spawning')
+ expect(harness.runtimes()['stable-session']!.processStatus).toBe('spawning')
+ expect(harness.runtimes()['second-session']!.processStatus).toBe('idle')
- first.resolve({
- ok: true,
- disposition: 'spawned',
- snapshot: {
- sessionId: 'stable-session',
- kind: 'claude',
- cwd: '/tmp/project',
- lifecycle: 'live',
- input: { ready: true, revision: 2, reason: 'ready' },
- },
- })
- await vi.waitFor(() => {
- expect(harness.runtimes()['stable-session'].processStatus).toBe('started')
- })
-
- // Model user and live-feed mutations while the second provider is still
- // unresolved. Its eventual outcome owns neither the removed first leaf nor
- // this newer draft/feed/title state.
+ // Model user and live-feed mutations while the provider is still
+ // unresolved: the parked sibling is closed, the recovering agent is moved
+ // to the other lane and renamed, and its draft and feed move on. The
+ // eventual outcome owns neither the removed row, nor the layout, nor this
+ // newer draft/feed/title state.
harness.setState(prev => ({
...prev,
- tabs: [{
- ...prev.tabs[0],
- root: { type: 'leaf', sessionId: 'second-session' },
- focusedSessionId: 'second-session',
- }],
+ stage: {
+ ...prev.stage,
+ lanes: [{}, { selectedSessionId: 'stable-session' }],
+ focusedLane: 1,
+ },
sessions: {
- 'second-session': {
- ...prev.sessions['second-session'],
+ 'stable-session': {
+ ...prev.sessions['stable-session']!,
title: 'Edited while recovering',
},
},
}))
harness.setRuntimes(prev => ({
- 'second-session': {
- ...prev['second-session'],
+ 'stable-session': {
+ ...prev['stable-session']!,
draftInput: 'newer draft',
queuedMessages: [{ content: 'live feed state', timestamp: 'now' }],
},
}))
- second.resolve({
+ pending.resolve({
ok: true,
disposition: 'spawned',
snapshot: {
- sessionId: 'second-session',
- kind: 'codex',
+ sessionId: 'stable-session',
+ kind: 'claude',
cwd: '/tmp/project',
lifecycle: 'live',
input: { ready: true, revision: 3, reason: 'ready' },
},
})
await expect(bootstrap).resolves.toEqual({
- restoredSessions: 2,
- expectedSessions: 2,
+ restoredSessions: 1,
+ expectedSessions: 1,
complete: true,
})
- expect(harness.state().tabs[0].root).toEqual({
- type: 'leaf',
- sessionId: 'second-session',
+ // The user's arrangement, not the file's seed.
+ expect(placement(harness.state())).toEqual({
+ listed: ['stable-session'],
+ lanes: [null, 'stable-session'],
})
- expect(harness.state().sessions['stable-session']).toBeUndefined()
- expect(harness.state().sessions['second-session'].title).toBe('Edited while recovering')
- expect(harness.runtimes()['stable-session']).toBeUndefined()
- expect(harness.runtimes()['second-session']).toMatchObject({
+ expect(harness.state().stage.focusedLane).toBe(1)
+ expect(harness.state().sessions['second-session']).toBeUndefined()
+ expect(harness.state().sessions['stable-session']!.title).toBe('Edited while recovering')
+ expect(harness.runtimes()['second-session']).toBeUndefined()
+ expect(harness.runtimes()['stable-session']).toMatchObject({
processStatus: 'started',
draftInput: 'newer draft',
queuedMessages: [{ content: 'live feed state', timestamp: 'now' }],
@@ -667,7 +674,6 @@ describe('rehydrateWorkspace backend reconciliation', () => {
harness.refs,
harness.setState,
harness.setRuntimes,
- harness.setTileTabs,
vi.fn(),
recoveryApi,
5,
@@ -687,9 +693,11 @@ describe('rehydrateWorkspace backend reconciliation', () => {
recoveryToken: expect.any(String),
})
expect(cancelledRecovery?.recoveryToken).toBe(admittedRecovery?.recoveryToken)
- expect(harness.state().tabs[0].root).toEqual({
- type: 'leaf',
- sessionId: 'stable-session',
+ // A v2 file with no lane grid boots onto the migration default: the pane
+ // the user was commanding in lane 0, beside one empty lane (plan §6.4).
+ expect(placement(harness.state())).toEqual({
+ listed: ['stable-session'],
+ lanes: ['stable-session', null],
})
expect(harness.runtimes()['stable-session']).toMatchObject({
processStatus: 'failed',
@@ -698,7 +706,7 @@ describe('rehydrateWorkspace backend reconciliation', () => {
})
})
- it('preserves a parked agent draft and Dispatch focus without spawning its backend', async () => {
+ it('seeds the parked agent the user was commanding into lane 0, spawns only it, and keeps every draft', async () => {
const persisted = makePersisted()
persisted.sessions['parked-session'] = {
cwd: '/tmp/project',
@@ -715,6 +723,9 @@ describe('rehydrateWorkspace backend reconciliation', () => {
detachedAt: 42,
},
}
+ // Deliberately the v2 ON-DISK shape: real users' files carry this envelope,
+ // and rehydrate is where it becomes a stage (#992). A classic-Dispatch focus
+ // on a parked agent is the #977 entry seed.
persisted.dispatchMode = {
scope: 'project',
focusedSessionId: 'parked-session',
@@ -724,13 +735,17 @@ describe('rehydrateWorkspace backend reconciliation', () => {
'parked-session': 'finish this after restart',
}
const harness = makeHarness()
- const recoverSession = vi.fn(async () => ({
+ // Echoes whichever session was asked for. The old mock hard-coded the grid
+ // leaf's id, which was fine while "the tile leaf" was the only thing boot
+ // could ever request — and would now answer a request for the parked agent
+ // with someone else's snapshot.
+ const recoverSession = vi.fn(async (options: SessionRecoverOptions) => ({
ok: true as const,
- disposition: 'adopted' as const,
+ disposition: 'spawned' as const,
snapshot: {
- sessionId: 'stable-session',
- kind: 'claude' as const,
- cwd: '/tmp/project',
+ sessionId: options.sessionId,
+ kind: options.kind ?? ('claude' as const),
+ cwd: options.cwd,
lifecycle: 'live' as const,
input: { ready: true, revision: 1, reason: 'ready' as const },
},
@@ -749,32 +764,56 @@ describe('rehydrateWorkspace backend reconciliation', () => {
harness.refs,
harness.setState,
harness.setRuntimes,
- harness.setTileTabs,
vi.fn(),
)
// WHY this assertion is stricter than merely checking the metadata row:
- // parked agents deliberately have no provider process after restart, but
- // they are still first-class workspace owners. Losing either their draft
- // or the Dispatch selection makes a successful rehydrate feel like data
- // loss and sends the next command to a different agent.
+ // a parked agent is a first-class workspace owner. Losing either its draft
+ // or the fact that the user was commanding it makes a successful rehydrate
+ // feel like data loss and sends the next command to a different agent.
+ //
+ // WHAT CHANGED with #992, because this case used to assert the opposite
+ // spawn. In v2 the tile leaf (`stable-session`) spawned and the parked
+ // agent stayed parked even though it was the one under the cursor — so the
+ // user's first prompt after a restart paid a wake. The boot-spawn set is
+ // the focused lane's occupant now, and the entry seed puts the agent the
+ // user was commanding in that lane. So exactly the roles swap: the agent
+ // they were talking to comes up live, the pane they had left behind waits.
+ // Still ONE spawn, still nothing spawned because a file merely lists it —
+ // the #258 fork-bomb guard is the count, and the count did not move.
expect(recoverSession).toHaveBeenCalledTimes(1)
- expect(result).toEqual({ restoredSessions: 1, expectedSessions: 1, complete: true })
- expect(harness.state().detachedSessions['parked-session']).toMatchObject({
+ expect(recoverSession).toHaveBeenCalledWith(expect.objectContaining({
sessionId: 'parked-session',
- projectTabId: 'tab-1',
- })
- expect(harness.state().dispatchMode).toMatchObject({
- focusedSessionId: 'parked-session',
- })
+ kind: 'codex',
+ }))
+ expect(result).toEqual({ restoredSessions: 1, expectedSessions: 1, complete: true })
+ // The detached record became the row's own membership: filed under the
+ // project it was parked from, ordered by when it left the screen (42),
+ // after the tile leaf (ordinal 0).
+ expect(harness.state().sessions['parked-session']).toMatchObject({
+ projectId: 'tab-1',
+ joinedAt: 42,
+ })
+ // Lane 0 shows it, beside one empty lane (the imported-workspace default,
+ // plan §6.4).
+ expect(placement(harness.state())).toEqual({
+ listed: ['stable-session', 'parked-session'],
+ lanes: ['parked-session', null],
+ })
+ expect(harness.state().stage.focusedLane).toBe(0)
expect(harness.runtimes()['parked-session']).toMatchObject({
+ processStatus: 'started',
+ draftInput: 'finish this after restart',
+ })
+ // The pane left behind: no backend, draft intact, ready to wake on use.
+ expect(harness.runtimes()['stable-session']).toMatchObject({
processStatus: 'idle',
inputReady: false,
- draftInput: 'finish this after restart',
+ draftInput: 'unfinished prompt',
})
})
- it('repairs detached records for deleted project tabs before publishing runtimes', async () => {
+ it('drops a session filed under a deleted project before publishing runtimes, and spawns nothing for it', async () => {
const persisted = makePersisted()
persisted.sessions['parked-session'] = {
cwd: '/tmp/project',
@@ -802,6 +841,7 @@ describe('rehydrateWorkspace backend reconciliation', () => {
detachedAt: 21,
},
}
+ // v2 on-disk shape on purpose (see the parked-draft case above).
persisted.dispatchMode = {
scope: 'global',
focusedSessionId: 'ghost-session',
@@ -843,21 +883,29 @@ describe('rehydrateWorkspace backend reconciliation', () => {
harness.refs,
harness.setState,
harness.setRuntimes,
- harness.setTileTabs,
vi.fn(),
)
- expect(result).toEqual({ restoredSessions: 1, expectedSessions: 1, complete: true })
- expect(recoverSession).toHaveBeenCalledTimes(1)
+ // The FOCUSED lane named the ghost, so the boot-spawn set is empty: the
+ // pointer under the cursor resolved to nothing, and a pointer is never
+ // ownership. Nothing is spawned in its place — promoting lane 0's occupant
+ // (or the old tile leaf) would be boot deciding what the user is working
+ // on. An empty set is still a COMPLETE boot, which is what unlocks
+ // autosave; `0 === 0` is the honest reading, not a special case.
+ expect(result).toEqual({ restoredSessions: 0, expectedSessions: 0, complete: true })
+ expect(recoverSession).not.toHaveBeenCalled()
expect(harness.state().sessions).toHaveProperty('stable-session')
expect(harness.state().sessions).toHaveProperty('parked-session')
expect(harness.state().sessions).not.toHaveProperty('ghost-session')
- expect(harness.state().detachedSessions).toHaveProperty('parked-session')
- expect(harness.state().detachedSessions).not.toHaveProperty('ghost-session')
+ // A ghost never falls back to the active project: that would hand a
+ // stranger's agent to whichever project happened to be open.
+ expect(placement(harness.state()).listed).toEqual(['stable-session', 'parked-session'])
expect(harness.runtimes()).toHaveProperty('parked-session')
expect(harness.runtimes()).not.toHaveProperty('ghost-session')
- expect(harness.state().dispatchMode?.focusedSessionId).toBeUndefined()
- expect(harness.state().dispatchMode?.tiled?.lanes).toEqual([
+ // The ghost's lane is emptied, never refilled; focus stays on the lane
+ // index the user left it on.
+ expect(harness.state().stage.focusedLane).toBe(1)
+ expect(harness.state().stage.lanes).toEqual([
{ selectedSessionId: 'parked-session' },
{ selectedSessionId: undefined },
])
diff --git a/src/renderer/src/workspace/hook/persistence/rehydrate.ts b/src/renderer/src/workspace/hook/persistence/rehydrate.ts
index 6a0d1b6de..8185d5dc4 100644
--- a/src/renderer/src/workspace/hook/persistence/rehydrate.ts
+++ b/src/renderer/src/workspace/hook/persistence/rehydrate.ts
@@ -13,35 +13,28 @@ import type {
} from '@shared/types/session'
import { emptyRuntime } from '@renderer/session-runtime/state'
import type { SessionRuntime } from '@renderer/session-runtime/state'
-import type { TileTabsState } from '@renderer/workspace/types'
import type {
- BuriedPaneRecord,
- DetachedSessionRecord,
SessionId,
SessionKind,
SessionMeta,
Tab,
- TileNode,
} from '@renderer/workspace/types'
-import { collectLeaves, remapTileTreeSessionIds } from '@renderer/workspace/tile-tree/treeOps'
import {
keepTiledLaneSessions,
- normalizeDispatchModeGrid,
+ normalizeStage,
remapTiledLanes,
} from '@renderer/workspace/dispatch/tiledDispatchSelectors'
import { remapSessionMetaRelationships } from '@renderer/workspace/idRemap'
-import { sanitizeTileTabsState } from '@renderer/workspace/layout/helpers'
import type { PersistedWorkspace } from '@renderer/workspace/persistence'
+import { migrateWorkspaceToStage } from '@renderer/workspace/workspaceShape'
import {
collectLiveProcessIds,
collectOwnedSessionIds,
- collectUnownedSessionIds,
} from '@renderer/workspace/sessionOwnership'
import type {
WorkspaceSetRuntimes,
WorkspaceSetState,
- WorkspaceSetTileTabs,
} from '@renderer/workspace/hook/context'
import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
import { resolveSessionBuiltInMcpDomains, sessionMcpOverrides } from '@renderer/workspace/mcpDomains'
@@ -110,8 +103,8 @@ async function recoverSessionBeforeDeadline(
}
}
-// Reconcile every visible persisted leaf under its ORIGINAL Agent Code
-// SessionId. Main either adopts the backend it already owns (renderer reload)
+// Reconcile the session the stage's focused lane shows under its ORIGINAL Agent
+// Code SessionId (see collectLiveProcessIds for why only that one). Main either adopts the backend it already owns (renderer reload)
// or starts one replacement under that same local id (full app restart).
//
// WHY the local id is stable while providerSessionId is only a launch hint:
@@ -133,21 +126,43 @@ async function recoverSessionBeforeDeadline(
// atomicity that actually belongs to main.
export async function rehydrateWorkspace(
- persisted: PersistedWorkspace,
+ persistedInput: PersistedWorkspace,
refs: WorkspaceRefs,
setState: WorkspaceSetState,
setRuntimes: WorkspaceSetRuntimes,
- setTileTabs: WorkspaceSetTileTabs,
newTab: (cwd: string) => Promise,
recoveryApi: WorkspaceRecoveryApi = window.api,
recoveryTimeoutMs = DEFAULT_SESSION_RECOVERY_TIMEOUT_MS,
): Promise<{ restoredSessions: number; expectedSessions: number; complete: boolean }> {
- perf.mark('workspace.rehydrate.start', {
- tabs: persisted.tabs.length,
- sessions: Object.keys(persisted.sessions).length,
- detachedSessions: Object.keys(persisted.detachedSessions ?? {}).length,
- buried: persisted.buried?.length ?? 0,
- })
+ // ONE normalization, before anything reasons about ownership (#992). The
+ // file may be v2 (tabs owning tile trees, a detached bucket, a buried
+ // bucket, a dispatchMode envelope), v3 (a pool and a stage), or the hybrid
+ // the intermediate builds wrote. `migrateWorkspaceToStage` is total over all
+ // three and everything below sees ONLY its output, so this function no
+ // longer knows that a tile tree, a detached record or a buried pane ever
+ // existed. Every row of `persisted.sessions` is owned (its `projectId` names
+ // one of `persisted.tabs`) and carries its index position; unowned metadata
+ // and ghosts were dropped inside the migration, which is where the v2
+ // ownership rules now live (legacyWorkspaceV2.ts).
+ const migrated = migrateWorkspaceToStage(persistedInput)
+ const persisted = {
+ tabs: migrated.projects.map(project => ({ id: project.id, title: project.title })) as Tab[],
+ activeTabId: migrated.activeProjectId,
+ sessions: migrated.sessions as Record,
+ stage: migrated.stage,
+ pinnedSessionIds: migrated.pinnedSessionIds,
+ drafts: migrated.drafts,
+ }
+ // Counts of what the FILE contained, for the journal: the shape of a bad
+ // boot is a ratio between these (see below), and after the migration the
+ // v2 buckets are no longer distinguishable.
+ const fileCounts = {
+ tabs: (persistedInput.projects ?? persistedInput.tabs ?? []).length,
+ sessions: Object.keys(persistedInput.sessions ?? {}).length,
+ detachedSessions: Object.keys(persistedInput.detachedSessions ?? {}).length,
+ buried: persistedInput.buried?.length ?? 0,
+ }
+ perf.mark('workspace.rehydrate.start', fileCounts)
// The always-on twin of the perf mark above. The perf channel is gated behind
// AGENT_CODE_PERF and is off by default, which is exactly why no cold boot has
// ever been measured. Shape matters here: #258's fork bomb (49 persisted, 9
@@ -155,17 +170,18 @@ export async function rehydrateWorkspace(
// ratio between these counts, and this is the first record of that ratio at
// the moment restore begins.
reportLifecycle('rehydrate.start', undefined, {
- tabs: persisted.tabs.length,
- leaves: Object.keys(persisted.sessions).length,
- detached: Object.keys(persisted.detachedSessions ?? {}).length,
- buried: persisted.buried?.length ?? 0,
+ tabs: fileCounts.tabs,
+ leaves: fileCounts.sessions,
+ detached: fileCounts.detachedSessions,
+ buried: fileCounts.buried,
})
const rehydrateStartedAt = Date.now()
const idMap = new Map()
const freshSessions: Record = {}
const ownedIds = collectOwnedSessionIds(persisted)
const liveProcessIds = collectLiveProcessIds(persisted)
- const staleIds = collectUnownedSessionIds(persisted)
+ // What the migration dropped: rows the file had that nothing owned.
+ const staleIds = Object.keys(persistedInput.sessions ?? {}).filter(id => !ownedIds.has(id))
if (staleIds.length > 0) {
// WHY log and drop instead of trying to repair by providerSessionId:
@@ -174,7 +190,7 @@ export async function rehydrateWorkspace(
// provider history identity and can legitimately be duplicated by clone,
// rewind, or failed restore paths. Using it as a repair key risks attaching
// a hidden stale row to the wrong visible pane. The only safe restore set is
- // the ids already owned by tab leaves, detached sessions, or buried panes.
+ // the ids the migration found an owner for.
// Dropping stale metadata here prevents invisible persisted rows from
// becoming real backend processes/proxies during startup.
// eslint-disable-next-line no-console
@@ -232,62 +248,26 @@ export async function rehydrateWorkspace(
// at a time and are safe to publish incrementally.
syncRecoveryProjection()
- const sanitizeRemappedNode = (n: TileNode): TileNode | null => {
- if (n.type === 'leaf') {
- // WHY layout membership follows durable ownership, not provider timing:
- // an unresolved or failed pane is still user-owned workspace state. The
- // runtime below communicates "starting" or "failed" honestly while the
- // stable leaf remains closable/retryable from the first paint.
- return freshSessions[n.sessionId] ? n : null
+ // A project exists while at least one session names it (U4). A project the
+ // file lists whose every session was dropped as unowned has nothing to show
+ // and nothing to spawn into, so it does not come back.
+ //
+ // WHY membership follows durable ownership, not provider timing: an
+ // unresolved or failed session is still user-owned workspace state. Its
+ // runtime communicates "starting" or "failed" honestly while its row — and
+ // therefore its project — is closable/retryable from the first paint.
+ //
+ // (Until #992 this rebuilt each tab's TILE TREE: remap every leaf through
+ // idMap, cut out leaves with no metadata, collapse the emptied splits, and
+ // re-point the tab's focus at a leaf that survived.)
+ const buildRemappedTabs = (): Tab[] => {
+ const populated = new Set()
+ for (const meta of Object.values(freshSessions)) {
+ if (meta.projectId !== undefined) populated.add(meta.projectId)
}
- const a = sanitizeRemappedNode(n.a)
- const b = sanitizeRemappedNode(n.b)
- if (!a && !b) return null
- if (!a) return b
- if (!b) return a
- return { ...n, a, b }
+ return persisted.tabs.filter(tab => populated.has(tab.id))
}
- const buildRemappedTabs = (): Tab[] =>
- persisted.tabs
- .map(t => {
- const remappedRoot = sanitizeRemappedNode(remapTileTreeSessionIds(t.root, idMap))
- if (!remappedRoot) return null
- const leaves = collectLeaves(remappedRoot)
- if (leaves.length === 0) return null
- const focused = idMap.get(t.focusedSessionId) ?? leaves[0]
- return {
- id: t.id,
- title: t.title,
- root: remappedRoot,
- focusedSessionId: focused,
- } satisfies Tab
- })
- .filter((t): t is Tab => t !== null)
-
- const buildRemappedBuried = (): BuriedPaneRecord[] =>
- (persisted.buried ?? [])
- .flatMap(entry => {
- // WHY fall back to the original sessionId when idMap has no entry:
- //
- // Buried panes are hibernated by design — no PTY, no rehydrate spawn,
- // metadata only. They never appear in idMap because the spawn loop
- // skipped them (see liveProcessIds filter). The previous behavior
- // ("drop if not in idMap") silently lost the buried pane on every
- // restart, defeating the purpose of "bury this for later". Use the
- // original sessionId as the key so the record round-trips intact.
- const mappedSessionId = idMap.get(entry.sessionId) ?? entry.sessionId
- const remapped: BuriedPaneRecord = {
- ...entry,
- id: mappedSessionId,
- sessionId: mappedSessionId,
- }
- if (entry.siblingLeafId) {
- remapped.siblingLeafId = idMap.get(entry.siblingLeafId) ?? entry.siblingLeafId
- }
- return [remapped]
- })
-
// WHY this still projects through idMap even though recovery maps id->id:
// the layout code historically consumes one common identity projection for
// leaves, pins, lanes, and relationship fields. Keeping that path while the
@@ -309,7 +289,6 @@ export async function rehydrateWorkspace(
for (const oldId of ids) {
// WHY fall back to the original id when not in idMap:
//
- // Same pattern as buildRemappedDetachedSessions / buildRemappedBuried.
// Hibernated sessions are seeded into freshSessions under their original
// persisted id and never get an idMap entry. A pin pointing at a parked
// dispatch agent is durable user state — dropping it on every restart
@@ -325,42 +304,6 @@ export async function rehydrateWorkspace(
return remapped
}
- const buildRemappedDetachedSessions = (): Record => {
- const out: Record = {}
- for (const entry of Object.values(persisted.detachedSessions ?? {})) {
- // WHY fall back to the original sessionId when idMap has no entry:
- //
- // Detached (hibernated) sessions are intentionally not respawned during
- // rehydrate — that is the entire point of the live-vs-owned split in
- // sessionOwnership.ts. They have no idMap entry because the spawn loop
- // skipped them. Pre-fix code dropped them here on every restart, which
- // silently emptied the dispatch parking pool after each launch. Falling
- // back to the original id preserves the record verbatim, ready to be
- // woken by an explicit user action later.
- //
- // A visible recovered session has an explicit identity mapping; a
- // hibernated one does not. Both resolve to the same durable id today, but
- // keeping this projection shared with the rest of layout restoration
- // prevents detached records from becoming a special-case identity path.
- const mappedSessionId = idMap.get(entry.sessionId) ?? entry.sessionId
- // WHY metadata survival is the gate here, not the presence of the raw
- // detached record:
- //
- // collectOwnedSessionIds rejects a detached agent when its project tab
- // no longer exists. Copying the raw record anyway would leave half of the
- // corrupted pair in renderer state: no SessionMeta/runtime, but a ghost
- // Dispatch owner that autosave and selectors must keep reasoning about.
- // freshSessions is the already-normalized ownership set, so closing the
- // projection over it repairs old workspace files on their first launch.
- if (!freshSessions[mappedSessionId]) continue
- out[mappedSessionId] = {
- ...entry,
- sessionId: mappedSessionId,
- }
- }
- return out
- }
-
const buildRemappedSessions = (): Record => {
// WHY relationship fields are projected at commit time instead of when
// each session finishes recovery:
@@ -385,18 +328,6 @@ export async function rehydrateWorkspace(
return out
}
- const buildRemappedTileTabs = (tabs: Tab[]): TileTabsState | null => {
- const persistedTileTabs = persisted.tileTabs
- if (!persistedTileTabs) return null
- const validTabIds = persistedTileTabs.tabIds.filter(id =>
- tabs.some(tab => tab.id === id),
- )
- return sanitizeTileTabsState({
- ...persistedTileTabs,
- tabIds: validTabIds,
- })
- }
-
let initialWorkspacePublished = false
const publishedSessionBaselines = new Map()
@@ -518,7 +449,6 @@ export async function rehydrateWorkspace(
const newTabs = buildRemappedTabs()
if (newTabs.length === 0) return false
- const restoredTileTabs = buildRemappedTileTabs(newTabs)
if (!initialWorkspacePublished) {
initialWorkspacePublished = true
const initialSessions = buildRemappedSessions()
@@ -529,47 +459,56 @@ export async function rehydrateWorkspace(
const currentActiveTabStillExists = newTabs.some(t => t.id === prev.activeTabId)
const activeTabId = currentActiveTabStillExists
? prev.activeTabId
- : restoredTileTabs?.focusedTabId
- ?? newTabs.find(t => t.id === persisted.activeTabId)?.id
+ : newTabs.find(t => t.id === persisted.activeTabId)?.id
?? newTabs[0].id
- // Grid shape is normalized OUTERMOST so the shape rules see the final
- // lane array: a workspace written before Grid Dispatch has no `rows`
- // (=> one row of every lane) and a legacy `ratios` array that has to be
- // split into the row's index fraction and the per-lane weights. Doing
- // it here rather than at every reader is what lets the rest of the
- // renderer assume a coherent grid.
- const remappedDispatchMode = normalizeDispatchModeGrid(keepTiledLaneSessions(
- remapTiledLanes(
- persisted.dispatchMode
- ? {
- ...persisted.dispatchMode,
- focusedSessionId: persisted.dispatchMode.focusedSessionId
- ? idMap.get(persisted.dispatchMode.focusedSessionId)
- : undefined,
- }
- : null,
- idMap,
- ),
+ // The stage comes from the migration at the top of this function
+ // (#992), not from a field read directly. That is what makes every
+ // file shape boot into a stage without a second code path:
+ // - a v3 file: its `stage`;
+ // - a v2 file with lanes: `dispatchMode.tiled`;
+ // - a v2 file that never had lanes (grid-only, or classic
+ // Dispatch): the seeded default, lane 0 holding the pane the user
+ // was last commanding (#977's entry seed) beside one empty lane.
+ // Bootstrap used to do that last case by calling enterTiledDispatch
+ // after rehydrate returned; doing it here means the FIRST published
+ // state already has a stage, so nothing can render — or autosave —
+ // a workspace that lacks one.
+ //
+ // The chain after it is unchanged and its ORDER is load-bearing:
+ // remap (restored sessions may carry new ids), then keep-live, then
+ // normalize OUTERMOST so the shape rules see the final lane array — a
+ // file written before the row grid has no `rows` (=> one row of every
+ // lane) and a legacy `ratios` array that has to be split into the
+ // row's index fraction and the per-lane weights. Doing it here rather
+ // than at every reader is what lets the rest of the renderer assume a
+ // coherent grid.
+ //
+ // A seeded or restored lane may name a HIBERNATED session. That is
+ // fine and deliberate, and how it wakes depends on its kind: a
+ // terminal leaf wakes its backend when it mounts; an agent leaf shows
+ // its committed transcript and wakes on the first send (TileLeaf.send
+ // -> ensureSessionLive, the #691 fix for a hibernated lane rejecting
+ // its first prompt). Neither spawns because a FILE lists it, which is
+ // what keeps the #258 fork-bomb guard intact.
+ const stage = normalizeStage(keepTiledLaneSessions(
+ remapTiledLanes(persisted.stage, idMap),
// WHY remapping alone cannot repair stale lane ownership:
// remapTiledLanes intentionally leaves unknown ids untouched because
// valid hibernated sessions keep their durable ids. After ownership
// normalization, initialSessions is the authority that distinguishes
- // those valid parked ids from deleted-tab ghosts. Closing every
- // Dispatch pointer over this same set prevents the repaired owner
- // record from lingering as a selected-but-unresolvable lane.
+ // those valid parked ids from deleted-tab ghosts. Closing every lane
+ // pointer over this same set prevents the repaired owner record from
+ // lingering as a selected-but-unresolvable lane.
new Set(Object.keys(initialSessions)),
- )) ?? null
+ ))
return {
tabs: newTabs,
activeTabId,
- dispatchMode: remappedDispatchMode,
+ stage,
sessions: initialSessions,
- detachedSessions: buildRemappedDetachedSessions(),
- buried: buildRemappedBuried(),
pinnedSessionIds: buildRemappedPinnedSessionIds(),
}
})
- setTileTabs(restoredTileTabs)
setRuntimes(prev => {
const out: Record = {}
for (const sessionId of Object.keys(freshSessions)) {
@@ -629,30 +568,29 @@ export async function rehydrateWorkspace(
return true
}
- // Publish every durable leaf before starting provider work. This is the
- // renderer-side half of bounded recovery: a hung provider remains a visible,
- // closable "starting" pane instead of withholding the entire workspace.
+ // Publish the whole durable workspace before starting provider work. This is
+ // the renderer-side half of bounded recovery: a hung provider remains a
+ // visible, closable "starting" session instead of withholding everything.
const publishedDurableWorkspace = commitRehydratedState()
- // Spawn live tile-leaf sessions concurrently. A single slow respawn
- // must not block the entire tab strip from coming back.
+ // Recover the boot-spawn set. A single slow respawn must not block the rest
+ // of the workspace from coming back.
//
// WHY this filter is liveProcessIds, not ownedIds (the original bug):
//
- // ownedIds includes detached and buried sessions — i.e. parked agents the
- // user has explicitly removed from their visible workspace. The previous
- // code spawned every owner on rehydrate, which meant every time you parked
- // dispatch agents and restarted, all of them came back as live processes
- // (plus a per-session mitmdump) regardless of whether you intended to use
- // them. With ~40 parked dispatch agents accumulating in detachedSessions,
- // a single restart fork-bombed the machine with 40 claude + 40 mitmdump
- // processes, all started in this Promise.all in the same ~3 seconds.
+ // ownedIds is every session the workspace owns, parked agents included. The
+ // previous code spawned every owner on rehydrate, which meant every time you
+ // parked agents and restarted, all of them came back as live processes (plus
+ // a per-session mitmdump) regardless of whether you intended to use them.
+ // With ~40 parked agents accumulated, a single restart fork-bombed the
+ // machine with 40 claude + 40 mitmdump processes, all started in this
+ // Promise.all in the same ~3 seconds.
//
- // liveProcessIds is the strictly smaller set the user is going to be
- // exposed to on launch — current tile-tree leaves only. Hibernated
- // sessions get metadata-restored above (so they're still rendered in
- // dispatch lists and revivable later), but no PTY/mitmdump/MCP host
- // is created until the user explicitly wakes one.
+ // liveProcessIds is the strictly smaller set that must have a backend at
+ // first paint — the focused lane's occupant (sessionOwnership.ts explains
+ // why that and no more). Every other session is metadata-restored above, so
+ // it is listed in its project's index and wakes on first use, but no
+ // PTY/mitmdump/MCP host is created for it here.
await Promise.all(
Object.entries(persisted.sessions)
.filter(([oldId]) => liveProcessIds.has(oldId))
@@ -857,11 +795,10 @@ export async function rehydrateWorkspace(
//
// `complete` here gates autosave (useBootstrap reads it to decide whether
// disk can be overwritten with the in-memory model). The invariant the gate
- // enforces is "no visible pane was silently dropped" — i.e. every leaf in
- // the user's tile tree got a working backend process. Hibernated sessions
- // (detached + buried) deliberately do not spawn a process during rehydrate;
+ // enforces is "every session boot was supposed to start got an outcome".
+ // Parked sessions deliberately do not spawn a process during rehydrate;
// counting them against expected would make `complete` false forever for
- // anyone who has parked a dispatch agent, permanently disabling autosave.
+ // anyone who has parked an agent, permanently disabling autosave.
// freshSessions also includes hibernated metadata seeds, so successful
// process telemetry counts liveBackendIds while the autosave safety gate
// counts resolvedIds (success OR retained failure).
diff --git a/src/renderer/src/workspace/hook/persistence/sessionRecovery.integration.test.ts b/src/renderer/src/workspace/hook/persistence/sessionRecovery.integration.test.ts
index a7ba468a8..2d7c508a6 100644
--- a/src/renderer/src/workspace/hook/persistence/sessionRecovery.integration.test.ts
+++ b/src/renderer/src/workspace/hook/persistence/sessionRecovery.integration.test.ts
@@ -6,6 +6,7 @@ import type { SessionRuntime } from '@renderer/session-runtime/state'
import type { PersistedWorkspace } from '@renderer/workspace/persistence'
import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
import type { SessionId, WorkspaceState } from '@renderer/workspace/types'
+import { freshStage } from '@renderer/workspace/dispatch/gridShape'
const { createSession, loadInitialHistoryForSession } = vi.hoisted(() => ({
createSession: vi.fn(),
@@ -95,6 +96,11 @@ function ref(current: T): MutableRefObject {
function makePersisted(): PersistedWorkspace {
return {
+ // Deliberately a v2 FILE (a tab owning a tile tree): this suite is the
+ // cross-layer restart proof, and the restart users will actually perform
+ // first is the one that upgrades this shape. The migration seeds lane 0
+ // with the tab's focus (#977), which is what makes `stable-session` the
+ // focused lane's occupant — and therefore the one session boot recovers.
tabs: [{
id: 'tab-1',
title: 'Project',
@@ -145,13 +151,10 @@ function makeRendererHarness() {
tabs: [],
activeTabId: 'tab-1',
sessions: {},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
- dispatchMode: null,
+ stage: freshStage(),
} as unknown as WorkspaceState
let runtimes: Record = {}
- let tileTabs: unknown = null
const refs = {
dangerousAgentsRef: ref(false),
useProxyStreamingRef: ref(false),
@@ -178,11 +181,6 @@ function makeRendererHarness() {
runtimes = typeof next === 'function' ? next(runtimes) : next
refs.latestRuntimesRef.current = runtimes
},
- setTileTabs: (next: unknown) => {
- tileTabs = typeof next === 'function'
- ? (next as (prev: unknown) => unknown)(tileTabs)
- : next
- },
}
}
@@ -217,7 +215,6 @@ describe('cross-layer session restart reconciliation', () => {
firstRenderer.refs,
firstRenderer.setState,
firstRenderer.setRuntimes,
- firstRenderer.setTileTabs,
vi.fn(),
recoveryApi,
)
@@ -227,7 +224,6 @@ describe('cross-layer session restart reconciliation', () => {
reloadedRenderer.refs,
reloadedRenderer.setState,
reloadedRenderer.setRuntimes,
- reloadedRenderer.setTileTabs,
vi.fn(),
recoveryApi,
)
@@ -258,10 +254,11 @@ describe('cross-layer session restart reconciliation', () => {
},
},
})
- expect(reloadedRenderer.state().tabs[0].root).toEqual({
- type: 'leaf',
- sessionId: 'stable-session',
- })
+ // Still owned, still on the lane it was on: a failed or pending backend is
+ // a fact about the runtime, never a reason to drop the session. (Tree era:
+ // "the tab's tile leaf survives".)
+ expect(reloadedRenderer.state().sessions['stable-session']).toMatchObject({ projectId: 'tab-1' })
+ expect(reloadedRenderer.state().stage.lanes[0]).toEqual({ selectedSessionId: 'stable-session' })
expect(reloadedRenderer.runtimes()['stable-session']).toMatchObject({
draftInput: 'unfinished prompt',
processStatus: 'started',
@@ -276,7 +273,6 @@ describe('cross-layer session restart reconciliation', () => {
restartedRenderer.refs,
restartedRenderer.setState,
restartedRenderer.setRuntimes,
- restartedRenderer.setTileTabs,
vi.fn(),
recoveryApi,
)
@@ -317,16 +313,16 @@ describe('cross-layer session restart reconciliation', () => {
failedRenderer.refs,
failedRenderer.setState,
failedRenderer.setRuntimes,
- failedRenderer.setTileTabs,
vi.fn(),
recoveryApi,
)
expect(failed).toEqual({ restoredSessions: 0, expectedSessions: 1, complete: true })
- expect(failedRenderer.state().tabs[0].root).toEqual({
- type: 'leaf',
- sessionId: 'stable-session',
- })
+ // Still owned, still on the lane it was on: a failed or pending backend is
+ // a fact about the runtime, never a reason to drop the session. (Tree era:
+ // "the tab's tile leaf survives".)
+ expect(failedRenderer.state().sessions['stable-session']).toMatchObject({ projectId: 'tab-1' })
+ expect(failedRenderer.state().stage.lanes[0]).toEqual({ selectedSessionId: 'stable-session' })
expect(failedRenderer.runtimes()['stable-session']).toMatchObject({
draftInput: 'unfinished prompt',
processStatus: 'failed',
@@ -342,7 +338,6 @@ describe('cross-layer session restart reconciliation', () => {
retryRenderer.refs,
retryRenderer.setState,
retryRenderer.setRuntimes,
- retryRenderer.setTileTabs,
vi.fn(),
recoveryApi,
)
@@ -388,7 +383,6 @@ describe('cross-layer session restart reconciliation', () => {
renderer.refs,
renderer.setState,
renderer.setRuntimes,
- renderer.setTileTabs,
vi.fn(),
recoveryApi,
)
@@ -405,10 +399,11 @@ describe('cross-layer session restart reconciliation', () => {
expect(mcpHost.revokeSession).toHaveBeenCalledTimes(1)
expect(manager.getBackendSnapshot('stable-session')).toBeNull()
expect(manager.list()).toEqual([])
- expect(renderer.state().tabs[0].root).toEqual({
- type: 'leaf',
- sessionId: 'stable-session',
- })
+ // Still owned, still on the lane it was on: a failed or pending backend is
+ // a fact about the runtime, never a reason to drop the session. (Tree era:
+ // "the tab's tile leaf survives".)
+ expect(renderer.state().sessions['stable-session']).toMatchObject({ projectId: 'tab-1' })
+ expect(renderer.state().stage.lanes[0]).toEqual({ selectedSessionId: 'stable-session' })
expect(renderer.runtimes()['stable-session']).toMatchObject({
processStatus: 'failed',
processError: expect.stringContaining('cancelled'),
diff --git a/src/renderer/src/workspace/hook/persistence/unifiedStage.integration.test.ts b/src/renderer/src/workspace/hook/persistence/unifiedStage.integration.test.ts
new file mode 100644
index 000000000..a46fe6f2f
--- /dev/null
+++ b/src/renderer/src/workspace/hook/persistence/unifiedStage.integration.test.ts
@@ -0,0 +1,398 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { MutableRefObject } from 'react'
+
+import type { SessionRuntime } from '@renderer/session-runtime/state'
+import type { PersistedWorkspace } from '@renderer/workspace/persistence'
+import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
+import type { SessionId, SessionMeta, WorkspaceState } from '@renderer/workspace/types'
+import type {
+ SessionRecoverOptions,
+ SessionRecoverResult,
+} from '@shared/types/session'
+
+// Same mock as the sibling recovery suite: rehydrate imports the perf
+// client unconditionally and this tier has no renderer globals to back it.
+vi.mock('@renderer/performance/client', () => ({
+ mark: vi.fn(),
+ span: () => ({ end: vi.fn(), fail: vi.fn() }),
+ measure: (name: string, fn: () => T | Promise) => fn(),
+}))
+
+import { rehydrateWorkspace } from './rehydrate'
+import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchSelectors'
+import {
+ activeProjectIdOfWorkspace,
+ projectIdOfSession,
+ projectsOfWorkspace,
+ stageOfWorkspace,
+} from '@renderer/workspace/workspaceStage'
+import { ownerV2Workspace } from '@renderer/workspace/workspaceShape.ownerV2Fixture'
+import { freshStage } from '@renderer/workspace/dispatch/gridShape'
+
+// Tier: integration. One fake, at the preload-bridge seam — every layer
+// above it (ownership projection, rehydrate's commit chain, dispatch row
+// construction, the live v3 stage/project selectors) runs for real against
+// the RECORDED owner fixture. These tests protect the boot contract of the
+// unified layout (#992): whatever a real v2 workspace.json contains, the
+// app comes up on one stage over one pool, with project affinity intact.
+//
+// The companion unit suites pin the rules piecewise (workspaceShape.test.ts
+// for the persisted migration, workspaceStage.test.ts for derivation over
+// synthetic state); what only this file can catch is the layers disagreeing
+// — e.g. rehydrate's committed state failing the selector that the render
+// path will run one frame later.
+
+function ref(current: T): MutableRefObject {
+ return { current }
+}
+
+/**
+ * Recovery fake: every requested session "starts" and is immediately live
+ * and input-ready. The unified-layout boot contract does not depend on
+ * provider behavior — only on rehydrate resolving every live-process leaf —
+ * so one uniform happy outcome isolates the layout integration from the
+ * provider matrix (sessionRecovery.integration.test.ts owns that matrix).
+ */
+function makeLiveRecoveryApi(calls: SessionRecoverOptions[] = []) {
+ return {
+ recoverSession: vi.fn(async (options: SessionRecoverOptions): Promise => {
+ calls.push(options)
+ return {
+ ok: true,
+ disposition: 'spawned',
+ snapshot: {
+ sessionId: options.sessionId,
+ sessionRunId: `run-${options.sessionId}`,
+ kind: options.kind ?? 'claude',
+ ...(options.providerRuntime ? { providerRuntime: options.providerRuntime } : {}),
+ cwd: options.cwd,
+ lifecycle: 'live',
+ input: { ready: true, revision: 1 },
+ },
+ }
+ }),
+ cancelSessionRecovery: vi.fn(async () => true),
+ defaultCwd: vi.fn(async () => '/tmp/fallback'),
+ }
+}
+
+function makeHarness() {
+ let state = {
+ tabs: [],
+ activeTabId: 'tab-1',
+ sessions: {},
+ pinnedSessionIds: [],
+ // What the store holds before bootstrap runs: the one-lane fresh stage.
+ stage: freshStage(),
+ } satisfies WorkspaceState as WorkspaceState
+ let runtimes: Record = {}
+ const refs = {
+ dangerousAgentsRef: ref(false),
+ useProxyStreamingRef: ref(false),
+ defaultBuiltInMcpDomainsRef: ref([]),
+ stateRef: ref(state),
+ latestStateRef: ref(state),
+ latestRuntimesRef: ref(runtimes),
+ } as unknown as WorkspaceRefs
+ return {
+ refs,
+ state: () => state,
+ setState(next: WorkspaceState | ((prev: WorkspaceState) => WorkspaceState)) {
+ state = typeof next === 'function' ? next(state) : next
+ refs.stateRef.current = state
+ refs.latestStateRef.current = state
+ },
+ setRuntimes(
+ next:
+ | Record
+ | ((prev: Record) => Record),
+ ) {
+ runtimes = typeof next === 'function' ? next(runtimes) : next
+ refs.latestRuntimesRef.current = runtimes
+ },
+ }
+}
+
+/** A pure-grid v2 workspace: multi-pane tab, no dispatchMode anywhere. */
+function gridHeavyV2Workspace(): PersistedWorkspace {
+ const sessions: Record = {
+ 's-a1': { cwd: '/x/app', kind: 'claude' },
+ 's-a2': { cwd: '/x/app', kind: 'claude' },
+ 's-a3': { cwd: '/x/app', kind: 'terminal' },
+ 's-b1': { cwd: '/x/service', kind: 'claude' },
+ }
+ return {
+ tabs: [
+ {
+ id: 'tab-a',
+ title: 'app',
+ focusedSessionId: 's-a1',
+ root: {
+ type: 'split',
+ direction: 'vertical',
+ ratio: 0.66,
+ a: { type: 'leaf', sessionId: 's-a1' },
+ b: {
+ type: 'split',
+ direction: 'horizontal',
+ ratio: 0.5,
+ a: { type: 'leaf', sessionId: 's-a2' },
+ b: { type: 'leaf', sessionId: 's-a3' },
+ },
+ },
+ },
+ {
+ id: 'tab-b',
+ title: 'service',
+ focusedSessionId: 's-b1',
+ root: { type: 'leaf', sessionId: 's-b1' },
+ },
+ ],
+ activeTabId: 'tab-a',
+ sessions,
+ }
+}
+
+describe('unified layout boot — recorded owner workspace', () => {
+ it('boots the v2 dispatch workspace onto its intact stage over the full pool', async () => {
+ const harness = makeHarness()
+ const calls: SessionRecoverOptions[] = []
+ const result = await rehydrateWorkspace(
+ ownerV2Workspace,
+ harness.refs,
+ harness.setState,
+ harness.setRuntimes,
+ vi.fn(),
+ makeLiveRecoveryApi(calls),
+ )
+
+ // Exactly ONE backend is spawned at boot: the occupant of the focused lane
+ // (lane 10 in the recording). The #258 fork-bomb guard, observed
+ // end-to-end, and tighter than it has ever been.
+ //
+ // WHY this assertion changed and is not a regression. Through 3b-i it
+ // named three OTHER sessions — the three tab leaves — because the boot
+ // spawn set was "every tile leaf". In this recording those are three
+ // one-pane tabs the user never looked at (they work entirely in lanes), so
+ // boot spent three agent spawns on panes nothing rendered while the lane
+ // under the cursor came up parked. With the tile tree gone the set is the
+ // focused lane's occupant: the one session the user can type into the
+ // instant the window paints. Every other lane wakes on first use — agents
+ // on first send (#691), terminals when their leaf mounts — which is the
+ // path all twelve of this user's lanes already took on every launch.
+ expect(result.complete).toBe(true)
+ expect(calls.map(call => call.sessionId)).toEqual(['1d0db3d8-b277-4a8d-81b1-5269d76ed48a'])
+
+ const state = harness.state()
+ // The stored grid is the workspace: same lanes, same ragged rows, same
+ // focused lane the file had — byte-faithful continuity for the user's
+ // actual working shape.
+ expect(state.stage.lanes).toHaveLength(12)
+ expect(state.stage.rows).toEqual([
+ { length: 6, capChildren: false, indexFraction: 0.1, height: 0.5869481693862371 },
+ { length: 6, height: 0.41305183061376294, indexFraction: 0.1 },
+ ])
+ expect(state.stage.focusedLane).toBe(10)
+ // The selector returns the STORED stage by reference when it is already
+ // shape-complete — the identity contract lane memos depend on.
+ expect(stageOfWorkspace(state)).toBe(state.stage)
+ // The v2 envelope did not survive the boot: no scope, no classic focus.
+ expect(state).not.toHaveProperty('dispatchMode')
+
+ // Projects and pool affinity through the live selectors.
+ expect(projectsOfWorkspace(state)).toHaveLength(3)
+ expect(activeProjectIdOfWorkspace(state)).toBe('e0224b91-da18-4b20-9cc0-da491569a6b5')
+ expect(projectIdOfSession(state, '575880c6-d447-49b8-aa9b-64705d70c287')).toBe(
+ 'e0224b91-da18-4b20-9cc0-da491569a6b5',
+ )
+ expect(projectIdOfSession(state, 'e6e19a29-f8b4-44da-bb9a-38fcfca2a314')).toBe(
+ '3bf27c7f-2e3a-4da1-a35a-e013ad86f937',
+ )
+
+ // The dispatch rows the lanes resolve from include terminals and the
+ // extension view: pool citizens, in the same visible order the user
+ // had. 17 owned sessions, none invisible.
+ const rows = buildVisibleDispatchRows(state)
+ expect(rows).toHaveLength(17)
+ expect(rows.some(row => row.sessionId === '20c09242-4210-433b-b4cd-c0d31b47c507')).toBe(true)
+ expect(rows.some(row => row.sessionId === '7327ced2-fb07-4b63-a357-50d3f94f8fb6')).toBe(true)
+ })
+
+ it('keeps every lane session resolvable in the index after boot', async () => {
+ const harness = makeHarness()
+ await rehydrateWorkspace(
+ ownerV2Workspace,
+ harness.refs,
+ harness.setState,
+ harness.setRuntimes,
+ vi.fn(),
+ makeLiveRecoveryApi(),
+ )
+ const state = harness.state()
+ const rowIds = new Set(buildVisibleDispatchRows(state).map(row => row.sessionId))
+ const laneIds = state.stage.lanes
+ .map(lane => lane.selectedSessionId)
+ .filter((id): id is SessionId => id !== undefined)
+ // Every lane's occupant must be selectable from the index after a real
+ // boot — a lane pointing at a row the index cannot produce is the
+ // "selected-but-unresolvable lane" bug class, caught here end-to-end
+ // instead of by imagination.
+ for (const id of laneIds) {
+ expect(rowIds.has(id), `lane session ${id} missing from index rows`).toBe(true)
+ }
+ })
+})
+
+describe('unified layout boot — pure-grid v2 workspace', () => {
+ it('boots onto the MIGRATED seeded default stage with tab leaves pooled', async () => {
+ const harness = makeHarness()
+ const calls: SessionRecoverOptions[] = []
+ const result = await rehydrateWorkspace(
+ gridHeavyV2Workspace(),
+ harness.refs,
+ harness.setState,
+ harness.setRuntimes,
+ vi.fn(),
+ makeLiveRecoveryApi(calls),
+ )
+ expect(result.complete).toBe(true)
+ // Only the seeded lane's occupant. v2 spawned all four leaves here (three
+ // panes of tab-a plus tab-b's one); three of them are now parked pool rows
+ // that wake when first placed or prompted. Cheaper boot, same reachability
+ // — the row assertions below are what pin "reachable".
+ expect(calls.map(call => call.sessionId)).toEqual(['s-a1'])
+
+ const state = harness.state()
+ // The file had no lane grid, so rehydrate published the migration's
+ // default — [2], lane 0 seeded with the session the user was commanding
+ // (their active tab's focus), everything else pooled. It is STORED, in
+ // the first state rehydrate commits: through stage 2 of #992 this was a
+ // value a selector derived on every read and bootstrap wrote later. This
+ // is the accepted-loss boot: the multi-pane arrangement is NOT
+ // reconstructed, and this test pins that the pooled leaves are still
+ // reachable rather than vanished.
+ expect(state.stage.lanes).toEqual([{ selectedSessionId: 's-a1' }, {}])
+ const stage = stageOfWorkspace(state)
+ expect(stage.rows).toEqual([{ length: 2 }])
+ expect(stage.lanes).toEqual([{ selectedSessionId: 's-a1' }, {}])
+ expect(stage.focusedLane).toBe(0)
+
+ const rowIds = new Set(buildVisibleDispatchRows(state).map(row => row.sessionId))
+ // Same-tab leaves pool into the visible index.
+ expect(rowIds.has('s-a2')).toBe(true)
+ expect(rowIds.has('s-a3')).toBe(true)
+ // The OTHER project's leaf is listed too. This assertion was pinned as
+ // `false` and marked TRANSITIONAL while a layout-wide scope still
+ // existed: with no dispatchMode the index defaulted to PROJECT scope, so
+ // tab-b's agent was alive but unlisted until its project became active —
+ // and the command that switched scope had already been deleted, which
+ // would have stranded it. Scope died with the envelope; the whole fleet
+ // is in every index.
+ expect(rowIds.has('s-b1')).toBe(true)
+ expect(state.sessions['s-b1']).toBeDefined()
+ expect(projectIdOfSession(state, 's-b1')).toBe('tab-b')
+
+ // Pool affinity survived boot through the live selector.
+ expect(projectIdOfSession(state, 's-a3')).toBe('tab-a')
+ expect(projectsOfWorkspace(state)).toEqual([
+ { id: 'tab-a', title: 'app' },
+ { id: 'tab-b', title: 'service' },
+ ])
+ })
+
+ it('keeps the seed honest when the active focus names a detached (parked) session', async () => {
+ // The owner fixture's real quirk, isolated: tab.focus pointing at a
+ // session outside that tab. Here the file HAS a lane grid, so the seed
+ // path is not taken — the assertion pins that a dangling-ish focus cannot
+ // leak into a lane on boot when a grid exists. Two EMPTY lanes must stay
+ // two empty lanes: the migration carries the user's shape over, it does
+ // not "improve" it.
+ const persisted = gridHeavyV2Workspace()
+ persisted.tabs![0]!.focusedSessionId = 's-b1'
+ const harness = makeHarness()
+ await rehydrateWorkspace(
+ { ...persisted, dispatchMode: { scope: 'global', tiled: { lanes: [{}, {}], focusedLane: 1 } } },
+ harness.refs,
+ harness.setState,
+ harness.setRuntimes,
+ vi.fn(),
+ makeLiveRecoveryApi(),
+ )
+ const state = harness.state()
+ expect(state.stage.lanes).toEqual([{}, {}])
+ expect(state.stage.focusedLane).toBe(1)
+ // Normalized once at boot (rows synthesized for a pre-row-grid file), so
+ // the selector has nothing left to do and hands back the same reference.
+ expect(state.stage.rows).toEqual([{ length: 2 }])
+ expect(stageOfWorkspace(state)).toBe(state.stage)
+ })
+})
+
+describe('unified layout boot — runtime seeds survive a first interaction', () => {
+ it('commits an empty runtime for parked pool members (no spawn, but addressable)', async () => {
+ const harness = makeHarness()
+ await rehydrateWorkspace(
+ ownerV2Workspace,
+ harness.refs,
+ harness.setState,
+ harness.setRuntimes,
+ vi.fn(),
+ makeLiveRecoveryApi(),
+ )
+ const runtimes = harness.refs.latestRuntimesRef.current
+ // The focused lane's occupant got a real recovered runtime.
+ expect(runtimes['1d0db3d8-b277-4a8d-81b1-5269d76ed48a']).toMatchObject({
+ processStatus: 'started',
+ })
+ // Everything else exists in the runtime map in the hibernated idle shape.
+ // Two different kinds of "everything else" are pinned, because they fail
+ // differently: a session SHOWN in an unfocused lane (its leaf renders on
+ // the first frame and must find a runtime to read, not a hole), and a
+ // former tile leaf that no lane shows (placing it later must find runtime
+ // state to wake). `idle` is also the value the wake decision reads: a
+ // selection gesture asks "is this runtime started?" now, not "is this row
+ // in the detached bucket?", so a parked session seeded as anything but
+ // idle would be placed without a wake and reject its first prompt (#690).
+ expect(runtimes['6d6cac8c-fe3d-4f5e-82e3-740036b4aebd']?.processStatus).toBe('idle')
+ expect(runtimes['575880c6-d447-49b8-aa9b-64705d70c287']?.processStatus).toBe('idle')
+ })
+})
+
+describe('unified layout boot — buried sessions', () => {
+ it('boots an old file\'s buried session as a parked, listed pool row', async () => {
+ // Bury/Revive were deleted in #992. An old workspace.json can still carry
+ // buried records — including ones whose metadata lives ONLY in the record.
+ // After a real rehydrate the session must be owned, listed in its
+ // project's index, addressable by a runtime, and not spawned.
+ const persisted = gridHeavyV2Workspace()
+ persisted.buried = [{
+ id: 's-hidden',
+ sessionId: 's-hidden',
+ sessionMeta: { cwd: '/x/app', kind: 'codex' },
+ buriedAt: 5,
+ sourceTabId: 'tab-a',
+ sourceTabTitle: 'app',
+ sourceTabIndex: 0,
+ }]
+ const harness = makeHarness()
+ const calls: SessionRecoverOptions[] = []
+ const result = await rehydrateWorkspace(
+ persisted,
+ harness.refs,
+ harness.setState,
+ harness.setRuntimes,
+ vi.fn(),
+ makeLiveRecoveryApi(calls),
+ )
+ expect(result.complete).toBe(true)
+ expect(calls.map(call => call.sessionId)).not.toContain('s-hidden')
+
+ const state = harness.state()
+ // An ordinary pool row: its metadata restored from the record, filed under
+ // its source project, positioned by when it left the screen.
+ expect(state).not.toHaveProperty('buried')
+ expect(state.sessions['s-hidden']).toEqual({ cwd: '/x/app', kind: 'codex', projectId: 'tab-a', joinedAt: 5 })
+ expect(buildVisibleDispatchRows(state).map(row => row.sessionId)).toContain('s-hidden')
+ expect(harness.refs.latestRuntimesRef.current['s-hidden']?.processStatus).toBe('idle')
+ })
+})
diff --git a/src/renderer/src/workspace/hook/persistence/useAutoSave.renderer.test.tsx b/src/renderer/src/workspace/hook/persistence/useAutoSave.renderer.test.tsx
index d121ef0ca..2110c4215 100644
--- a/src/renderer/src/workspace/hook/persistence/useAutoSave.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/persistence/useAutoSave.renderer.test.tsx
@@ -7,6 +7,7 @@ import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
import type { WorkspaceState } from '@renderer/workspace/types'
import { useAutoSave } from './useAutoSave'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
vi.mock('@renderer/performance/client', () => ({
span: () => ({ end: vi.fn(), fail: vi.fn() }),
@@ -36,22 +37,17 @@ describe('workspace autosave durability retry', () => {
tabs: [{
id: 'tab-a',
title: 'recorded',
- root: { type: 'leaf', sessionId: 'successor' },
- focusedSessionId: 'successor',
}],
activeTabId: 'tab-a',
- dispatchMode: null,
+ stage: oneLaneStage('successor'),
sessions: {
- successor: { cwd: '/recorded/worktree', kind: 'codex' },
+ successor: { cwd: '/recorded/worktree', kind: 'codex', projectId: 'tab-a', joinedAt: 0 },
},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
}
const refs = {
latestStateRef: ref(state),
latestRuntimesRef: ref({ successor: emptyRuntime() }),
- latestTileTabsRef: ref(null),
saveTimerRef: ref | null>(null),
} as unknown as WorkspaceRefs
const saveWorkspace = vi.fn()
@@ -84,6 +80,78 @@ describe('workspace autosave durability retry', () => {
unmount()
})
+ it('writes the live stage and the v3 project triple, and no v2 mode envelope (#992)', async () => {
+ vi.useFakeTimers()
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const state: WorkspaceState = {
+ tabs: [{
+ id: 'tab-a',
+ title: 'recorded',
+ }],
+ activeTabId: 'tab-a',
+ // The user's actual shape: three lanes, the middle one empty by choice,
+ // focus on the last. Autosave must write THIS, verbatim.
+ //
+ // Through stage 2 of #992 this case had no stored grid and asserted the
+ // opposite kind of thing — that the v3 half was DERIVED at save time
+ // (the seeded [2] default, focused session in lane 0). A derived stage
+ // at the durability boundary would now overwrite the user's lanes with
+ // a guess on every save, so the contract is inverted: what is in memory
+ // is what is written.
+ stage: {
+ lanes: [{ selectedSessionId: 'successor' }, {}, { selectedSessionId: 'successor' }],
+ rows: [{ length: 3 }],
+ focusedLane: 2,
+ },
+ sessions: {
+ successor: { cwd: '/recorded/worktree', kind: 'codex', projectId: 'tab-a', joinedAt: 0 },
+ },
+ pinnedSessionIds: [],
+ }
+ const refs = {
+ latestStateRef: ref(state),
+ latestRuntimesRef: ref({ successor: emptyRuntime() }),
+ saveTimerRef: ref | null>(null),
+ } as unknown as WorkspaceRefs
+ const saveWorkspace = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { saveWorkspace },
+ })
+
+ const { unmount } = renderHook(() => useAutoSave(state, 0, refs, true))
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(400)
+ })
+ expect(saveWorkspace).toHaveBeenCalledTimes(1)
+
+ const saved = JSON.parse(saveWorkspace.mock.calls[0][0]).workspace
+ expect(saved.projects).toEqual([{ id: 'tab-a', title: 'recorded' }])
+ expect(saved.activeProjectId).toBe('tab-a')
+ expect(saved.stage).toEqual({
+ lanes: [{ selectedSessionId: 'successor' }, {}, { selectedSessionId: 'successor' }],
+ rows: [{ length: 3 }],
+ focusedLane: 2,
+ })
+ // v3 ONLY. Not one v2 field is written: no mode envelope, and — since the
+ // tile tree and the detached/buried buckets were deleted in 3b-ii — no
+ // `tabs`, which was the last of them. Writing an empty or synthesized
+ // `tabs` "for compatibility" would be worse than omitting it: an older
+ // build would read it as 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.
+ expect(saved).not.toHaveProperty('dispatchMode')
+ expect(saved).not.toHaveProperty('tabs')
+ expect(saved).not.toHaveProperty('activeTabId')
+ expect(saved).not.toHaveProperty('detachedSessions')
+ expect(saved).not.toHaveProperty('buried')
+ // Ownership travels ON the row, which is why none of the above is needed.
+ expect(saved.sessions.successor).toMatchObject({ projectId: 'tab-a', joinedAt: 0 })
+
+ unmount()
+ })
+
it('retries a failed unload flush when another guard vetoes the unload', async () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
@@ -91,22 +159,17 @@ describe('workspace autosave durability retry', () => {
tabs: [{
id: 'tab-a',
title: 'recorded',
- root: { type: 'leaf', sessionId: 'successor' },
- focusedSessionId: 'successor',
}],
activeTabId: 'tab-a',
- dispatchMode: null,
+ stage: oneLaneStage('successor'),
sessions: {
- successor: { cwd: '/recorded/worktree', kind: 'codex' },
+ successor: { cwd: '/recorded/worktree', kind: 'codex', projectId: 'tab-a', joinedAt: 0 },
},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
}
const refs = {
latestStateRef: ref(state),
latestRuntimesRef: ref({ successor: emptyRuntime() }),
- latestTileTabsRef: ref(null),
saveTimerRef: ref | null>(null),
} as unknown as WorkspaceRefs
const saveWorkspace = vi.fn()
@@ -149,22 +212,17 @@ describe('workspace autosave durability retry', () => {
tabs: [{
id: 'tab-a',
title: 'recorded',
- root: { type: 'leaf', sessionId: 'successor' },
- focusedSessionId: 'successor',
}],
activeTabId: 'tab-a',
- dispatchMode: null,
+ stage: oneLaneStage('successor'),
sessions: {
- successor: { cwd: '/recorded/worktree', kind: 'codex' },
+ successor: { cwd: '/recorded/worktree', kind: 'codex', projectId: 'tab-a', joinedAt: 0 },
},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
}
const refs = {
latestStateRef: ref(state),
latestRuntimesRef: ref({ successor: emptyRuntime() }),
- latestTileTabsRef: ref(null),
saveTimerRef: ref | null>(null),
} as unknown as WorkspaceRefs
const saveWorkspace = vi.fn().mockRejectedValue(
diff --git a/src/renderer/src/workspace/hook/persistence/useAutoSave.ts b/src/renderer/src/workspace/hook/persistence/useAutoSave.ts
index 3b1c450cf..0ee3f657d 100644
--- a/src/renderer/src/workspace/hook/persistence/useAutoSave.ts
+++ b/src/renderer/src/workspace/hook/persistence/useAutoSave.ts
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef } from 'react'
import type { PersistedWorkspace } from '@renderer/workspace/persistence'
import type { SessionId, WorkspaceState } from '@renderer/workspace/types'
-import { pruneSessionOwnership, repairPersistedTabs } from '@renderer/workspace/sessionOwnership'
+import { pruneSessionOwnership } from '@renderer/workspace/sessionOwnership'
import { withNormalizedBuiltInMcpDomains } from '@renderer/workspace/mcpDomains'
import { isAgentSessionKind } from '@shared/types/providerKind'
@@ -55,44 +55,25 @@ export function useAutoSave(
// Autosave is the durability boundary. If an action accidentally leaves
// an unowned row in `state.sessions`, writing it to workspace.json turns a
// transient invariant violation into a startup respawn on every future
- // launch. Pruning here is the last line of defense: owned hidden sessions
- // (detached/buried) still persist, but metadata with no owner cannot make
- // itself durable.
+ // launch. Pruning here is the last line of defense: parked sessions still
+ // persist, but metadata with no owner cannot make itself durable.
+ //
+ // "No owner" means the row's `projectId` names no project. That is also
+ // what a session looks like if some writer forgot to FILE it
+ // (fileSessionInProject), in which case this warning is the only trace
+ // of a session that will be gone after restart — treat it as a bug
+ // report, not as housekeeping.
// eslint-disable-next-line no-console
console.warn('[workspace] dropping unowned sessions during autosave:', pruned.droppedSessionIds)
}
- // Repair the tile trees BEFORE serializing them. `pruneSessionOwnership`
- // above scrubs every pointer that aims at a session, but the trees are
- // themselves an ownership surface and used to be written verbatim — so a
- // leaf whose metadata had already been removed from `state.sessions` could
- // become durable. That shape is not merely untidy: rehydrate counts such a
- // leaf as a pane it must restore, can never restore it, and therefore
- // reports `partial-restore` and holds autosave off on every subsequent
- // launch. Since autosave is the only writer of workspace.json, the file
- // then cannot be repaired by the app at all.
- //
- // WHY `pruned.sessions` and not `s.sessions`: they agree on exactly the
- // question being asked. `pruned.sessions` keeps `ownedIds ∩ own keys of
- // s.sessions`, and every tile leaf with metadata is owned by construction,
- // so a leaf is missing here if and only if it was already an orphan in
- // this same snapshot. Nothing `pruneSessionOwnership` drops for an
- // unrelated reason (unowned metadata, a detached record whose parent tab
- // is gone, a buried pane) can ever be a tile leaf — which is what stops
- // this from deleting a live pane. If that ever stops holding, this call
- // becomes destructive, so keep the two in step.
- const repairedTabs = repairPersistedTabs({
- tabs: s.tabs,
- sessions: pruned.sessions,
- activeTabId: s.activeTabId,
- tileTabs: refs.latestTileTabsRef.current,
- })
- if (repairedTabs.droppedLeafSessionIds.length > 0) {
- // eslint-disable-next-line no-console
- console.warn(
- '[workspace] dropping tile leaves with no session metadata during autosave:',
- { leaves: repairedTabs.droppedLeafSessionIds, tabs: repairedTabs.droppedTabIds },
- )
- }
+ // (A `repairPersistedTabs` pass ran here until #992. Tile trees were an
+ // ownership surface written verbatim, so a leaf whose metadata was already
+ // gone could become durable — and rehydrate, counting it as a pane it must
+ // restore and never could, reported `partial-restore` and held autosave off
+ // on every later launch. Since autosave is the only writer of
+ // workspace.json, the file could then never be repaired by the app. With
+ // ownership on the row, an orphan is an unowned row and the prune above
+ // already dropped it; there is no second structure to cut it out of.)
// Collect non-empty drafts so in-progress prompts survive crashes. Only agent
// panes own a composer. Extension panes stay in `pruned.sessions` so their
@@ -113,15 +94,31 @@ export function useAutoSave(
const persistedPinnedSessionIds = s.pinnedSessionIds.filter(
id => pruned.sessions[id] !== undefined,
)
+ // v3 ONLY (#992). Nothing of the v2 shape is written any more: no `tabs`
+ // with tile trees, no `detachedSessions`, no `buried`, no `dispatchMode`.
+ // An older build opening this file finds no `tabs`, fails its rehydrate and
+ // lands in its recovery path with autosave LOCKED — it cannot show the
+ // workspace, and it cannot overwrite it either (see PersistedWorkspace).
+ //
+ // A project that no longer holds a session is not written. It owns nothing
+ // (U4), and reading one back would give a header over an empty list.
+ const populatedProjectIds = new Set()
+ for (const meta of Object.values(pruned.sessions)) {
+ if (meta.projectId !== undefined) populatedProjectIds.add(meta.projectId)
+ }
+ const projects = s.tabs
+ .filter(tab => populatedProjectIds.has(tab.id))
+ .map(tab => ({ id: tab.id, title: tab.title }))
const persisted: PersistedWorkspace = {
- tabs: repairedTabs.tabs.map(t => ({
- id: t.id,
- title: t.title,
- focusedSessionId: t.focusedSessionId,
- root: t.root,
- })),
- activeTabId: repairedTabs.activeTabId,
- dispatchMode: pruned.dispatchMode,
+ projects,
+ activeProjectId: projects.some(project => project.id === s.activeTabId)
+ ? s.activeTabId
+ : (projects[0]?.id ?? ''),
+ // The lane grid, scrubbed against the same live ids as the pool: what is
+ // in memory is what is written. (Through stage 2 of #992 this was
+ // DERIVED at save time from the v2 half; a derivation at the durability
+ // boundary would overwrite the user's lanes with a guess on every save.)
+ stage: pruned.stage,
// WHY normalize MCP domains at the persistence boundary:
//
// The provider process only receives short-lived MCP URLs/tokens, but
@@ -130,18 +127,19 @@ export function useAutoSave(
// stable contract: duplicate domains, hand-edited junk, or removed
// experimental names cannot become durable state that future launches
// keep trying to inject.
+ //
+ // Every row already carries its pool membership (`projectId`,
+ // `joinedAt`) — it is ordinary SessionMeta, stamped when the session was
+ // filed, so there is nothing to derive here.
sessions: Object.fromEntries(
Object.entries(pruned.sessions).map(([id, meta]) => [
id,
withNormalizedBuiltInMcpDomains(meta),
]),
),
- detachedSessions: pruned.detachedSessions,
- buried: pruned.buried,
pinnedSessionIds: persistedPinnedSessionIds.length > 0
? persistedPinnedSessionIds
: undefined,
- tileTabs: repairedTabs.tileTabs,
drafts: Object.keys(drafts).length > 0 ? drafts : undefined,
}
let json = ''
@@ -175,9 +173,8 @@ export function useAutoSave(
}
}
saveSpan.end({
- tabs: persisted.tabs.length,
+ tabs: projects.length,
sessions: Object.keys(persisted.sessions).length,
- tileTabs: persisted.tileTabs?.tabIds.length ?? 0,
bytes: json.length,
})
})
@@ -210,7 +207,6 @@ export function useAutoSave(
}, [
refs.latestRuntimesRef,
refs.latestStateRef,
- refs.latestTileTabsRef,
refs.pendingAdoptionWindowIdsRef,
])
diff --git a/src/renderer/src/workspace/hook/persistence/useBootstrap.stage.renderer.test.tsx b/src/renderer/src/workspace/hook/persistence/useBootstrap.stage.renderer.test.tsx
new file mode 100644
index 000000000..e68cdca82
--- /dev/null
+++ b/src/renderer/src/workspace/hook/persistence/useBootstrap.stage.renderer.test.tsx
@@ -0,0 +1,227 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { MutableRefObject } from 'react'
+import { renderHook } from '@testing-library/react'
+
+import { emptyRuntime } from '@renderer/session-runtime/state'
+import type { SessionRuntime } from '@renderer/session-runtime/state'
+import type { PersistedWorkspace } from '@renderer/workspace/persistence'
+import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
+import type { SessionId, WorkspaceState } from '@renderer/workspace/types'
+import type {
+ SessionRecoverOptions,
+ SessionRecoverResult,
+} from '@shared/types/session'
+
+vi.mock('@renderer/performance/client', () => ({
+ mark: vi.fn(),
+ span: () => ({ end: vi.fn(), fail: vi.fn() }),
+ measure: (name: string, fn: () => T | Promise) => fn(),
+}))
+
+import { useBootstrap } from './useBootstrap'
+import { freshStage } from '@renderer/workspace/dispatch/gridShape'
+
+// The stage guarantee (#992): every boot path lands on a STORED stage whose
+// shape depends on where the workspace came from — fresh installs get one row
+// × one lane (nothing to explain, plan §4.5), imported v2 workspaces without a
+// lane grid get the migration default [2] (plan §6.4), and a workspace that
+// already HAS a grid keeps it exactly. This suite pins all three at the
+// bootstrap seam, with rehydrate real and recovery faked at the preload
+// bridge.
+//
+// WHAT CHANGED, because the assertions changed kind: through stage 2 of the
+// merge bootstrap guaranteed this by CALLING enterTiledDispatch after each
+// path, and the suite counted those calls ([1], [2], never). The action is
+// deleted. Bootstrap now does nothing about the stage at all: the store starts
+// on a one-lane stage, newTab fills its empty focused lane, and rehydrate
+// publishes the migrated stage in its first commit. So the suite asserts the
+// STATE each path ends on — which is the thing the user sees, and is a
+// stronger claim than "a function was called with [2]".
+
+const originalApiDescriptor = Object.getOwnPropertyDescriptor(window, 'api')
+
+afterEach(() => {
+ if (originalApiDescriptor) {
+ Object.defineProperty(window, 'api', originalApiDescriptor)
+ } else {
+ Reflect.deleteProperty(window, 'api')
+ }
+})
+
+function ref(current: T): MutableRefObject {
+ return { current }
+}
+
+function makeHarness(persisted: PersistedWorkspace | null) {
+ let state = {
+ tabs: [],
+ activeTabId: 'tab-1',
+ sessions: {},
+ pinnedSessionIds: [],
+ stage: freshStage(),
+ } satisfies WorkspaceState as WorkspaceState
+ let runtimes: Record = {}
+ const refs = {
+ bootRef: ref(false),
+ dangerousAgentsRef: ref(false),
+ useProxyStreamingRef: ref(false),
+ defaultBuiltInMcpDomainsRef: ref([]),
+ stateRef: ref(state),
+ latestStateRef: ref(state),
+ latestRuntimesRef: ref(runtimes),
+ } as unknown as WorkspaceRefs
+
+ const newTab = vi.fn(async () => {
+ // Minimal stand-in for the real newTab action: mint one project with
+ // one live leaf, the shape bootstrap's autosave unlock checks for — and
+ // place it in the empty focused lane, as the real action does. That rule
+ // is pinned against the REAL hook in actions/newTabPlacement.renderer
+ // .test.tsx; it is mirrored here only so this suite can observe that
+ // bootstrap leaves the result alone.
+ const leaf: SessionId = 'fresh-session'
+ state = {
+ ...state,
+ stage: { ...state.stage, lanes: [{ selectedSessionId: leaf }] },
+ tabs: [
+ {
+ id: 'tab-1',
+ title: 'Project',
+ },
+ ],
+ activeTabId: 'tab-1',
+ sessions: { ...state.sessions, [leaf]: { cwd: '/tmp/fresh', kind: 'claude', projectId: 'tab-1', joinedAt: 0 } },
+ }
+ refs.stateRef.current = state
+ refs.latestStateRef.current = state
+ })
+
+ const api = {
+ loadWorkspace: vi.fn(async () =>
+ persisted === null ? null : JSON.stringify({ workspace: persisted }),
+ ),
+ defaultCwd: vi.fn(async () => '/tmp/fresh'),
+ recoverSession: vi.fn(async (options: SessionRecoverOptions): Promise => ({
+ ok: true,
+ disposition: 'spawned',
+ snapshot: {
+ sessionId: options.sessionId,
+ sessionRunId: `run-${options.sessionId}`,
+ kind: options.kind ?? 'claude',
+ cwd: options.cwd,
+ lifecycle: 'live',
+ input: { ready: true, revision: 1 },
+ },
+ })),
+ cancelSessionRecovery: vi.fn(async () => true),
+ }
+ Object.defineProperty(window, 'api', { value: api, configurable: true })
+
+ return {
+ refs,
+ state: () => state,
+ runtimes: () => runtimes,
+ newTab,
+ setState(next: WorkspaceState | ((prev: WorkspaceState) => WorkspaceState)) {
+ state = typeof next === 'function' ? next(state) : next
+ refs.stateRef.current = state
+ refs.latestStateRef.current = state
+ },
+ setRuntimes(
+ next:
+ | Record
+ | ((prev: Record) => Record),
+ ) {
+ runtimes = typeof next === 'function' ? next(runtimes) : next
+ refs.latestRuntimesRef.current = runtimes
+ },
+ }
+}
+
+function renderBootstrap(harness: ReturnType) {
+ const setBootstrapComplete = vi.fn()
+ const setRestoreStatus = vi.fn()
+ const { unmount } = renderHook(() =>
+ useBootstrap(
+ harness.refs,
+ harness.setState,
+ harness.setRuntimes,
+ harness.newTab,
+ setBootstrapComplete,
+ setRestoreStatus,
+ ),
+ )
+ return { unmount, setBootstrapComplete, setRestoreStatus }
+}
+
+describe('bootstrap stage guarantee', () => {
+ it('lands a fresh install on one lane showing its one agent', async () => {
+ const harness = makeHarness(null)
+ const { unmount, setBootstrapComplete } = renderBootstrap(harness)
+ await vi.waitFor(() => expect(harness.newTab).toHaveBeenCalled())
+ // Autosave unlocked: the fresh workspace is real and savable.
+ await vi.waitFor(() => expect(setBootstrapComplete).toHaveBeenCalledWith(true))
+ expect(harness.state().stage).toEqual({
+ lanes: [{ selectedSessionId: 'fresh-session' }],
+ rows: [{ length: 1 }],
+ focusedLane: 0,
+ })
+ unmount()
+ })
+
+ it('lands an imported grid-only workspace on the seeded [2] default', async () => {
+ const persisted: PersistedWorkspace = {
+ tabs: [
+ {
+ id: 'tab-a',
+ title: 'app',
+ focusedSessionId: 's-a1',
+ root: { type: 'leaf', sessionId: 's-a1' },
+ },
+ ],
+ activeTabId: 'tab-a',
+ sessions: { 's-a1': { cwd: '/x/app', kind: 'claude' } },
+ }
+ const harness = makeHarness(persisted)
+ const { unmount, setBootstrapComplete } = renderBootstrap(harness)
+ await vi.waitFor(() => expect(setBootstrapComplete).toHaveBeenCalledWith(true))
+ // NOT the fresh [1]: an importing user demonstrably has agents, and the
+ // second lane is what shows that a lane is a slot.
+ expect(harness.state().stage).toEqual({
+ lanes: [{ selectedSessionId: 's-a1' }, {}],
+ rows: [{ length: 2 }],
+ focusedLane: 0,
+ })
+ expect(harness.newTab).not.toHaveBeenCalled()
+ unmount()
+ })
+
+ it('keeps a stored stage exactly, including lanes the user left empty', async () => {
+ const stage = {
+ lanes: [{}, { selectedSessionId: 's-a1' }, {}],
+ rows: [{ length: 3 }],
+ focusedLane: 2,
+ }
+ const persisted: PersistedWorkspace = {
+ tabs: [
+ {
+ id: 'tab-a',
+ title: 'app',
+ focusedSessionId: 's-a1',
+ root: { type: 'leaf', sessionId: 's-a1' },
+ },
+ ],
+ activeTabId: 'tab-a',
+ stage,
+ sessions: { 's-a1': { cwd: '/x/app', kind: 'claude' } },
+ }
+ const harness = makeHarness(persisted)
+ const { unmount, setBootstrapComplete } = renderBootstrap(harness)
+ await vi.waitFor(() => expect(setBootstrapComplete).toHaveBeenCalledWith(true))
+ // Lane 0 is empty and FOCUS is on an empty lane — both are the user's
+ // choices. A boot that "helpfully" seeded lane 0 with the focused pane
+ // (the entry seed) here would be #681's auto-fill on every launch; the
+ // seed applies only to a file that never had lanes.
+ expect(harness.state().stage).toEqual(stage)
+ unmount()
+ })
+})
diff --git a/src/renderer/src/workspace/hook/persistence/useBootstrap.ts b/src/renderer/src/workspace/hook/persistence/useBootstrap.ts
index cc52b7cbf..f53c85468 100644
--- a/src/renderer/src/workspace/hook/persistence/useBootstrap.ts
+++ b/src/renderer/src/workspace/hook/persistence/useBootstrap.ts
@@ -1,15 +1,12 @@
import { useEffect } from 'react'
import type { PersistedWorkspace } from '@renderer/workspace/persistence'
-import type { WorkspaceModeId } from '@renderer/app-state/settings/types'
import type {
WorkspaceSetRuntimes,
WorkspaceSetState,
- WorkspaceSetTileTabs,
} from '@renderer/workspace/hook/context'
import type { WorkspaceRefs } from '@renderer/workspace/hook/refs'
-import type { DispatchModeState } from '@renderer/workspace/types'
import { rehydrateWorkspace } from '@renderer/workspace/hook/persistence/rehydrate'
import { reconcileStuckTranscriptLoads } from '@renderer/workspace/hook/actions/initialHistory'
@@ -53,7 +50,6 @@ export function useBootstrap(
refs: WorkspaceRefs,
setState: WorkspaceSetState,
setRuntimes: WorkspaceSetRuntimes,
- setTileTabs: WorkspaceSetTileTabs,
newTab: (cwd: string) => Promise,
setBootstrapComplete: (complete: boolean) => void,
// Mirrors setBootstrapComplete in lifetime — set once at the end of
@@ -62,16 +58,17 @@ export function useBootstrap(
// render the partial/fallback states without each call site needing
// to recompute "is autosave actually running right now".
setRestoreStatus: (status: WorkspaceRestoreStatus) => void,
- // WHY these two extra params: the "Default Workspace Mode" setting
- // only matters on a brand-new install (no workspace.json). Rather
- // than have useBootstrap reach into the app store directly — which
- // would couple persistence to settings and add a re-render dep we
- // don't want — the composer (`useWorkspace`) reads the setting once
- // and threads it in alongside the dispatch entry point. We capture
- // both in the once-only useEffect closure, so later setting changes
- // don't retroactively rerun bootstrap.
- defaultWorkspaceMode: WorkspaceModeId,
- enterDispatchMode: (scope?: DispatchModeState['scope']) => Promise,
+ // `defaultWorkspaceMode` was a param here until #992 stage 8: the
+ // "Default Workspace Mode" setting chose between grid and Dispatch on a
+ // fresh install, and there is one layout now. Deleted with the setting.
+ // Two more params lived here until the stage became a required field:
+ // `enterDispatchMode` (fresh installs could boot into classic Dispatch) and
+ // `enterTiledDispatch`, which an `ensureStage` helper called after every
+ // boot path to give a workspace a lane grid if it lacked one. Neither is
+ // needed: the store's initial state already holds a one-lane stage, newTab
+ // fills its empty focused lane with the first agent, and rehydrate
+ // publishes the migrated stage in its very first commit. Boot no longer
+ // knows that lanes exist.
): void {
useEffect(() => {
if (refs.bootRef.current) return
@@ -93,26 +90,11 @@ export function useBootstrap(
await perf.measure('workspace.bootstrap.initialNewTab', () => newTab(cwd))
canAutosaveBootState = refs.latestStateRef.current.tabs.length > 0
finalStatus = 'fresh'
- // WHY apply the default mode here, after newTab resolves:
- //
- // `enterDispatchMode` no longer spawns anything: the auto-created
- // project terminal was retired, so entering Dispatch is now purely
- // a layout change.
- if (defaultWorkspaceMode === 'dispatch') {
- try {
- // Global, not project (#973): a fresh install has exactly one
- // tab, so project scope would show the same agents while
- // hiding the scope switch's purpose; global is also the scope
- // the owner runs in and the one every later tab benefits from.
- await enterDispatchMode('global')
- } catch (dispatchErr) {
- // Non-fatal: user lands in grid mode, can flip later.
- // We don't surface a toast because a fresh-install user
- // hasn't even seen the workspace yet — a stray error
- // toast on an empty app is more confusing than helpful.
- console.warn('[workspace] default dispatch entry failed:', dispatchErr)
- }
- }
+ // A fresh install lands on ONE row × ONE lane showing its one
+ // agent (plan §4.5 — nothing to explain before the first agent
+ // exists; growth is user-paced). That shape is the store's
+ // initial `freshStage()` plus newTab's empty-lane placement; no
+ // step here creates it.
bootstrapSpan.end({ mode: 'fresh' })
} catch (err) {
bootstrapSpan.fail(err, { mode: 'fresh' })
@@ -135,11 +117,11 @@ export function useBootstrap(
refs,
setState,
setRuntimes,
- setTileTabs,
newTab,
),
{
- tabs: parsed.workspace.tabs.length,
+ // v3 files list `projects`; v2 files list `tabs`.
+ tabs: (parsed.workspace.projects ?? parsed.workspace.tabs ?? []).length,
sessions: Object.keys(parsed.workspace.sessions).length,
},
)
@@ -174,6 +156,11 @@ export function useBootstrap(
// restart after fixing the underlying spawn/proxy problem.
console.warn('[workspace] rehydrate incomplete; autosave remains disabled:', restoreResult)
}
+ // An imported v2 workspace without a stored lane grid arrives here
+ // already on the migration default [2] (seeded) — NOT the fresh
+ // [1]: an importing user demonstrably has agents; the second lane
+ // is what shows a lane is a slot (plan §6.4). rehydrate published
+ // it through migrateWorkspaceToStage.
bootstrapSpan.end({ mode: 'rehydrate' })
} catch (err) {
bootstrapSpan.fail(err, { mode: 'rehydrate' })
@@ -185,6 +172,9 @@ export function useBootstrap(
try {
await perf.measure('workspace.bootstrap.fallbackNewTab', () => newTab(cwd))
finalStatus = 'persisted-fallback'
+ // The recovery shell gets the minimal [1] stage by the same route
+ // as the fresh path: this is not the user's real workspace, just
+ // enough surface to work in while the real file stays protected.
// WHY this intentionally does NOT unlock autosave:
//
// We only reach this path after a persisted workspace existed but
diff --git a/src/renderer/src/workspace/hook/refs.ts b/src/renderer/src/workspace/hook/refs.ts
index 25f619c2d..459b64151 100644
--- a/src/renderer/src/workspace/hook/refs.ts
+++ b/src/renderer/src/workspace/hook/refs.ts
@@ -7,7 +7,6 @@ import type { HistoryWindow } from '@renderer/session-runtime/historyBoundary.js
import type {
ReaderModeState,
SpotlightState,
- TileTabsState,
} from '@renderer/workspace/types'
import type { SessionId, WorkspaceState } from '@renderer/workspace/types'
import type { ConfigurableBuiltInMcpDomain } from '@mcp/shared/types'
@@ -31,7 +30,6 @@ export type WorkspaceRefs = {
stateRef: MutableRefObject
latestStateRef: MutableRefObject
latestRuntimesRef: MutableRefObject>
- latestTileTabsRef: MutableRefObject
dangerousAgentsRef: MutableRefObject
useProxyStreamingRef: MutableRefObject
defaultBuiltInMcpDomainsRef: MutableRefObject
@@ -70,7 +68,6 @@ export type WorkspaceRefs = {
export function useWorkspaceRefs(
initialState: WorkspaceState,
initialRuntimes: Record,
- initialTileTabs: TileTabsState | null,
dangerousAgentsEnabled: boolean,
useProxyStreaming: boolean,
defaultBuiltInMcpDomains: ConfigurableBuiltInMcpDomain[],
@@ -102,7 +99,6 @@ export function useWorkspaceRefs(
const stateRef = useRef(initialState)
const latestStateRef = useRef(initialState)
const latestRuntimesRef = useRef(initialRuntimes)
- const latestTileTabsRef = useRef(initialTileTabs)
const dangerousAgentsRef = useRef(dangerousAgentsEnabled)
const useProxyStreamingRef = useRef(useProxyStreaming)
const defaultBuiltInMcpDomainsRef = useRef(defaultBuiltInMcpDomains)
@@ -132,7 +128,6 @@ export function useWorkspaceRefs(
// Ref mirror of runtimes so the debounced save callback can read
// current drafts without re-creating the callback on every render.
latestRuntimesRef,
- latestTileTabsRef,
// Settings mirror refs. Ref-mirrored so the spawn callbacks read
// the live value without having to subscribe per-call.
diff --git a/src/renderer/src/workspace/hook/runtimeIsolation.renderer.test.tsx b/src/renderer/src/workspace/hook/runtimeIsolation.renderer.test.tsx
index 3d099e3f1..380322940 100644
--- a/src/renderer/src/workspace/hook/runtimeIsolation.renderer.test.tsx
+++ b/src/renderer/src/workspace/hook/runtimeIsolation.renderer.test.tsx
@@ -1,10 +1,10 @@
import { act, cleanup, fireEvent, render } from '@testing-library/react'
-import { useEffect } from 'react'
+import { Fragment, useEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@renderer/app-state/hooks'
import { emptyRuntime, type SessionRuntime } from '@renderer/session-runtime/state'
-import { TileTree } from '@renderer/workspace/tile-tree/TileTree'
-import type { TileNode, WorkspaceState } from '@renderer/workspace/types'
+import { renderWorkspaceLeaf } from '@renderer/workspace/tile-tree/TileTree'
+import type { WorkspaceState } from '@renderer/workspace/types'
import { useWorkspace } from './index'
import { useRenderedLeaseHygiene } from './effects/useRenderedLeaseHygiene'
import { appendCodexTranscriptObservation } from '@renderer/lifecycle/codexTranscriptObservationOutbox'
@@ -28,7 +28,9 @@ vi.mock('@renderer/features/sessionFeed/SessionFeedContext', () => ({ useSession
vi.mock('./persistence/useBootstrap', async () => {
const { useEffect } = await import('react')
return { useBootstrap: (...args: Parameters) => {
- useEffect(() => args[5](true), [args[5]])
+ // args[4] is setBootstrapComplete (it was [5] until #992 removed the
+ // setTileTabs parameter that sat before it).
+ useEffect(() => args[4](true), [args[4]])
} }
})
@@ -45,9 +47,22 @@ function Controller({ legacy = false }: { legacy?: boolean }) {
current = useWorkspace(false)
useRenderedLeaseHygiene(current)
counts.controller += 1
- return <>{current.runtimeServices} >
+ // One subscribed leaf per LANE, mounted the way the stage mounts them (#992):
+ // the lane's occupant, focused when it is the focused lane, asking for focus
+ // by lane index. Deriving the list from live state is what lets the "lane
+ // changes session" case below observe a subscription MOVE rather than a
+ // fixed pair of panes. (This walked the active tab's tile tree until the
+ // tree was deleted.)
+ const stage = current.state.stage
+ const focusedSessionId = stage.lanes[stage.focusedLane]?.selectedSessionId ?? null
+ return <>{current.runtimeServices}
+ {stage.lanes.map((lane, laneIndex) => lane.selectedSessionId ? (
+
+ {renderWorkspaceLeaf(lane.selectedSessionId, focusedSessionId, current, 'tab', 'agent', true, true,
+ () => current.setTiledFocusedLane(laneIndex))}
+
+ ) : null)}
+ >
}
beforeEach(() => {
@@ -57,11 +72,13 @@ beforeEach(() => {
counts.chronology = []
saveWorkspace.mockClear()
reportSessionLifecycle.mockClear()
- const root: TileNode = { type: 'split', direction: 'vertical', ratio: 0.5,
- a: { type: 'leaf', sessionId: 'one' }, b: { type: 'leaf', sessionId: 'two' } }
const state: WorkspaceState = { ...original.workspaceState,
- tabs: [{ id: 'tab', title: 'Test', focusedSessionId: 'one', root }], activeTabId: 'tab',
- sessions: { one: { kind: 'claude', cwd: '/repo' }, two: { kind: 'claude', cwd: '/repo' } },
+ tabs: [{ id: 'tab', title: 'Test' }], activeTabId: 'tab',
+ sessions: {
+ one: { kind: 'claude', cwd: '/repo', projectId: 'tab', joinedAt: 0 },
+ two: { kind: 'claude', cwd: '/repo', projectId: 'tab', joinedAt: 1 },
+ },
+ stage: { lanes: [{ selectedSessionId: 'one' }, { selectedSessionId: 'two' }], rows: [{ length: 2 }], focusedLane: 0 },
}
useAppStore.setState({ workspaceState: state, workspaceRuntimes: { one: emptyRuntime(), two: emptyRuntime() } })
Object.defineProperty(window, 'api', { configurable: true, value: {
@@ -88,13 +105,9 @@ describe('runtime updates below the workspace controller', () => {
await act(async () => {
useAppStore.getState().setWorkspaceState(prev => ({
...prev,
- sessions: { ...prev.sessions, three: { cwd: '/repo', kind: 'claude' } },
- detachedSessions: { ...prev.detachedSessions, three: {
- sessionId: 'three', surface: 'dispatch', projectTabId: 'tab',
- projectTabTitle: 'Test', projectTabIndex: 0, detachedAt: 1,
- } },
+ sessions: { ...prev.sessions, three: { cwd: '/repo', kind: 'claude', projectId: 'tab', joinedAt: 2 } },
}))
- // This is the same timing as a prior cleanup changing root ownership.
+ // This is the same timing as a prior cleanup changing ownership.
// A render-body mirror still sees the old set and silently skips/misroutes
// the next close; the real store subscription must update it immediately.
const closed = close('three', { preConfirmed: true, captureUndo: false, onlyIf: () => true })
@@ -137,16 +150,17 @@ describe('runtime updates below the workspace controller', () => {
expect(counts.controller).toBe(before)
})
- it('keeps layout actions fresh and moves subscriptions when a leaf changes session', () => {
+ it('keeps layout actions fresh and moves subscriptions when a lane changes session', () => {
const view = render( )
fireEvent.click(view.getByTestId('two'))
- expect(current.activeTab?.focusedSessionId).toBe('two')
+ expect(current.state.stage.focusedLane).toBe(1)
act(() => {
const store = useAppStore.getState()
store.setWorkspaceRuntimes(prev => ({ ...prev, three: emptyRuntime() }))
+ // Lane 0 is re-aimed from `one` to `three`; `one` stays in the pool.
store.setWorkspaceState(prev => ({ ...prev,
- sessions: { ...prev.sessions, three: { kind: 'claude', cwd: '/repo' } },
- tabs: prev.tabs.map(tab => ({ ...tab, root: { type: 'leaf', sessionId: 'three' } })),
+ sessions: { ...prev.sessions, three: { kind: 'claude', cwd: '/repo', projectId: 'tab', joinedAt: 2 } },
+ stage: { ...prev.stage, lanes: [{ selectedSessionId: 'three' }, { selectedSessionId: 'two' }] },
}))
})
expect(view.queryByTestId('one')).toBeNull()
@@ -156,7 +170,7 @@ describe('runtime updates below the workspace controller', () => {
act(() => current.setDraftInput('three', 'new pane draft'))
expect(view.getByTestId('three')).toHaveTextContent('new pane draft')
fireEvent.click(view.getByTestId('three'))
- expect(current.activeTab?.focusedSessionId).toBe('three')
+ expect(current.state.stage.focusedLane).toBe(0)
})
it('still clears a rendered-view lease acquired after terminal mode hid the feed', () => {
diff --git a/src/renderer/src/workspace/hook/selectors/commandTargetSessionId.ts b/src/renderer/src/workspace/hook/selectors/commandTargetSessionId.ts
index f77e70bd8..e22ecdc97 100644
--- a/src/renderer/src/workspace/hook/selectors/commandTargetSessionId.ts
+++ b/src/renderer/src/workspace/hook/selectors/commandTargetSessionId.ts
@@ -1,52 +1,62 @@
// Single source of truth for "which session is the user currently
-// commanding?" — a Dispatch-aware focus reader.
+// commanding?".
//
// WHY this is its own file:
//
-// The detached-sessions model split focus into two fields. Tab.focusedSessionId
-// is grid-only (it has a hard "must be a leaf in tab.root" invariant) and
-// drives reader, spotlight, resize, split, duplicate, and bury. Dispatch
-// selection lives on dispatchMode.focusedSessionId and can point at a
-// detached session that has no tile-tree placement at all.
+// Focus used to live in two fields (the grid's Tab.focusedSessionId and
+// Dispatch's dispatchMode.focusedSessionId), and reading either one directly
+// silently ignored the other surface. Both are gone with #992. The answer
+// now has two parts: an open focus takeover (Spotlight or Reader) is what
+// the user is commanding, and otherwise it is the focused lane's occupant.
+// Reading `stage.lanes[focusedLane]` directly is the new version of the old
+// mistake: it ignores Spotlight.
//
-// Most lifecycle commands (close, kill, provider replace, copy assistant,
-// prompt template) want "whatever the user is visibly commanding right now"
-// regardless of which surface is on screen. Reading tab.focusedSessionId
-// directly silently ignores Dispatch selection; reading
-// dispatchMode.focusedSessionId directly silently ignores grid focus when
-// Dispatch is off. Importing this helper documents the intent — call sites
-// that use this file are saying "I work on detached AND grid sessions",
-// while call sites that read tab.focusedSessionId directly are explicitly
-// declaring "I am grid-only."
-//
-// Keep that distinction visible in the diff. Don't fold this into
+// Importing this helper documents the intent. Don't fold it into
// useWorkspace as a derived getter that everything reads automatically;
// the explicit import is the documentation.
+import { useAppStore } from '@renderer/app-state/store'
import { resolveStrictDispatchCommandTarget } from '@renderer/workspace/dispatch/dispatchTarget'
-import { selectedGridRelatedSessionId } from '@renderer/workspace/gridRelatedAgents'
-import type { SessionId, WorkspaceState } from '@renderer/workspace/types'
+import type { ReaderModeState, SessionId, SpotlightState, WorkspaceState } from '@renderer/workspace/types'
import type { Workspace } from '@renderer/workspace/workspaceStore'
export function commandTargetSessionId(workspace: Workspace): string | null {
return commandTargetSessionIdForState(workspace.state)
}
-export function commandTargetSessionIdForState(state: WorkspaceState): SessionId | null {
- if (!state.dispatchMode) {
- const activeTab = state.tabs.find(tab => tab.id === state.activeTabId)
- // WHY grid related selection participates in command targeting:
- // the physical tile focus must remain the parent leaf, but once the pane is
- // visibly rendering a related child, global commands like reload/close/copy
- // need to act on the same session the composer is commanding. The selector
- // validates the child against current relationship state and falls back to
- // the parent if the child was closed or detached elsewhere.
- return selectedGridRelatedSessionId(
- state,
- activeTab?.id ?? state.activeTabId,
- activeTab?.focusedSessionId,
- )
- }
+/** The focus takeover on screen right now, if any. Reader wins over Spotlight
+ * for the same reason `control.ts` orders them that way: Reader is opened from
+ * inside Spotlight and covers it. */
+function currentFocusTakeover(): ReaderModeState | SpotlightState | null {
+ const store = useAppStore.getState()
+ return store.workspaceReaderMode ?? store.workspaceSpotlight
+}
+
+export function commandTargetSessionIdForState(
+ state: WorkspaceState,
+ // WHY a default that reads the store, instead of a required argument (#1013
+ // parity review, MAJOR): about twenty call sites pass only
+ // `refs.stateRef.current` or `workspace.state`. A required parameter would
+ // make each of them choose, and the next caller that forgets would bring
+ // the bug back. Tests pass it explicitly.
+ takeover: ReaderModeState | SpotlightState | null = currentFocusTakeover(),
+): SessionId | null {
+ // WHY an open Spotlight/Reader answers first (#1013 parity review, MAJOR):
+ // while a takeover is up, the only agent on screen is the takeover's, so
+ // that is the one being commanded. Picking another agent inside Spotlight
+ // deliberately leaves the stage lanes alone (spotlight.ts, U2/#681); main
+ // did that by mirroring the pick into the tree or Dispatch focus that this
+ // function read. With both fields gone, a lane-only answer meant Tail, Jump
+ // Latest, Close Focused Session, Stop Goal Loop, reload and provider switch
+ // all acted on the lane agent HIDDEN behind Spotlight. `control.ts` already
+ // reported the takeover as the focused session; this makes the commands
+ // agree with it. A takeover whose session is gone (the sanity hooks clear
+ // it a render later) falls through to the lane instead of naming a dead id.
+ if (takeover && state.sessions[takeover.focusedSessionId]) return takeover.focusedSessionId
+
+ // A grid branch lived here until #992: with no Dispatch state it read the
+ // active tab's focused tile leaf (through the related-agent selection). The
+ // stage is the only workspace, so the focused lane is the only target.
// WHY strict Dispatch targeting is used here:
// commandTargetSessionIdForState is consumed by lifecycle and destructive
diff --git a/src/renderer/src/workspace/idRemap.ts b/src/renderer/src/workspace/idRemap.ts
index f8fbcda5d..c90a830df 100644
--- a/src/renderer/src/workspace/idRemap.ts
+++ b/src/renderer/src/workspace/idRemap.ts
@@ -8,10 +8,10 @@ import type { SessionId, SessionMeta } from '@renderer/workspace/types'
// a fresh id for an existing pane and must swap old -> new everywhere the old
// id is referenced: rehydrate (respawn on restart), replaceSession (reload /
// provider-switch / resume / rewind), reloadAgentSessions ("reload all"),
-// undo-close. The tile tree, detached/buried records, Dispatch focus, and
-// tiled lanes are remapped at those sites; this module covers the remaining
-// cross-session references — SessionMeta relationship pointers and the pinned
-// list — so they don't get left pointing at dead ids. Centralizing it here is
+// undo-close. The pool row and the stage's lanes are remapped at those sites
+// (remapTiledLanes); this module covers the remaining cross-session references
+// — SessionMeta relationship pointers and the pinned list — so they don't get
+// left pointing at dead ids. Centralizing it here is
// what stops the next remap site from forgetting one of these (the same class
// of bug as the tiled-lane divergence).
// ============================================================================
@@ -102,16 +102,5 @@ export function remapPinnedSessionIds(
return pinned.map(id => idMap.get(id) ?? id)
}
-export function remapGridRelatedSelections(
- selections: Record | undefined,
- idMap: Map,
-): Record {
- const out: Record = {}
- for (const [ownerId, selectedId] of Object.entries(selections ?? {})) {
- const nextOwnerId = idMap.get(ownerId) ?? ownerId
- const nextSelectedId = idMap.get(selectedId) ?? selectedId
- if (nextOwnerId === nextSelectedId) continue
- out[nextOwnerId] = nextSelectedId
- }
- return out
-}
+// `remapGridRelatedSelections` lived here until #992 deleted the selection map
+// it remapped (see TileTree.tsx).
diff --git a/src/renderer/src/workspace/idleOrchestrationAgents.renderer.test.tsx b/src/renderer/src/workspace/idleOrchestrationAgents.renderer.test.tsx
index e716a5b0a..9dab61030 100644
--- a/src/renderer/src/workspace/idleOrchestrationAgents.renderer.test.tsx
+++ b/src/renderer/src/workspace/idleOrchestrationAgents.renderer.test.tsx
@@ -53,35 +53,18 @@ const working = (): SessionRuntime => answered({ sessionStatus: 'running', strea
* manual finished agent the user opened by hand
*/
function mountRun(options: { busy?: string[] } = {}) {
- const row = (sessionId: string, detachedAt: number) => ({
- sessionId,
- surface: 'dispatch' as const,
- projectTabId: 'tab',
- projectTabTitle: 'repo',
- projectTabIndex: 0,
- detachedAt,
- })
const state: WorkspaceState = {
- tabs: [{ id: 'tab', title: 'repo', root: { type: 'leaf', sessionId: 'lead' }, focusedSessionId: 'lead' }],
+ tabs: [{ id: 'tab', title: 'repo' }],
activeTabId: 'tab',
- dispatchMode: { scope: 'project', focusedSessionId: 'lead' },
+ stage: { lanes: [{ selectedSessionId: 'lead' }], rows: [{ length: 1 }], focusedLane: 0 },
sessions: {
- lead: { cwd: '/repo', kind: 'claude', title: 'Lead' },
- coord: { cwd: '/repo', kind: 'claude', title: 'Coordinator', orchestrationParentId: 'lead', orchestrationRootId: 'lead' },
- worker: { cwd: '/repo/.worktrees/a', kind: 'codex', title: 'Worker', orchestrationParentId: 'coord', orchestrationRootId: 'lead' },
- done: { cwd: '/repo/.worktrees/b', kind: 'codex', title: 'Done', orchestrationParentId: 'lead', orchestrationRootId: 'lead' },
- busy: { cwd: '/repo/.worktrees/c', kind: 'codex', title: 'Busy', orchestrationParentId: 'lead', orchestrationRootId: 'lead' },
- manual: { cwd: '/repo', kind: 'claude', title: 'Manual' },
- },
- detachedSessions: {
- coord: row('coord', 1),
- worker: row('worker', 2),
- done: row('done', 3),
- busy: row('busy', 4),
- manual: row('manual', 5),
+ lead: { cwd: '/repo', kind: 'claude', title: 'Lead', projectId: 'tab', joinedAt: 0 },
+ coord: { cwd: '/repo', kind: 'claude', title: 'Coordinator', orchestrationParentId: 'lead', orchestrationRootId: 'lead', projectId: 'tab', joinedAt: 1 },
+ worker: { cwd: '/repo/.worktrees/a', kind: 'codex', title: 'Worker', orchestrationParentId: 'coord', orchestrationRootId: 'lead', projectId: 'tab', joinedAt: 2 },
+ done: { cwd: '/repo/.worktrees/b', kind: 'codex', title: 'Done', orchestrationParentId: 'lead', orchestrationRootId: 'lead', projectId: 'tab', joinedAt: 3 },
+ busy: { cwd: '/repo/.worktrees/c', kind: 'codex', title: 'Busy', orchestrationParentId: 'lead', orchestrationRootId: 'lead', projectId: 'tab', joinedAt: 4 },
+ manual: { cwd: '/repo', kind: 'claude', title: 'Manual', projectId: 'tab', joinedAt: 5 },
},
- gridRelatedSelections: {},
- buried: [],
pinnedSessionIds: [],
}
const refs = makeRefs(state)
diff --git a/src/renderer/src/workspace/idleOrchestrationAgents.test.ts b/src/renderer/src/workspace/idleOrchestrationAgents.test.ts
index 0379769dd..a20ea0a42 100644
--- a/src/renderer/src/workspace/idleOrchestrationAgents.test.ts
+++ b/src/renderer/src/workspace/idleOrchestrationAgents.test.ts
@@ -9,6 +9,7 @@ import {
} from '@renderer/workspace/idleOrchestrationAgents'
import type { SessionMeta, WorkspaceState } from '@renderer/workspace/types'
import type { Entry } from '@shared/types/transcript'
+import { freshStage } from '@renderer/workspace/dispatch/gridShape'
// Which orchestration workers Close Idle Orchestration Agents may close (#960).
//
@@ -40,15 +41,15 @@ type WorkerSpec = {
kind?: SessionMeta['kind']
/** `null` = no runtime observed yet. */
runtime?: SessionRuntime | null
- /** Not placed in any project (buried sessions have no tab placement). */
- buried?: boolean
+ /** Not filed under any project: unowned metadata no index lists. (Until
+ * #992 this was how the fixture spelled a BURIED session.) */
+ unfiled?: boolean
}
-/** One project tab: the user's lead agent is the grid root and every worker is
- * a Dispatch row, filed in list order. */
+/** One project: the user's lead agent first, then every worker filed in list
+ * order. */
function workspace(workers: WorkerSpec[]): { state: WorkspaceState; runtimes: Record } {
- const sessions: WorkspaceState['sessions'] = { [LEAD]: { cwd: '/repo', kind: 'claude', title: 'Lead' } }
- const detachedSessions: WorkspaceState['detachedSessions'] = {}
+ const sessions: WorkspaceState['sessions'] = { [LEAD]: { cwd: '/repo', kind: 'claude', title: 'Lead', projectId: 'tab', joinedAt: 0 } }
const runtimes: Record = { [LEAD]: answered() }
workers.forEach((worker, index) => {
sessions[worker.id] = {
@@ -58,27 +59,15 @@ function workspace(workers: WorkerSpec[]): { state: WorkspaceState; runtimes: Re
...(worker.parent === null
? {}
: { orchestrationParentId: worker.parent ?? LEAD, orchestrationRootId: LEAD }),
- }
- if (!worker.buried) {
- detachedSessions[worker.id] = {
- sessionId: worker.id,
- surface: 'dispatch',
- projectTabId: 'tab',
- projectTabTitle: 'repo',
- projectTabIndex: 0,
- detachedAt: index + 1,
- }
+ ...(worker.unfiled ? {} : { projectId: 'tab', joinedAt: index + 1 }),
}
if (worker.runtime !== null) runtimes[worker.id] = worker.runtime ?? answered()
})
const state: WorkspaceState = {
- tabs: [{ id: 'tab', title: 'repo', root: { type: 'leaf', sessionId: LEAD }, focusedSessionId: LEAD }],
+ tabs: [{ id: 'tab', title: 'repo' }],
activeTabId: 'tab',
- dispatchMode: null,
+ stage: freshStage(),
sessions,
- detachedSessions,
- gridRelatedSelections: {},
- buried: [],
pinnedSessionIds: [],
}
return { state, runtimes }
@@ -127,9 +116,10 @@ describe('which orchestration workers count as idle', () => {
})
it('ignores a finished worker that is not placed in any project', () => {
- // closeSession never ends buried sessions; listing one would promise a
- // close that cannot happen.
- const { state, runtimes } = workspace([{ id: 'worker', buried: true }])
+ // closeSession refuses a session whose project it cannot resolve; listing
+ // one would promise a close that cannot happen. (Until #992 this case was
+ // a BURIED worker, which closeSession likewise never ended.)
+ const { state, runtimes } = workspace([{ id: 'worker', unfiled: true }])
expect(idleOrchestrationCloseTargets(state, runtimes)).toEqual([])
})
})
diff --git a/src/renderer/src/workspace/idleOrchestrationAgents.ts b/src/renderer/src/workspace/idleOrchestrationAgents.ts
index bdb36a6a6..700aa565b 100644
--- a/src/renderer/src/workspace/idleOrchestrationAgents.ts
+++ b/src/renderer/src/workspace/idleOrchestrationAgents.ts
@@ -40,7 +40,7 @@ type Runtimes = Record
* `when`, which the palette evaluates for every command on every render, and
* idleness scans transcripts. The run re-derives the real target list and says
* so when nothing is idle, so offering the command for a workspace whose only
- * orchestration child is buried or busy costs one toast, while an exact gate
+ * orchestration child is busy costs one toast, while an exact gate
* would cost transcript scans on every palette keystroke.
*/
export function hasOrchestrationAgents(state: WorkspaceState): boolean {
diff --git a/src/renderer/src/workspace/layout/helpers.ts b/src/renderer/src/workspace/layout/helpers.ts
index 054e28f2f..440ca7364 100644
--- a/src/renderer/src/workspace/layout/helpers.ts
+++ b/src/renderer/src/workspace/layout/helpers.ts
@@ -1,15 +1,12 @@
-import type { SessionId, TileNode } from '@renderer/workspace/types'
import type { SlashPickerState } from '@renderer/session-runtime/state'
-import type { TileTabsState } from '@renderer/workspace/types'
// Layout & picker utilities for the workspace store.
//
-// These are the small pure helpers the useWorkspace hook calls
-// inline during layout mutations — tab titling, ratio math, tile-
-// tabs state sanitization, picker equality, split-ratio walking.
-// Separated out because they're the most easily unit-testable
-// shapes in the whole workspace module and don't need any React
-// or session-state context.
+// This module used to also hold the Tile Tabs sanitizer, ratio math, and the
+// tile-tree divider walker. All of that died with the tile tree and Tile Tabs
+// (#992): the stage's lane/row weights live in dispatch/gridShape.ts and have
+// their own normalization. What is left is the pair of helpers that never
+// depended on a tree.
/** Derive a tab title from a cwd — use the last path segment
* ("agent-code"), falling back to the full cwd if none. */
@@ -18,57 +15,6 @@ export function titleFromCwd(cwd: string): string {
return parts[parts.length - 1] ?? cwd
}
-/** Evenly-divided ratios summing to 1 for `count` tabs. */
-export function equalRatios(count: number): number[] {
- if (count <= 0) return []
- return Array.from({ length: count }, () => 1 / count)
-}
-
-/** Renormalize ratios so they sum to exactly 1. Recovers from
- * rounding drift or malformed persisted state. */
-export function normalizeRatios(ratios: number[]): number[] {
- if (ratios.length === 0) return []
- const total = ratios.reduce((sum, value) => sum + value, 0)
- if (total <= 0) return equalRatios(ratios.length)
- return ratios.map(value => value / total)
-}
-
-/** Float-tolerant ratio equality — used to skip setState when a
- * tile-tab resize hasn't produced a visible change. */
-export function ratiosEqual(a: number[], b: number[]): boolean {
- if (a.length !== b.length) return false
- for (let i = 0; i < a.length; i++) {
- if (Math.abs(a[i] - b[i]) > 0.0001) return false
- }
- return true
-}
-
-/** Sanitize TileTabsState against invariants:
- * - At least 2 tile-tabs (single-tab tiled mode is a no-op).
- * - Deduplicate tabIds.
- * - focusedTabId must be one of tabIds (falls back to head).
- * - Ratios count matches tabIds count (rebuild equal ratios
- * otherwise).
- * Returns null when the state collapses to an invalid shape — the
- * caller clears tile-tabs mode. */
-export function sanitizeTileTabsState(tileTabs: TileTabsState): TileTabsState | null {
- if (tileTabs.tabIds.length < 2) return null
- const tabIds = Array.from(new Set(tileTabs.tabIds))
- if (tabIds.length < 2) return null
- const focusedTabId = tabIds.includes(tileTabs.focusedTabId)
- ? tileTabs.focusedTabId
- : tabIds[0]
- const ratios = tileTabs.ratios.length === tabIds.length
- ? normalizeRatios(tileTabs.ratios)
- : equalRatios(tabIds.length)
- return {
- ...tileTabs,
- tabIds,
- focusedTabId,
- ratios,
- }
-}
-
/**
* Cheap structural comparison for SlashPickerState. The picker object
* itself is always fresh (parsed anew from each terminal snapshot in
@@ -91,44 +37,3 @@ export function pickerEqual(
}
return true
}
-
-/** Walk the tile tree and set the ratio of the split whose `a`
- * subtree contains aSession and whose `b` subtree contains
- * bSession. Ratios clamp to [0.1, 0.9] so no pane vanishes. Used
- * by drag-resize in TileLeaf and the split-ratio palette commands. */
-function containsSession(node: TileNode, sessionId: SessionId): boolean {
- if (node.type === 'leaf') return node.sessionId === sessionId
- return containsSession(node.a, sessionId) || containsSession(node.b, sessionId)
-}
-
-export function setRatioBetween(
- node: TileNode,
- aSession: SessionId,
- bSession: SessionId,
- ratio: number,
-): TileNode {
- if (node.type === 'leaf') return node
- const aContainsA = containsSession(node.a, aSession)
- const bContainsB = containsSession(node.b, bSession)
- if (aContainsA && bContainsB) {
- const nextRatio = Math.min(0.9, Math.max(0.1, ratio))
- return Math.abs(nextRatio - node.ratio) < 0.001 ? node : { ...node, ratio: nextRatio }
- }
- // WHY recurse only into sides that can still contain both endpoints:
- // the old implementation collected both subtree leaf arrays at every
- // split, making divider drag O(N^2) in pane count. Drag is a per-frame
- // path, so repeated array allocation here turns directly into UI jank.
- // Membership checks let us preserve the pure immutable tree update while
- // avoiding most work on branches that cannot possibly hold the target split.
- const aHasBoth = aContainsA && containsSession(node.a, bSession)
- const bHasBoth = containsSession(node.b, aSession) && bContainsB
- if (!aHasBoth && !bHasBoth) return node
- const nextA = aHasBoth ? setRatioBetween(node.a, aSession, bSession, ratio) : node.a
- const nextB = bHasBoth ? setRatioBetween(node.b, aSession, bSession, ratio) : node.b
- if (nextA === node.a && nextB === node.b) return node
- return {
- ...node,
- a: nextA,
- b: nextB,
- }
-}
diff --git a/src/renderer/src/workspace/legacyWorkspaceV2.test.ts b/src/renderer/src/workspace/legacyWorkspaceV2.test.ts
new file mode 100644
index 000000000..49fb1341d
--- /dev/null
+++ b/src/renderer/src/workspace/legacyWorkspaceV2.test.ts
@@ -0,0 +1,168 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ collectLegacyLeaves,
+ legacyEntrySeed,
+ legacyMemberships,
+ type LegacyTab,
+ type LegacyTileNode,
+ type LegacyWorkspaceV2Fields,
+} from '@renderer/workspace/legacyWorkspaceV2'
+import type { SessionId, SessionMeta } from '@renderer/workspace/types'
+
+// The v2 ownership rules, tested against the v2 shapes they were learned from.
+//
+// These cases lived in sessionOwnership.test.ts while live state still had a
+// tile tree, a detached bucket and a buried bucket. #992 deleted those from
+// live state; the rules survive in the legacy reader because every one of them
+// decides what an UPGRADING user keeps, and each was a production incident
+// before it was a rule.
+
+type V2 = LegacyWorkspaceV2Fields & { sessions: Record }
+
+const leaf = (sessionId: string): LegacyTileNode => ({ type: 'leaf', sessionId })
+const meta = (cwd = '/work/project-a'): SessionMeta => ({ cwd, kind: 'claude' })
+const tab = (id: string, root: LegacyTileNode, focusedSessionId = ''): LegacyTab =>
+ ({ id, title: id, root, focusedSessionId })
+
+function detached(sessionId: string, projectTabId: string, detachedAt: number) {
+ return {
+ sessionId, surface: 'dispatch' as const, projectTabId,
+ projectTabTitle: projectTabId, projectTabIndex: 0, detachedAt,
+ }
+}
+
+describe('collectLegacyLeaves', () => {
+ it('lists leaves depth-first, which is the order the grid showed them in', () => {
+ const tree: LegacyTileNode = {
+ type: 'split', direction: 'vertical', ratio: 0.5,
+ a: leaf('a'),
+ b: { type: 'split', direction: 'horizontal', ratio: 0.5, a: leaf('b'), b: leaf('c') },
+ }
+ expect(collectLegacyLeaves(tree)).toEqual(['a', 'b', 'c'])
+ })
+
+ it('is total on a malformed tree instead of throwing at boot', () => {
+ // A throw here is a lost workspace: rehydrate would fall to its recovery
+ // path with autosave locked.
+ expect(collectLegacyLeaves(undefined)).toEqual([])
+ expect(collectLegacyLeaves({ type: 'split', direction: 'vertical', ratio: 0.5, a: leaf('a') } as unknown as LegacyTileNode))
+ .toEqual(['a'])
+ expect(collectLegacyLeaves({ type: 'mystery' } as unknown as LegacyTileNode)).toEqual([])
+ })
+})
+
+describe('legacyMemberships', () => {
+ it('orders a project as v2 listed it: tree leaves first, then detached oldest-first', () => {
+ const input: V2 = {
+ tabs: [tab('tabA', { type: 'split', direction: 'vertical', ratio: 0.5, a: leaf('l1'), b: leaf('l2') })],
+ sessions: { l1: meta(), l2: meta(), d1: meta(), d2: meta() },
+ detachedSessions: { d2: detached('d2', 'tabA', 900), d1: detached('d1', 'tabA', 400) },
+ }
+ const members = legacyMemberships(input)
+ const ordered = [...members.entries()].sort((a, b) => a[1].joinedAt - b[1].joinedAt).map(([id]) => id)
+
+ expect(ordered).toEqual(['l1', 'l2', 'd1', 'd2'])
+ // Every leaf ordinal is smaller than every real timestamp, which is what
+ // "leaves first" meant.
+ expect(members.get('l2')!.joinedAt).toBeLessThan(members.get('d1')!.joinedAt)
+ })
+
+ it('does not own a tile leaf that has no metadata', () => {
+ // Recorded from a real workspace.json: a split whose `b` leaf named a
+ // session with no row in `sessions`. Counting it made restore completion
+ // unsatisfiable, so autosave stayed locked and the file could never be
+ // repaired — every launch for three weeks journalled
+ // `expectedCount 4, resolvedCount 3, ok false`.
+ const input: V2 = {
+ tabs: [tab('tabA', { type: 'split', direction: 'vertical', ratio: 0.5, a: leaf('live'), b: leaf('orphan') }, 'orphan')],
+ sessions: { live: meta() },
+ }
+ expect([...legacyMemberships(input).keys()]).toEqual(['live'])
+ })
+
+ it('does not read metadata through the prototype chain', () => {
+ // A leaf id like `toString` resolves to an inherited function under a bare
+ // index read, which would classify a genuine orphan as healthy.
+ const input: V2 = { tabs: [tab('tabA', leaf('toString'), 'toString')], sessions: {} }
+ expect(legacyMemberships(input).size).toBe(0)
+ })
+
+ it('collapses a production-shaped ghost pool without touching valid parked agents', () => {
+ // WHY the observed production cardinalities instead of one more
+ // one-record example: the bug was first mistaken for legitimate lazy
+ // recovery because each invalid record looked well-formed on its own. 8
+ // real parked agents beside 82 records whose project tab had been closed;
+ // parent-tab reachability is the rule, not any count. Spawning that pool
+ // at boot was the #258 fork bomb.
+ const input: V2 = {
+ tabs: [tab('tabA', leaf('live'), 'live')],
+ sessions: { live: meta() },
+ detachedSessions: {},
+ }
+ for (let index = 0; index < 8; index += 1) {
+ input.sessions[`parked-${index}`] = meta()
+ input.detachedSessions![`parked-${index}`] = detached(`parked-${index}`, 'tabA', index)
+ }
+ for (let index = 0; index < 82; index += 1) {
+ input.sessions[`ghost-${index}`] = meta(`/work/deleted-${index}`)
+ input.detachedSessions![`ghost-${index}`] = detached(`ghost-${index}`, `deleted-tab-${index}`, index)
+ }
+ const members = legacyMemberships(input)
+
+ expect(members.size).toBe(9)
+ expect(members.has('parked-7')).toBe(true)
+ expect(members.has('ghost-0')).toBe(false)
+ expect(members.has('ghost-81')).toBe(false)
+ })
+
+ it('keeps a buried session unconditionally, flagging a dead source project for re-parenting', () => {
+ // v2 owned buried sessions even when their source tab had been closed
+ // (Revive minted a tab for them), and a buried record could be the ONLY
+ // place its metadata lived.
+ const buriedMeta = meta('/work/archive')
+ const input: V2 = {
+ tabs: [tab('tabA', leaf('live'), 'live')],
+ sessions: { live: meta() },
+ buried: [{
+ id: 'hidden', sessionId: 'hidden', sessionMeta: buriedMeta, buriedAt: 7,
+ sourceTabId: 'already-closed', sourceTabTitle: 'archive', sourceTabIndex: 2,
+ }],
+ }
+ expect(legacyMemberships(input).get('hidden')).toEqual({
+ projectId: null, joinedAt: 7, restoredMeta: buriedMeta,
+ })
+ })
+
+ it('never gives a session a second owner: leaf beats detached beats buried', () => {
+ const input: V2 = {
+ tabs: [tab('tabA', leaf('s'), 's'), tab('tabB', leaf('other'), 'other')],
+ sessions: { s: meta(), other: meta() },
+ detachedSessions: { s: detached('s', 'tabB', 50) },
+ buried: [{ id: 's', sessionId: 's', sessionMeta: meta(), buriedAt: 9, sourceTabId: 'tabB', sourceTabTitle: 'b', sourceTabIndex: 1 }],
+ }
+ expect(legacyMemberships(input).get('s')).toEqual({ projectId: 'tabA', joinedAt: 0 })
+ })
+})
+
+describe('legacyEntrySeed (#977)', () => {
+ const base: V2 = {
+ tabs: [tab('tabA', leaf('a1'), 'a1'), tab('tabB', leaf('b1'), 'b1')],
+ activeTabId: 'tabA',
+ sessions: { a1: meta(), b1: meta() },
+ }
+
+ it('prefers the classic-Dispatch focus over the active tab s tree focus', () => {
+ expect(legacyEntrySeed(base)).toBe('a1')
+ expect(legacyEntrySeed({ ...base, dispatchMode: { scope: 'project', focusedSessionId: 'b1' } })).toBe('b1')
+ })
+
+ it('is null when the candidate is missing, and never a buried session', () => {
+ expect(legacyEntrySeed({ ...base, dispatchMode: { focusedSessionId: 'ghost' } })).toBeNull()
+ // The user hid it on purpose; an upgrade must not put it back on screen.
+ expect(legacyEntrySeed({
+ ...base,
+ buried: [{ id: 'a1', sessionId: 'a1', sessionMeta: meta(), buriedAt: 1, sourceTabId: 'tabA', sourceTabTitle: 'a', sourceTabIndex: 0 }],
+ })).toBeNull()
+ })
+})
diff --git a/src/renderer/src/workspace/legacyWorkspaceV2.ts b/src/renderer/src/workspace/legacyWorkspaceV2.ts
new file mode 100644
index 000000000..19a69accd
--- /dev/null
+++ b/src/renderer/src/workspace/legacyWorkspaceV2.ts
@@ -0,0 +1,263 @@
+import type {
+ SessionId,
+ SessionMeta,
+ TabId,
+ TiledDispatchState,
+} from '@renderer/workspace/types'
+
+// ---------------------------------------------------------------------------
+// The v2 workspace, as old files carry it (#992).
+//
+// WHY this file exists: the unified layout deleted the tile tree, the
+// `detachedSessions` bucket, the `buried` bucket and the `dispatchMode`
+// envelope from LIVE state. Users' workspace.json files still contain all four,
+// and will for as long as anyone upgrades from a build older than this one.
+// Every v2 type and every v2 reading rule lives HERE and nowhere else, so that:
+//
+// - the live types (`types.ts`) describe only what exists at runtime, and a
+// grep for `TileNode` or `DetachedSessionRecord` outside this file and the
+// migration means someone is reasoning about state that no longer exists;
+// - the ownership rules v2 earned the hard way (below) stay attached to the
+// data they were learned from, instead of surviving as folklore in
+// modules that no longer have the fields the rules are about.
+//
+// Nothing here is imported by a reducer, a selector or a component. The ONE
+// consumer is `migrateWorkspaceToStage` (workspaceShape.ts). If a second one
+// appears, it is reading a dead shape and should be reading the pool.
+// ---------------------------------------------------------------------------
+
+export type LegacySplitDirection = 'vertical' | 'horizontal'
+
+/** A v2 tile tree. Vertical = `a` left / `b` right; horizontal = top / bottom. */
+export type LegacyTileNode =
+ | { type: 'leaf'; sessionId: SessionId }
+ | {
+ type: 'split'
+ direction: LegacySplitDirection
+ ratio: number
+ a: LegacyTileNode
+ b: LegacyTileNode
+ }
+
+/** A v2 project tab: a title plus the tree that OWNED its visible sessions. */
+export type LegacyTab = {
+ id: TabId
+ title: string
+ focusedSessionId: SessionId
+ root: LegacyTileNode
+}
+
+/** A v2 session that was live but placed in no tile tree ("parked in Dispatch"). */
+export type LegacyDetachedSessionRecord = {
+ sessionId: SessionId
+ surface: 'dispatch'
+ /** Project affinity — the durable parent relation. */
+ projectTabId: TabId
+ projectTabTitle: string
+ projectTabIndex: number
+ /** The ONLY key that ordered rows inside a project group in v2. */
+ detachedAt: number
+}
+
+/** A v2 hidden-but-live pane. Carries its OWN SessionMeta (see below). */
+export type LegacyBuriedPaneRecord = {
+ id: string
+ sessionId: SessionId
+ sessionMeta: SessionMeta
+ buriedAt: number
+ sourceTabId: TabId
+ sourceTabTitle: string
+ sourceTabIndex: number
+ direction?: LegacySplitDirection
+ ratio?: number
+ side?: 'a' | 'b'
+ siblingLeafId?: SessionId
+ note?: string
+}
+
+/**
+ * The v2 "Dispatch Mode" envelope. `scope` and the classic `focusedSessionId`
+ * are read for the entry seed and otherwise discarded; `tiled` becomes the
+ * stage.
+ */
+export type LegacyDispatchMode = {
+ scope?: 'project' | 'global'
+ focusedSessionId?: SessionId
+ tiled?: TiledDispatchState
+}
+
+/** The v2 fields a persisted workspace may carry. All optional: a v3 file has none. */
+export type LegacyWorkspaceV2Fields = {
+ tabs?: LegacyTab[]
+ activeTabId?: TabId
+ dispatchMode?: LegacyDispatchMode | null
+ detachedSessions?: Record
+ buried?: LegacyBuriedPaneRecord[]
+}
+
+/**
+ * Depth-first leaves of a v2 tree — the order the grid showed them in, and
+ * therefore the order the index listed them in.
+ *
+ * Total on malformed input on purpose: a hand-edited file can hold a node with
+ * no `type`, or a split missing a child (the old `collectLeaves` threw on
+ * that, and a throw at boot is a lost workspace). Anything that is not a
+ * recognizable node contributes no leaves.
+ */
+export function collectLegacyLeaves(node: LegacyTileNode | null | undefined): SessionId[] {
+ if (!node || typeof node !== 'object') return []
+ if (node.type === 'leaf') {
+ return typeof node.sessionId === 'string' && node.sessionId.length > 0 ? [node.sessionId] : []
+ }
+ if (node.type === 'split') {
+ return [...collectLegacyLeaves(node.a), ...collectLegacyLeaves(node.b)]
+ }
+ return []
+}
+
+/**
+ * Does `sessions` actually carry metadata for this id?
+ *
+ * WHY an own-property check and not a bare `sessions[id]` truthiness test: a
+ * plain index read walks the prototype chain, so a leaf id of `toString`,
+ * `constructor`, or `valueOf` resolves to an inherited function and reads as
+ * "has metadata". Session ids are `randomUUID()` today, so this needs a
+ * hand-edited workspace.json to reach; hand-edited files are an explicit
+ * threat model for everything that reads that file, so the check is total.
+ * The value must also be truthy: an own key holding `undefined` is "no
+ * metadata".
+ */
+export function hasSessionMeta(
+ sessions: Record,
+ id: SessionId,
+): boolean {
+ // `Object.prototype.hasOwnProperty.call` rather than `Object.hasOwn`: this
+ // project's TS lib target predates ES2022.
+ return Object.prototype.hasOwnProperty.call(sessions, id) && Boolean(sessions[id])
+}
+
+/** How one v2 session belonged to the workspace, in pool terms. */
+export type LegacyMembership = {
+ /**
+ * The project that owned it. `null` means "owned, but its project is gone":
+ * only a buried session can be in that state (see rule 3), and the migration
+ * re-parents it to the active project.
+ */
+ projectId: TabId | null
+ /**
+ * Its position inside that project's index. v2 listed a project as
+ * `[...treeLeaves (depth-first), ...detached (oldest detachedAt first)]`.
+ * Leaves get their depth-first ORDINAL (0, 1, 2, …) and detached/buried
+ * sessions get their millisecond timestamp, so one ascending sort over this
+ * number reproduces the v2 order exactly: every ordinal is smaller than
+ * every timestamp, which is what "leaves first" meant.
+ */
+ joinedAt: number
+ /** Buried metadata can outlive its `sessions` row; carry it so it is restored. */
+ restoredMeta?: SessionMeta
+}
+
+/**
+ * Which v2 sessions were OWNED, by which project, in what order.
+ *
+ * These rules are v2's, preserved exactly, because each one was a production
+ * incident before it was a rule:
+ *
+ * 1. A tile leaf is owned by its tab — but ONLY if `sessions` has metadata
+ * for it. A leaf with no metadata has no cwd and no kind; there is nothing
+ * to restore. Counting it once froze a real user's workspace for three
+ * weeks: restore could never complete, so autosave (the file's only
+ * writer) stayed locked and the corrupt tree could never be rewritten.
+ *
+ * 2. A detached record is owned by `projectTabId` — but ONLY if that project
+ * still exists. Closing a tab killed its visible and detached sessions
+ * together, yet older builds and interrupted saves left the detached half
+ * behind, and blindly treating the record as an owner made it immortal.
+ * Real workspaces accumulated 80+ such ghosts; the #258 fork bomb was 40
+ * of them being SPAWNED at boot. A missing parent means there is no
+ * surface from which the agent can be found or managed: it is dropped.
+ *
+ * 3. A buried record is owned UNCONDITIONALLY, even when its source tab is
+ * gone (v2's Revive minted a tab for it). Hence `projectId: null` rather
+ * than a drop. Its metadata may live only in the record.
+ *
+ * 4. Metadata that none of the above claims is UNOWNED and is dropped. The
+ * `sessions` map is metadata for owners, never an owner itself — "metadata
+ * exists but nothing owns it" is how orphan rows became invisible backend
+ * processes.
+ *
+ * 5. Dispatch focus and lane selections are POINTERS, not ownership. A stale
+ * focus id must never resurrect work the user can no longer see.
+ *
+ * Precedence when a session is claimed twice (it never should be): leaf, then
+ * detached, then buried — the placement the user most recently arranged wins,
+ * and the fold never creates a second owner.
+ */
+export function legacyMemberships(
+ input: LegacyWorkspaceV2Fields & { sessions: Record },
+): Map {
+ const tabs = input.tabs ?? []
+ const liveProjectIds = new Set(tabs.map(tab => tab.id))
+ const out = new Map()
+
+ for (const tab of tabs) {
+ let ordinal = 0
+ for (const sessionId of collectLegacyLeaves(tab.root)) {
+ const position = ordinal++
+ if (out.has(sessionId)) continue
+ if (!hasSessionMeta(input.sessions, sessionId)) continue
+ out.set(sessionId, { projectId: tab.id, joinedAt: position })
+ }
+ }
+
+ for (const record of Object.values(input.detachedSessions ?? {})) {
+ if (!record || out.has(record.sessionId)) continue
+ if (!liveProjectIds.has(record.projectTabId)) continue
+ if (!hasSessionMeta(input.sessions, record.sessionId)) continue
+ out.set(record.sessionId, {
+ projectId: record.projectTabId,
+ joinedAt: Number.isFinite(record.detachedAt) ? record.detachedAt : 0,
+ })
+ }
+
+ for (const record of input.buried ?? []) {
+ if (!record || out.has(record.sessionId)) continue
+ const meta = hasSessionMeta(input.sessions, record.sessionId)
+ ? undefined
+ : record.sessionMeta
+ if (!meta && !hasSessionMeta(input.sessions, record.sessionId)) continue
+ out.set(record.sessionId, {
+ projectId: liveProjectIds.has(record.sourceTabId) ? record.sourceTabId : null,
+ // "When it left the screen" is the honest order key for a hidden pane.
+ joinedAt: Number.isFinite(record.buriedAt) ? record.buriedAt : 0,
+ ...(meta ? { restoredMeta: meta } : {}),
+ })
+ }
+
+ return out
+}
+
+/**
+ * The session v2's user was commanding — #977's entry seed — or null.
+ *
+ * Precedence: the classic-Dispatch focus, then the active tab's tree focus.
+ * A BURIED session is never the seed: the user hid it on purpose, and the
+ * first thing an upgrade does must not be to put it back on screen.
+ *
+ * WHY seeding does not violate #681: it is continuity with the pane the user
+ * was just commanding, never a prediction from the index. It fills lane 0 of
+ * a workspace that had no lanes and nothing else; all other lanes arrive
+ * empty and stay empty.
+ */
+export function legacyEntrySeed(
+ input: LegacyWorkspaceV2Fields & { sessions: Record },
+): SessionId | null {
+ const dispatchFocused = input.dispatchMode?.focusedSessionId ?? null
+ const treeFocused =
+ (input.tabs ?? []).find(tab => tab.id === input.activeTabId)?.focusedSessionId ?? null
+ const candidate = dispatchFocused ?? treeFocused
+ if (!candidate) return null
+ if (!hasSessionMeta(input.sessions, candidate)) return null
+ if ((input.buried ?? []).some(entry => entry?.sessionId === candidate)) return null
+ return candidate
+}
diff --git a/src/renderer/src/workspace/mergeProjectTabs.test.ts b/src/renderer/src/workspace/mergeProjectTabs.test.ts
index d37eb1782..48d7142de 100644
--- a/src/renderer/src/workspace/mergeProjectTabs.test.ts
+++ b/src/renderer/src/workspace/mergeProjectTabs.test.ts
@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest'
-import { mergeProjectTabs, retargetTileTabsAfterMerge } from '@renderer/workspace/mergeProjectTabs'
+import { mergeProjectTabs } from '@renderer/workspace/mergeProjectTabs'
import { collectOwnedSessionIds } from '@renderer/workspace/sessionOwnership'
import { resolveTabSessions } from '@renderer/workspace/queries'
-import type { SessionMeta, TileTabsState, WorkspaceState } from '@renderer/workspace/types'
+import type { SessionMeta, WorkspaceState } from '@renderer/workspace/types'
// The workspace that motivated #913, reduced: three tabs for one repository
// (two of them holding worktree agents) plus an unrelated project, with every
@@ -15,48 +15,35 @@ function meta(cwd: string): SessionMeta {
function fixture(): WorkspaceState {
return {
tabs: [
- { id: 'tab-b', title: 'agent-code', focusedSessionId: 'b-audit', root: { type: 'leaf', sessionId: 'b-audit' } },
- { id: 'tab-startup', title: 'startup', focusedSessionId: 'pitch', root: { type: 'leaf', sessionId: 'pitch' } },
- { id: 'tab-e', title: 'agent-code', focusedSessionId: 'e-root', root: {
- type: 'split', direction: 'vertical', ratio: 0.5,
- a: { type: 'leaf', sessionId: 'e-root' },
- b: { type: 'leaf', sessionId: 'e-grok' },
- } },
- { id: 'tab-g', title: 'agent-code', focusedSessionId: 'g-review', root: { type: 'leaf', sessionId: 'g-review' } },
+ { id: 'tab-b', title: 'agent-code' },
+ { id: 'tab-startup', title: 'startup' },
+ { id: 'tab-e', title: 'agent-code' },
+ { id: 'tab-g', title: 'agent-code' },
],
activeTabId: 'tab-g',
- dispatchMode: {
- tiled: {
- lanes: [{ selectedSessionId: 'g-review' }, { selectedSessionId: 'e-tldr' }],
- rows: [
- { length: 1, projectTabIds: ['tab-g', 'tab-e'] },
- { length: 1, projectTabId: 'tab-b' },
- ],
- focusedLane: 0,
- },
- } as unknown as WorkspaceState['dispatchMode'],
- sessions: {
- 'b-audit': meta('/dev/agent-code'),
- 'b-verify': meta('/dev/agent-code/.worktrees/opencode-terminal-headless'),
- pitch: meta('/dev/startup'),
- 'e-root': meta('/dev/agent-code'),
- 'e-grok': meta('/dev/agent-code/.worktrees/grok-package-wiring'),
- 'e-tldr': meta('/dev/agent-code'),
- 'g-review': meta('/dev/agent-code'),
- 'g-buried': meta('/dev/agent-code'),
- 'e-buried': meta('/dev/agent-code'),
+ stage: {
+ lanes: [{ selectedSessionId: 'g-review' }, { selectedSessionId: 'e-tldr' }],
+ rows: [
+ { length: 1, projectTabIds: ['tab-g', 'tab-e'] },
+ // The legacy single binding, deliberately: a row written before
+ // bindings became a set must be folded into one by the merge.
+ { length: 1, projectTabId: 'tab-b' },
+ ],
+ focusedLane: 0,
},
- detachedSessions: {
- 'b-verify': { sessionId: 'b-verify', surface: 'dispatch', projectTabId: 'tab-b', projectTabTitle: 'agent-code', projectTabIndex: 0, detachedAt: 10 },
- 'e-tldr': { sessionId: 'e-tldr', surface: 'dispatch', projectTabId: 'tab-e', projectTabTitle: 'agent-code', projectTabIndex: 2, detachedAt: 20 },
+ sessions: {
+ 'b-audit': { ...meta('/dev/agent-code'), projectId: 'tab-b', joinedAt: 0 },
+ 'b-verify': { ...meta('/dev/agent-code/.worktrees/opencode-terminal-headless'), projectId: 'tab-b', joinedAt: 10 },
+ pitch: { ...meta('/dev/startup'), projectId: 'tab-startup', joinedAt: 0 },
+ 'e-root': { ...meta('/dev/agent-code'), projectId: 'tab-e', joinedAt: 0 },
+ 'e-grok': { ...meta('/dev/agent-code/.worktrees/grok-package-wiring'), projectId: 'tab-e', joinedAt: 1 },
+ 'e-tldr': { ...meta('/dev/agent-code'), projectId: 'tab-e', joinedAt: 20 },
+ 'g-review': { ...meta('/dev/agent-code'), projectId: 'tab-g', joinedAt: 0 },
+ // Parked agents no lane shows. (In v2 these two were `buried` records;
+ // burial folded into the pool with #992, so they are ordinary rows.)
+ 'g-parked': { ...meta('/dev/agent-code'), projectId: 'tab-g', joinedAt: 30 },
+ 'e-parked': { ...meta('/dev/agent-code'), projectId: 'tab-e', joinedAt: 35 },
},
- buried: [{
- id: 'g-buried', sessionId: 'g-buried', sessionMeta: meta('/dev/agent-code'), buriedAt: 30,
- sourceTabId: 'tab-g', sourceTabTitle: 'agent-code', sourceTabIndex: 3,
- }, {
- id: 'e-buried', sessionId: 'e-buried', sessionMeta: meta('/dev/agent-code'), buriedAt: 35,
- sourceTabId: 'tab-e', sourceTabTitle: 'agent-code', sourceTabIndex: 2,
- }],
pinnedSessionIds: ['e-grok'],
}
}
@@ -72,59 +59,55 @@ describe('mergeProjectTabs', () => {
// deleted by the next autosave (see collectOwnedSessionIds).
expect(collectOwnedSessionIds(state)).toEqual(collectOwnedSessionIds(before))
expect(state.tabs.map(tab => tab.id)).toEqual(['tab-startup', 'tab-e'])
- expect(state.tabs[1]).toBe(before.tabs[2]) // the target's tree is untouched
+ expect(state.tabs[1]).toBe(before.tabs[2]) // the target itself is untouched
expect(state.activeTabId).toBe('tab-e')
- // Grid panes of the removed tabs are now Dispatch agents of the target;
- // detached records that pointed at a removed tab follow it with the
- // target's title and NEW index.
- expect(resolveTabSessions(state, 'tab-e')).toEqual(['e-root', 'e-grok', 'b-verify', 'e-tldr', 'b-audit', 'g-review'])
- expect(state.detachedSessions['b-audit']).toEqual({
- sessionId: 'b-audit', surface: 'dispatch', projectTabId: 'tab-e', projectTabTitle: 'agent-code', projectTabIndex: 1, detachedAt: 1000,
+ // The target's own sessions keep their order and come first; the moved
+ // ones are APPENDED, source projects in project order (B before G, though
+ // the caller named them the same way here), each in its own index order.
+ expect(resolveTabSessions(state, 'tab-e')).toEqual([
+ 'e-root', 'e-grok', 'e-tldr', 'e-parked',
+ 'b-audit', 'b-verify', 'g-review', 'g-parked',
+ ])
+ // Appended STRICTLY after the target's last row, whatever clock stamped it.
+ expect(state.sessions['b-audit']).toMatchObject({ projectId: 'tab-e', joinedAt: 1000 })
+ expect(state.sessions['g-parked']).toMatchObject({ projectId: 'tab-e', joinedAt: 1003 })
+ // Nothing else about a moved session changes — no process is touched.
+ expect(state.sessions['b-verify']).toEqual({
+ ...before.sessions['b-verify'], projectId: 'tab-e', joinedAt: 1001,
})
- expect(state.detachedSessions['b-verify']).toMatchObject({ projectTabId: 'tab-e', projectTabIndex: 1, detachedAt: 10 })
- // The target's OWN records moved from letter C to B when tab B left, and
- // must not keep the old letter next to the ones they were just joined by.
- expect(state.detachedSessions['e-tldr']).toMatchObject({ projectTabId: 'tab-e', projectTabIndex: 1, detachedAt: 20 })
- expect(state.buried[0]).toMatchObject({ sourceTabId: 'tab-e', sourceTabTitle: 'agent-code', sourceTabIndex: 1 })
- // A buried record of the surviving target moves letter with it too.
- expect(state.buried[1]).toMatchObject({ id: 'e-buried', sourceTabId: 'tab-e', sourceTabIndex: 1, buriedAt: 35 })
+ // The target's own rows are the same objects.
+ expect(state.sessions['e-tldr']).toBe(before.sessions['e-tldr'])
expect(state.pinnedSessionIds).toEqual(['e-grok'])
- // Row filters that named a removed tab name the target once; the legacy
- // single binding is folded into the array; lanes are untouched.
- const rows = (state.dispatchMode as { tiled: { rows: unknown[]; lanes: unknown[] } }).tiled
- expect(rows.rows).toEqual([{ length: 1, projectTabIds: ['tab-e'] }, { length: 1, projectTabIds: ['tab-e'] }])
- expect(rows.lanes).toEqual([{ selectedSessionId: 'g-review' }, { selectedSessionId: 'e-tldr' }])
+ // Row filters that named a removed project name the target once; the
+ // legacy single binding is folded into the array; lanes are untouched.
+ expect(state.stage.rows).toEqual([{ length: 1, projectTabIds: ['tab-e'] }, { length: 1, projectTabIds: ['tab-e'] }])
+ expect(state.stage.lanes).toEqual([{ selectedSessionId: 'g-review' }, { selectedSessionId: 'e-tldr' }])
expect(summary).toEqual({
targetTabId: 'tab-e', targetTitle: 'agent-code', targetIndex: 1, removedTabIds: ['tab-b', 'tab-g'],
- detachedFromGrid: ['b-audit', 'g-review'], repointedDetached: ['b-verify'], repointedBuried: ['g-buried'],
+ movedSessionIds: ['b-audit', 'b-verify', 'g-review', 'g-parked'],
})
expect(resolveTabSessions(state, 'tab-startup')).toEqual(['pitch'])
})
- it('skips a source pane with no metadata and counts a pane that already had a detached record once', () => {
+ it('appends after the target s last row even when that row was stamped later than `now`', () => {
+ // A clock that went backwards, or a target holding a session created a
+ // moment ago: the moved sessions must still land at the END.
const before = fixture()
- // `phantom` is a leaf the ownership rules already treat as absent;
- // `g-review` is both a grid pane of G and, by a broken earlier save, a
- // detached record of G.
- before.tabs[3]!.root = {
- type: 'split', direction: 'horizontal', ratio: 0.5,
- a: { type: 'leaf', sessionId: 'g-review' },
- b: { type: 'leaf', sessionId: 'phantom' },
- }
- before.detachedSessions['g-review'] = {
- sessionId: 'g-review', surface: 'dispatch', projectTabId: 'tab-g', projectTabTitle: 'agent-code', projectTabIndex: 3, detachedAt: 40,
- }
+ before.sessions['e-tldr'] = { ...before.sessions['e-tldr']!, joinedAt: 5_000 }
const result = mergeProjectTabs(before, { targetTabId: 'tab-e', sourceTabIds: ['tab-g'], now: 1000 })
if (!result.ok) throw new Error(result.reason)
- expect(collectOwnedSessionIds(result.state)).toEqual(collectOwnedSessionIds(before))
- expect(result.state.detachedSessions['phantom']).toBeUndefined()
- expect(result.state.detachedSessions['g-review']).toMatchObject({ projectTabId: 'tab-e', projectTabIndex: 2, detachedAt: 40 })
- expect(result.summary.detachedFromGrid).toEqual([])
- expect(result.summary.repointedDetached).toEqual(['g-review'])
+ expect(resolveTabSessions(result.state, 'tab-e').slice(-2)).toEqual(['g-review', 'g-parked'])
+ expect(result.state.sessions['g-review']!.joinedAt).toBeGreaterThan(5_000)
})
+ // "skips a source pane with no metadata and counts a pane that already had a
+ // detached record once" lived here until #992. Both halves were about v2's
+ // owner structures disagreeing — a tile leaf with no metadata, and a session
+ // that was a leaf AND a detached record at once. Neither can be represented:
+ // a session is a row, and a row names one project.
+
it('refuses a target among the sources, an unknown tab, and an empty selection', () => {
const state = fixture()
expect(mergeProjectTabs(state, { targetTabId: 'tab-e', sourceTabIds: ['tab-e', 'tab-b'], now: 1 })).toEqual({ ok: false, reason: 'target_is_source' })
@@ -134,24 +117,3 @@ describe('mergeProjectTabs', () => {
})
})
-describe('retargetTileTabsAfterMerge', () => {
- const tiled: TileTabsState = { tabIds: ['tab-b', 'tab-e', 'tab-g'], focusedTabId: 'tab-g', direction: 'vertical', ratios: [0.2, 0.5, 0.3] }
-
- it('drops merged tabs, moves focus to the target and keeps the surviving ratios aligned', () => {
- expect(retargetTileTabsAfterMerge(tiled, ['tab-g'], 'tab-e')).toMatchObject({ tabIds: ['tab-b', 'tab-e'], focusedTabId: 'tab-e' })
- const ratios = retargetTileTabsAfterMerge(tiled, ['tab-g'], 'tab-e')!.ratios
- expect(ratios[0]! / ratios[1]!).toBeCloseTo(0.4)
- })
-
- it('gives a tiled source\'s slot to a target that was not tiled, so the kept tab stays on screen', () => {
- const twoTiled: TileTabsState = { tabIds: ['tab-b', 'tab-g'], focusedTabId: 'tab-g', direction: 'vertical', ratios: [0.3, 0.7] }
- expect(retargetTileTabsAfterMerge(twoTiled, ['tab-g'], 'tab-e')).toMatchObject({ tabIds: ['tab-b', 'tab-e'], focusedTabId: 'tab-e', ratios: [0.3, 0.7] })
- // Two tiled sources into an untiled target: one slot, the other leaves.
- expect(retargetTileTabsAfterMerge(tiled, ['tab-b', 'tab-g'], 'tab-startup')).toMatchObject({ tabIds: ['tab-startup', 'tab-e'], focusedTabId: 'tab-startup' })
- })
-
- it('exits tiled tabs when fewer than two remain, and leaves an absent layout absent', () => {
- expect(retargetTileTabsAfterMerge(tiled, ['tab-b', 'tab-g'], 'tab-e')).toBeNull()
- expect(retargetTileTabsAfterMerge(null, ['tab-b'], 'tab-e')).toBeNull()
- })
-})
diff --git a/src/renderer/src/workspace/mergeProjectTabs.ts b/src/renderer/src/workspace/mergeProjectTabs.ts
index 210489890..13e8325c0 100644
--- a/src/renderer/src/workspace/mergeProjectTabs.ts
+++ b/src/renderer/src/workspace/mergeProjectTabs.ts
@@ -1,73 +1,64 @@
import type {
- DetachedSessionRecord,
SessionId,
+ SessionMeta,
TabId,
- TileTabsState,
WorkspaceState,
} from '@renderer/workspace/types'
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
-import { sanitizeTileTabsState } from '@renderer/workspace/layout/helpers'
-import { hasSessionMeta } from '@renderer/workspace/sessionOwnership'
+import { resolveTabSessions } from '@renderer/workspace/queries'
-// Merge Project Tabs (#913): fold source tabs into a target tab WITHOUT
+// Merge Project Tabs (#913): fold source projects into a target project WITHOUT
// touching any process.
//
// WHY a pure planner over WorkspaceState instead of a sequence of existing
-// actions (detach, close tab): `closeTab` kills every session it owns, which
-// is the one thing a merge must never do, and `detachSession` refuses the last
-// grid pane of a tab because a tab cannot exist without a tile tree. A merge
-// removes the tab itself, so that guard does not apply; the leaf simply
-// becomes a detached record of the target. Doing the whole re-pointing in one
-// pure function makes the invariant testable in isolation: the owned-session
-// set (`collectOwnedSessionIds`) is identical before and after, so no session
-// can be orphaned and then deleted by the next autosave.
+// actions: `closeTab` kills every session it owns, which is the one thing a
+// merge must never do. Doing the whole re-pointing in one pure function makes
+// the invariant testable in isolation: the owned-session set
+// (`collectOwnedSessionIds`) is identical before and after, so no session can
+// be orphaned and then deleted by the next autosave.
//
-// WHY source grid panes go to Dispatch rather than into the target's grid:
-// `Tab.root` is a tile tree the user built. Attaching every source pane would
-// turn one tab into a wall of splits, and `buildDispatchGroups` already lists
-// detached sessions under their tab, so nothing is hidden. The user attaches
-// what they want afterwards.
+// WHAT a merge is now (#992): every session of a source project is re-filed
+// under the target — `projectId` changes, nothing else — and the emptied
+// source projects are removed. Until the unified layout this was three
+// re-pointings over three owner structures (source tile leaves became detached
+// records of the target, because attaching them would have turned the target's
+// tree into a wall of splits; source detached records got a new
+// `projectTabId`; buried records a new `sourceTabId`), plus a refresh of the
+// display-index snapshots each record carried.
+//
+// WHY moved sessions are APPENDED to the target's index, in the order they
+// were listed: a merge is "put these in here too". Keeping each session's old
+// `joinedAt` would interleave them with the target's own rows by creation
+// time — and a migrated v2 tree leaf holds a tiny ordinal, so it would jump
+// ABOVE everything the target already had. Appending is predictable and
+// leaves the target's existing labels where they were.
//
// WHY there is no undo entry: undo-close exists to bring back sessions that
// were KILLED, by re-spawning them from a captured SessionMeta. A merge kills
-// nothing, so there is nothing to re-spawn; reversing it would mean rebuilding
-// the removed tabs' tile trees from a snapshot while the sessions inside them
-// have kept running and may since have been attached, buried or closed. Every
-// moved agent stays reachable in the target's Dispatch list, so the manual
-// reverse (new tab, attach) is always available and never lossy.
-//
-// A consequence worth knowing before relying on "nothing restarts": it is true
-// for the running app only. Grid panes are the only sessions rehydrate spawns
-// at launch (`collectLiveProcessIds`); a merged pane is now a detached record,
-// so after the next launch it is hibernated like every other Dispatch agent
-// and wakes on its first use instead of being live from the start.
+// nothing, so there is nothing to re-spawn, and the sessions have kept running
+// and may since have been moved or closed. Every moved agent stays reachable
+// in the target's index.
//
// What is deliberately left alone, because every session survives and these
-// are keyed by session, not tab: Dispatch lanes, lane focus, pins, expanded
-// parents, `gridRelatedSelections` (only read for grid panes, and
-// `detachSessionToDispatch` leaves them too), and pane-close undo entries
-// anchored on a source pane (they resolve as stale, exactly as after a
-// detach).
+// are keyed by session, not project: lanes, lane focus, pins, expanded
+// parents, and undo entries anchored on a source project (they resolve as
+// stale — see UndoLineage for why a merge never publishes lineage).
export type MergeProjectTabsInput = {
targetTabId: TabId
sourceTabIds: readonly TabId[]
- /** Stamp for the new detached records; injected so tests are deterministic. */
+ /** Base stamp for the moved sessions' new index positions; injected so
+ * tests are deterministic. */
now: number
}
export type MergeProjectTabsSummary = {
targetTabId: TabId
targetTitle: string
- /** Index of the target in the merged tab array (the letter Dispatch shows). */
+ /** Index of the target in the merged project array (the letter it shows). */
targetIndex: number
removedTabIds: TabId[]
- /** Source grid panes that became detached records of the target. */
- detachedFromGrid: SessionId[]
- /** Source detached records re-pointed at the target. */
- repointedDetached: SessionId[]
- /** Buried records re-pointed at the target. */
- repointedBuried: SessionId[]
+ /** Every session re-filed under the target, in its new index order. */
+ movedSessionIds: SessionId[]
}
export type MergeProjectTabsResult =
@@ -90,98 +81,43 @@ export function mergeProjectTabs(
const tabs = state.tabs.filter(tab => !sourceSet.has(tab.id))
const targetIndex = tabs.findIndex(tab => tab.id === input.targetTabId)
const target = tabs[targetIndex]!
- const affinity = {
- projectTabId: target.id,
- projectTabTitle: target.title,
- projectTabIndex: targetIndex,
- }
- // Removing tabs shifts the index of every tab after them, and the records
- // of SURVIVING tabs carry that index as a snapshot (`projectTabIndex`,
- // `sourceTabIndex`). `buildDispatchGroups` recomputes it at render, but the
- // agent-status model reads the raw value, and a merge that leaves the
- // target's own records one letter behind the ones it just received would
- // show two letters for one tab. `closeTab` leaves these stale and relies on
- // the recompute; here the whole state is in hand, so refreshing is free.
- const indexOf = new Map(tabs.map((tab, index) => [tab.id, index] as const))
- const detachedSessions: Record = {}
- const repointedDetached: SessionId[] = []
- for (const [sessionId, record] of Object.entries(state.detachedSessions)) {
- if (sourceSet.has(record.projectTabId)) {
- detachedSessions[sessionId] = { ...record, ...affinity }
- repointedDetached.push(sessionId)
- continue
- }
- const index = indexOf.get(record.projectTabId)
- detachedSessions[sessionId] = index === undefined || index === record.projectTabIndex
- ? record
- : { ...record, projectTabIndex: index }
- }
- const detachedFromGrid: SessionId[] = []
- for (const tab of state.tabs) {
- if (!sourceSet.has(tab.id)) continue
- for (const sessionId of collectLeaves(tab.root)) {
- // A leaf with no SessionMeta is already an orphan the ownership rules
- // would drop; carrying it as a detached record would only resurrect it.
- // Same own-property test as ownership, so the two never disagree about
- // what counts as metadata.
- if (!hasSessionMeta(state.sessions, sessionId)) continue
- // A pane that already has a detached record is a pre-existing
- // ownership violation (a session cannot be both a grid leaf and
- // detached). The merge ends it: the pane is no longer a leaf, so the
- // record becomes its only owner. Keep the record's own stamp and count
- // the session once, under whichever list already claimed it.
- const existing = detachedSessions[sessionId]
- if (existing) {
- detachedSessions[sessionId] = { ...existing, ...affinity }
- if (!repointedDetached.includes(sessionId)) repointedDetached.push(sessionId)
- continue
- }
- detachedSessions[sessionId] = {
- sessionId,
- surface: 'dispatch',
- ...affinity,
- detachedAt: input.now,
- }
- detachedFromGrid.push(sessionId)
- }
- }
-
- const repointedBuried: SessionId[] = []
- const buried = state.buried.map(record => {
- if (sourceSet.has(record.sourceTabId)) {
- repointedBuried.push(record.sessionId)
- return {
- ...record,
- sourceTabId: target.id,
- sourceTabTitle: target.title,
- sourceTabIndex: targetIndex,
- }
- }
- const index = indexOf.get(record.sourceTabId)
- return index === undefined || index === record.sourceTabIndex
- ? record
- : { ...record, sourceTabIndex: index }
+ // Source projects in PROJECT order (not the order the caller named them),
+ // each in its own index order: the merged list reads top-to-bottom the way
+ // the separate lists did.
+ const movedSessionIds = state.tabs
+ .filter(tab => sourceSet.has(tab.id))
+ .flatMap(tab => resolveTabSessions(state, tab.id))
+ // Strictly after everything the target already lists, whatever clock those
+ // rows were stamped with.
+ const lastTargetPosition = resolveTabSessions(state, target.id)
+ .reduce((max, id) => Math.max(max, state.sessions[id]?.joinedAt ?? 0), 0)
+ const base = Math.max(input.now, lastTargetPosition + 1)
+ const sessions: Record = { ...state.sessions }
+ movedSessionIds.forEach((sessionId, offset) => {
+ sessions[sessionId] = { ...sessions[sessionId]!, projectId: target.id, joinedAt: base + offset }
})
// Row project filters name tabs; a filter that named a source now names the
// target once. Lanes are session-keyed and need nothing.
- const tiledRows = state.dispatchMode?.tiled?.rows
- const dispatchMode = state.dispatchMode?.tiled && tiledRows
+ //
+ // `rows` is optional on a stage that predates the grid (a flat lane list);
+ // such a stage has no row metadata to re-point, so it is passed through
+ // untouched rather than normalized here — normalizing is the reducers' job
+ // and doing it as a side effect of a merge would hide the write.
+ const stageRows = state.stage.rows
+ const stage = stageRows
? {
- ...state.dispatchMode,
- tiled: {
- ...state.dispatchMode.tiled,
- rows: tiledRows.map(row => {
- const bound = row.projectTabIds ?? (row.projectTabId ? [row.projectTabId] : undefined)
- if (!bound || !bound.some(id => sourceSet.has(id))) return row
- const { projectTabId: _legacy, ...rest } = row
- const projectTabIds = [...new Set(bound.map(id => (sourceSet.has(id) ? target.id : id)))]
- return { ...rest, projectTabIds }
- }),
- },
+ ...state.stage,
+ rows: stageRows.map(row => {
+ const bound = row.projectTabIds ?? (row.projectTabId ? [row.projectTabId] : undefined)
+ if (!bound || !bound.some(id => sourceSet.has(id))) return row
+ const { projectTabId: _legacy, ...rest } = row
+ const projectTabIds = [...new Set(bound.map(id => (sourceSet.has(id) ? target.id : id)))]
+ return { ...rest, projectTabIds }
+ }),
}
- : state.dispatchMode
+ : state.stage
return {
ok: true,
@@ -189,60 +125,16 @@ export function mergeProjectTabs(
...state,
tabs,
activeTabId: sourceSet.has(state.activeTabId) ? target.id : state.activeTabId,
- detachedSessions,
- buried,
- dispatchMode,
+ sessions,
+ stage,
},
summary: {
targetTabId: target.id,
targetTitle: target.title,
targetIndex,
removedTabIds: sources,
- detachedFromGrid,
- repointedDetached,
- repointedBuried,
+ movedSessionIds,
},
}
}
-/**
- * The tiled-tabs half of a merge, shaped as a functional update so the hook
- * can apply it through `setTileTabs(prev => ...)` against the live value
- * rather than a render-time snapshot.
- *
- * WHY a tiled source is REPLACED by the target rather than dropped when the
- * target is not itself tiled: the user was looking at that source's tile, and
- * after the merge its agents belong to the target. Dropping the slot would
- * leave `activeTabId` on the target while `MainSurface` keeps rendering the
- * tiled set (it renders tiled tabs whenever the layout is set), so the tab the
- * user just kept would be the one tab not on screen. Only the first tiled
- * source takes the slot; the rest leave, and `sanitizeTileTabsState` exits
- * tiled tabs below two. Focus follows the same rule, so a focus that sat on a
- * source always lands on the target, which is tiled in either branch.
- */
-export function retargetTileTabsAfterMerge(
- tileTabs: TileTabsState | null,
- sourceTabIds: readonly TabId[],
- targetTabId: TabId,
-): TileTabsState | null {
- if (!tileTabs) return null
- const sourceSet = new Set(sourceTabIds)
- let slotTaken = tileTabs.tabIds.includes(targetTabId)
- const kept = tileTabs.tabIds
- .map((id, index) => ({ id, ratio: tileTabs.ratios[index] }))
- .flatMap(item => {
- if (!sourceSet.has(item.id)) return [item]
- if (slotTaken) return []
- slotTaken = true
- return [{ id: targetTabId, ratio: item.ratio }]
- })
- const tabIds = kept.map(item => item.id)
- const ratios = kept.map(item => item.ratio).filter((ratio): ratio is number => typeof ratio === 'number')
- const focusedTabId = sourceSet.has(tileTabs.focusedTabId) ? targetTabId : tileTabs.focusedTabId
- return sanitizeTileTabsState({
- ...tileTabs,
- tabIds,
- focusedTabId,
- ratios: ratios.length === tabIds.length ? ratios : [],
- })
-}
diff --git a/src/renderer/src/workspace/persistence.ts b/src/renderer/src/workspace/persistence.ts
index 1a168f0fe..0f636ceee 100644
--- a/src/renderer/src/workspace/persistence.ts
+++ b/src/renderer/src/workspace/persistence.ts
@@ -1,13 +1,13 @@
+import type { LegacyWorkspaceV2Fields } from '@renderer/workspace/legacyWorkspaceV2'
import type {
- BuriedPaneRecord,
- DetachedSessionRecord,
- DispatchModeState,
+ ProjectRef,
SessionId,
SessionMeta,
TabId,
- TileNode,
+ TiledDispatchState,
} from '@renderer/workspace/types'
-import type { TileTabsState } from '@renderer/workspace/types'
+
+export type { LegacyDispatchMode } from '@renderer/workspace/legacyWorkspaceV2'
// ---------------------------------------------------------------------------
// Persisted state shape (serialized to ~/.config/agent-code/workspace.json)
@@ -15,39 +15,59 @@ import type { TileTabsState } from '@renderer/workspace/types'
/**
* Persisted workspace shape. Live runtime state is NOT here: main reconciles
- * each visible local SessionId with a backend, and runtime state rebuilds from
+ * each session the stage shows with a backend, and runtime state rebuilds from
* the returned level snapshot plus subsequent SessionFeed events.
+ *
+ * TWO GENERATIONS share this type, because one function reads both:
+ *
+ * v3 (#992) — what this build WRITES. A fleet pool (`sessions`, each row
+ * carrying its `projectId` and `joinedAt`), the `projects` that group it,
+ * and the `stage` that places some of it on screen.
+ *
+ * v2 — what older builds wrote: `tabs` owning tile trees, a
+ * `detachedSessions` bucket, a `buried` bucket, a `dispatchMode` envelope.
+ * Declared in legacyWorkspaceV2.ts and spread in here as all-optional
+ * fields. NEVER written by this build.
+ *
+ * WHY detect by shape instead of a version number: every v3 field has an
+ * unambiguous meaning and every v2 field has an unambiguous translation, so a
+ * read-time normalizer (`migrateWorkspaceToStage`) is total over both — and
+ * over a file that carries BOTH, which the intermediate builds of #992 wrote.
+ * A version integer would promise a downgrade path nobody has tested. This is
+ * the same discipline `normalizeGridShape` uses for the legacy `ratios` array.
+ *
+ * DOWNGRADE: a build older than #992 opening a v3-only file finds no `tabs`,
+ * throws in its rehydrate, and lands in its `persisted-fallback` path — a
+ * fresh recovery tab with autosave LOCKED, so the v3 file is not overwritten.
+ * The old build cannot show the workspace, but it cannot destroy it either.
*/
-export type PersistedWorkspace = {
- // Tab tree keyed by durable Agent Code SessionIds. These are ownership keys,
- // not launch-scoped placeholders: renderer reload adopts an existing backend
- // and full app restart cold-starts one under the same id. Provider history
- // identity is stored separately in SessionMeta.providerSessionId.
- tabs: Array<{
- id: TabId
- title: string
- focusedSessionId: SessionId
- root: TileNode
- }>
- activeTabId: TabId
- dispatchMode?: DispatchModeState | null
- sessions: Record
- detachedSessions?: Record
- buried?: BuriedPaneRecord[]
+export type PersistedWorkspace = LegacyWorkspaceV2Fields & {
/**
- * Ordered list of pinned session ids. Optional because legacy
- * workspace.json files predate this field; rehydrate defaults the
- * runtime state to [] when this is absent or malformed.
+ * The pool. Keyed by durable Agent Code SessionIds — ownership keys, not
+ * launch-scoped placeholders: a renderer reload adopts an existing backend
+ * and a full restart cold-starts one under the same id. Provider history
+ * identity is stored separately in SessionMeta.providerSessionId.
*
- * These ids use the same durable local ownership keys as tile leaves,
- * detached sessions, and buried panes. Failed backend recovery retains the
- * pin because the pane remains retryable; only truly unowned/corrupt rows are
- * removed during rehydrate.
+ * In a v3 file every row has `projectId` naming a member of `projects`; a
+ * row that does not is a ghost and is dropped on read (the v2 rule for a
+ * detached record whose project was closed, restated for the pool).
+ */
+ sessions: Record
+ /** Projects: grouping only — an id, a title, a stable index letter. */
+ projects?: ProjectRef[]
+ /** Spawn defaults + index highlight; owns nothing. Former `activeTabId`. */
+ activeProjectId?: TabId
+ /** The workspace stage — ragged rows of lanes. Former `dispatchMode.tiled`. */
+ stage?: TiledDispatchState
+ /**
+ * Ordered list of pinned session ids. Optional because legacy files predate
+ * it; a failed backend recovery retains the pin because the session remains
+ * retryable, and only a pin naming a session the pool dropped is removed.
*/
pinnedSessionIds?: SessionId[]
- tileTabs?: TileTabsState | null
- /** Draft input text per session, keyed by sessionId. Persisted so
- * in-progress prompts survive app crashes and restarts. Only
- * non-empty drafts are saved to keep the file small. */
+ // `tileTabs` was persisted here until #992 deleted Tile Tabs. Old files
+ // may still carry it; it is ignored on read and never written again.
+ /** Draft input text per session. Persisted so in-progress prompts survive
+ * crashes and restarts. Only non-empty drafts are saved. */
drafts?: Record
}
diff --git a/src/renderer/src/workspace/pool.test.ts b/src/renderer/src/workspace/pool.test.ts
new file mode 100644
index 000000000..c2e5e5358
--- /dev/null
+++ b/src/renderer/src/workspace/pool.test.ts
@@ -0,0 +1,195 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ fileSessionInProject,
+ inheritedMembership,
+ workspaceWithoutSessions,
+} from '@renderer/workspace/pool'
+import { resolveTabSessions } from '@renderer/workspace/queries'
+import { collectUnownedSessionIds } from '@renderer/workspace/sessionOwnership'
+import type { WorkspaceState } from '@renderer/workspace/types'
+
+// pool.ts is the ONE place a session enters or leaves a project (#992). Every
+// close path (session, Close Tab, bulk, reload-all's failures, kill) commits
+// through `workspaceWithoutSessions`, and every spawn files through
+// `fileSessionInProject`. The action suites exercise them end to end; this file
+// pins the invariants directly, because they are what replaced the tile tree's
+// structural guarantees and a regression here is silent everywhere else:
+//
+// - no row ever names a project that does not exist (autosave would drop it,
+// leaving a backend running with no row);
+// - no project ever lists zero sessions (a phantom tab);
+// - no lane or pin ever points at a removed session;
+// - the user's STAGE SHAPE never changes because agents closed (#681).
+
+function workspace(): WorkspaceState {
+ return {
+ tabs: [{ id: 'a', title: 'A' }, { id: 'b', title: 'B' }, { id: 'c', title: 'C' }],
+ activeTabId: 'b',
+ sessions: {
+ a1: { cwd: '/a', kind: 'claude', projectId: 'a', joinedAt: 0 },
+ b1: { cwd: '/b', kind: 'claude', projectId: 'b', joinedAt: 0 },
+ b2: { cwd: '/b', kind: 'codex', projectId: 'b', joinedAt: 5 },
+ c1: { cwd: '/c', kind: 'terminal', projectId: 'c', joinedAt: 0 },
+ },
+ stage: {
+ lanes: [{ selectedSessionId: 'b1' }, { selectedSessionId: 'b2' }, {}, { selectedSessionId: 'b1' }],
+ rows: [{ length: 2 }, { length: 2 }],
+ focusedLane: 1,
+ },
+ pinnedSessionIds: ['b1', 'c1'],
+ }
+}
+
+function expectCoherent(state: WorkspaceState): void {
+ expect(collectUnownedSessionIds(state), 'every row names a live project').toEqual([])
+ for (const tab of state.tabs) {
+ expect(resolveTabSessions(state, tab.id).length, `project ${tab.id} lists a session`).toBeGreaterThan(0)
+ }
+ for (const lane of state.stage.lanes) {
+ if (lane.selectedSessionId !== undefined) expect(state.sessions[lane.selectedSessionId]).toBeDefined()
+ }
+ for (const pinned of state.pinnedSessionIds) expect(state.sessions[pinned]).toBeDefined()
+}
+
+describe('workspaceWithoutSessions', () => {
+ it('removes a row, empties EVERY lane that showed it, drops its pin, and keeps the stage shape', () => {
+ const prev = workspace()
+ const next = workspaceWithoutSessions(prev, ['b1'])
+
+ expect(Object.keys(next.sessions)).toEqual(['a1', 'b2', 'c1'])
+ // b1 was mirrored in lanes 0 and 3: both go empty. Neither is refilled with
+ // a neighbour and neither is removed — four lanes in two rows, still.
+ expect(next.stage.lanes).toEqual([{}, { selectedSessionId: 'b2' }, {}, {}])
+ expect(next.stage.rows).toBe(prev.stage.rows)
+ expect(next.stage.focusedLane).toBe(1)
+ expect(next.pinnedSessionIds).toEqual(['c1'])
+ // Project B still holds b2, so it stays, and stays active.
+ expect(next.tabs).toBe(prev.tabs)
+ expect(next.activeTabId).toBe('b')
+ expectCoherent(next)
+ })
+
+ it('removes a project with its LAST session and moves the active project to the previous neighbour', () => {
+ const next = workspaceWithoutSessions(workspace(), ['b1', 'b2'])
+
+ expect(next.tabs.map(tab => tab.id)).toEqual(['a', 'c'])
+ // The cursor trails a deletion: A, not C.
+ expect(next.activeTabId).toBe('a')
+ expectCoherent(next)
+ })
+
+ it('falls forward to the next project when the removed one was first', () => {
+ const prev = { ...workspace(), activeTabId: 'a' }
+ const next = workspaceWithoutSessions(prev, ['a1'])
+
+ expect(next.tabs.map(tab => tab.id)).toEqual(['b', 'c'])
+ expect(next.activeTabId).toBe('b')
+ })
+
+ it('does not move the active project when a BACKGROUND project is removed', () => {
+ // A close issued from Agent Activity, Close Old Agents or automation must
+ // not yank the user to another project.
+ const next = workspaceWithoutSessions(workspace(), ['c1'])
+
+ expect(next.tabs.map(tab => tab.id)).toEqual(['a', 'b'])
+ expect(next.activeTabId).toBe('b')
+ expectCoherent(next)
+ })
+
+ it('leaves an empty workspace with no active project rather than a dangling id', () => {
+ const next = workspaceWithoutSessions(workspace(), ['a1', 'b1', 'b2', 'c1'])
+
+ expect(next.tabs).toEqual([])
+ expect(next.sessions).toEqual({})
+ expect(next.activeTabId).toBe('')
+ expect(next.pinnedSessionIds).toEqual([])
+ expect(next.stage.lanes).toEqual([{}, {}, {}, {}])
+ })
+
+ it('returns the SAME state when nothing it was asked to remove exists', () => {
+ // Identity matters: every close path calls this inside a setState updater,
+ // and a fresh object for a no-op re-renders every lane in the workspace.
+ const prev = workspace()
+ expect(workspaceWithoutSessions(prev, ['gone', 'also-gone'])).toBe(prev)
+ expect(workspaceWithoutSessions(prev, [])).toBe(prev)
+ })
+
+ describe('alsoIfEmpty', () => {
+ it('removes a named project that is already empty', () => {
+ // Close Tab's last commit can find its project's final session already
+ // gone (it exited on its own mid-operation). No session is left to take
+ // the project with it, so the project is named explicitly.
+ const prev = workspace()
+ delete prev.sessions.c1
+ prev.pinnedSessionIds = ['b1']
+ const next = workspaceWithoutSessions(prev, [], ['c'])
+
+ expect(next.tabs.map(tab => tab.id)).toEqual(['a', 'b'])
+ expectCoherent(next)
+ })
+
+ it('is NEVER a force: a named project that still holds a session survives', () => {
+ // Removing it would leave b2 naming a project that does not exist — an
+ // unowned row the next autosave drops while its backend keeps running.
+ // This function deletes rows, never processes.
+ const prev = workspace()
+ const next = workspaceWithoutSessions(prev, ['b1'], ['b'])
+
+ expect(next.tabs.map(tab => tab.id)).toEqual(['a', 'b', 'c'])
+ expect(next.sessions.b2).toBeDefined()
+ expectCoherent(next)
+ })
+ })
+
+ it('does not sweep a project that is empty for an unrelated reason', () => {
+ // ⌘T creates the project and its first agent in two steps. A close landing
+ // between them must not delete the project the user is mid-way through
+ // creating: only projects that HELD a removed session are candidates.
+ const prev = workspace()
+ prev.tabs = [...prev.tabs, { id: 'pending', title: 'Pending' }]
+ const next = workspaceWithoutSessions(prev, ['a1'])
+
+ expect(next.tabs.map(tab => tab.id)).toEqual(['b', 'c', 'pending'])
+ })
+})
+
+describe('fileSessionInProject', () => {
+ it('stamps membership and an order key without touching anything else on the row', () => {
+ const sessions = { s: { cwd: '/x', kind: 'codex' as const, title: 'Keep me' } }
+ const filed = fileSessionInProject(sessions, 's', 'p', 42)
+
+ expect(filed.s).toEqual({ cwd: '/x', kind: 'codex', title: 'Keep me', projectId: 'p', joinedAt: 42 })
+ expect(sessions.s).not.toHaveProperty('projectId')
+ })
+
+ it('defaults the order key to now, so a new session joins at the END of its project', () => {
+ const before = Date.now()
+ const filed = fileSessionInProject({ s: { cwd: '/x', kind: 'claude' } }, 's', 'p')
+ expect(filed.s!.joinedAt).toBeGreaterThanOrEqual(before)
+ })
+
+ it('cannot resurrect a row that a racing kill already removed', () => {
+ // Spawn awaits main; the user can close the pane meanwhile. Filing must
+ // not re-create the row from nothing.
+ const sessions = {}
+ expect(fileSessionInProject(sessions, 'gone', 'p')).toBe(sessions)
+ })
+})
+
+describe('inheritedMembership', () => {
+ it('hands a successor its predecessor\'s project AND position', () => {
+ // A provider switch or reload replaces the row under a new id. Re-stamping
+ // `joinedAt` would send the agent to the bottom of its project's list
+ // every time the user reloads it.
+ expect(inheritedMembership({ cwd: '/x', kind: 'claude', projectId: 'p', joinedAt: 7 }))
+ .toEqual({ projectId: 'p', joinedAt: 7 })
+ })
+
+ it('inherits nothing from an unknown or unfiled predecessor', () => {
+ // Empty, not a guess: the caller's own filing — or the ownership prune —
+ // decides, rather than a stale or invented project.
+ expect(inheritedMembership(undefined)).toEqual({})
+ expect(inheritedMembership({ cwd: '/x', kind: 'claude' })).toEqual({})
+ })
+})
diff --git a/src/renderer/src/workspace/pool.ts b/src/renderer/src/workspace/pool.ts
new file mode 100644
index 000000000..a9fce176a
--- /dev/null
+++ b/src/renderer/src/workspace/pool.ts
@@ -0,0 +1,154 @@
+import { clearTiledLaneSessions } from '@renderer/workspace/dispatch/tiledDispatchSelectors'
+import type {
+ SessionId,
+ SessionMeta,
+ TabId,
+ WorkspaceState,
+} from '@renderer/workspace/types'
+
+// ---------------------------------------------------------------------------
+// Write-side helpers for the fleet pool (#992).
+//
+// WHY these are functions and not literals at each call site: before the
+// unified layout, "file this new session under that project" was a hand-built
+// `DetachedSessionRecord` literal repeated at six spawn sites, and "remove
+// this session" was a different hand-written sequence in each of four close
+// paths (delete the row, delete the detached record, collapse the tree,
+// maybe promote a survivor, clear lanes, maybe remove the tab). Two of those
+// close paths disagreed for months. Membership is one field now, which makes
+// the literal tempting again — and the point of this file is that the NEXT
+// fact a filing or a removal has to maintain gets added in one place.
+//
+// Read-side questions live in queries.ts.
+// ---------------------------------------------------------------------------
+
+/**
+ * File `sessionId` under `projectId`: stamp its membership and its place at
+ * the END of that project's index.
+ *
+ * Call it in the same `setState` that makes the session visible anywhere. The
+ * row itself is written earlier by `spawn`; until this runs the session is
+ * un-filed metadata — no index lists it and autosave would drop it, which is
+ * the right outcome for a spawn whose caller bailed out.
+ *
+ * `joinedAt` defaults to now. Pass the predecessor's value when the session
+ * REPLACES another (reload, provider switch, undo) so the row keeps its place
+ * instead of jumping to the bottom of the list.
+ *
+ * Returns the same map when the session does not exist, so a caller racing a
+ * kill cannot resurrect a row.
+ */
+export function fileSessionInProject(
+ sessions: Record,
+ sessionId: SessionId,
+ projectId: TabId,
+ joinedAt: number = Date.now(),
+): Record {
+ const meta = sessions[sessionId]
+ if (!meta) return sessions
+ return { ...sessions, [sessionId]: { ...meta, projectId, joinedAt } }
+}
+
+/**
+ * The membership a successor should inherit from the session it replaces.
+ * Spread it into the successor's row. Empty when the predecessor is unknown,
+ * so the caller's own filing (or the ownership prune) decides instead of a
+ * stale guess.
+ */
+export function inheritedMembership(
+ predecessor: SessionMeta | undefined,
+): Pick {
+ if (!predecessor?.projectId) return {}
+ return {
+ projectId: predecessor.projectId,
+ ...(predecessor.joinedAt !== undefined ? { joinedAt: predecessor.joinedAt } : {}),
+ }
+}
+
+/**
+ * Remove sessions from the workspace, and every project they leave empty.
+ *
+ * This is the WHOLE removal: rows out of the pool, their lanes emptied, their
+ * pins dropped, and any project with no session left removed with them.
+ *
+ * WHY a project is removed when its last session goes: a project owns nothing
+ * (U4) and has no directory of its own — its only content is its sessions. An
+ * empty one would be a header over an empty list with no way to put anything
+ * in it except ⌘T, which creates a project anyway. v2 had the same rule in a
+ * different costume: a tab whose tree emptied and had no detached row to
+ * promote was removed.
+ *
+ * What is deliberately NOT done:
+ * - A lane that showed a removed session goes EMPTY. It is never refilled
+ * with a neighbour (#681) and never removed — the user shaped the stage,
+ * and closing agents must not reshape it.
+ * - `activeTabId` moves only if the active project was removed, and then to
+ * its previous neighbour (the list-UI convention: the cursor trails a
+ * deletion). A close issued from a background surface (Agent Activity,
+ * Close Old Agents, automation) must not yank the user elsewhere.
+ *
+ * `alsoIfEmpty` names projects to remove if nothing is left in them even
+ * though this call removed none of their sessions — a project whose last
+ * session was already gone by the time its Close Tab commit ran. It is NEVER
+ * a force: a project that still holds a session survives, because removing it
+ * would orphan that session's backend (a row is deleted here, a process is
+ * not — killing is the caller's job, done BEFORE this commit).
+ */
+export function workspaceWithoutSessions(
+ prev: WorkspaceState,
+ removedSessionIds: Iterable,
+ alsoIfEmpty: Iterable = [],
+): WorkspaceState {
+ const removed = new Set(removedSessionIds)
+ const sessions: Record = {}
+ let touched = false
+ for (const [id, meta] of Object.entries(prev.sessions)) {
+ if (removed.has(id)) {
+ touched = true
+ continue
+ }
+ sessions[id] = meta
+ }
+
+ const populated = new Set()
+ for (const meta of Object.values(sessions)) {
+ if (meta.projectId !== undefined) populated.add(meta.projectId)
+ }
+ // Only projects that HELD a removed session (or were named) are candidates:
+ // a project that is empty for some other reason is not this call's business,
+ // and sweeping it here would make an unrelated close delete a project the
+ // user is mid-way through creating.
+ const emptied = new Set()
+ for (const projectId of alsoIfEmpty) {
+ if (!populated.has(projectId)) emptied.add(projectId)
+ }
+ for (const id of removed) {
+ const projectId = prev.sessions[id]?.projectId
+ if (projectId !== undefined && !populated.has(projectId)) emptied.add(projectId)
+ }
+ if (!touched && emptied.size === 0) return prev
+
+ const tabs = emptied.size === 0 ? prev.tabs : prev.tabs.filter(tab => !emptied.has(tab.id))
+ let activeTabId = prev.activeTabId
+ if (emptied.has(prev.activeTabId)) {
+ const index = prev.tabs.findIndex(tab => tab.id === prev.activeTabId)
+ // Walk left from the removed project to the nearest survivor, then right.
+ const survivor =
+ [...prev.tabs.slice(0, Math.max(0, index))].reverse().find(tab => !emptied.has(tab.id)) ??
+ prev.tabs.slice(index + 1).find(tab => !emptied.has(tab.id))
+ activeTabId = survivor?.id ?? ''
+ }
+
+ const pinnedSessionIds = prev.pinnedSessionIds.some(id => removed.has(id))
+ ? prev.pinnedSessionIds.filter(id => !removed.has(id))
+ : prev.pinnedSessionIds
+
+ return {
+ ...prev,
+ tabs,
+ activeTabId,
+ sessions,
+ pinnedSessionIds,
+ stage: clearTiledLaneSessions(prev.stage, removed),
+ }
+}
diff --git a/src/renderer/src/workspace/queries.directory.test.ts b/src/renderer/src/workspace/queries.directory.test.ts
index a77139440..7853d009b 100644
--- a/src/renderer/src/workspace/queries.directory.test.ts
+++ b/src/renderer/src/workspace/queries.directory.test.ts
@@ -2,26 +2,23 @@ import { describe, expect, it } from 'vitest'
import { findTabsHoldingDirectory } from '@renderer/workspace/queries'
import type { WorkspaceState } from '@renderer/workspace/types'
+import { oneLaneStage } from '@renderer/workspace/testing/stageFixtures'
// The rule ⌘T and the operator's projects.open now share (#913).
const state: WorkspaceState = {
tabs: [
- { id: 'tab-a', title: 'agent-code', focusedSessionId: 'a', root: { type: 'leaf', sessionId: 'a' } },
- { id: 'tab-b', title: 'startup', focusedSessionId: 'b', root: { type: 'leaf', sessionId: 'b' } },
- { id: 'tab-c', title: 'agent-code', focusedSessionId: 'c', root: { type: 'leaf', sessionId: 'c' } },
+ { id: 'tab-a', title: 'agent-code' },
+ { id: 'tab-b', title: 'startup' },
+ { id: 'tab-c', title: 'agent-code' },
],
activeTabId: 'tab-a',
- dispatchMode: null,
+ stage: oneLaneStage('a'),
sessions: {
- a: { cwd: '/dev/agent-code', kind: 'claude' },
- b: { cwd: '/dev/startup', kind: 'codex' },
- c: { cwd: '/dev/agent-code/.worktrees/grok', kind: 'claude' },
- parked: { cwd: '/dev/agent-code', kind: 'codex' },
+ a: { cwd: '/dev/agent-code', kind: 'claude', projectId: 'tab-a', joinedAt: 0 },
+ b: { cwd: '/dev/startup', kind: 'codex', projectId: 'tab-b', joinedAt: 0 },
+ c: { cwd: '/dev/agent-code/.worktrees/grok', kind: 'claude', projectId: 'tab-c', joinedAt: 0 },
+ parked: { cwd: '/dev/agent-code', kind: 'codex', projectId: 'tab-c', joinedAt: 1 },
},
- detachedSessions: {
- parked: { sessionId: 'parked', surface: 'dispatch', projectTabId: 'tab-c', projectTabTitle: 'agent-code', projectTabIndex: 2, detachedAt: 1 },
- },
- buried: [],
pinnedSessionIds: [],
}
diff --git a/src/renderer/src/workspace/queries.ts b/src/renderer/src/workspace/queries.ts
index 4d50b040e..cf0f37a3e 100644
--- a/src/renderer/src/workspace/queries.ts
+++ b/src/renderer/src/workspace/queries.ts
@@ -1,4 +1,3 @@
-import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
import type {
SessionId,
TabId,
@@ -7,136 +6,94 @@ import type {
// Canonical session-set queries for the workspace.
//
-// WHY this file exists: the workspace has FIVE session-placement
-// buckets (grid via tile-tree leaves, detached via
-// state.detachedSessions, buried via state.buried, plus pinned +
-// focused as cross-cutting attributes). Asking "which sessions are
-// in tab X?" without composing the right subset has been the
-// recurring root cause of PRs #37, #39, #44, #45, #46, #58, #59,
-// #69, #83, and issue #104. Every patch caught an instance; none
-// caught the pattern.
+// WHY this file exists, and what changed under it (#992):
//
-// The pattern is broken because surfaces reach for `tab.root`
-// directly (via collectLeaves) without remembering that detached
-// agents also "belong" to the tab via projectTabId. This file is
-// the contract: callers should ask their question through one of
-// these functions, and the implementation handles the union
-// correctly once. Adding a new surface that walks the grid directly
-// is — per the resolver-discipline CI check — a build failure.
+// The workspace used to have FIVE session-placement buckets (grid via
+// tile-tree leaves, detached via state.detachedSessions, buried via
+// state.buried, plus pinned + focused as cross-cutting attributes). Asking
+// "which sessions are in project X?" without composing the right subset was
+// the recurring root cause of PRs #37, #39, #44, #45, #46, #58, #59, #69,
+// #83, and issue #104: surfaces reached for `tab.root` directly and forgot
+// that detached agents also belonged to the tab via `projectTabId`. This
+// file was the contract that composed the union once.
//
-// SCOPE: these queries answer "membership" questions ("which
-// sessions are in this tab?"). They do NOT decide which session a
-// command targets — that's the focus-resolution concern, handled by
-// `commandTargetSessionId` in
-// `hook/selectors/commandTargetSessionId.ts`, which already
-// correctly composes Dispatch focus → grid focus.
+// There is no union any more. A session belongs to a project because its own
+// row says so (`SessionMeta.projectId`), and its position is its own
+// `joinedAt`. The contract this file holds is therefore smaller but not
+// gone: callers still ask membership questions HERE, so that the day a
+// second fact affects membership or order there is one place to put it —
+// which is the lesson of those ten PRs, not the five buckets themselves.
+//
+// SCOPE: these queries answer "membership" questions. They do NOT decide
+// which session a command targets — that is the focused lane's occupant,
+// resolved by `commandTargetSessionId` in
+// `hook/selectors/commandTargetSessionId.ts`.
+
+type PoolView = Pick
/**
- * Every live session owned by this tab, regardless of placement.
- *
- * Composes:
- * - grid leaves (collectLeaves(tab.root)) — the visible tile tree
- * - detached agents whose `projectTabId === tabId` and whose
- * surface === 'dispatch' — Dispatch Mode agents that live
- * outside the grid but belong to this project
- *
- * Includes detached terminals as well as detached agents. Dispatch rows are
- * session rows now; terminals can be parked out of the grid and attached
- * back later just like provider sessions. Agent-only surfaces must filter by
- * kind at their own boundary instead of baking that policy into membership.
- *
- * Excludes `state.buried` deliberately: burying a pane is the
- * user's signal to put it away. Surfaces that ask "what's in this
- * tab right now" should not surface buried items as if they were
- * active. The unbury / undo flow is the place to walk
- * `state.buried`.
- *
- * Order: grid leaves first (in depth-first tile-tree order), then
- * detached agents oldest-detached-first (matches the existing UI
- * ordering documented in
- * `dispatchSelectors.detachedDispatchSessionIdsForTab`).
+ * The project a session belongs to, or undefined when the session is unknown
+ * or has not been filed yet (the instant between `spawn` writing a row and
+ * its caller stamping membership).
*/
-export function resolveTabSessions(
- state: WorkspaceState,
- tabId: TabId,
-): SessionId[] {
- const tab = state.tabs.find(t => t.id === tabId)
- const gridIds = tab ? collectLeaves(tab.root) : []
- const detachedIds = Object.values(state.detachedSessions)
- .filter(entry => (
- entry.surface === 'dispatch' &&
- entry.projectTabId === tabId &&
- state.sessions[entry.sessionId] !== undefined
- ))
- .sort((a, b) => a.detachedAt - b.detachedAt)
- .map(entry => entry.sessionId)
- // De-dupe defensively — the types-level invariant says a session
- // is in the tile tree OR detachedSessions, never both, but a
- // future bug that violates that invariant should not silently
- // produce duplicates in callers' filter/count loops.
- const seen = new Set()
- const out: SessionId[] = []
- for (const id of [...gridIds, ...detachedIds]) {
- if (seen.has(id)) continue
- seen.add(id)
- out.push(id)
- }
- return out
+export function projectIdOf(state: PoolView, sessionId: SessionId): TabId | undefined {
+ return state.sessions[sessionId]?.projectId
}
/**
- * Every live session in the workspace, across every tab and every
- * placement.
+ * Every session owned by this project, in index order.
*
- * Used by surfaces that genuinely operate globally: cross-tab
- * pickers, global telemetry, the "most recent session" finder. For
- * per-tab questions use `resolveTabSessions` instead — passing an
- * `activeTabId` filter on top of this is a code smell that usually
- * means the caller wanted `resolveTabSessions` to begin with.
+ * Order: ascending `joinedAt`, ties broken by the `sessions` map's insertion
+ * order (Array.prototype.sort is stable, and Object.keys preserves insertion
+ * order for string keys). A row with no `joinedAt` sorts as 0 — first —
+ * which is where a migrated v2 tree leaf belongs and is harmless for a row
+ * that is mid-spawn.
*
- * The `state.sessions` map already includes every live session by
- * definition. Iterating it directly is the cleanest implementation;
- * the helper exists for discoverability (so callers don't reach for
- * `Object.keys(state.sessions)` directly and bypass any future
- * filtering or ordering rules this layer adds).
+ * Includes every session kind: terminals and extension views are pool
+ * citizens like agents. Agent-only surfaces must filter by kind at their own
+ * boundary instead of baking that policy into membership.
*/
-export function resolveAllSessions(state: WorkspaceState): SessionId[] {
+export function resolveTabSessions(
+ state: PoolView,
+ tabId: TabId,
+): SessionId[] {
return Object.keys(state.sessions)
+ .filter(id => state.sessions[id]?.projectId === tabId)
+ .sort((a, b) => (state.sessions[a]?.joinedAt ?? 0) - (state.sessions[b]?.joinedAt ?? 0))
}
/**
- * Is this session currently detached (i.e. lives in
- * `state.detachedSessions`, not in any tab's tile tree)?
+ * Every session in the workspace, across every project.
*
- * WHY this helper exists rather than letting callers index
- * `state.detachedSessions[sessionId]` directly: that subscript is the
- * exact pattern the resolver-discipline CI check flags. Some commands
- * legitimately need to ask "is this thing detached?" (e.g. the
- * attach-to-grid command's when-guard, which should only show for
- * detached agents). Routing through a named query keeps the API
- * surface honest — the violating pattern stays in the resolver layer
- * where it's defined and reviewed.
+ * Used by surfaces that genuinely operate globally: cross-project pickers,
+ * global telemetry, the "most recent session" finder. For per-project
+ * questions use `resolveTabSessions` instead.
*
- * Returns `false` for unknown session ids — callers should always
- * pair this with a `state.sessions[id]` existence check if they need
- * to distinguish "detached" from "doesn't exist."
+ * The helper exists for discoverability, so callers don't reach for
+ * `Object.keys(state.sessions)` directly and bypass any future filtering or
+ * ordering rules this layer adds.
*/
-export function isDetached(state: WorkspaceState, sessionId: SessionId): boolean {
- return state.detachedSessions[sessionId] !== undefined
+export function resolveAllSessions(state: PoolView): SessionId[] {
+ return Object.keys(state.sessions)
}
+// `isDetached(state, id)` lived here until #992. "Detached" meant "owned by a
+// detachedSessions record rather than a tile leaf"; with neither structure
+// there is nothing for it to distinguish. Callers that really meant "has no
+// backend right now" read the session's runtime (`processStatus`) instead.
+
/**
- * Tabs that currently hold a session whose working directory is exactly
- * `cwd`, in tab order.
+ * Projects that currently hold a session whose working directory is exactly
+ * `cwd`, in project order.
*
* WHY this is the definition of "this project is already open" (#913): a
- * `Tab` carries no directory of its own; its title is the basename chosen at
- * creation and the only durable link to a folder is the cwd of the sessions
- * it holds. The operator capability `projects.open` has used this rule since
- * it shipped; the path picker now shares it so ⌘T stops minting a fresh tab
- * for a folder that is already on screen. Exact match on purpose: a worktree
- * is a different directory, and Merge Project Tabs is the tool for folding
- * worktree tabs together.
+ * project carries no directory of its own; its title is the basename chosen
+ * at creation and the only durable link to a folder is the cwd of the
+ * sessions it holds. The operator capability `projects.open` has used this
+ * rule since it shipped; the path picker shares it so ⌘T stops minting a
+ * fresh project for a folder that is already open. Exact match on purpose: a
+ * worktree is a different directory, and Merge Project Tabs is the tool for
+ * folding worktree projects together.
*
* The comparison is on the cwd string as stored. `expandCwd` resolves `~`
* and trailing slashes but not symlinks, so a session spawned through
diff --git a/src/renderer/src/workspace/sessionOwnership.test.ts b/src/renderer/src/workspace/sessionOwnership.test.ts
index 34b319d16..8c6fcd722 100644
--- a/src/renderer/src/workspace/sessionOwnership.test.ts
+++ b/src/renderer/src/workspace/sessionOwnership.test.ts
@@ -4,379 +4,190 @@ import {
collectLiveProcessIds,
collectOwnedSessionIds,
pruneSessionOwnership,
- repairPersistedTabs,
} from '@renderer/workspace/sessionOwnership'
-import type {
- SessionId,
- SessionMeta,
- TileNode,
- WorkspaceState,
-} from '@renderer/workspace/types'
+import type { SessionMeta, WorkspaceState } from '@renderer/workspace/types'
-function leaf(sessionId: string): TileNode {
- return { type: 'leaf', sessionId }
-}
+// (extensionPaneOwnership.test.ts was folded into this file with #992. It pinned
+// that an extension view sits BETWEEN the two sets — owned, never spawned — by
+// asserting the gap between them was exactly that pane. With the boot-spawn
+// set narrowed to the focused lane the gap is "almost everything", so the two
+// halves are asserted directly below instead.)
+//
+// Ownership over the pool (#992): a session is owned because its own row names
+// a project that exists. The v2 rules this module used to enforce — tile
+// leaves, detached records, buried panes, and the production ghost pool that
+// taught them — are tested where they now live, in legacyWorkspaceV2.test.ts.
+
+const agent = (projectId?: string, joinedAt = 0): SessionMeta => ({
+ cwd: '/work/project-a',
+ kind: 'claude',
+ ...(projectId ? { projectId, joinedAt } : {}),
+})
function makeState(): WorkspaceState {
return {
- tabs: [
- { id: 'tabA', title: 'project-a', root: leaf('live'), focusedSessionId: 'live' },
- ],
+ tabs: [{ id: 'tabA', title: 'project-a' }],
activeTabId: 'tabA',
- dispatchMode: {
- scope: 'project',
- focusedSessionId: 'missing',
- tiled: {
- focusedLane: 1,
- lanes: [
- { selectedSessionId: 'live' },
- { selectedSessionId: 'missing' },
- ],
- },
+ stage: {
+ focusedLane: 1,
+ lanes: [{ selectedSessionId: 'live' }, { selectedSessionId: 'unfiled' }],
},
sessions: {
- live: { cwd: '/work/project-a', kind: 'claude' },
- missing: { cwd: '/work/project-a', kind: 'claude' },
+ live: agent('tabA'),
+ // Written by `spawn`, never filed by its caller: no project at all.
+ unfiled: agent(),
},
- detachedSessions: {},
- buried: [],
pinnedSessionIds: [],
}
}
-describe('pruneSessionOwnership', () => {
- it('clears stale tiled lane ids while preserving lane shape', () => {
- const result = pruneSessionOwnership(makeState())
+describe('collectOwnedSessionIds', () => {
+ it('owns a session whose project exists, and nothing else', () => {
+ const state = makeState()
+ state.sessions.parked = agent('tabA', 5)
+ state.sessions.ghost = agent('closed-project')
- expect(result.sessions).toEqual({
- live: { cwd: '/work/project-a', kind: 'claude' },
- })
- expect(result.dispatchMode?.focusedSessionId).toBeUndefined()
- expect(result.dispatchMode?.tiled?.focusedLane).toBe(1)
- expect(result.dispatchMode?.tiled?.lanes).toEqual([
- { selectedSessionId: 'live' },
- { selectedSessionId: undefined },
- ])
+ expect([...collectOwnedSessionIds(state)].sort()).toEqual(['live', 'parked'])
})
- it('drops detached sessions whose project tab no longer exists', () => {
+ it('does not treat a lane, a pin or the active project as ownership', () => {
+ // Pointers are not owners. A stale pointer must never keep a session
+ // alive or bring one back — this is the rule that stopped dispatch focus
+ // from resurrecting work the user could no longer see.
const state = makeState()
- state.sessions.parked = { cwd: '/work/project-a', kind: 'codex' }
- state.sessions.ghost = { cwd: '/work/deleted-project', kind: 'claude' }
- state.detachedSessions = {
- parked: {
- sessionId: 'parked',
- surface: 'dispatch',
- projectTabId: 'tabA',
- projectTabTitle: 'project-a',
- projectTabIndex: 0,
- detachedAt: 20,
- },
- ghost: {
- sessionId: 'ghost',
- surface: 'dispatch',
- projectTabId: 'deleted-tab',
- projectTabTitle: 'deleted-project',
- projectTabIndex: 1,
- detachedAt: 10,
- },
- }
- state.dispatchMode = {
- scope: 'global',
- focusedSessionId: 'ghost',
- tiled: {
- focusedLane: 1,
- lanes: [
- { selectedSessionId: 'parked' },
- { selectedSessionId: 'ghost' },
- ],
- },
- }
-
- const result = pruneSessionOwnership(state)
+ state.sessions.ghost = agent('closed-project')
+ state.stage = { focusedLane: 0, lanes: [{ selectedSessionId: 'ghost' }] }
+ state.pinnedSessionIds = ['ghost']
+ state.activeTabId = 'closed-project'
- expect(result.sessions).toEqual({
- live: { cwd: '/work/project-a', kind: 'claude' },
- parked: { cwd: '/work/project-a', kind: 'codex' },
- })
- expect(result.detachedSessions).toEqual({
- parked: expect.objectContaining({
- sessionId: 'parked',
- projectTabId: 'tabA',
- }),
- })
- expect(result.droppedSessionIds).toEqual(expect.arrayContaining(['missing', 'ghost']))
- expect(result.dispatchMode?.focusedSessionId).toBeUndefined()
- expect(result.dispatchMode?.tiled?.lanes).toEqual([
- { selectedSessionId: 'parked' },
- { selectedSessionId: undefined },
- ])
+ expect(collectOwnedSessionIds(state).has('ghost')).toBe(false)
})
- it('collapses a production-shaped ghost pool without touching valid hidden ownership', () => {
+ it('does not read metadata through the prototype chain', () => {
+ // A session id like `toString` resolves to an inherited function under a
+ // bare index read. Reaching this needs a hand-edited file, which is an
+ // explicit threat model for everything that reads workspace.json.
const state = makeState()
- for (let index = 0; index < 8; index += 1) {
- const sessionId = `parked-${index}`
- state.sessions[sessionId] = { cwd: '/work/project-a', kind: 'codex' }
- state.detachedSessions[sessionId] = {
- sessionId,
- surface: 'dispatch',
- projectTabId: 'tabA',
- projectTabTitle: 'project-a',
- projectTabIndex: 0,
- detachedAt: index,
- }
- }
- for (let index = 0; index < 82; index += 1) {
- const sessionId = `ghost-${index}`
- state.sessions[sessionId] = { cwd: `/work/deleted-${index}`, kind: 'claude' }
- state.detachedSessions[sessionId] = {
- sessionId,
- surface: 'dispatch',
- projectTabId: `deleted-tab-${index}`,
- projectTabTitle: `deleted-${index}`,
- projectTabIndex: index + 1,
- detachedAt: index,
- }
- }
- state.sessions.buried = { cwd: '/work/archive', kind: 'claude' }
- state.buried = [{
- id: 'buried',
- sessionId: 'buried',
- sessionMeta: state.sessions.buried,
- buriedAt: 1,
- sourceTabId: 'already-closed-source-tab',
- sourceTabTitle: 'archive',
- sourceTabIndex: 2,
- }]
+ expect(collectOwnedSessionIds({ ...state, sessions: Object.create({ toString: agent('tabA') }) }).size).toBe(0)
+ })
- const result = pruneSessionOwnership(state)
+ it('keeps a process-less extension view owned', () => {
+ // Ownership and "needs a process" are different questions. If ownership
+ // were derived from the live set, autosave would drop the view's metadata
+ // on the next save.
+ const state = makeState()
+ state.sessions.view = { cwd: '', kind: 'extension-view', extensionViewId: 'timer.main', projectId: 'tabA', joinedAt: 1 }
- // WHY use the observed production cardinalities instead of only another
- // one-record example: the bug was initially mistaken for legitimate lazy
- // recovery because the invalid records looked individually well-formed.
- // This fixture locks in the actual distinction—five/eight/etc. is not the
- // algorithm, parent-tab reachability is—while proving an 82-record ghost
- // pool collapses to the real owned workspace in one save cycle.
- expect(Object.keys(result.sessions)).toHaveLength(10)
- expect(Object.keys(result.detachedSessions)).toHaveLength(8)
- expect(result.buried).toHaveLength(1)
- expect(result.sessions).toHaveProperty('live')
- expect(result.sessions).toHaveProperty('buried')
- expect(result.sessions).not.toHaveProperty('ghost-0')
- expect(result.sessions).not.toHaveProperty('ghost-81')
- expect(result.droppedSessionIds).toHaveLength(83)
+ expect(collectOwnedSessionIds(state).has('view')).toBe(true)
})
})
+describe('collectLiveProcessIds — the boot-spawn set', () => {
+ it('is the focused lane s occupant and nothing else', () => {
+ const state = makeState()
+ state.sessions.other = agent('tabA', 1)
+ state.stage = {
+ focusedLane: 1,
+ lanes: [{ selectedSessionId: 'live' }, { selectedSessionId: 'other' }, {}],
+ }
-// Regression fixture for the "Autosave off" freeze.
-//
-// Recorded from a real ~/.config/agent-code/workspace.json: tab "agent-code"
-// held a vertical split whose `b` leaf pointed at a session id that had no row
-// in `sessions`, and the tab's focusedSessionId pointed at that same dead id.
-// Every launch afterwards journalled `expectedCount 4, resolvedCount 3, ok
-// false` and refused to autosave, which meant the file could never be
-// repaired. The exact shape matters, so it is reproduced rather than
-// paraphrased.
-function makeOrphanLeafState(): WorkspaceState {
- const state = makeState()
- state.tabs = [
- {
- id: 'tabA',
- title: 'agent-code',
- root: {
- type: 'split',
- direction: 'vertical',
- ratio: 0.5,
- a: leaf('live'),
- b: leaf('orphan'),
- },
- focusedSessionId: 'orphan',
- },
- ]
- return state
-}
-
-describe('collectLiveProcessIds', () => {
- it('excludes tile leaves that have no session metadata', () => {
- // The gate denominator must only count panes that CAN be restored.
- // Counting the orphan is what made restore completion unsatisfiable.
- expect([...collectLiveProcessIds(makeOrphanLeafState())]).toEqual(['live'])
+ // `live` is on a lane too — and is NOT spawned at boot. It renders its
+ // committed transcript and wakes on its first send, exactly as every
+ // restored lane always has. See the function's comment for why this is
+ // not 16 processes in one Promise.all.
+ expect([...collectLiveProcessIds(state)]).toEqual(['other'])
})
- it('still counts every leaf that does have metadata', () => {
- const state = makeOrphanLeafState()
- state.sessions.orphan = { cwd: '/work/project-a', kind: 'claude' }
+ it('is empty when the focused lane is empty', () => {
+ const state = makeState()
+ state.stage = { focusedLane: 1, lanes: [{ selectedSessionId: 'live' }, {}] }
- expect([...collectLiveProcessIds(state)].sort()).toEqual(['live', 'orphan'])
+ expect(collectLiveProcessIds(state).size).toBe(0)
})
- it('restores the gate invariant: every live id is a key of sessions', () => {
- // This is the property whose absence froze the workspace — the gate
- // compares |resolvedIds| to |liveProcessIds| while resolvedIds can only
- // ever contain keys of `sessions`, so the comparison is satisfiable only
- // when liveProcessIds is a subset of those keys. Asserted directly rather
- // than restating a fixture's expected size, so it holds for any input.
- const state = makeOrphanLeafState()
- const live = collectLiveProcessIds(state)
+ it('never spawns a session nothing owns, however it is pointed at', () => {
+ // The #258 shape, restated: a lane naming unowned metadata must not turn
+ // that metadata into a backend process.
+ const state = makeState() // the focused lane names `unfiled`
+ expect(collectLiveProcessIds(state).size).toBe(0)
- expect([...live].every(id =>
- Object.prototype.hasOwnProperty.call(state.sessions, id))).toBe(true)
- expect(live.has('orphan')).toBe(false)
+ state.sessions.ghost = agent('closed-project')
+ state.stage = { focusedLane: 0, lanes: [{ selectedSessionId: 'ghost' }] }
+ expect(collectLiveProcessIds(state).size).toBe(0)
})
- it('does not read metadata through the prototype chain', () => {
- // A leaf id like `toString` resolves to an inherited function under a bare
- // index read, which would classify a genuine orphan as healthy and
- // reproduce the freeze on a hand-edited workspace.json.
- const state = makeOrphanLeafState()
- state.tabs[0].root = { type: 'leaf', sessionId: 'toString' }
- state.tabs[0].focusedSessionId = 'toString'
+ it('restores the gate invariant: every live id is a key of sessions', () => {
+ // Rehydrate compares |resolved| to |live| while `resolved` can only ever
+ // contain keys of `sessions`, so the comparison is satisfiable only when
+ // the live set is a subset of those keys. A lane naming a session with no
+ // metadata once froze a real workspace for three weeks: restore never
+ // completed, so autosave — the file's only writer — stayed locked.
+ const state = makeState()
+ state.stage = { focusedLane: 0, lanes: [{ selectedSessionId: 'closed-long-ago' }] }
- expect([...collectLiveProcessIds(state)]).toEqual([])
+ expect(collectLiveProcessIds(state).size).toBe(0)
})
-})
-describe('collectOwnedSessionIds', () => {
- it('keeps tile leaves owned independently of the live-process set', () => {
- // Ownership and "needs a process" are different questions. A pane kind
- // that deliberately spawns nothing (extension views) narrows the live set;
- // if ownership were derived from that narrowed set, autosave would drop the
- // pane's SessionMeta and manufacture the very orphan leaf this module
- // repairs. Pinning them as separate sources keeps that impossible.
- const state = makeOrphanLeafState()
- state.sessions.orphan = { cwd: '/work/project-a', kind: 'claude' }
+ it('never spawns for an extension view', () => {
+ // It has no process. Recovering one would fall through SessionManager's
+ // provider switch into the terminal branch and start a stray shell.
+ const state = makeState()
+ state.sessions.view = { cwd: '', kind: 'extension-view', extensionViewId: 'timer.main', projectId: 'tabA', joinedAt: 1 }
+ state.stage = { focusedLane: 0, lanes: [{ selectedSessionId: 'view' }] }
- expect([...collectOwnedSessionIds(state)].sort()).toEqual(['live', 'orphan'])
+ expect(collectLiveProcessIds(state).size).toBe(0)
})
})
-describe('repairPersistedTabs', () => {
- function repair(
- state: WorkspaceState,
- sessions: Record = { live: state.sessions.live },
- ) {
- return repairPersistedTabs({
- tabs: state.tabs,
- sessions,
- activeTabId: state.activeTabId,
- tileTabs: null,
- })
- }
-
- it('collapses an orphaned split into its survivor and repoints tab focus', () => {
- const result = repair(makeOrphanLeafState())
-
- expect(result.droppedLeafSessionIds).toEqual(['orphan'])
- expect(result.droppedTabIds).toEqual([])
- expect(result.tabs).toHaveLength(1)
- // The split is gone entirely — the survivor is promoted to root, exactly
- // as a normal pane close would have left it.
- expect(result.tabs[0].root).toEqual({ type: 'leaf', sessionId: 'live' })
- expect(result.tabs[0].focusedSessionId).toBe('live')
- expect(result.tabs[0].title).toBe('agent-code')
- })
-
- it('repoints focus that names a session outside this tab', () => {
- // The invariant a tab owes is "focus names a leaf I contain". Testing
- // against the sessions map instead would leave this dangling, and rehydrate
- // does not repair it either because the id resolves fine.
- const state = makeOrphanLeafState()
- state.tabs[0].focusedSessionId = 'elsewhere'
-
- const result = repair(state, { live: state.sessions.live, elsewhere: state.sessions.live })
+describe('pruneSessionOwnership — what autosave may make durable', () => {
+ it('drops unowned rows and empties the lanes that named them, keeping the shape', () => {
+ const result = pruneSessionOwnership(makeState())
- expect(result.tabs[0].focusedSessionId).toBe('live')
+ expect(result.sessions).toEqual({ live: agent('tabA') })
+ expect(result.droppedSessionIds).toEqual(['unfiled'])
+ // The lane goes empty; it is not removed and focus does not move.
+ expect(result.stage.focusedLane).toBe(1)
+ expect(result.stage.lanes).toEqual([{ selectedSessionId: 'live' }, { selectedSessionId: undefined }])
})
- it('leaves healthy trees untouched', () => {
- const state = makeOrphanLeafState()
- state.sessions.orphan = { cwd: '/work/project-a', kind: 'claude' }
+ it('collapses a ghost pool in one save cycle without touching parked agents', () => {
+ // The production cardinalities that made the original bug hard to see —
+ // 8 legitimately parked agents beside 82 records whose project had been
+ // closed — restated for the pool. The distinction is project
+ // reachability, not any count.
+ const state = makeState()
+ delete state.sessions.unfiled
+ for (let index = 0; index < 8; index += 1) state.sessions[`parked-${index}`] = agent('tabA', index + 1)
+ for (let index = 0; index < 82; index += 1) state.sessions[`ghost-${index}`] = agent(`deleted-tab-${index}`)
- const result = repair(state, state.sessions)
+ const result = pruneSessionOwnership(state)
- expect(result.droppedLeafSessionIds).toEqual([])
- // Identity, not just equality: a healthy save must not churn the tree.
- expect(result.tabs[0]).toBe(state.tabs[0])
- expect(result.activeTabId).toBe(state.activeTabId)
+ expect(Object.keys(result.sessions)).toHaveLength(9)
+ expect(result.sessions).toHaveProperty('parked-7')
+ expect(result.sessions).not.toHaveProperty('ghost-0')
+ expect(result.droppedSessionIds).toHaveLength(82)
})
- it('reports one id when the same orphan occupies several leaves', () => {
- const state = makeOrphanLeafState()
- state.tabs[0].root = {
- type: 'split',
- direction: 'vertical',
- ratio: 0.5,
- a: leaf('orphan'),
- b: { type: 'split', direction: 'horizontal', ratio: 0.5, a: leaf('orphan'), b: leaf('live') },
+ it('scrubs row bindings to projects that no longer exist', () => {
+ // A binding to a closed project filters that row's index to nothing, with
+ // no UI path back: the picker only lists projects that exist.
+ const state = makeState()
+ state.stage = {
+ focusedLane: 0,
+ lanes: [{ selectedSessionId: 'live' }],
+ rows: [{ length: 1, projectTabIds: ['tabA', 'closed-project'] }],
}
- const result = repair(state)
-
- expect(result.droppedLeafSessionIds).toEqual(['orphan'])
- expect(result.tabs[0].root).toEqual({ type: 'leaf', sessionId: 'live' })
+ expect(pruneSessionOwnership(state).stage.rows).toEqual([{ length: 1, projectTabIds: ['tabA'] }])
})
- it('drops a tab whose every leaf is orphaned and repoints activeTabId', () => {
- // The one destructive branch in the whole change.
- const state = makeOrphanLeafState()
- state.tabs = [
- { id: 'tabA', title: 'agent-code', root: leaf('orphan'), focusedSessionId: 'orphan' },
- { id: 'tabB', title: 'other', root: leaf('live'), focusedSessionId: 'live' },
- ]
- state.activeTabId = 'tabA'
-
- const result = repair(state)
-
- expect(result.droppedTabIds).toEqual(['tabA'])
- expect(result.tabs.map(t => t.id)).toEqual(['tabB'])
- expect(result.activeTabId).toBe('tabB')
- })
-
- it('drops a dead tab out of tileTabs rather than persisting a dangling id', () => {
- const state = makeOrphanLeafState()
- state.tabs = [
- { id: 'tabA', title: 'agent-code', root: leaf('orphan'), focusedSessionId: 'orphan' },
- { id: 'tabB', title: 'b', root: leaf('live'), focusedSessionId: 'live' },
- { id: 'tabC', title: 'c', root: leaf('live'), focusedSessionId: 'live' },
- ]
-
- const result = repairPersistedTabs({
- tabs: state.tabs,
- sessions: { live: state.sessions.live },
- activeTabId: 'tabB',
- tileTabs: {
- tabIds: ['tabA', 'tabB', 'tabC'],
- focusedTabId: 'tabA',
- direction: 'vertical',
- ratios: [0.34, 0.33, 0.33],
- },
- })
-
- expect(result.tileTabs?.tabIds).toEqual(['tabB', 'tabC'])
- // Focus pointed at the dropped tab, and ratios must match the new count.
- expect(result.tileTabs?.focusedTabId).toBe('tabB')
- expect(result.tileTabs?.ratios).toHaveLength(2)
- })
-
- it('keeps every tab when nothing was dropped', () => {
- const state = makeOrphanLeafState()
- const tileTabs = {
- tabIds: ['tabA', 'tabB'],
- focusedTabId: 'tabA',
- direction: 'vertical' as const,
- ratios: [0.5, 0.5],
- }
-
- const result = repairPersistedTabs({
- tabs: state.tabs,
- sessions: { live: state.sessions.live },
- activeTabId: state.activeTabId,
- tileTabs,
- })
+ it('returns the stage by reference when nothing needed scrubbing', () => {
+ // Autosave runs on a debounce for the life of the app; a prune that
+ // rebuilt a healthy stage every time would churn every lane memo.
+ const state = makeState()
+ delete state.sessions.unfiled
+ state.stage = { focusedLane: 0, lanes: [{ selectedSessionId: 'live' }], rows: [{ length: 1 }] }
- // Only a dropped TAB can invalidate tileTabs; a collapsed split cannot.
- expect(result.tileTabs).toBe(tileTabs)
+ expect(pruneSessionOwnership(state).stage).toBe(state.stage)
})
})
diff --git a/src/renderer/src/workspace/sessionOwnership.ts b/src/renderer/src/workspace/sessionOwnership.ts
index b802030f0..e2e3ad2c9 100644
--- a/src/renderer/src/workspace/sessionOwnership.ts
+++ b/src/renderer/src/workspace/sessionOwnership.ts
@@ -1,221 +1,132 @@
import type {
- BuriedPaneRecord,
- DetachedSessionRecord,
- DispatchModeState,
SessionId,
SessionMeta,
TabId,
- TileNode,
- TileTabsState,
+ TiledDispatchState,
} from '@renderer/workspace/types'
-import { closeLeaf, collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
-import { sanitizeTileTabsState } from '@renderer/workspace/layout/helpers'
import {
keepTiledLaneSessions,
scrubGridRowMetadata,
} from '@renderer/workspace/dispatch/tiledDispatchSelectors'
+import { hasSessionMeta } from '@renderer/workspace/legacyWorkspaceV2'
-type SessionOwnershipTab = {
- id: TabId
- root: TileNode
-}
+export { hasSessionMeta }
export type SessionOwnershipInput = {
- tabs: SessionOwnershipTab[]
+ tabs: ReadonlyArray<{ id: TabId }>
sessions: Record
- detachedSessions?: Record
- buried?: BuriedPaneRecord[]
}
export type PrunedSessionOwnership = {
sessions: Record
- detachedSessions: Record
- buried: BuriedPaneRecord[]
- dispatchMode: DispatchModeState | null | undefined
+ stage: TiledDispatchState
droppedSessionIds: SessionId[]
}
-// WHY this module exists — and why TWO ownership sets, not one:
+// WHY this module exists — and why TWO sets, not one:
+//
+// The `sessions` map is metadata. A row being PRESENT has never been allowed
+// to mean "this session belongs to the workspace", because the inverse
+// conflation was a production incident twice over:
+//
+// - Orphan metadata getting respawned into invisible backend processes and
+// proxies during startup (the original OOM bug).
+// - The first fix collapsing everything into one `owned` set used for BOTH
+// persistence pruning AND the boot spawn list. Every session the user had
+// parked then got a full claude/codex process plus mitmdump on every
+// restart. After weeks of "park this agent for later" the pool grew to 40+
+// and each launch spawned the whole herd in parallel (#258: 49 persisted,
+// 9 visible, 40 parked -> 40 claude + 40 mitmdump, loadavg 906).
+//
+// Before #992 ownership was STRUCTURAL: a session was owned because a tile
+// leaf, a `detachedSessions` record or a `buried` record named it. Those
+// structures are gone. Ownership is now one fact on the row itself:
//
-// Workspace persistence has three independent ownership surfaces for a session:
-// visible tile leaves, detached non-grid surfaces (dispatch parking), and
-// buried panes. The `sessions` map is deliberately only metadata for those
-// owners. Treating the map itself as authority creates a fourth hidden state:
-// "metadata exists but no UI/surface owns it". The original OOM bug was the
-// inverse direction of that same conflation: orphan metadata getting respawned
-// into invisible backend processes/proxies during startup.
+// a session is OWNED <=> its `projectId` names a project that exists.
//
-// The first fix collapsed everything into a single `owned` set and used it for
-// BOTH persistence pruning AND rehydrate spawn filtering. That solved orphan
-// metadata, but it also meant every detached session — which the user has
-// explicitly removed from their visible workspace — got a full claude/codex
-// process plus mitmdump on every restart. After weeks of "park this agent in
-// dispatch for later", the detached pool grew to 40+ records and each app
-// launch spawned the whole herd in parallel via rehydrate's Promise.all.
+// That is the same rule v2 applied to a detached record ("a missing parent
+// means there is no surface from which the agent can be found or managed —
+// drop that closed ownership island as one unit"), restated for the pool. A
+// row whose project is gone is a ghost; a row with no `projectId` at all was
+// never filed (a spawn whose caller bailed out). Neither may become durable,
+// and neither may EVER be spawned.
//
-// The fix is to split the two concepts the previous code conflated:
+// The two sets are still two questions:
//
-// collectOwnedSessionIds → metadata-preservation set.
-// Tile leaves + detached + buried.
-// Used by `pruneSessionOwnership` to decide which
-// rows in `sessions`, `detachedSessions`, and
-// `buried` survive a save cycle. Detached and
-// buried records are durable user state; losing
-// them would lose the cwd/providerSessionId needed
-// to revive a parked agent later.
+// collectOwnedSessionIds -> metadata-preservation set. Which rows survive
+// a save and a load. Parked agents are durable
+// user state; losing them loses the
+// cwd/providerSessionId needed to wake one later.
//
-// collectLiveProcessIds → rehydrate-spawn set.
-// Tile leaves ONLY. The question this answers is
-// "which sessions does the user currently see on
-// screen, such that a backend process must exist
-// for typing/scrolling/streaming to work?". A
-// detached or buried session can be revived later
-// by an explicit user action; until then it is
-// metadata only and must NOT spawn a PTY, a
-// mitmdump, an MCP host, or any other runtime
-// resource.
+// collectLiveProcessIds -> boot-spawn set. Which sessions must have a
+// backend the moment the window paints.
//
-// Dispatch focus is intentionally excluded from both sets. It is a selection
-// pointer, not ownership; allowing it to keep a session alive would let a
-// stale focus id resurrect work the user can no longer see or manage.
+// Lane selections, pins and the active project are POINTERS, not ownership. A
+// stale pointer must never keep a session alive or bring one back.
+
/**
- * Does `sessions` actually carry metadata for this id?
- *
- * WHY an own-property check and not a bare `sessions[id]` truthiness test: a plain
- * index read walks the prototype chain, so a leaf id of `toString`,
- * `constructor`, or `valueOf` resolves to an inherited function and reads as
- * "has metadata". That is precisely inverted from what every caller here
- * wants, and it would make such a leaf invisible to BOTH the restore gate and
- * the repair guard — i.e. it would reproduce the permanent-freeze bug while
- * looking healthy. Session ids are `randomUUID()` today, so this needs a
- * hand-edited workspace.json to reach; hand-edited files are named as an
- * explicit threat model throughout this module, so the check should be total.
- *
- * The value must also be truthy, not merely present: rehydrate decides what to
- * restore with a truthiness test of its own (`freshSessions[id] ?`), so an own
- * key holding `undefined` has to read as "no metadata" on this side too or the
- * two halves disagree about what a pane is.
+ * The metadata-preservation set: every session whose `projectId` names a
+ * project that exists (and which actually has metadata — see hasSessionMeta).
*/
-export function hasSessionMeta(
- sessions: Record,
- id: SessionId,
-): boolean {
- // `Object.prototype.hasOwnProperty.call` rather than `Object.hasOwn`: this
- // project's TS lib target predates ES2022, and a own-property check is not
- // worth moving the whole compiler target for.
- return Object.prototype.hasOwnProperty.call(sessions, id) && Boolean(sessions[id])
+export function collectOwnedSessionIds(input: SessionOwnershipInput): Set {
+ const projectIds = new Set(input.tabs.map(tab => tab.id))
+ const owned = new Set()
+ for (const id of Object.keys(input.sessions)) {
+ if (!hasSessionMeta(input.sessions, id)) continue
+ const projectId = input.sessions[id]!.projectId
+ if (projectId !== undefined && projectIds.has(projectId)) owned.add(id)
+ }
+ return owned
}
/**
- * Every tile leaf that has real metadata — the ownership half of "visible".
+ * The boot-spawn set: the FOCUSED lane's occupant, and nothing else.
+ *
+ * WHY so narrow. The question this answers is "which sessions must have a
+ * backend before the user can do anything?", and on a stage the honest answer
+ * is the one under the cursor. Every other session — shown in a lane or not —
+ * already has a complete wake path that does not need boot's help:
*
- * WHY this is separate from `collectLiveProcessIds`, which on this branch
- * returns the same thing:
+ * - an agent leaf renders its committed transcript with no backend at all,
+ * and wakes on its first send (TileLeaf.send -> ensureSessionLive, the
+ * #691 fix for a parked lane rejecting its first prompt);
+ * - a terminal leaf wakes its shell when it mounts;
+ * - placing a parked session in a lane wakes it first (#690).
*
- * Ownership answers "whose SessionMeta must survive a save?" while the live set
- * answers "which sessions need a backend process?". Those coincide today, and
- * `collectOwnedSessionIds` used to be built directly on the live set because of
- * that. But the sets are not the same question, and collapsing them makes any
- * future narrowing of "needs a process" silently narrow ownership too — which
- * deletes user data.
+ * v2 spawned every TILE LEAF at boot, and the recorded real workspace shows
+ * what that had become: 3 one-pane tabs spawned 3 agents that no lane showed,
+ * while the 12 lanes the user actually worked in all booted parked and woke on
+ * first send. So "wake on first use" is not a new risk being introduced here;
+ * it is the path the product's only heavy user already exercised for every
+ * agent they touched. Spawning ALL lane occupants instead would be bounded by
+ * the 16-lane cap rather than unbounded like #258 — but 16 agent processes
+ * and 16 proxies in one Promise.all is the same incident at 40% scale, to
+ * save a wake the user already pays today.
*
- * That is not hypothetical. The in-flight extension-view work adds panes that
- * are real tile leaves with real metadata but deliberately spawn NO process, by
- * skipping them in `collectLiveProcessIds`. Built on the old shape, that skip
- * also removed them from the owned set, so `pickOwnedSessions` dropped their
- * metadata on the very next autosave — turning them into exactly the orphan
- * leaves this module now repairs, and handing the repair guard a live pane to
- * collapse out of the user's tree. Splitting the two sets here is what makes
- * "excluded from spawning" and "excluded from persistence" independent, so a
- * process-less pane kind is safe to add in one place.
+ * This set is ALSO the denominator of rehydrate's restore-completion gate
+ * (`expectedSessions`), so it must stay a subset of `keys(sessions)`: a lane
+ * naming a session with no metadata contributes nothing (there is no cwd or
+ * kind to spawn), or the gate could never be satisfied and autosave — the
+ * file's only writer — would stay locked forever. That exact shape froze a
+ * real workspace for three weeks under v2.
+ *
+ * If you add a session kind that must NOT spawn a process, narrow it HERE.
+ * Narrowing `collectOwnedSessionIds` instead would drop its metadata on the
+ * next save.
*/
-export function collectTileLeafIds(input: SessionOwnershipInput): Set {
- const leaves = new Set()
- for (const tab of input.tabs) {
- for (const id of collectLeaves(tab.root)) {
- if (!hasSessionMeta(input.sessions, id)) continue
- leaves.add(id)
- }
- }
- return leaves
-}
-
-export function collectOwnedSessionIds(input: SessionOwnershipInput): Set {
- const owned = collectTileLeafIds(input)
- const existingTabIds = new Set(input.tabs.map(tab => tab.id))
-
- for (const entry of Object.values(input.detachedSessions ?? {})) {
- // WHY a detached record is not ownership by itself:
- //
- // Dispatch agents are children of a project tab. Closing that tab kills
- // its visible and detached sessions together, but older builds and
- // interrupted saves could leave the detached half behind. Blindly treating
- // that stale record as an owner made it immortal: autosave preserved both
- // the record and its SessionMeta forever, and rehydrate constructed an
- // idle runtime for it on every launch. Real workspaces accumulated dozens
- // of these ghosts even though the user had only a handful of open agents.
- //
- // `projectTabId` is the durable parent relation, so a missing parent means
- // there is no surface from which the agent can be found or managed. Drop
- // that closed ownership island as one unit instead of manufacturing a
- // fourth, invisible workspace surface.
- if (!existingTabIds.has(entry.projectTabId)) continue
- owned.add(entry.sessionId)
- }
-
- for (const entry of input.buried ?? []) {
- owned.add(entry.sessionId)
- }
-
- return owned
-}
-
-// WHY this is a separate set from `collectOwnedSessionIds`:
-//
-// See the module header. tl;dr: persistence wants to keep more than rehydrate
-// wants to spawn. Tile leaves are the only sessions whose absence would
-// produce a broken user-visible pane on startup; everything else is parked
-// state that the user must opt back into.
-//
-// WHY leaves without SessionMeta are excluded (via `collectTileLeafIds`):
-//
-// This set is BOTH the rehydrate spawn list and the denominator of the
-// restore-completion gate (`expectedSessions` in rehydrate.ts). A leaf whose id
-// has no row in `sessions` has no cwd and no kind, so there is literally
-// nothing to spawn for it — rehydrate's respawn loop iterates
-// `persisted.sessions` and can never even reach it. Counting it made
-// `resolvedIds.size === expectedSessions` unsatisfiable FOREVER: restore
-// reported `partial-restore`, autosave stayed locked to protect disk, and
-// because autosave is the only writer of workspace.json the corrupt tree could
-// never be rewritten. A single dangling leaf permanently froze a real user's
-// workspace file for three weeks (observed on every boot: expectedCount 4,
-// resolvedCount 3, ok false).
-//
-// The invariant this restores is `liveProcessIds ⊆ keys(sessions)`, which is
-// what makes the gate a real subset-equality test instead of a comparison that
-// can never hold. It is deliberately NOT "spawn a fresh session for the
-// orphan": we do not know its cwd or provider, and inventing one would
-// resurrect a pane the user never asked for, pointed at the wrong directory.
-//
-// If you add a pane kind that must NOT spawn a process, narrow it HERE and
-// nowhere else. Narrowing `collectTileLeafIds` instead would drop its metadata
-// on the next save — see the comment on that function.
-export function collectLiveProcessIds(input: SessionOwnershipInput): Set {
- const live = collectTileLeafIds(input)
- for (const id of live) {
- // Extension-view leaves have NO backing process (no PTY, no agent). They ARE
- // tile leaves — owned and persisted like any other — but they must never enter
- // the live-process set: rehydrate spawns or recovers a process for every id in
- // here, and an `extension-view` kind would fall through SessionManager's
- // provider switch into the terminal-spawn branch and start a stray shell.
- // Their pane is reconstructed purely from SessionMeta.extensionViewId by
- // ExtensionViewLeaf, so there is nothing to spawn.
- //
- // Narrowed HERE and nowhere else, exactly as the comment above instructs:
- // narrowing collectTileLeafIds instead would drop these panes from the OWNED
- // set, and pickOwnedSessions would delete their metadata on the next autosave.
- if (input.sessions[id]?.kind === 'extension-view') live.delete(id)
- }
+export function collectLiveProcessIds(
+ input: SessionOwnershipInput & { stage: TiledDispatchState },
+): Set {
+ const live = new Set()
+ const focused = input.stage.lanes[input.stage.focusedLane]?.selectedSessionId
+ if (focused === undefined) return live
+ if (!collectOwnedSessionIds(input).has(focused)) return live
+ // Extension views have NO backing process (no PTY, no agent). Their pane is
+ // reconstructed purely from SessionMeta.extensionViewId; recovering one
+ // would fall through SessionManager's provider switch into the
+ // terminal-spawn branch and start a stray shell.
+ if (input.sessions[focused]?.kind === 'extension-view') return live
+ live.add(focused)
return live
}
@@ -235,193 +146,49 @@ export function pickOwnedSessions(
return out
}
+/**
+ * What autosave may make durable: owned rows, and a stage whose every pointer
+ * resolves inside them.
+ *
+ * WHY autosave prunes instead of faithfully serializing runtime state: it is
+ * the durability boundary. If an action leaves an unowned row in
+ * `state.sessions`, writing it turns a transient invariant violation into a
+ * permanent one. Pruning here is the last line of defense, and it closes the
+ * model under restore: nothing this returns points at something it does not
+ * also contain.
+ *
+ * (`repairPersistedTabs` lived below until #992. It rewrote tile TREES before
+ * serialization, because a tree leaf was itself an owner and an orphan leaf
+ * could therefore never be pruned away — it had to be cut out of the tree.
+ * With ownership on the row, an orphan is just an unowned row and the
+ * ordinary prune drops it; there is no structure left to repair.)
+ */
export function pruneSessionOwnership(
- input: SessionOwnershipInput & {
- dispatchMode?: DispatchModeState | null
- },
+ // WHY `stage` is required here although ownership never reads it: the stage
+ // is a POINTER surface, not an owner (U2 — lanes are space, the pool is the
+ // home). It has to be scrubbed against the same live ids in the same pass,
+ // or the file can name a session in a lane that the same file no longer
+ // contains. Required rather than optional because the live state always has
+ // one and an optional field would let a caller silently skip the scrub.
+ input: SessionOwnershipInput & { stage: TiledDispatchState },
): PrunedSessionOwnership {
const ownedIds = collectOwnedSessionIds(input)
const sessions = pickOwnedSessions(input.sessions, ownedIds)
const liveIds = new Set(Object.keys(sessions))
-
- // WHY filter owner records after filtering `sessions`:
- //
- // A corrupted workspace can fail both directions. The OOM bug came from
- // metadata without an owner, but the inverse is also possible after a failed
- // rehydrate or hand-edited workspace.json: an owner points at missing
- // metadata. Persisting that shape means the next load has to reason about a
- // pane whose cwd/kind no longer exists. Pruning owner records to ids that
- // survived in `sessions` keeps the serialized model closed under restore.
- //
- // Detached records are also normalized by session id while we are here. The
- // object key is a lookup convenience, not user data; keeping an old runtime
- // key around a remapped record makes later lifecycle actions target the wrong
- // entry.
- const detachedSessions: Record = {}
- for (const entry of Object.values(input.detachedSessions ?? {})) {
- if (!liveIds.has(entry.sessionId)) continue
- const sessionId = entry.sessionId
- detachedSessions[sessionId] = {
- ...entry,
- sessionId,
- }
- }
-
- const buried = (input.buried ?? []).filter(entry => liveIds.has(entry.sessionId))
const droppedSessionIds = Object.keys(input.sessions).filter(id => !liveIds.has(id))
- const focusedSessionId = input.dispatchMode?.focusedSessionId
- const dispatchMode = input.dispatchMode
- ? scrubGridRowMetadata(
- keepTiledLaneSessions({
- // WHY tiled lanes are scrubbed at the same durability boundary as
- // focusedSessionId: autosave must serialize a model closed under
- // restore. Kill/close paths already clear lanes, but corrupt or
- // hand-edited workspace state can reach this persistence guard directly.
- // If we only scrub classic focus, a tiled lane can keep pointing at a
- // pruned session and force rehydrate/auto-fill to repair stale state on
- // every launch.
- ...input.dispatchMode,
- focusedSessionId: focusedSessionId && liveIds.has(focusedSessionId)
- ? focusedSessionId
- : undefined,
- }, liveIds),
- // Grid rows also name a PROJECT and a set of expanded parent sessions.
- // A binding to a closed tab filters that row's index to nothing with no
- // UI path back (the picker only lists tabs that exist), so it has to be
- // scrubbed at the same durability boundary as every other pointer.
- new Set(input.tabs.map(tab => tab.id)),
- liveIds,
- )
- : input.dispatchMode
-
- return {
- sessions,
- detachedSessions,
- buried,
- dispatchMode,
- droppedSessionIds,
- }
-}
-
-
-/**
- * Repair the tab structures that autosave is about to serialize: drop tile
- * leaves whose session id has no `sessions` row, collapsing each orphaned split
- * into its surviving sibling, and fix up the pointers that repair invalidates.
- *
- * WHY this belongs at the autosave boundary and not in a close/kill path:
- *
- * `pruneSessionOwnership` already claims to keep the serialized model "closed
- * under restore", and it scrubs every pointer that aims AT a session —
- * `sessions`, `detachedSessions`, `buried`, dispatch focus, tiled lanes. Tile
- * trees were the one owner class it never validated, because `useAutoSave`
- * serialized `state.tabs` verbatim. That asymmetry is what let a torn in-memory
- * state (a leaf whose metadata had already been removed) become durable, and
- * durable corruption here is uniquely bad: it disables the very autosave that
- * would fix it.
- *
- * There is a self-reference that makes this the ONLY place the repair can
- * happen. Ownership is *derived from* tile leaves — `collectOwnedSessionIds`
- * walks the trees — so an orphan leaf can never be removed by pruning
- * `sessions` against owners. The orphan IS an owner; there is nothing for
- * `pickOwnedSessions` to drop. The tree itself has to be rewritten.
- *
- * WHAT THIS DOES NOT DO — do not let the next reader assume otherwise: it
- * repairs the object being SERIALIZED, not `state.tabs`. For the rest of the
- * session the orphan leaf stays in the live tree and still renders, as a
- * default-provider pane stuck idle with a `?` label (TileTree renderWorkspaceLeaf
- * falls back to DEFAULT_PROVIDER and an empty runtime). So on-screen and
- * on-disk deliberately diverge until the next launch, which is the trade this
- * whole change makes: a pane that cannot be restored must not be allowed to
- * hold the user's entire workspace file hostage.
- *
- * A tab that loses every leaf is dropped: its root would be empty, which
- * `TileNode` cannot represent and no pane could render. That is the one
- * destructive branch here, which is why `activeTabId` and `tileTabs` are
- * repaired in the same pure function rather than at the call site — it keeps
- * the whole destructive path testable without a React harness.
- */
-export function repairPersistedTabs<
- TTab extends SessionOwnershipTab & { focusedSessionId?: SessionId },
->(input: {
- tabs: readonly TTab[]
- sessions: Record