Fix stale sidebar agent status spinner (#4577)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
c5d3d595a4
commit
9190a3391f
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 } = {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -567,6 +567,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
|||
},
|
||||
agentStatus: {
|
||||
onSet: () => noopUnsubscribe,
|
||||
onClear: () => noopUnsubscribe,
|
||||
getSnapshot: () => Promise.resolve([]),
|
||||
inferInterrupt: () => Promise.resolve(false),
|
||||
onMigrationUnsupported: () => noopUnsubscribe,
|
||||
|
|
|
|||
Loading…
Reference in New Issue