diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index b5a397973..77463e159 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -1062,6 +1062,27 @@ describe('AgentHookServer listener replay', () => { expect(listener).toHaveBeenNthCalledWith(4, []) }) + it('notifies pane-status-clear listener when pane teardown evicts a cached status', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setPaneStatusClearListener(listener) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', agentType: 'claude' } + }, + 'conn-1' + ) + server.clearPaneState(PANE) + server.clearPaneState(PANE) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith(PANE) + }) + it('hydrates cached statuses as not observed in the current runtime', async () => { const dir = mkdtempSync(join(tmpdir(), 'orca-agent-hooks-')) const firstServer = new AgentHookServer() diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 54115644c..c36ce287e 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -77,6 +77,7 @@ export type AgentHookStatusChangeEntry = { } type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void +type PaneStatusClearListener = (paneKey: string) => void type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void type PaneKeyAliasEntry = { stablePaneKey: string @@ -384,6 +385,7 @@ export class AgentHookServer { // caller's knowledge of whether this is a packaged build. private env = 'production' private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null + private onPaneStatusCleared: PaneStatusClearListener | null = null private statusChangeListeners = new Set() // Why: directory that holds the on-disk endpoint file. Set via start()'s // `userDataPath` option so the class has no direct Electron dependency @@ -444,6 +446,10 @@ export class AgentHookServer { } } + setPaneStatusClearListener(listener: PaneStatusClearListener | null): void { + this.onPaneStatusCleared = listener + } + /** Snapshot of the current cached statuses, in the IPC-shaped form the * renderer consumes. Used by the `agentStatus:getSnapshot` IPC after * workspace tabs have hydrated, so the dashboard catches up on any @@ -832,6 +838,7 @@ export class AgentHookServer { ): void { let aliasChanged = false let statusChanged = false + const clearedStatusPaneKeys = new Set() for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { if (entry.ptyId === ptyId) { this.legacyPaneKeyAliases.delete(legacyPaneKey) @@ -841,6 +848,7 @@ export class AgentHookServer { options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) { statusChanged = true + clearedStatusPaneKeys.add(entry.stablePaneKey) } if (shouldClearStablePaneKey) { // Why: after hydrate, legacy rows are stored under the stable key. If @@ -859,6 +867,9 @@ export class AgentHookServer { if (statusChanged) { this.scheduleStatusPersist() this.notifyStatusChangeListeners() + for (const paneKey of clearedStatusPaneKeys) { + this.onPaneStatusCleared?.(paneKey) + } } } @@ -1131,6 +1142,7 @@ export class AgentHookServer { this.token = '' this.env = 'production' this.onAgentStatus = null + this.onPaneStatusCleared = null for (const timer of this.assistantMessageRetryTimers.values()) { clearTimeout(timer) } @@ -1199,6 +1211,7 @@ export class AgentHookServer { this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) this.scheduleStatusPersist() this.notifyStatusChangeListeners() + this.onPaneStatusCleared?.(resolvedPaneKey) } } diff --git a/src/main/index.ts b/src/main/index.ts index e9347aba3..284c7133a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -580,6 +580,7 @@ function openMainWindow(): BrowserWindow { // replay-loop through lastStatusByPaneKey runs only on deliberate // window recreations instead of stacking on top of stale listeners. agentHookServer.setListener(null) + agentHookServer.setPaneStatusClearListener(null) setMigrationUnsupportedPtyListener(null) // Why: any running synthesized-title spinner timer would fire into a // destroyed webContents; stop it here instead of deferring to per-pane @@ -629,6 +630,12 @@ function openMainWindow(): BrowserWindow { } } ) + agentHookServer.setPaneStatusClearListener((paneKey) => { + if (mainWindow?.isDestroyed()) { + return + } + mainWindow?.webContents.send('agentStatus:clear', { paneKey }) + }) setMigrationUnsupportedPtyListener((event) => { if (mainWindow?.isDestroyed()) { return diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 198fd9c4f..7d2f827f1 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2376,6 +2376,8 @@ export type PreloadApi = { agentStatus: { /** Listen for agent status updates forwarded from native hook receivers. */ onSet: (callback: (data: AgentStatusIpcPayload) => void) => () => void + /** Listen for main-process pane teardown that evicted a cached hook status. */ + onClear: (callback: (data: { paneKey: string }) => void) => () => void /** Return the current main-process hook cache after renderer hydration. */ getSnapshot: () => Promise inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 0f9fb0a4b..77d6cedc7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3422,6 +3422,12 @@ const api = { ipcRenderer.on('agentStatus:set', listener) return () => ipcRenderer.removeListener('agentStatus:set', listener) }, + onClear: (callback: (data: { paneKey: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { paneKey: string }) => + callback(data) + ipcRenderer.on('agentStatus:clear', listener) + return () => ipcRenderer.removeListener('agentStatus:clear', listener) + }, /** Pull the current cached hook statuses after renderer workspace-session * hydration. This avoids losing startup replays before the renderer * knows which tabs exist. */ diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx b/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx index 0b2e4cbaa..aac20f34b 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx @@ -17,6 +17,7 @@ type MockState = { ptyIdsByTabId: Record agentStatusEpoch: number agentStatusByPaneKey: Record + runtimeAgentOrchestrationByPaneKey: Record> migrationUnsupportedByPtyId: Record retainedAgentsByPaneKey: Record } @@ -43,6 +44,8 @@ function makeTab(id: string, worktreeId: string): TerminalTab { function makeAgentStatusEntry(args: { paneKey: string state: AgentStatusEntry['state'] + worktreeId?: string + parentPaneKey?: string }): AgentStatusEntry { return { paneKey: args.paneKey, @@ -50,7 +53,15 @@ function makeAgentStatusEntry(args: { prompt: '', updatedAt: 1_000, stateStartedAt: 1_000, - stateHistory: [] + stateHistory: [], + worktreeId: args.worktreeId, + orchestration: args.parentPaneKey + ? { + taskId: 'task-1', + dispatchId: 'dispatch-1', + parentPaneKey: args.parentPaneKey + } + : undefined } } @@ -100,6 +111,7 @@ describe('useWorktreeActivityStatus', () => { ptyIdsByTabId: {}, agentStatusEpoch: 0, agentStatusByPaneKey: {}, + runtimeAgentOrchestrationByPaneKey: {}, migrationUnsupportedByPtyId: {}, retainedAgentsByPaneKey: {} } @@ -262,6 +274,44 @@ describe('useWorktreeActivityStatus', () => { expect(renderToStaticMarkup()).toBe('done') }) + it('lets a completed worker suppress its parent pane stale working title', () => { + const worktreeId = 'repo1::/path/wt1' + const parentPaneKey = makePaneKey('tab-parent', LEAF_ID) + const childPaneKey = makePaneKey('tab-child', SECOND_LEAF_ID) + mockState = { + ...mockState, + tabsByWorktree: { + [worktreeId]: [makeTab('tab-parent', worktreeId)] + }, + ptyIdsByTabId: { + 'tab-parent': ['pty-parent'] + }, + runtimePaneTitlesByTabId: { + 'tab-parent': { + 1: '⠋ Codex' + } + }, + terminalLayoutsByTabId: { + 'tab-parent': { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null + } + }, + agentStatusEpoch: 1, + agentStatusByPaneKey: { + [childPaneKey]: makeAgentStatusEntry({ + paneKey: childPaneKey, + state: 'done', + worktreeId, + parentPaneKey + }) + } + } + + expect(renderToStaticMarkup()).toBe('done') + }) + it('scopes cached agent summaries to the matching worktree', () => { const firstWorktreeId = 'repo1::/path/wt1' const secondWorktreeId = 'repo1::/path/wt2' diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index f45f3cf84..6b0342289 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -12,6 +12,8 @@ const LEAF_ID = '11111111-1111-4111-8111-111111111111' function makeAgentStatusEntry(args: { paneKey: string state: AgentStatusEntry['state'] + worktreeId?: string + parentPaneKey?: string }): AgentStatusEntry { return { paneKey: args.paneKey, @@ -19,7 +21,15 @@ function makeAgentStatusEntry(args: { prompt: '', updatedAt: 1_000, stateStartedAt: 1_000, - stateHistory: [] + stateHistory: [], + worktreeId: args.worktreeId, + orchestration: args.parentPaneKey + ? { + taskId: 'task-1', + dispatchId: 'dispatch-1', + parentPaneKey: args.parentPaneKey + } + : undefined } } @@ -55,6 +65,7 @@ describe('selectWorktreeAgentActivitySummary', () => { [firstPaneKey]: makeAgentStatusEntry({ paneKey: firstPaneKey, state: 'working' }) }, migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: { 'tab-2:0': { entry: makeAgentStatusEntry({ paneKey: 'tab-2:0', state: 'done' }), @@ -93,6 +104,7 @@ describe('selectWorktreeAgentActivitySummary', () => { [paneKey]: entry }, migrationUnsupportedByPtyId, + runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey } const sameStatePing = { @@ -130,6 +142,7 @@ describe('selectWorktreeAgentActivitySummary', () => { [paneKey]: makeAgentStatusEntry({ paneKey, state: 'working' }) }, migrationUnsupportedByPtyId, + runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey } const changedState = { @@ -150,4 +163,86 @@ describe('selectWorktreeAgentActivitySummary', () => { }) expect(nowSpy).toHaveBeenCalledTimes(2) }) + + it('summarizes worktree-attributed rows missing from the tab list', () => { + vi.spyOn(Date, 'now').mockReturnValue(2_000) + const childPaneKey = makePaneKey('tab-child', '22222222-2222-4222-8222-222222222222') + const state: AgentActivityInput = { + tabsByWorktree: { + 'repo::/wt-1': [makeTab('tab-parent', 'repo::/wt-1')] + }, + agentStatusEpoch: 0, + agentStatusByPaneKey: { + [childPaneKey]: makeAgentStatusEntry({ + paneKey: childPaneKey, + state: 'done', + worktreeId: 'repo::/wt-1' + }) + }, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + } + + expect(selectWorktreeAgentActivitySummary(state, 'repo::/wt-1')).toMatchObject({ + hasLiveDone: true + }) + }) + + it('uses completed worker orchestration to suppress a stale parent pane title', () => { + vi.spyOn(Date, 'now').mockReturnValue(2_000) + const parentPaneKey = makePaneKey('tab-parent', LEAF_ID) + const childPaneKey = makePaneKey('tab-child', '22222222-2222-4222-8222-222222222222') + const state: AgentActivityInput = { + tabsByWorktree: { + 'repo::/wt-1': [makeTab('tab-parent', 'repo::/wt-1')] + }, + agentStatusEpoch: 0, + agentStatusByPaneKey: { + [childPaneKey]: makeAgentStatusEntry({ + paneKey: childPaneKey, + state: 'done', + worktreeId: 'repo::/wt-1', + parentPaneKey + }) + }, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + } + + const summary = selectWorktreeAgentActivitySummary(state, 'repo::/wt-1') + expect(summary.agentStatusPaneIdsByTabId['tab-parent']).toEqual(new Set([LEAF_ID])) + }) + + it('uses runtime orchestration metadata for completed worker parent-pane suppression', () => { + vi.spyOn(Date, 'now').mockReturnValue(2_000) + const parentPaneKey = makePaneKey('tab-parent', LEAF_ID) + const childPaneKey = makePaneKey('tab-child', '22222222-2222-4222-8222-222222222222') + const state: AgentActivityInput = { + tabsByWorktree: { + 'repo::/wt-1': [makeTab('tab-parent', 'repo::/wt-1')] + }, + agentStatusEpoch: 0, + agentStatusByPaneKey: { + [childPaneKey]: makeAgentStatusEntry({ + paneKey: childPaneKey, + state: 'done', + worktreeId: 'repo::/wt-1' + }) + }, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: { + [childPaneKey]: { + taskId: 'task-1', + dispatchId: 'dispatch-1', + parentPaneKey + } + }, + retainedAgentsByPaneKey: {} + } + + const summary = selectWorktreeAgentActivitySummary(state, 'repo::/wt-1') + expect(summary.agentStatusPaneIdsByTabId['tab-parent']).toEqual(new Set([LEAF_ID])) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index 45b4138ab..ac44c11b7 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -3,7 +3,8 @@ import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry + type AgentStatusEntry, + type AgentStatusOrchestrationContext } from '../../../../shared/agent-status-types' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' @@ -35,6 +36,7 @@ export type AgentActivityInput = Pick< | 'retainedAgentsByPaneKey' > & { tabsByWorktree: AgentActivityTabsByWorktree + runtimeAgentOrchestrationByPaneKey?: AppState['runtimeAgentOrchestrationByPaneKey'] } type AgentActivityCache = { @@ -42,6 +44,7 @@ type AgentActivityCache = { agentStatusEpoch: number migrationUnsupportedByPtyId: AppState['migrationUnsupportedByPtyId'] retainedAgentsByPaneKey: AppState['retainedAgentsByPaneKey'] + runtimeAgentOrchestrationByPaneKey: AppState['runtimeAgentOrchestrationByPaneKey'] | undefined summaries: Map } @@ -57,12 +60,14 @@ export function selectWorktreeAgentActivitySummary( function getWorktreeAgentActivitySummaries( state: AgentActivityInput ): Map { + const runtimeAgentOrchestrationByPaneKey = state.runtimeAgentOrchestrationByPaneKey if ( agentActivityCache && agentActivityCache.tabsByWorktree === state.tabsByWorktree && agentActivityCache.agentStatusEpoch === state.agentStatusEpoch && agentActivityCache.migrationUnsupportedByPtyId === state.migrationUnsupportedByPtyId && - agentActivityCache.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey + agentActivityCache.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey && + agentActivityCache.runtimeAgentOrchestrationByPaneKey === runtimeAgentOrchestrationByPaneKey ) { return agentActivityCache.summaries } @@ -93,12 +98,22 @@ function getWorktreeAgentActivitySummaries( if (!paneIdentity) { continue } - const worktreeId = tabIdToWorktreeId.get(paneIdentity.tabId) ?? null + const orchestration = resolveEntryOrchestration( + entry, + runtimeAgentOrchestrationByPaneKey?.[paneKey] + ) + const worktreeId = + tabIdToWorktreeId.get(paneIdentity.tabId) ?? + entry.worktreeId ?? + worktreeIdForPaneKey(orchestration?.parentPaneKey, tabIdToWorktreeId) if (!worktreeId || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { continue } const summary = summaryForWorktree(worktreeId) addAgentStatusPaneId(summary, paneIdentity.tabId, paneIdentity.paneId) + if (entry.state === 'done') { + addParentPaneId(summary, orchestration, worktreeId, tabIdToWorktreeId) + } applyLiveAgentState(summary, entry) } @@ -117,6 +132,11 @@ function getWorktreeAgentActivitySummaries( if (paneIdentity) { addAgentStatusPaneId(summary, paneIdentity.tabId, paneIdentity.paneId) } + const orchestration = resolveEntryOrchestration( + retained.entry, + runtimeAgentOrchestrationByPaneKey?.[retained.entry.paneKey] + ) + addParentPaneId(summary, orchestration, retained.worktreeId, tabIdToWorktreeId) } agentActivityCache = { @@ -124,11 +144,31 @@ function getWorktreeAgentActivitySummaries( agentStatusEpoch: state.agentStatusEpoch, migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId, retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, + runtimeAgentOrchestrationByPaneKey, summaries } return summaries } +function resolveEntryOrchestration( + entry: Pick, + runtimeOrchestration: AgentStatusOrchestrationContext | undefined +): AgentStatusOrchestrationContext | undefined { + if (!entry.orchestration) { + return runtimeOrchestration + } + if (!runtimeOrchestration) { + return entry.orchestration + } + if ( + entry.orchestration.taskId === runtimeOrchestration.taskId && + entry.orchestration.dispatchId === runtimeOrchestration.dispatchId + ) { + return { ...entry.orchestration, ...runtimeOrchestration } + } + return entry.orchestration +} + function applyLiveAgentState( summary: WorktreeAgentActivitySummary, entry: Pick @@ -159,13 +199,32 @@ function addAgentStatusPaneId( } function worktreeIdForPaneKey( - paneKey: string, + paneKey: string | undefined, tabIdToWorktreeId: Map ): string | null { const paneIdentity = parseAgentStatusPaneKey(paneKey) return paneIdentity ? (tabIdToWorktreeId.get(paneIdentity.tabId) ?? null) : null } +function addParentPaneId( + summary: WorktreeAgentActivitySummary, + orchestration: AgentStatusOrchestrationContext | undefined, + worktreeId: string, + tabIdToWorktreeId: Map +): void { + const parentPaneIdentity = parseAgentStatusPaneKey(orchestration?.parentPaneKey) + if (!parentPaneIdentity) { + return + } + // Why: a completed worker can be the only visible row for a worktree while + // its parent pane still carries a stale spinner title. Let that row own the + // parent pane's title for this worktree without touching other worktrees. + if (tabIdToWorktreeId.get(parentPaneIdentity.tabId) !== worktreeId) { + return + } + addAgentStatusPaneId(summary, parentPaneIdentity.tabId, parentPaneIdentity.paneId) +} + function parseAgentStatusPaneKey( paneKey: string | undefined ): { tabId: string; paneId: string } | null { diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index fd0b8cbbb..2547305f5 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -2591,6 +2591,7 @@ describe('useIpcEvents agent status snapshot integration', () => { function buildWindowApi(args: { onSet: (cb: (data: AgentStatusSetData) => void) => () => void + onClear?: (cb: (data: { paneKey: string }) => void) => () => void getSnapshot?: () => Promise drop?: (paneKey: string) => void remoteWorkspace?: Record @@ -2690,6 +2691,7 @@ describe('useIpcEvents agent status snapshot integration', () => { }, agentStatus: { onSet: args.onSet, + onClear: args.onClear ?? vi.fn(() => () => {}), getSnapshot: args.getSnapshot ?? vi.fn(() => Promise.resolve([])), drop: args.drop ?? vi.fn() }, @@ -3113,6 +3115,157 @@ describe('useIpcEvents agent status snapshot integration', () => { ) }) + it('clears a worktree-attributed live row when main reports pane teardown', async () => { + const setAgentStatus = vi.fn() + const removeAgentStatus = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + const onClearListenerRef: { current: ((data: { paneKey: string }) => void) | null } = { + current: null + } + + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + removeAgentStatus, + workspaceSessionReady: true, + settings: { terminalFontSize: 13, notifications: { enabled: false } }, + repos: [{ id: 'repo-1', connectionId: null }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }, + tabsByWorktree: { 'wt-1': [] }, + terminalLayoutsByTabId: {}, + agentStatusByPaneKey: { + [FUTURE_PANE_KEY]: { + state: 'working', + prompt: 'hidden worker', + agentType: 'codex', + updatedAt: 1_700_000_000_200, + stateStartedAt: 1_700_000_000_000, + paneKey: FUTURE_PANE_KEY, + worktreeId: 'wt-1', + stateHistory: [] + } + } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + }, + onClear: (cb) => { + onClearListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + if (typeof onClearListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onClear listener to be registered') + } + + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + state: 'working', + prompt: 'hidden worker', + agentType: 'codex', + worktreeId: 'wt-1', + receivedAt: 1_700_000_000_200, + stateStartedAt: 1_700_000_000_000, + orchestration: { + parentPaneKey: 'parent-tab:11111111-1111-4111-8111-111111111111' + } + }) + + expect(setAgentStatus).toHaveBeenCalledTimes(1) + expect(setAgentStatus).toHaveBeenCalledWith( + FUTURE_PANE_KEY, + expect.objectContaining({ state: 'working', prompt: 'hidden worker', agentType: 'codex' }), + undefined, + { updatedAt: 1_700_000_000_200, stateStartedAt: 1_700_000_000_000 }, + expectWorktreeRouting('wt-1') + ) + + onClearListenerRef.current({ paneKey: FUTURE_PANE_KEY }) + + expect(removeAgentStatus).toHaveBeenCalledTimes(1) + expect(removeAgentStatus).toHaveBeenCalledWith(FUTURE_PANE_KEY) + }) + + it('keeps a completed worktree-attributed row when main reports pane teardown', async () => { + const removeAgentStatus = vi.fn() + const onClearListenerRef: { current: ((data: { paneKey: string }) => void) | null } = { + current: null + } + + const storeState: StoreLike = buildStoreState({ + removeAgentStatus, + workspaceSessionReady: true, + agentStatusByPaneKey: { + [FUTURE_PANE_KEY]: { + state: 'done', + prompt: 'hidden worker', + agentType: 'codex', + updatedAt: 1_700_000_000_200, + stateStartedAt: 1_700_000_000_000, + paneKey: FUTURE_PANE_KEY, + worktreeId: 'wt-1', + stateHistory: [] + } + } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: () => () => {}, + onClear: (cb) => { + onClearListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + + if (typeof onClearListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onClear listener to be registered') + } + + onClearListenerRef.current({ paneKey: FUTURE_PANE_KEY }) + + expect(removeAgentStatus).not.toHaveBeenCalled() + }) + it('does not retain a Cursor spinner terminal title when the hook reports done', async () => { const setAgentStatus = vi.fn() const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index fbd6147c1..6996d4dde 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2443,6 +2443,19 @@ export function useIpcEvents(): void { applyAgentStatus(data) }) ) + const unsubscribeAgentStatusClear = window.api.agentStatus.onClear?.((data) => { + if (typeof data?.paneKey !== 'string') { + return + } + const store = useAppStore.getState() + if (store.agentStatusByPaneKey[data.paneKey]?.state === 'done') { + return + } + store.removeAgentStatus(data.paneKey) + }) + if (unsubscribeAgentStatusClear) { + unsubs.push(unsubscribeAgentStatusClear) + } const unsubscribeMigrationUnsupported = window.api.agentStatus.onMigrationUnsupported?.( (entry) => { const store = useAppStore.getState() diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index aab7c3b13..57e1c23a6 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -567,6 +567,7 @@ function createWebPreloadApi(): Partial { }, agentStatus: { onSet: () => noopUnsubscribe, + onClear: () => noopUnsubscribe, getSnapshot: () => Promise.resolve([]), inferInterrupt: () => Promise.resolve(false), onMigrationUnsupported: () => noopUnsubscribe,