From 4e6d2b8e116465918b14cc5d901d6c13da3236d2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:37:51 -0700 Subject: [PATCH] fix(agent-map): resume selected retained agents --- .../ipc/dashboard-payload-validation.test.ts | 12 +- src/main/ipc/dashboard-payload-validation.ts | 3 +- .../AgentTerminalDialog.test.tsx | 3 +- .../dashboard-popout/AgentTerminalDialog.tsx | 3 +- ...-agent-session-selected-completion.test.ts | 125 +++++++++++++++ .../src/lib/resume-sleeping-agent-session.ts | 53 ++++++- .../src/lib/reveal-agent-pane.test.ts | 22 +++ src/renderer/src/lib/reveal-agent-pane.ts | 16 +- ...gent-session-launch-host-collision.test.ts | 102 ++++++++++++ .../src/lib/sleeping-agent-session-launch.ts | 42 ++++- .../workspace-activation-path-gate.test.ts | 150 +++++++++++++++++- src/renderer/src/lib/worktree-activation.ts | 50 +++++- src/shared/dashboard-snapshot.ts | 2 + 13 files changed, 556 insertions(+), 27 deletions(-) create mode 100644 src/renderer/src/lib/resume-sleeping-agent-session-selected-completion.test.ts create mode 100644 src/renderer/src/lib/sleeping-agent-session-launch-host-collision.test.ts diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 83970cfa4ca..b9d2a8aa378 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -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) }) }) diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index 6186e638fd1..98ae4640266 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -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)) ) } diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx index 7022d7e5731..618006ccdb0 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx @@ -134,7 +134,8 @@ describe('AgentTerminalDialog', () => { worktreeId: 'w1', executionHostId: 'runtime:env-1', tabId: 'tab1', - leafId: 'leaf1' + leafId: 'leaf1', + paneKey: 'tab1:leaf1' }) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx index a5eea33adc3..e834f5dec69 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx @@ -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) } diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-selected-completion.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-selected-completion.test.ts new file mode 100644 index 00000000000..b79e420fc1f --- /dev/null +++ b/src/renderer/src/lib/resume-sleeping-agent-session-selected-completion.test.ts @@ -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 { + 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 { + 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() + }) +}) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 103c5c2261f..b051fecd251 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -68,6 +68,25 @@ function getNewestActiveRecordsByClaimKey( return newestRecords } +function getPreservedPassiveClaimOwner( + record: SleepingAgentSessionRecord, + records: readonly SleepingAgentSessionRecord[], + state: ReturnType, + 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 } diff --git a/src/renderer/src/lib/reveal-agent-pane.test.ts b/src/renderer/src/lib/reveal-agent-pane.test.ts index a56bf18097c..6451ef76d3a 100644 --- a/src/renderer/src/lib/reveal-agent-pane.test.ts +++ b/src/renderer/src/lib/reveal-agent-pane.test.ts @@ -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:` 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', () => { diff --git a/src/renderer/src/lib/reveal-agent-pane.ts b/src/renderer/src/lib/reveal-agent-pane.ts index 53e77e77011..df2cf31eef3 100644 --- a/src/renderer/src/lib/reveal-agent-pane.ts +++ b/src/renderer/src/lib/reveal-agent-pane.ts @@ -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 diff --git a/src/renderer/src/lib/sleeping-agent-session-launch-host-collision.test.ts b/src/renderer/src/lib/sleeping-agent-session-launch-host-collision.test.ts new file mode 100644 index 00000000000..66720cfafff --- /dev/null +++ b/src/renderer/src/lib/sleeping-agent-session-launch-host-collision.test.ts @@ -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 +})) + +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' + }) + ) + }) +}) diff --git a/src/renderer/src/lib/sleeping-agent-session-launch.ts b/src/renderer/src/lib/sleeping-agent-session-launch.ts index 6d5f0d5ded2..f6b16093fe3 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch.ts @@ -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 } diff --git a/src/renderer/src/lib/workspace-activation-path-gate.test.ts b/src/renderer/src/lib/workspace-activation-path-gate.test.ts index 8cec38ffa26..d4784f0f75e 100644 --- a/src/renderer/src/lib/workspace-activation-path-gate.test.ts +++ b/src/renderer/src/lib/workspace-activation-path-gate.test.ts @@ -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. diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index c35b273f545..ff304e8cb84 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -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') { diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index a3a888d820f..9e60d58c103 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -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 = {