diff --git a/src/main/index.ts b/src/main/index.ts index 61c7d799f94..f387242a368 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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()) { diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index 1625098df55..f1cc0b66b7c 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -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) && diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts new file mode 100644 index 00000000000..98f9ec369c5 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts @@ -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 { + 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 { + 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 }) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts new file mode 100644 index 00000000000..f3d32a9b462 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts @@ -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 } + } +} diff --git a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx index 5d6853d848c..9a9c4e45a74 100644 --- a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx +++ b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx @@ -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() + }) }) diff --git a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts index 47704958da8..28e43d54e5b 100644 --- a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts @@ -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(EMPTY_DASHBOARD_SNAPSHOT) const columnSignatureRef = useRef('') const retainedRepoIconsRef = useRef(undefined) + const snapshotRef = useRef(snapshot) useEffect(() => { + let topologyRefreshTimer: ReturnType | null = null + let topologyRefreshStartedAt: number | null = null + let staleRefreshTimer: ReturnType | null = null + const transientClearWatermarks = new Map() + + 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 diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index 606445c532e..808ffba0236 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -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, diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx index d8b8503e954..c632f19bc4e 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx @@ -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 => { await act(async () => root.render()) 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 () => { diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index a2f7bb5e180..2c42757036d 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -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 diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index 57ab1419e7e..4f563886774 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -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. */