Fix stale sidebar agent status spinner (#4577)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-03 15:21:39 -04:00 committed by GitHub
parent c5d3d595a4
commit 9190a3391f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 426 additions and 6 deletions

View File

@ -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()

View File

@ -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<StatusChangeListener>()
// 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<string>()
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)
}
}

View File

@ -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

View File

@ -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<AgentStatusIpcPayload[]>
inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise<boolean>

View File

@ -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. */

View File

@ -17,6 +17,7 @@ type MockState = {
ptyIdsByTabId: Record<string, string[]>
agentStatusEpoch: number
agentStatusByPaneKey: Record<string, AgentStatusEntry>
runtimeAgentOrchestrationByPaneKey: Record<string, NonNullable<AgentStatusEntry['orchestration']>>
migrationUnsupportedByPtyId: Record<string, never>
retainedAgentsByPaneKey: Record<string, unknown>
}
@ -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(<StatusProbe worktreeId={worktreeId} />)).toBe('<span>done</span>')
})
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(<StatusProbe worktreeId={worktreeId} />)).toBe('<span>done</span>')
})
it('scopes cached agent summaries to the matching worktree', () => {
const firstWorktreeId = 'repo1::/path/wt1'
const secondWorktreeId = 'repo1::/path/wt2'

View File

@ -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]))
})
})

View File

@ -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<string, WorktreeAgentActivitySummary>
}
@ -57,12 +60,14 @@ export function selectWorktreeAgentActivitySummary(
function getWorktreeAgentActivitySummaries(
state: AgentActivityInput
): Map<string, WorktreeAgentActivitySummary> {
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<AgentStatusEntry, 'orchestration'>,
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<AgentStatusEntry, 'state'>
@ -159,13 +199,32 @@ function addAgentStatusPaneId(
}
function worktreeIdForPaneKey(
paneKey: string,
paneKey: string | undefined,
tabIdToWorktreeId: Map<string, string>
): 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<string, string>
): 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 {

View File

@ -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<AgentStatusSetData[]>
drop?: (paneKey: string) => void
remoteWorkspace?: Record<string, unknown>
@ -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 } = {

View File

@ -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()

View File

@ -567,6 +567,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
},
agentStatus: {
onSet: () => noopUnsubscribe,
onClear: () => noopUnsubscribe,
getSnapshot: () => Promise.resolve([]),
inferInterrupt: () => Promise.resolve(false),
onMigrationUnsupported: () => noopUnsubscribe,