fix(agent-map): resume selected retained agents

This commit is contained in:
Brennan Benson
2026-08-14 18:37:51 -07:00
parent 032fa82691
commit 4e6d2b8e11
13 changed files with 556 additions and 27 deletions
@@ -534,7 +534,8 @@ describe('dashboard payload validation', () => {
worktreeId: 'worktree-1',
executionHostId: 'runtime:env-1',
tabId: 'tab-1',
leafId: null
leafId: null,
paneKey: 'tab-1:leaf-1'
})
).toBe(true)
expect(
@@ -549,5 +550,14 @@ describe('dashboard payload validation', () => {
expect(
isDashboardRevealAgentArgs({ repoId: 'repo-1', worktreeId: 'worktree-1', tabId: '' })
).toBe(false)
expect(
isDashboardRevealAgentArgs({
repoId: 'repo-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: null,
paneKey: ''
})
).toBe(false)
})
})
+2 -1
View File
@@ -78,7 +78,8 @@ export function isDashboardRevealAgentArgs(value: unknown): value is DashboardRe
(isBoundedString(args.executionHostId, MAX_ID_LENGTH) &&
normalizeExecutionHostId(args.executionHostId) !== null)) &&
isBoundedString(args.tabId, MAX_ID_LENGTH) &&
(args.leafId === null || isBoundedString(args.leafId, MAX_ID_LENGTH))
(args.leafId === null || isBoundedString(args.leafId, MAX_ID_LENGTH)) &&
(args.paneKey === undefined || isBoundedString(args.paneKey, MAX_ID_LENGTH))
)
}
@@ -134,7 +134,8 @@ describe('AgentTerminalDialog', () => {
worktreeId: 'w1',
executionHostId: 'runtime:env-1',
tabId: 'tab1',
leafId: 'leaf1'
leafId: 'leaf1',
paneKey: 'tab1:leaf1'
})
})
@@ -45,7 +45,8 @@ function AgentTerminalFrame({
worktreeId: card.worktreeId,
executionHostId: card.executionHostId,
tabId: card.tabId,
leafId: card.leafId
leafId: card.leafId,
paneKey: card.paneKey
})
onOpenChange(false)
}
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { useAppStore } from '@/store'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
const initialAppStoreState = useAppStore.getState()
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
afterEach(() => {
useAppStore.setState(initialAppStoreState, true)
})
function makeCompletedRecord(
overrides: Partial<SleepingAgentSessionRecord> = {}
): SleepingAgentSessionRecord {
return {
paneKey: makePaneKey('closed-tab', LEAF_ID),
tabId: 'closed-tab',
worktreeId: 'wt-1',
agent: 'claude',
providerSession: { key: 'session_id', id: 'sess-1' },
prompt: 'finish the task',
state: 'done',
origin: 'worktree-sleep',
capturedAt: 1,
updatedAt: 1,
...overrides
}
}
function makeTerminalTab(id: string): Record<string, unknown> {
return {
id,
ptyId: null,
worktreeId: 'wt-1',
title: 'shell',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
}
describe('selected completed agent resume', () => {
it('resumes the exact completed record when its pane is gone', () => {
const selected = makeCompletedRecord()
const other = makeCompletedRecord({
paneKey: makePaneKey('other-closed-tab', LEAF_ID),
tabId: 'other-closed-tab',
providerSession: { key: 'session_id', id: 'sess-2' }
})
useAppStore.setState({
tabsByWorktree: { 'wt-1': [] },
sleepingAgentSessionsByPaneKey: {
[selected.paneKey]: selected,
[other.paneKey]: other
}
} as never)
const launched = resumeSleepingAgentSessionsForWorktree('wt-1', {
resumeCompletedPaneKey: selected.paneKey
})
const state = useAppStore.getState()
expect(launched).toBe(1)
expect(state.tabsByWorktree['wt-1']).toHaveLength(1)
expect(state.tabsByWorktree['wt-1']?.[0]?.launchAgent).toBe('claude')
expect(state.sleepingAgentSessionsByPaneKey[selected.paneKey]).toBeUndefined()
})
it('leaves a preserved selected pane for in-place cold restore', () => {
const record = makeCompletedRecord({
paneKey: makePaneKey('tab-1', LEAF_ID),
tabId: 'tab-1'
})
useAppStore.setState({
activeWorktreeId: 'wt-1',
tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1')] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }
}
},
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
const launched = resumeSleepingAgentSessionsForWorktree('wt-1', {
resumeCompletedPaneKey: record.paneKey
})
expect(launched).toBe(0)
expect(useAppStore.getState().tabsByWorktree['wt-1']).toHaveLength(1)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
})
it('does not duplicate a selected provider session that is already active', () => {
const record = makeCompletedRecord()
useAppStore.setState({
tabsByWorktree: { 'wt-1': [makeTerminalTab('live-tab')] },
agentStatusByPaneKey: {
'live-tab:leaf-1': {
paneKey: 'live-tab:leaf-1',
tabId: 'live-tab',
worktreeId: 'wt-1',
agentType: 'claude',
state: 'working',
providerSession: record.providerSession
}
},
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
const launched = resumeSleepingAgentSessionsForWorktree('wt-1', {
resumeCompletedPaneKey: record.paneKey
})
expect(launched).toBe(0)
expect(useAppStore.getState().tabsByWorktree['wt-1']).toHaveLength(1)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
})
@@ -68,6 +68,25 @@ function getNewestActiveRecordsByClaimKey(
return newestRecords
}
function getPreservedPassiveClaimOwner(
record: SleepingAgentSessionRecord,
records: readonly SleepingAgentSessionRecord[],
state: ReturnType<typeof useAppStore.getState>,
excludedPaneKey?: string
): SleepingAgentSessionRecord | null {
const claimKey = getProviderSessionClaimKey(record)
return (
records.find(
(candidate) =>
candidate.paneKey !== excludedPaneKey &&
state.sleepingAgentSessionsByPaneKey[candidate.paneKey] === candidate &&
isPassiveCompletedHibernationEvidence(candidate) &&
getProviderSessionClaimKey(candidate) === claimKey &&
recordPaneIsOwnedByPreservedPane(candidate, state)
) ?? null
)
}
function getAgentStatusTabId(entry: {
paneKey: string
tabId?: string | undefined
@@ -181,10 +200,38 @@ export function resumeSleepingAgentSessionsForWorktree(
}
const isPaneOwned = recordPaneIsOwnedByPreservedPane(record, currentState)
if (isPassiveCompletedHibernationEvidence(record)) {
// Why: completed-agent hibernation is passive history; activation should
// only keep displayable evidence, never start new work from it.
if (!isPaneOwned || activeClaimKeys.has(claimKey)) {
const explicitlySelected = options?.resumeCompletedPaneKey === record.paneKey
// Why: completed-agent hibernation stays passive unless the user opened
// this exact card; broad workspace activation must not restart history.
if (!explicitlySelected) {
if (!isPaneOwned || activeClaimKeys.has(claimKey)) {
state.clearSleepingAgentSession(record.paneKey)
}
continue
}
if (
activeClaimKeys.has(claimKey) ||
activeOrQueuedResumeClaimsProviderSession(record, currentState, isPaneOwned)
) {
state.clearSleepingAgentSession(record.paneKey)
continue
}
const preservedOwner = getPreservedPassiveClaimOwner(
record,
validWorktreeRecords,
currentState,
options?.forceFreshSelectedCompletion ? record.paneKey : undefined
)
if (preservedOwner) {
if (preservedOwner !== record) {
state.clearSleepingAgentSession(record.paneKey)
}
continue
}
if (launchSleepingAgentSession(record, options)) {
launched += 1
freshlyLaunchedClaimKeys.add(claimKey)
clearPassiveCompletedRecordsForClaimKey(worktreeRecords, claimKey, record.paneKey)
}
continue
}
@@ -53,6 +53,28 @@ describe('revealAgentPane', () => {
})
})
it('requests only the selected retained card and focuses its replacement tab', () => {
mocks.activateAndRevealWorkspace.mockReturnValue({
primaryTabId: 'shell-tab',
resumedAgentTabId: 'resumed-tab'
})
mocks.tabsByWorktree = { 'wt-1': [{ id: 'shell-tab' }, { id: 'resumed-tab' }] }
expect(
revealAgentPane({
worktreeId: 'wt-1',
tabId: 'closed-tab',
leafId: 'closed-leaf',
paneKey: 'closed-tab:closed-leaf'
})
).toBe(true)
expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', {
resumeCompletedPaneKey: 'closed-tab:closed-leaf'
})
expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith('resumed-tab', null, {})
})
// Dashboard/agent-map cards carry `folder:<id>` in worktreeId; the dispatcher
// inside activateAndRevealWorkspace is what routes those to the folder path.
it('passes a folder workspace key straight through to the dispatcher', () => {
+10 -6
View File
@@ -8,6 +8,8 @@ export type RevealAgentPaneTarget = {
worktreeId: string
tabId: string
leafId: string | null
/** Exact retained card to resume when its completed pane no longer exists. */
paneKey?: string
executionHostId?: ExecutionHostId
}
@@ -35,10 +37,10 @@ export function revealAgentPane(
target: RevealAgentPaneTarget,
options?: RevealAgentPaneOptions
): boolean {
const activated = activateAndRevealWorkspace(
target.worktreeId,
target.executionHostId ? { executionHostId: target.executionHostId } : {}
)
const activated = activateAndRevealWorkspace(target.worktreeId, {
...(target.executionHostId ? { executionHostId: target.executionHostId } : {}),
...(target.paneKey ? { resumeCompletedPaneKey: target.paneKey } : {})
})
if (!activated) {
options?.onTargetUnavailable?.()
return false
@@ -46,13 +48,15 @@ export function revealAgentPane(
// Why: read tabs AFTER activation — waking a slept agent can add its `--resume`
// tab, and a husk tab can be replaced rather than revived.
const tabs = useAppStore.getState().tabsByWorktree[target.worktreeId] ?? []
if (!tabs.some((tab) => tab.id === target.tabId)) {
const targetTabExists = tabs.some((tab) => tab.id === target.tabId)
const focusTabId = activated.resumedAgentTabId ?? (targetTabExists ? target.tabId : undefined)
if (!focusTabId || !tabs.some((tab) => tab.id === focusTabId)) {
// Why: the wake already focused the replacement tab; re-activating the dead
// id would yank the user back to a tab that no longer renders.
options?.onTargetUnavailable?.()
return false
}
activateTabAndFocusPane(target.tabId, target.leafId, {
activateTabAndFocusPane(focusTabId, activated.resumedAgentTabId ? null : target.leafId, {
...(options?.ackPaneKeyOnSuccess ? { ackPaneKeyOnSuccess: options.ackPaneKeyOnSuccess } : {}),
...(options?.flashFocusedPane ? { flashFocusedPane: true } : {}),
...(options?.scrollToBottomIfOutputSinceLastView
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
const mocks = vi.hoisted(() => ({
getKnownWorktreeById: vi.fn(),
getLocalProjectExecutionRuntimeContext: vi.fn(),
resolveAgentResumeLaunchTarget: vi.fn(() => ({ platform: 'linux', shell: undefined })),
queueTabStartupCommand: vi.fn(),
createTab: vi.fn(() => ({ id: 'resumed-tab' })),
state: {} as Record<string, unknown>
}))
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } }))
vi.mock('@/lib/tui-agent-startup', () => ({
buildAgentResumeStartupPlan: () => ({ launchCommand: "claude '--resume' 'session-1'" })
}))
vi.mock('@/lib/telemetry', () => ({ tuiAgentToAgentKind: () => 'claude' }))
vi.mock('@/components/tab-bar/reconcile-order', () => ({ reconcileTabOrder: () => [] }))
vi.mock('@/lib/agent-resume-launch-target', () => ({
resolveAgentResumeLaunchTarget: mocks.resolveAgentResumeLaunchTarget
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getExecutionHostIdForWorktree: () => 'local'
}))
vi.mock('@/lib/local-preflight-context', () => ({
getLocalProjectExecutionRuntimeContext: mocks.getLocalProjectExecutionRuntimeContext
}))
vi.mock('../../../shared/tui-agent-launch-defaults', () => ({
resolveTuiAgentLaunchArgs: () => '',
resolveTuiAgentLaunchEnv: () => ({})
}))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
const { launchSleepingAgentSession } = await import('./sleeping-agent-session-launch')
const record: SleepingAgentSessionRecord = {
paneKey: 'closed-tab:leaf-1',
tabId: 'closed-tab',
worktreeId: 'colliding-worktree',
agent: 'claude',
providerSession: { key: 'session_id', id: 'session-1' },
prompt: 'finish',
state: 'done',
origin: 'worktree-sleep',
capturedAt: 1,
updatedAt: 1,
connectionId: 'remote-connection'
}
describe('sleeping agent resume host collision', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getKnownWorktreeById.mockReturnValue({
id: record.worktreeId,
repoId: 'repo-1',
path: '/remote/worktree'
})
mocks.state = {
getKnownWorktreeById: mocks.getKnownWorktreeById,
repos: [
{ id: 'repo-1', path: 'C:\\local', executionHostId: 'local' },
{
id: 'repo-1',
path: '/remote',
connectionId: 'remote-connection',
executionHostId: 'ssh:remote-connection'
}
],
settings: { agentCmdOverrides: {} },
tabsByWorktree: { [record.worktreeId]: [] },
openFiles: [],
browserTabsByWorktree: {},
tabBarOrderByWorktree: {},
createTab: mocks.createTab,
queueTabStartupCommand: mocks.queueTabStartupCommand,
claimAutomaticAgentResume: vi.fn(),
clearSleepingAgentSession: vi.fn(),
setActiveTabType: vi.fn(),
setTabBarOrder: vi.fn()
}
})
it('does not let a colliding local WSL project override an SSH resume target', () => {
expect(launchSleepingAgentSession(record, { executionHostId: 'ssh:remote-connection' })).toBe(
true
)
expect(mocks.getKnownWorktreeById).toHaveBeenCalledWith(
record.worktreeId,
'ssh:remote-connection'
)
expect(mocks.getLocalProjectExecutionRuntimeContext).not.toHaveBeenCalled()
expect(mocks.resolveAgentResumeLaunchTarget).toHaveBeenCalledWith(
expect.objectContaining({
projectRuntime: undefined,
connectionId: 'remote-connection',
executionHostId: 'ssh:remote-connection',
worktreePath: '/remote/worktree'
})
)
})
})
@@ -14,10 +14,19 @@ import {
resolveTuiAgentLaunchEnv
} from '../../../shared/tui-agent-launch-defaults'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
import { translate } from '@/i18n/i18n'
export type ResumeSleepingAgentSessionsOptions = {
suppressNavigation?: boolean
/** Exact completed pane the user explicitly asked to reopen. Generic
* workspace activation keeps completed hibernation evidence passive. */
resumeCompletedPaneKey?: string
/** Folder panes do not cold-restore on activation; replace only the selected
* completion before its preserved pane can claim the provider session. */
forceFreshSelectedCompletion?: boolean
/** Pins resume launch resolution when workspace ids collide across hosts. */
executionHostId?: ExecutionHostId
/** Provider-session claim keys already woken in place by mounted panes
* (WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT). Their sleeping records are
* cleared only after the in-place spawn succeeds, so the generic resume
@@ -26,17 +35,33 @@ export type ResumeSleepingAgentSessionsOptions = {
/** Called with the tab id of each freshly launched resume tab, so
* navigation-suppressed callers can background-mount exactly those tabs. */
onSessionLaunched?: (tabId: string) => void
/** Reports only the explicitly selected completed record's replacement. */
onSelectedCompletedSessionLaunched?: (tabId: string) => void
}
function getResumeLaunchTarget(worktreeId: string): AgentResumeLaunchTarget {
function getResumeLaunchTarget(
record: SleepingAgentSessionRecord,
executionHostId?: ExecutionHostId
): AgentResumeLaunchTarget {
const state = useAppStore.getState()
const worktree = state.getKnownWorktreeById(worktreeId)
const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null
const worktreeId = record.worktreeId
const worktree = state.getKnownWorktreeById(worktreeId, executionHostId)
const resolvedExecutionHostId =
executionHostId ?? getExecutionHostIdForWorktree(state, worktreeId)
const repo = worktree
? (state.repos.find(
(entry) =>
entry.id === worktree.repoId && getRepoExecutionHostId(entry) === resolvedExecutionHostId
) ?? null)
: null
// The resume tab is created without a shell override, so the global Windows shell wins.
return resolveAgentResumeLaunchTarget({
projectRuntime: getLocalProjectExecutionRuntimeContext(state, worktreeId),
connectionId: repo?.connectionId,
executionHostId: getExecutionHostIdForWorktree(state, worktreeId),
projectRuntime:
resolvedExecutionHostId === 'local'
? getLocalProjectExecutionRuntimeContext(state, worktreeId)
: undefined,
connectionId: repo?.connectionId ?? record.connectionId,
executionHostId: resolvedExecutionHostId,
worktreePath: worktree?.path,
terminalWindowsShell: state.settings?.terminalWindowsShell
})
@@ -68,7 +93,7 @@ export function launchSleepingAgentSession(
): boolean {
const state = useAppStore.getState()
const launchConfig = record.launchConfig
const resumeTarget = getResumeLaunchTarget(record.worktreeId)
const resumeTarget = getResumeLaunchTarget(record, options?.executionHostId)
const startupPlan = buildAgentResumeStartupPlan({
agent: record.agent,
providerSession: record.providerSession,
@@ -130,5 +155,8 @@ export function launchSleepingAgentSession(
}
appendTabToWorktreeOrder(record.worktreeId, tab.id)
options?.onSessionLaunched?.(tab.id)
if (options?.resumeCompletedPaneKey === record.paneKey) {
options.onSelectedCompletedSessionLaunched?.(tab.id)
}
return true
}
@@ -5,6 +5,8 @@ import type { ProjectGroup } from '../../../shared/project-group-types'
import type { FolderWorkspacePathStatusReason } from '../../../shared/folder-workspace-path-status'
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
import { resolveWindowShortcutAction } from '../../../shared/window-shortcut-policy'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { makePaneKey } from '../../../shared/stable-pane-id'
// Why: drives the real activation module against a real store, so a regression in
// the workspace dispatch or in the path-status guard itself both surface here.
@@ -63,7 +65,9 @@ async function seedFolderWorkspace(
activeWorktreeId: null,
activeWorkspaceKey: null,
activeView: 'terminal',
folderWorkspacePathStatuses: {}
folderWorkspacePathStatuses: {},
sleepingAgentSessionsByPaneKey: {},
tabsByWorktree: {}
})
mocks.getPathStatus.mockResolvedValue({ path: folderWorkspace.folderPath, ...status })
// Why the real fetch: it writes the cache under the exact key + request snapshot
@@ -94,6 +98,32 @@ describe('Cmd/Ctrl+1-9 folder-workspace path gate (#10716)', () => {
expect(mocks.toastError).toHaveBeenCalledTimes(1)
})
it('keeps a selected completion retryable when folder activation is blocked', async () => {
await seedFolderWorkspace({ exists: false, reason: 'missing' })
const worktreeId = folderWorkspaceKey(folderWorkspace.id)
const record = {
paneKey: makePaneKey('completed-tab', '11111111-1111-4111-8111-111111111111'),
tabId: 'completed-tab',
worktreeId,
agent: 'claude',
providerSession: { key: 'session_id', id: 'completed-session' },
prompt: 'completed task',
state: 'done',
origin: 'worktree-sleep',
capturedAt: 1,
updatedAt: 1
} satisfies SleepingAgentSessionRecord
store.setState({ sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } })
const result = activateAndRevealWorkspace(worktreeId, {
resumeCompletedPaneKey: record.paneKey
})
expect(result).toBe(false)
expect(store.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
expect(store.getState().tabsByWorktree[worktreeId]).toBeUndefined()
})
it('blocks the number shortcut when the SSH connection owning the folder is ambiguous', async () => {
await seedFolderWorkspace({ exists: false, reason: 'ambiguous-connection' })
@@ -112,6 +142,124 @@ describe('Cmd/Ctrl+1-9 folder-workspace path gate (#10716)', () => {
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('attributes a mixed wake to the explicitly selected completed folder agent', async () => {
await seedFolderWorkspace({ exists: true })
const worktreeId = folderWorkspaceKey(folderWorkspace.id)
const completed = {
paneKey: makePaneKey('completed-tab', '11111111-1111-4111-8111-111111111111'),
tabId: 'completed-tab',
worktreeId,
agent: 'claude',
providerSession: { key: 'session_id', id: 'completed-session' },
prompt: 'completed task',
state: 'done',
origin: 'worktree-sleep',
capturedAt: 1,
updatedAt: 1
} satisfies SleepingAgentSessionRecord
const active = {
...completed,
paneKey: makePaneKey('active-tab', '22222222-2222-4222-8222-222222222222'),
tabId: 'active-tab',
providerSession: { key: 'session_id', id: 'active-session' },
prompt: 'active task',
state: 'working',
capturedAt: 2,
updatedAt: 2
} satisfies SleepingAgentSessionRecord
store.setState({
sleepingAgentSessionsByPaneKey: {
[completed.paneKey]: completed,
[active.paneKey]: active
}
})
const result = activateAndRevealWorkspace(worktreeId, {
resumeCompletedPaneKey: completed.paneKey
})
expect(result).not.toBe(false)
if (!result) {
throw new Error('folder workspace activation unexpectedly failed')
}
const state = store.getState()
expect(state.tabsByWorktree[worktreeId]).toHaveLength(2)
const resumedAgentTabId = result.resumedAgentTabId
if (!resumedAgentTabId) {
throw new Error('selected completed agent did not receive a replacement tab')
}
expect(state.pendingStartupByTabId[resumedAgentTabId]?.resumeProviderSession).toEqual(
completed.providerSession
)
const otherTabId = state.tabsByWorktree[worktreeId]?.find(
(tab) => tab.id !== resumedAgentTabId
)?.id
if (!otherTabId) {
throw new Error('active sleeping agent did not receive a resume tab')
}
expect(state.pendingStartupByTabId[otherTabId]?.resumeProviderSession).toEqual(
active.providerSession
)
})
it('fresh-resumes the selected folder agent when its preserved pane cannot cold-restore', async () => {
await seedFolderWorkspace({ exists: true })
const worktreeId = folderWorkspaceKey(folderWorkspace.id)
const leafId = '11111111-1111-4111-8111-111111111111'
const record = {
paneKey: makePaneKey('completed-tab', leafId),
tabId: 'completed-tab',
worktreeId,
agent: 'claude',
providerSession: { key: 'session_id', id: 'completed-session' },
prompt: 'completed task',
state: 'done',
origin: 'worktree-sleep',
capturedAt: 1,
updatedAt: 1
} satisfies SleepingAgentSessionRecord
store.setState({
tabsByWorktree: {
[worktreeId]: [
{
id: 'completed-tab',
ptyId: null,
worktreeId,
title: 'Claude',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
'completed-tab': {
root: { type: 'leaf', leafId },
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: 'dead-pty' }
}
},
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
})
const result = activateAndRevealWorkspace(worktreeId, {
resumeCompletedPaneKey: record.paneKey
})
if (!result || !result.resumedAgentTabId) {
throw new Error('selected folder agent did not receive a replacement tab')
}
expect(store.getState().tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual([
result.resumedAgentTabId
])
expect(
store.getState().pendingStartupByTabId[result.resumedAgentTabId]?.resumeProviderSession
).toEqual(record.providerSession)
expect(store.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
// Why per AGENTS.md: the gate must hold on Windows/Linux Ctrl too, not just Cmd.
// The digit chord resolves to the same jumpToWorktreeIndex action on every platform,
// so all three land on the guarded activator this suite pins above.
+44 -6
View File
@@ -185,6 +185,32 @@ export type ActivateAndRevealResult = {
/** Id of the primary terminal tab seeded with `opts.startup`, or null. Prefer this over
* `activeTabIdByWorktree`, which may point at another tab if setup/issue scripts opened their own. */
primaryTabId: string | null
/** Fresh tab created for an explicitly selected completed agent. */
resumedAgentTabId?: string
}
type WorkspaceAgentResumeOptions = {
executionHostId?: ExecutionHostId
resumeCompletedPaneKey?: string
}
function resumeAgentsForWorkspace(
workspaceId: string,
opts?: WorkspaceAgentResumeOptions,
forceFreshSelectedCompletion = false
): string | null {
let resumedTabId: string | null = null
resumeSleepingAgentSessionsForWorktree(workspaceId, {
...(opts?.executionHostId ? { executionHostId: opts.executionHostId } : {}),
...(opts?.resumeCompletedPaneKey
? { resumeCompletedPaneKey: opts.resumeCompletedPaneKey }
: {}),
...(forceFreshSelectedCompletion ? { forceFreshSelectedCompletion: true } : {}),
onSelectedCompletedSessionLaunched: (tabId) => {
resumedTabId = tabId
}
})
return resumedTabId
}
function ensureFolderWorkspaceInitialTerminal(
@@ -211,6 +237,7 @@ export function activateAndRevealFolderWorkspace(
startup?: WorktreeStartupPayload
runtimeEnvironmentId?: string | null
executionHostId?: ExecutionHostId
resumeCompletedPaneKey?: string
}
): ActivateAndRevealResult | false {
const state = useAppStore.getState()
@@ -260,8 +287,12 @@ export function activateAndRevealFolderWorkspace(
if (!state.isNavigatingHistory) {
state.recordWorktreeVisit(workspaceKey)
}
resumeSleepingAgentSessionsForWorktree(workspaceKey)
const primaryTabId = ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup)
const resumedTabId = resumeAgentsForWorkspace(
workspaceKey,
opts,
Boolean(opts?.resumeCompletedPaneKey)
)
const ensuredTabId = ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup)
if (opts?.sidebarRevealBehavior) {
state.revealWorktreeInSidebar(workspaceKey, { behavior: opts.sidebarRevealBehavior })
@@ -269,7 +300,10 @@ export function activateAndRevealFolderWorkspace(
state.revealWorktreeInSidebar(workspaceKey)
}
return { primaryTabId }
return {
primaryTabId: ensuredTabId,
...(resumedTabId ? { resumedAgentTabId: resumedTabId } : {})
}
}
export function activateAndRevealWorktree(
@@ -284,6 +318,7 @@ export function activateAndRevealWorktree(
notifyHostRuntime?: boolean
revealInSidebar?: boolean
executionHostId?: ExecutionHostId
resumeCompletedPaneKey?: string
backendStartupTerminalSpawned?: boolean
}
): ActivateAndRevealResult | false {
@@ -336,7 +371,7 @@ export function activateAndRevealWorktree(
}
// Why: sleeping destroys the local PTY but preserves the provider session id, so waking should restore those CLI sessions automatically.
resumeSleepingAgentSessionsForWorktree(worktreeId)
const resumedTabId = resumeAgentsForWorkspace(worktreeId, opts)
// 4. Ensure a focusable surface exists for externally-created worktrees
const primaryTabId = ensureWorktreeHasInitialTerminal(
@@ -382,7 +417,10 @@ export function activateAndRevealWorktree(
ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId)
}
return { primaryTabId }
return {
primaryTabId,
...(resumedTabId ? { resumedAgentTabId: resumedTabId } : {})
}
}
export function ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId: string): void {
@@ -743,7 +781,7 @@ function queueSetupAndIssueCommands(
*/
export function activateAndRevealWorkspace(
workspaceId: string,
opts?: { executionHostId?: ExecutionHostId }
opts?: WorkspaceAgentResumeOptions
): ActivateAndRevealResult | false {
const workspaceScope = parseWorkspaceKey(workspaceId)
if (workspaceScope?.type === 'folder') {
+2
View File
@@ -212,6 +212,8 @@ export type DashboardRevealAgentArgs = {
executionHostId?: ExecutionHostId
tabId: string
leafId: string | null
/** Optional for mixed-version pop-outs; identifies retained resume evidence. */
paneKey?: string
}
export type DashboardSpawnAgentArgs = {