perf(agent-map): stream status updates to popout

This commit is contained in:
Brennan Benson
2026-08-05 18:56:58 -07:00
parent 2a6552f27f
commit 1f4a8ed9be
10 changed files with 450 additions and 20 deletions
+21 -12
View File
@@ -188,7 +188,10 @@ import {
ensureAutoUpdaterConfigured
} from './window/attach-main-window-services'
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
import { zoomDashboardPopoutIfFocused } from './window/dashboard-popout-window'
import {
getDashboardPopoutWindow,
zoomDashboardPopoutIfFocused
} from './window/dashboard-popout-window'
import {
createSystemTray,
destroySystemTray,
@@ -303,6 +306,7 @@ import {
} from '../shared/synthetic-agent-title'
import type { AgentStatusState } from '../shared/agent-status-types'
import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions'
import { isAskUserQuestionTool } from '../shared/agent-question-answered-intent'
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
import {
HEADLESS_RUNTIME_WINDOW_ID,
@@ -1450,7 +1454,16 @@ function openMainWindow(): BrowserWindow {
maybeAutoRenameBranchOnFirstWorkFromHook({ paneKey, tabId, worktreeId, payload, isReplay })
const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey)
const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey)
mainWindow?.webContents.send('agentStatus:set', {
const suppressSyntheticCodexAutoApprovalTitle =
payload.agentType === 'codex' &&
(payload.state === 'waiting' || payload.state === 'blocked')
? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({
agentType: payload.agentType,
state: payload.state,
launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken })
})
: false
const statusEvent = {
...payload,
paneKey,
...(launchToken ? { launchToken } : {}),
@@ -1464,19 +1477,14 @@ function openMainWindow(): BrowserWindow {
...(promptInteractionKey ? { promptInteractionKey } : {}),
...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
...(orchestration ? { orchestration } : {})
})
}
mainWindow?.webContents.send('agentStatus:set', statusEvent)
if (!suppressSyntheticCodexAutoApprovalTitle || isAskUserQuestionTool(payload.toolName)) {
getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent)
}
recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state)
// Why: native OSC titles miss some idle/permission frames, so inject hook-derived ones to keep the renderer title tracker in sync.
const profile = getSyntheticAgentTitleProfile(payload.agentType)
const suppressSyntheticCodexAutoApprovalTitle =
payload.agentType === 'codex' &&
(payload.state === 'waiting' || payload.state === 'blocked')
? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({
agentType: payload.agentType,
state: payload.state,
launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken })
})
: false
if (
profile &&
shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) &&
@@ -1491,6 +1499,7 @@ function openMainWindow(): BrowserWindow {
return
}
mainWindow?.webContents.send('agentStatus:clear', clear)
getDashboardPopoutWindow()?.webContents.send('agentStatus:clear', clear)
})
setMigrationUnsupportedPtyListener((event) => {
if (mainWindow?.isDestroyed()) {
@@ -284,6 +284,7 @@ function isDashboardCard(value: unknown): boolean {
isFiniteNumber(card.startedAt) &&
(card.finishedAt === null || isFiniteNumber(card.finishedAt)) &&
isFiniteNumber(card.stateChangedAt) &&
(card.statusUpdatedAt === undefined || isFiniteNumber(card.statusUpdatedAt)) &&
typeof card.unseen === 'boolean' &&
isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) &&
isOptionalBoundedString(card.conversationName, MAX_LABEL_LENGTH) &&
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types'
import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot'
import { patchDashboardSnapshotFromAgentStatus } from './dashboard-agent-status-patch'
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
return {
paneKey: 'tab-1:leaf-1',
ptyId: 'pty-1',
agentType: 'codex',
bucket: 'working',
dotState: 'working',
task: 'old task',
repoId: 'repo-1',
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
repoName: 'Repo',
worktreeName: 'Worktree',
startedAt: 100,
finishedAt: null,
stateChangedAt: 100,
statusUpdatedAt: 150,
unseen: false,
...overrides
}
}
function event(overrides: Partial<AgentStatusIpcPayload> = {}): AgentStatusIpcPayload {
return {
paneKey: 'tab-1:leaf-1',
state: 'blocked',
prompt: 'Need a decision',
connectionId: null,
receivedAt: 300,
stateStartedAt: 250,
interactivePrompt: '{"question":"Continue?"}',
...overrides
}
}
function snapshot(cards: DashboardCard[] = [card()]): DashboardSnapshot {
return { generatedAt: 200, cards }
}
describe('patchDashboardSnapshotFromAgentStatus', () => {
it('patches one known card without rebuilding the dashboard topology', () => {
const original = snapshot([card(), card({ paneKey: 'tab-2:leaf-2' })])
const result = patchDashboardSnapshotFromAgentStatus(original, event())
expect(result.matched).toBe(true)
expect(result.snapshot.cards[0]).toMatchObject({
bucket: 'attention',
dotState: 'blocked',
task: 'Need a decision',
lastUserMessage: 'Need a decision',
askSummary: '{"question":"Continue?"}',
stateChangedAt: 250,
statusUpdatedAt: 300,
unseen: true
})
expect(result.snapshot.cards[1]).toBe(original.cards[1])
})
it('preserves cached fields omitted from same-state hook pings', () => {
const original = snapshot([
card({
bucket: 'attention',
dotState: 'waiting',
askSummary: 'Pick one',
lastAgentMessage: 'Waiting',
stateChangedAt: 250,
unseen: true
})
])
const result = patchDashboardSnapshotFromAgentStatus(
original,
event({ state: 'waiting', prompt: '', interactivePrompt: undefined, stateStartedAt: 250 })
)
expect(result.snapshot.cards[0]).toMatchObject({
task: 'old task',
askSummary: 'Pick one',
lastAgentMessage: 'Waiting',
unseen: true
})
})
it('ignores stale, wrong-workspace, and session-only events', () => {
const original = snapshot()
expect(
patchDashboardSnapshotFromAgentStatus(original, event({ receivedAt: 150 })).snapshot
).toBe(original)
expect(
patchDashboardSnapshotFromAgentStatus(original, event({ worktreeId: 'other' })).snapshot
).toBe(original)
expect(
patchDashboardSnapshotFromAgentStatus(original, event({ providerSessionOnly: true })).snapshot
).toBe(original)
})
it('asks the caller for topology only when the pane is unknown', () => {
const original = snapshot()
const result = patchDashboardSnapshotFromAgentStatus(
original,
event({ paneKey: 'tab-new:leaf-new' })
)
expect(result).toEqual({ matched: false, snapshot: original })
})
})
@@ -0,0 +1,83 @@
import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types'
import {
dashboardCardDisplayState,
type DashboardCard,
type DashboardCardSubagent,
type DashboardSnapshot
} from '../../../../shared/dashboard-snapshot'
import { dashboardBucketForDotState } from '../dashboard/dashboard-card-bucket'
export type DashboardAgentStatusPatchResult = {
matched: boolean
snapshot: DashboardSnapshot
}
function patchedSubagents(
card: DashboardCard,
event: AgentStatusIpcPayload
): DashboardCardSubagent[] | undefined {
if (event.subagents === undefined) {
return card.subagents
}
return event.subagents.map((subagent) => ({
id: `${card.paneKey}\u0000subagent:${subagent.id}`,
name: subagent.description || subagent.agentType || 'unknown',
dotState: subagent.state
}))
}
/** Applies an already bounded hook event inside the pop-out renderer. */
export function patchDashboardSnapshotFromAgentStatus(
snapshot: DashboardSnapshot,
event: AgentStatusIpcPayload
): DashboardAgentStatusPatchResult {
if (event.providerSessionOnly) {
return { matched: true, snapshot }
}
const index = snapshot.cards.findIndex((card) => card.paneKey === event.paneKey)
if (index < 0) {
return { matched: false, snapshot }
}
const card = snapshot.cards[index]
if (
event.receivedAt <= (card.statusUpdatedAt ?? 0) ||
(event.worktreeId !== undefined && event.worktreeId !== card.worktreeId)
) {
return { matched: true, snapshot }
}
const stateChanged = event.stateStartedAt > card.stateChangedAt
const unseen = stateChanged ? card.startedAt !== 0 : card.unseen
const dotState = event.state
const bucket = dashboardBucketForDotState(dashboardCardDisplayState({ dotState, unseen }))
const nextCard: DashboardCard = {
...card,
...(event.agentType ? { agentType: event.agentType } : {}),
...(event.prompt ? { task: event.prompt, lastUserMessage: event.prompt } : {}),
...(event.lastAssistantMessage !== undefined
? { lastAgentMessage: event.lastAssistantMessage || undefined }
: {}),
...(event.orchestration?.parentPaneKey
? { parentPaneKey: event.orchestration.parentPaneKey }
: {}),
bucket,
dotState,
unseen,
stateChangedAt: stateChanged ? event.stateStartedAt : card.stateChangedAt,
statusUpdatedAt: event.receivedAt,
finishedAt: dotState === 'done' && stateChanged ? event.stateStartedAt : card.finishedAt,
askSummary:
bucket === 'attention'
? event.interactivePrompt !== undefined
? event.interactivePrompt || undefined
: card.askSummary
: undefined,
subagents: patchedSubagents(card, event)
}
const cards = snapshot.cards.slice()
cards[index] = nextCard
return {
matched: true,
snapshot: { ...snapshot, generatedAt: Math.max(snapshot.generatedAt, event.receivedAt), cards }
}
}
@@ -4,6 +4,10 @@ import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot'
import type {
AgentStatusClearIpcPayload,
AgentStatusIpcPayload
} from '../../../../shared/agent-status-types'
import { useDashboardSnapshot } from './useDashboardSnapshot'
;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
@@ -35,6 +39,9 @@ function snapshot(cards: DashboardCard[]): DashboardSnapshot {
}
let apply: (next: DashboardSnapshot) => void
let applyStatus: (next: AgentStatusIpcPayload) => void
let applyClear: (next: AgentStatusClearIpcPayload) => void
const requestSnapshot = vi.fn(async () => {})
const startViewTransition = vi.fn((cb: () => void) => {
cb()
return {
@@ -62,7 +69,17 @@ describe('useDashboardSnapshot', () => {
apply = cb
return () => {}
},
requestSnapshot: vi.fn(async () => {})
requestSnapshot
},
agentStatus: {
onSet: (cb: (next: AgentStatusIpcPayload) => void) => {
applyStatus = cb
return () => {}
},
onClear: (cb: (next: AgentStatusClearIpcPayload) => void) => {
applyClear = cb
return () => {}
}
}
}
;(document as unknown as { startViewTransition: unknown }).startViewTransition =
@@ -140,4 +157,85 @@ describe('useDashboardSnapshot', () => {
act(() => apply({ ...snapshot([card({})]), repoIconsByRepoId: {} }))
expect(result.current.repoIconsByRepoId).toEqual({})
})
it('applies known hook status without requesting a rebuilt snapshot', () => {
const { result } = renderHook(() => useDashboardSnapshot())
act(() => apply(snapshot([card({ statusUpdatedAt: 100 })])))
requestSnapshot.mockClear()
act(() =>
applyStatus({
paneKey: 'pk',
state: 'blocked',
prompt: 'Choose',
connectionId: null,
receivedAt: 300,
stateStartedAt: 250
})
)
expect(requestSnapshot).not.toHaveBeenCalled()
expect(result.current.cards[0]).toMatchObject({
bucket: 'attention',
task: 'Choose',
statusUpdatedAt: 300
})
})
it('trailing-debounces an unknown-pane burst into one topology refresh', () => {
vi.useFakeTimers()
renderHook(() => useDashboardSnapshot())
act(() => apply(snapshot([card({})])))
requestSnapshot.mockClear()
act(() => {
for (let index = 0; index < 10; index += 1) {
applyStatus({
paneKey: `unknown-${index}`,
state: 'working',
prompt: '',
connectionId: null,
receivedAt: 300 + index,
stateStartedAt: 250 + index
})
vi.advanceTimersByTime(40)
}
applyClear({ paneKey: 'pk' })
vi.advanceTimersByTime(249)
})
expect(requestSnapshot).not.toHaveBeenCalled()
act(() => vi.advanceTimersByTime(1))
expect(requestSnapshot).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
it('refreshes within the max wait while unknown events continue', () => {
vi.useFakeTimers()
renderHook(() => useDashboardSnapshot())
act(() => apply(snapshot([card({})])))
requestSnapshot.mockClear()
const unknown = (index: number): void =>
applyStatus({
paneKey: `unknown-${index}`,
state: 'working',
prompt: '',
connectionId: null,
receivedAt: 300 + index,
stateStartedAt: 250 + index
})
act(() => {
unknown(0)
for (let index = 1; index <= 4; index += 1) {
vi.advanceTimersByTime(200)
unknown(index)
}
})
expect(requestSnapshot).not.toHaveBeenCalled()
act(() => vi.advanceTimersByTime(200))
expect(requestSnapshot).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
})
@@ -1,9 +1,14 @@
import { useEffect, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types'
import {
EMPTY_DASHBOARD_SNAPSHOT,
type DashboardSnapshot
} from '../../../../shared/dashboard-snapshot'
import { patchDashboardSnapshotFromAgentStatus } from './dashboard-agent-status-patch'
const TOPOLOGY_REFRESH_DEBOUNCE_MS = 250
const TOPOLOGY_REFRESH_MAX_WAIT_MS = 1_000
/** Which column each card sits in — the only thing a view transition should
* animate on. Content-only updates (such as new messages) must not. */
@@ -36,8 +41,58 @@ export function useDashboardSnapshot(): DashboardSnapshot {
const [snapshot, setSnapshot] = useState<DashboardSnapshot>(EMPTY_DASHBOARD_SNAPSHOT)
const columnSignatureRef = useRef('')
const retainedRepoIconsRef = useRef<DashboardSnapshot['repoIconsByRepoId']>(undefined)
const snapshotRef = useRef(snapshot)
useEffect(() => {
let topologyRefreshTimer: ReturnType<typeof setTimeout> | null = null
let topologyRefreshStartedAt: number | null = null
let staleRefreshTimer: ReturnType<typeof setTimeout> | null = null
const transientClearWatermarks = new Map<string, number>()
const requestTopologyRefresh = (): void => {
if (topologyRefreshTimer) {
clearTimeout(topologyRefreshTimer)
}
const now = Date.now()
topologyRefreshStartedAt ??= now
const remainingMaxWait = Math.max(
0,
TOPOLOGY_REFRESH_MAX_WAIT_MS - (now - topologyRefreshStartedAt)
)
topologyRefreshTimer = setTimeout(
() => {
topologyRefreshTimer = null
topologyRefreshStartedAt = null
void window.api.dashboard.requestSnapshot()
},
Math.min(TOPOLOGY_REFRESH_DEBOUNCE_MS, remainingMaxWait)
)
}
const scheduleStaleRefresh = (next: DashboardSnapshot): void => {
if (staleRefreshTimer) {
clearTimeout(staleRefreshTimer)
staleRefreshTimer = null
}
let nextDeadline = Number.POSITIVE_INFINITY
for (const card of next.cards) {
if (
card.statusUpdatedAt !== undefined &&
(card.dotState === 'working' ||
card.dotState === 'blocked' ||
card.dotState === 'waiting')
) {
nextDeadline = Math.min(nextDeadline, card.statusUpdatedAt + AGENT_STATUS_STALE_AFTER_MS)
}
}
if (Number.isFinite(nextDeadline)) {
staleRefreshTimer = setTimeout(
requestTopologyRefresh,
Math.max(0, nextDeadline - Date.now() + 1)
)
}
}
const apply = (incoming: DashboardSnapshot): void => {
// The bridge omits repoIconsByRepoId on throttled republishes when it has
// not changed, rather than re-sending data URLs 4x/sec. Retain the last
@@ -50,6 +105,8 @@ export function useDashboardSnapshot(): DashboardSnapshot {
if (next.repoIconsByRepoId !== undefined) {
retainedRepoIconsRef.current = next.repoIconsByRepoId
}
snapshotRef.current = next
scheduleStaleRefresh(next)
const nextSignature = columnSignature(next)
const layoutChanged = nextSignature !== columnSignatureRef.current
columnSignatureRef.current = nextSignature
@@ -72,8 +129,45 @@ export function useDashboardSnapshot(): DashboardSnapshot {
}
const unsubscribe = window.api.dashboard.onSnapshot(apply)
const unsubscribeStatus = window.api.agentStatus.onSet((event) => {
if (
typeof event.connectionId === 'string' &&
event.receivedAt <= (transientClearWatermarks.get(event.connectionId) ?? -1)
) {
return
}
const result = patchDashboardSnapshotFromAgentStatus(snapshotRef.current, event)
if (!result.matched) {
if (snapshotRef.current.generatedAt !== 0) {
requestTopologyRefresh()
}
return
}
if (result.snapshot !== snapshotRef.current) {
apply(result.snapshot)
}
})
const unsubscribeClear = window.api.agentStatus.onClear((event) => {
if ('transient' in event && event.transient) {
transientClearWatermarks.set(
event.connectionId,
Math.max(transientClearWatermarks.get(event.connectionId) ?? -1, event.clearedAt)
)
}
requestTopologyRefresh()
})
void window.api.dashboard.requestSnapshot()
return unsubscribe
return () => {
unsubscribe()
unsubscribeStatus()
unsubscribeClear()
if (topologyRefreshTimer) {
clearTimeout(topologyRefreshTimer)
}
if (staleRefreshTimer) {
clearTimeout(staleRefreshTimer)
}
}
}, [])
return snapshot
@@ -285,6 +285,7 @@ export function buildDashboardSnapshot(
startedAt: row.startedAt,
finishedAt,
stateChangedAt: row.entry.stateStartedAt || row.startedAt,
statusUpdatedAt: row.entry.updatedAt,
// Same derivation as WorktreeCardAgents' unvisitedByPaneKey, so the
// board and the sidebar bold/mute the same agents at the same time.
unseen,
@@ -180,7 +180,6 @@ describe('useDashboardPopoutBridge', () => {
'repos',
'worktreesByRepo',
'tabsByWorktree',
'agentStatusByPaneKey',
'retainedAgentsByPaneKey',
'migrationUnsupportedByPtyId',
'runtimeAgentOrchestrationByPaneKey',
@@ -199,9 +198,15 @@ describe('useDashboardPopoutBridge', () => {
key
).toBe(true)
}
expect(
dashboardSnapshotInputsChanged(
{ ...previousState, agentStatusByPaneKey: { ...previousState.agentStatusByPaneKey } },
previousState
)
).toBe(false)
expect(
dashboardSnapshotInputsChanged({ ...previousState, agentStatusEpoch: 1 }, previousState)
).toBe(true)
).toBe(false)
// Why: each card's preview terminal keys against a host-input profile
// derived from these. Not republishing leaves the pop-out encoding bytes
@@ -313,7 +318,7 @@ describe('useDashboardPopoutBridge repo icon publishing', () => {
const previousState = makeSnapshotWatchState()
act(() =>
mocks.subscribeStore.mock.calls[0][0](
{ ...previousState, agentStatusEpoch: 1 },
{ ...previousState, acknowledgedAgentsByPaneKey: { pane: 1 } },
previousState
)
)
@@ -322,6 +327,19 @@ describe('useDashboardPopoutBridge repo icon publishing', () => {
const previousState = makeSnapshotWatchState()
act(() => mocks.subscribeStore.mock.calls[0][0]({ ...previousState }, previousState))
}
const notifyStatusChurn = (): void => {
const previousState = makeSnapshotWatchState()
act(() =>
mocks.subscribeStore.mock.calls[0][0](
{
...previousState,
agentStatusByPaneKey: { ...previousState.agentStatusByPaneKey },
agentStatusEpoch: previousState.agentStatusEpoch + 1
},
previousState
)
)
}
const mountAndOpen = async (): Promise<void> => {
await act(async () => root.render(<Harness enabled />))
setPopoutOpen(true)
@@ -352,6 +370,19 @@ describe('useDashboardPopoutBridge repo icon publishing', () => {
expect(mocks.publishSnapshot).toHaveBeenCalledTimes(1)
})
it('does no snapshot work during live status churn', async () => {
await mountAndOpen()
mocks.buildDashboardSnapshot.mockClear()
mocks.publishSnapshot.mockClear()
for (let index = 0; index < 100; index += 1) {
notifyStatusChurn()
}
expect(mocks.buildDashboardSnapshot).not.toHaveBeenCalled()
expect(mocks.publishSnapshot).not.toHaveBeenCalled()
})
// A burst collapses onto the trailing timer, so that edge carries most of the
// republishes this PR exists to slim down.
it('omits the icon map on the throttled trailing republish', async () => {
@@ -46,7 +46,6 @@ export function dashboardSnapshotInputsChanged(
state.repos !== previousState.repos ||
state.worktreesByRepo !== previousState.worktreesByRepo ||
state.tabsByWorktree !== previousState.tabsByWorktree ||
state.agentStatusByPaneKey !== previousState.agentStatusByPaneKey ||
state.retainedAgentsByPaneKey !== previousState.retainedAgentsByPaneKey ||
state.migrationUnsupportedByPtyId !== previousState.migrationUnsupportedByPtyId ||
state.runtimeAgentOrchestrationByPaneKey !== previousState.runtimeAgentOrchestrationByPaneKey ||
@@ -62,8 +61,8 @@ export function dashboardSnapshotInputsChanged(
state.detectedAgentIds !== previousState.detectedAgentIds ||
state.remoteDetectedAgentIds !== previousState.remoteDetectedAgentIds ||
state.runtimeDetectedAgentIds !== previousState.runtimeDetectedAgentIds ||
// Why: freshness can change a bucket without replacing any backing map.
state.agentStatusEpoch !== previousState.agentStatusEpoch ||
// Live hook status is relayed straight from main to the pop-out. Rebuilding
// every card here would put map refresh work on the main renderer's hot path.
// Why: each card carries the host-input profile its preview terminal keys
// against, and the pop-out cannot re-derive it. Every slice that resolves
// an execution host must republish or the preview keeps encoding bytes for
+3
View File
@@ -121,6 +121,9 @@ export type DashboardCard = {
/** When the agent entered its current state — column ordering key (cards
* that moved into a bucket most recently sort first). 0 when unknown. */
stateChangedAt: number
/** Last accepted hook update. Optional for mixed-version snapshots; the
* pop-out uses it to request one refresh when a live state becomes stale. */
statusUpdatedAt?: number
/** Mirrors the sidebar's unvisited signal: the agent changed state since the
* user last acknowledged it (visited its tab / opened its dashboard dialog).
* Derived from the app-wide ack map so both surfaces mute in lockstep. */