From f869c5580ca314792208626786aadc8122daccf2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:11:13 -0700 Subject: [PATCH] fix(agents): preserve Kanban verdicts and AI Vault monitoring (#34649) * fix(agents): preserve turn verdicts and monitoring in secondary views * fix(agents): publish accepted native status changes to dashboard * fix(agents): suppress equal rebuilt status notifications * test(agents): explain proxy read forwarding in perf fixture * test(agents): type production status fixtures completely * test(agents): stabilize status fixture formatting * Align web session test mocks with status transactions --- config/tsconfig.tc.web.json | 4 + .../ipc/dashboard-payload-validation.test.ts | 13 + src/main/ipc/dashboard-payload-validation.ts | 4 + .../dashboard-popout/AgentKanbanCard.test.tsx | 22 +- .../dashboard-popout/AgentKanbanCard.tsx | 1 + .../AgentTerminalDialog.test.tsx | 10 + .../dashboard-agent-status-patch.test.ts | 42 ++ .../dashboard-agent-status-patch.ts | 8 +- ...uild-dashboard-snapshot.rows-cache.test.ts | 504 +++++++++++++++++- .../dashboard/build-dashboard-snapshot.ts | 2 + .../dashboard/dashboard-card-bucket.ts | 4 +- .../dashboard/dashboard-row-bucket.ts | 4 +- .../useDashboardPopoutBridge.test.tsx | 2 + .../dashboard/useDashboardPopoutBridge.ts | 17 +- .../right-sidebar/AiVaultSessionRow.test.tsx | 77 ++- .../right-sidebar/AiVaultSessionRow.tsx | 4 +- .../AiVaultSessionVirtualList.tsx | 4 +- .../right-sidebar/AiVaultVirtualRow.test.tsx | 11 +- .../right-sidebar/AiVaultVirtualRow.tsx | 4 +- .../ai-vault-original-pane-actions.ts | 4 +- .../ai-vault-original-pane-index.ts | 11 +- .../right-sidebar/ai-vault-original-pane.ts | 11 +- .../ai-vault-session-row-display.tsx | 4 +- .../saved-resume-cancellation.test.ts | 10 + .../saved-resume-startup-cancellation.ts | 3 +- ...-status-refused-resume.performance.test.ts | 7 + .../src/lib/forget-refused-pane-resume.ts | 3 +- ...sion-mirror-empty-inventory-settle.test.ts | 2 +- .../snapshot-apply.ts | 33 +- ...ntime-session-browser-create-focus.test.ts | 17 +- .../runtime/web-runtime-session-snapshot.ts | 11 +- ...me-session-terminal-host-authority.test.ts | 12 +- .../src/runtime/web-runtime-session.test.ts | 21 +- ...abs-agent-completion-notifications.test.ts | 78 ++- .../active-session-subscription.ts | 4 +- .../agent-status-primitives.ts | 2 + .../global-session-events.ts | 4 +- .../global-session-inventory-event.ts | 9 +- .../web-session-tabs-sync/load-initial.ts | 9 +- .../web-session-tabs-sync/snapshot-api.ts | 41 +- .../runtime/web-session-tabs-sync/state.ts | 1 + .../web-session-tabs-sync/store-patch.ts | 12 +- .../visibility-resume-repair.ts | 3 +- .../store/host-twin-worktree-purge.test.ts | 16 + .../agent-status-authority-actions.ts | 117 ++-- .../agent-status/agent-status-batch.test.ts | 276 +++++++++- .../agent-status-cleanup-actions.ts | 9 +- .../agent-status/agent-status-contract.ts | 8 + .../agent-status/agent-status-drop-actions.ts | 11 +- .../agent-status/agent-status-drop-reducer.ts | 6 +- .../agent-status/agent-status-live-actions.ts | 6 +- .../agent-status-orchestration-actions.ts | 3 +- .../agent-status-program-status-actions.ts | 3 +- .../agent-status-provider-session-actions.ts | 3 +- .../agent-status/agent-status-runtime.ts | 95 +++- .../agent-status-slice-contract.ts | 6 + .../agent-status-worktree-drop-actions.ts | 3 +- .../agent-status-worktree-purge-leak.test.ts | 7 + .../store/slices/agent-status/agent-status.ts | 2 + .../purge-stale-runtime-host-state.test.ts | 15 + .../retired-terminal-tab-state-sweep.ts | 11 +- .../worktrees-fetch-removal-purge.test.ts | 31 ++ .../slices/worktrees-slice-test-harness.ts | 2 + .../listing/fetched-worktree-merge.ts | 7 +- .../session/worktree-slice-lookups.ts | 4 +- .../teardown/host-twin-worktree-purge.ts | 17 +- .../purge-stale-runtime-host-state.ts | 6 +- .../teardown/worktree-purge-omitters.ts | 11 +- .../teardown/worktree-purge-state.ts | 26 +- .../teardown/worktree-purge-surface-patch.ts | 5 +- src/shared/dashboard-snapshot.ts | 17 +- ...empty-inventory-daemon-oracle.unit.test.ts | 2 +- 72 files changed, 1594 insertions(+), 180 deletions(-) diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index b1f956552a0..3dd1ef616a8 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -37,6 +37,10 @@ "../src/main/jira/adf-markdown.ts", "../src/main/jira/adf-text-marks.ts", "../src/main/jira/adf-media-destination.ts", + "../src/main/ipc/dashboard-payload-validation.ts", + "../src/main/ipc/dashboard-agent-launch-validation.ts", + "../src/main/ipc/dashboard-workspace-payload-validation.ts", + "../src/main/ipc/dashboard-filter-payload-validation.ts", "../src/main/ipc/deferred-emoji-shortcode-dataset.ts", "../src/main/ipc/worktree-branch-name.ts", "../src/main/ipc/worktree-logic.ts", diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 695f8e0504f..f256f2e5ea6 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -112,6 +112,19 @@ describe('dashboard payload validation', () => { ).toBe(true) }) + it('admits legacy and known verdicts without widening the base dot states', () => { + for (const verdict of [undefined, 'interrupted', 'failed', 'unconfirmed']) { + expect(isDashboardSnapshot({ ...SNAPSHOT, cards: [{ ...SNAPSHOT.cards[0], verdict }] })).toBe( + true + ) + } + for (const verdict of [null, 'success', {}, 1]) { + expect(isDashboardSnapshot({ ...SNAPSHOT, cards: [{ ...SNAPSHOT.cards[0], verdict }] })).toBe( + false + ) + } + }) + it('rejects malformed or unbounded snapshot fields', () => { expect(isDashboardSnapshot({ ...SNAPSHOT, generatedAt: Number.NaN })).toBe(false) expect( diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index c507d724a1f..955a7084ad5 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -233,6 +233,10 @@ function isDashboardCard(value: unknown): boolean { DASHBOARD_BUCKETS.has(card.bucket) && typeof card.dotState === 'string' && DASHBOARD_DOT_STATES.has(card.dotState) && + (card.verdict === undefined || + card.verdict === 'interrupted' || + card.verdict === 'failed' || + card.verdict === 'unconfirmed') && (card.workingMode === undefined || (card.dotState === 'working' && card.workingMode === 'monitoring')) && isBoundedString(card.task, AGENT_STATUS_MAX_FIELD_LENGTH, true) && diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx index f289fc2b594..6dac14aa918 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx @@ -76,6 +76,25 @@ describe('AgentKanbanCard', () => { vi.clearAllMocks() }) + it('rerenders on a verdict-only change without a clean completion tint', () => { + const initial = card({ dotState: 'done', bucket: 'idle' }) + const onOpenTerminal = vi.fn() + const { container, rerender } = renderCard({ card: initial, now: 2000, onOpenTerminal }) + for (const verdict of ['interrupted', 'failed', 'unconfirmed'] as const) { + rerender( + + + + ) + expect(agentStateDotRender).toHaveBeenLastCalledWith(verdict) + expect(container.firstElementChild?.className).not.toContain('emerald') + } + }) + it('does not render an invented age when the start time is unknown', () => { renderCard({ card: card({ startedAt: 0 }), now: 2_000_000_000 }) @@ -86,7 +105,8 @@ describe('AgentKanbanCard', () => { const attentionCard = card({ bucket: 'attention', dotState: 'waiting', - askSummary: 'Approve deploy?' + askSummary: 'Approve deploy?', + verdict: 'failed' }) const { container, rerender } = renderCard({ card: attentionCard, now: 2_000 }) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index 8f1ad5c04ca..15592303bb6 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -86,6 +86,7 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean { a.bucket === b.bucket && a.dotState === b.dotState && a.workingMode === b.workingMode && + a.verdict === b.verdict && a.task === b.task && a.lastUserMessage === b.lastUserMessage && a.lastAgentMessage === b.lastAgentMessage && diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx index 8983bb116b4..5a0d9d822e1 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx @@ -82,6 +82,16 @@ describe('AgentTerminalDialog', () => { ) }) + it.each([ + ['interrupted', 'Interrupted'], + ['failed', 'Failed'], + ['unconfirmed', 'Couldn’t confirm'] + ] as const)('keeps an acknowledged %s verdict in the terminal header', (verdict, label) => { + const statusCard = { ...card({ dotState: 'done', bucket: 'idle', unseen: false }), verdict } + render( {}} onReveal={() => {}} />) + expect(screen.getByText(new RegExp(label))).toBeInTheDocument() + }) + it('passes null when the card carries no profile, so the preview routes by client OS', () => { render( {}} onReveal={() => {}} />) 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 index 62ff4b88568..b1323fff3bc 100644 --- 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 @@ -3,6 +3,8 @@ import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-type import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' import { patchDashboardSnapshotFromAgentStatus } from './dashboard-agent-status-patch' +import { dashboardCardDisplayState } from '../../../../shared/dashboard-snapshot' + function card(overrides: Partial = {}): DashboardCard { return { paneKey: 'tab-1:leaf-1', @@ -44,6 +46,46 @@ function snapshot(cards: DashboardCard[] = [card()]): DashboardSnapshot { } describe('patchDashboardSnapshotFromAgentStatus', () => { + it.each(['cancellation', 'failure', 'unconfirmed'] as const)( + 'projects a newer %s verdict and clears it on the next full status', + (outcome) => { + const patched = patchDashboardSnapshotFromAgentStatus( + snapshot(), + event({ + state: 'done', + mainAgent: { state: 'done', outcome, stateStartedAt: 250 } + }) + ).snapshot + expect(dashboardCardDisplayState(patched.cards[0])).toBe( + outcome === 'cancellation' + ? 'interrupted' + : outcome === 'failure' + ? 'failed' + : 'unconfirmed' + ) + expect(patched.cards[0].bucket).toBe('done') + for (const interrupted of [undefined, false]) { + const cleared = patchDashboardSnapshotFromAgentStatus( + patched, + event({ state: 'working', interrupted, receivedAt: 400, stateStartedAt: 350 }) + ).snapshot + expect(dashboardCardDisplayState(cleared.cards[0])).toBe('working') + } + expect( + patchDashboardSnapshotFromAgentStatus( + patched, + event({ receivedAt: 400, programStatusOnly: true }) + ).snapshot + ).toBe(patched) + expect( + patchDashboardSnapshotFromAgentStatus( + patched, + event({ receivedAt: 400, providerSessionOnly: true }) + ).snapshot + ).toBe(patched) + } + ) + 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()) 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 index 9fbb156b48d..cfca0221c55 100644 --- a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts @@ -1,6 +1,7 @@ +import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict' import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' import { - dashboardCardDisplayState, + dashboardCardBaseDisplayState, type DashboardCard, type DashboardCardSubagent, type DashboardSnapshot @@ -32,7 +33,7 @@ export function patchDashboardSnapshotFromAgentStatus( snapshot: DashboardSnapshot, event: AgentStatusIpcPayload ): DashboardAgentStatusPatchResult { - if (event.providerSessionOnly) { + if (event.providerSessionOnly || event.programStatusOnly) { return { matched: true, snapshot } } const index = snapshot.cards.findIndex((card) => card.paneKey === event.paneKey) @@ -54,7 +55,7 @@ export function patchDashboardSnapshotFromAgentStatus( const workingMode = event.state === 'working' && event.workingMode === 'monitoring' ? event.workingMode : undefined const bucket = dashboardBucketForDotState( - dashboardCardDisplayState({ dotState, workingMode, unseen }) + dashboardCardBaseDisplayState({ dotState, workingMode, unseen }) ) const nextCard: DashboardCard = { ...card, @@ -68,6 +69,7 @@ export function patchDashboardSnapshotFromAgentStatus( : {}), bucket, dotState, + verdict: agentVerdictDisplayMark(event) ?? undefined, workingMode, unseen, stateChangedAt: stateChanged ? event.stateStartedAt : card.stateChangedAt, diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts index fb73aa9bd22..dd52e52aa95 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts @@ -1,5 +1,49 @@ -import { describe, expect, it } from 'vitest' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +// @vitest-environment happy-dom + +import { dashboardCardDisplayState } from '../../../../shared/dashboard-snapshot' +import { admitDashboardSnapshot } from '../../../../main/ipc/dashboard-payload-validation' +import { buildDashboardBucketCounts } from './build-dashboard-bucket-counts' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createElement, Fragment } from 'react' +import { act, cleanup, render, screen, within } from '@testing-library/react' +import { TooltipProvider } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import * as snapshotBuilder from './build-dashboard-snapshot' +import { useDashboardPopoutBridge } from './useDashboardPopoutBridge' +import { useDashboardSnapshot } from '../dashboard-popout/useDashboardSnapshot' +import { AgentKanbanCard } from '../dashboard-popout/AgentKanbanCard' +import { AgentTerminalPanel } from '../dashboard-popout/AgentTerminalDialog' +import { projectStatus } from '../native-chat/structured-agent-session-status-projection' +import type { StructuredTab } from '../native-chat/structured-agent-session-tabs' +import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' +import type { DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import type { ExecutionHostId } from '../../../../shared/execution-host' + +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + return { useAppStore: createTestStore() } +}) +vi.mock('@/runtime/structured-chat-name-observation', () => ({ + subscribeStructuredChatTabNames: () => () => {}, + getStructuredChatTabNames: () => new Map() +})) +vi.mock('./dashboard-chat-program-acquisition', () => ({ + createDashboardChatProgramHolds: () => ({ reconcile: () => {}, release: () => {} }) +})) +const iconRender = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: ({ agent, size }: { agent: string; size: number }) => { + if (size === 14) { + iconRender(agent) + } + return null + } +})) + +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { Worktree } from '../../../../shared/worktree/types' @@ -51,7 +95,12 @@ function tab(id: string, worktreeId: string): TerminalTab { } } -function entry(paneKey: string, tabId: string, worktreeId: string): AgentStatusEntry { +function entry( + paneKey: string, + tabId: string, + worktreeId: string, + overrides: Partial = {} +): AgentStatusEntry { return { paneKey, state: 'done', @@ -61,11 +110,12 @@ function entry(paneKey: string, tabId: string, worktreeId: string): AgentStatusE stateHistory: [], agentType: 'claude', tabId, - worktreeId + worktreeId, + ...overrides } } -function baseState(): DashboardSnapshotState { +function baseState(overrides: Partial = {}): DashboardSnapshotState { return { chatNames: new Map(), repos: [{ id: 'r1', path: '/r1', displayName: 'Repo One', badgeColor: '#000', addedAt: 1 }], @@ -95,11 +145,81 @@ function baseState(): DashboardSnapshotState { ptyIdsByTabId: { tab1: ['pty-tab1'], tab2: ['pty-tab2'] }, runtimePaneTitlesByTabId: { tab1: { 0: 'shell' }, tab2: { 0: 'shell' } }, acknowledgedAgentsByPaneKey: {}, - settings: null + settings: null, + ...overrides } } describe('buildDashboardSnapshot rows cache', () => { + it.each([ + ['cancellation', 'interrupted'], + ['failure', 'failed'], + ['unconfirmed', 'unconfirmed'], + ['success', 'idle'] + ] as const)( + 'preserves %s through snapshot admission without changing columns', + (outcome, mark) => { + const state = baseState({ + agentStatusByPaneKey: { + [PANE_1]: entry(PANE_1, 'tab1', 'w1', { + state: 'done', + mainAgent: { state: 'done', outcome, stateStartedAt: NOW - 5000 } + }) + }, + acknowledgedAgentsByPaneKey: { [PANE_1]: NOW } + }) + const snapshot = buildDashboardSnapshot(state, NOW) + const admitted = admitDashboardSnapshot(snapshot) + expect(admitted?.droppedCardCount).toBe(0) + const card = admitted!.snapshot.cards[0] + expect(dashboardCardDisplayState(card)).toBe(mark) + expect(card.bucket).toBe('idle') + expect(buildDashboardBucketCounts(state, NOW).idle).toBe(1) + for (const acknowledgedAgentsByPaneKey of [{}, { [PANE_1]: NOW }]) { + const aged = buildDashboardSnapshot( + { ...state, acknowledgedAgentsByPaneKey }, + NOW + AGENT_STATUS_STALE_AFTER_MS + 1000 + ) + expect(dashboardCardDisplayState(aged.cards[0])).toBe( + outcome === 'success' ? (aged.cards[0].unseen ? 'done' : 'idle') : mark + ) + } + } + ) + + it('keeps failure separate from live children and pending questions', () => { + for (const state of ['working', 'waiting'] as const) { + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { + [PANE_1]: entry(PANE_1, 'tab1', 'w1', { + state, + interactivePrompt: 'Approve deploy?', + mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: NOW - 1000 } + }) + } + }), + NOW + ) + expect(dashboardCardDisplayState(snapshot.cards[0])).toBe('failed') + expect(snapshot.cards[0].bucket).toBe(state === 'working' ? 'working' : 'attention') + expect(snapshot.cards[0].askSummary).toBe(state === 'waiting' ? 'Approve deploy?' : undefined) + const aged = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { + [PANE_1]: entry(PANE_1, 'tab1', 'w1', { + state, + mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: NOW - 1000 } + }) + } + }), + NOW + AGENT_STATUS_STALE_AFTER_MS + 1000 + ) + expect(aged.cards[0].dotState).toBe('idle') + expect(dashboardCardDisplayState(aged.cards[0])).toBe('failed') + } + }) + it('matches the uncached snapshot exactly across unrelated and targeted writes', () => { const cache = createWorktreeAgentRowsCache() const first = baseState() @@ -216,3 +336,375 @@ describe('buildDashboardSnapshot rows cache', () => { ) }) }) + +const initialStoreState = useAppStore.getState() +const noop = () => {} +function PopoutFixture() { + useDashboardPopoutBridge(true) + const snapshot = useDashboardSnapshot() + return createElement( + TooltipProvider, + null, + createElement( + Fragment, + null, + ...snapshot.cards.map((card) => + createElement( + 'section', + { key: card.paneKey, 'data-testid': card.tabId }, + createElement(AgentKanbanCard, { card, now: NOW, onOpenTerminal: noop }), + card.tabId === 'tab1' + ? createElement(AgentTerminalPanel, { card, onOpenChange: noop, onReveal: noop }) + : null + ) + ) + ) + ) +} + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() + Reflect.deleteProperty(window, 'api') + iconRender.mockClear() +}) + +describe('native status publication to a mounted popout', () => { + it.each(['local', 'runtime:server-1'] as const)( + 'delivers latest turns from %s without incidental input changes', + async (executionHostId) => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + useAppStore.setState(initialStoreState, true) + const nativeTab = ( + id: string, + worktreeId: string, + agent: 'claude' | 'codex', + host: ExecutionHostId = executionHostId + ) => + ({ + id, + worktreeId, + groupId: 'group', + contentType: 'agent-session' as const, + entityId: id, + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: NOW, + isPinned: false, + agentSessionAgent: agent, + executionHostId: host + }) satisfies StructuredTab + const target = nativeTab('tab1', 'w1', 'claude') + const other = nativeTab('tab2', 'w2', 'codex', 'runtime:other') + useAppStore.setState({ + ...baseState(), + tabsByWorktree: {}, + agentStatusByPaneKey: {}, + worktreesByRepo: { + r1: [ + { ...worktree('w1'), hostId: executionHostId }, + { ...worktree('w2'), hostId: 'runtime:other' } + ] + }, + unifiedTabsByWorktree: { w1: [target], w2: [other] }, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {}, + refreshGitHubForWorktreeIfStale: async () => {}, + setGeneratedTabTitleFromAgentPrompt: () => {} + }) + const update = ( + tab: ReturnType, + status: AgentSessionStatusSummary['status'], + turnOutcome?: AgentSessionStatusSummary['turnOutcome'], + backgroundTasks?: AgentSessionStatusSummary['backgroundTasks'] + ) => + useAppStore.getState().transactAgentStatuses((transaction) => + projectStatus( + transaction, + tab, + { + sessionId: tab.entityId, + workspaceId: tab.worktreeId, + agent: tab.agentSessionAgent, + status, + hostExecutionOwned: true, + latestPrompt: 'fixed prompt', + providerSession: { key: 'session_id', id: tab.entityId }, + updatedAt: Date.now(), + turnOutcome, + backgroundTasks + }, + 'live', + null + ) + ) + update(target, 'idle', 'success') + update(other, 'idle', 'success') + let openChanged: (open: boolean) => void = noop + let receive: (snapshot: DashboardSnapshot) => void = noop + const build = vi.spyOn(snapshotBuilder, 'buildDashboardSnapshot') + const publish = vi.fn(async (snapshot: DashboardSnapshot) => { + const admitted = admitDashboardSnapshot(snapshot) + expect(admitted?.droppedCardCount).toBe(0) + receive(admitted!.snapshot) + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + dashboard: { + onRevealAgent: () => noop, + onAckAgent: () => noop, + onPopoutOpenChanged: (listener: typeof openChanged) => { + openChanged = listener + return noop + }, + onSnapshotRequested: () => noop, + getPopoutOpen: async () => true, + onSnapshot: (listener: typeof receive) => { + receive = listener + return noop + }, + requestSnapshot: async () => {}, + publishSnapshot: publish + }, + agentStatus: { onSet: () => noop, onClear: () => noop } + } + }) + const mounted = render(createElement(PopoutFixture)) + await act(async () => {}) + expect(within(screen.getByTestId('tab1')).getByLabelText('Done')).toBeTruthy() + iconRender.mockClear() + publish.mockClear() + build.mockClear() + await act(async () => useAppStore.setState({ sidebarWidth: 333 })) + await act(async () => vi.advanceTimersByTime(250)) + expect(build).not.toHaveBeenCalled() + expect(publish).not.toHaveBeenCalled() + expect(iconRender).not.toHaveBeenCalled() + for (const [outcome, label] of [ + ['failure', 'Failed'], + ['cancellation', 'Interrupted'], + ['unconfirmed', 'Couldn’t confirm'] + ] as const) { + iconRender.mockClear() + publish.mockClear() + build.mockClear() + await act(async () => update(target, 'working')) + await act(async () => vi.advanceTimersByTime(250)) + expect(within(screen.getByTestId('tab1')).getByLabelText('Working')).toBeTruthy() + await act(async () => update(target, 'idle', outcome)) + await act(async () => vi.advanceTimersByTime(250)) + expect(within(screen.getByTestId('tab1')).getByLabelText(label)).toBeTruthy() + expect(within(screen.getByTestId('tab1')).getByText(`Claude · ${label}`)).toBeTruthy() + expect(iconRender.mock.calls.map((call) => call[0])).toEqual(['claude', 'claude']) + expect(build).toHaveBeenCalledTimes(2) + expect(publish).toHaveBeenCalledTimes(2) + } + iconRender.mockClear() + await act(async () => update(other, 'working')) + await act(async () => vi.advanceTimersByTime(250)) + expect(iconRender.mock.calls.map((call) => call[0])).toEqual(['codex']) + await act(async () => update(target, 'idle', 'failure')) + await act(async () => vi.advanceTimersByTime(250)) + expect(within(screen.getByTestId('tab1')).getByLabelText('Failed')).toBeTruthy() + await act(async () => + update(target, 'idle', 'success', [{ id: 'job', kind: 'command', state: 'working' }]) + ) + await act(async () => vi.advanceTimersByTime(250)) + expect( + within(screen.getByTestId('tab1')).getByLabelText('Monitoring background tasks') + ).toBeTruthy() + build.mockClear() + publish.mockClear() + await act(async () => update(target, 'working')) + await act(async () => { + vi.advanceTimersByTime(1) + update(target, 'idle', 'failure') + }) + await act(async () => { + vi.advanceTimersByTime(1) + update(target, 'idle', 'cancellation') + }) + expect(build).toHaveBeenCalledOnce() + await act(async () => vi.advanceTimersByTime(250)) + expect(build).toHaveBeenCalledTimes(2) + expect(publish).toHaveBeenCalledTimes(2) + expect(within(screen.getByTestId('tab1')).getByLabelText('Interrupted')).toBeTruthy() + await act(async () => + useAppStore + .getState() + .transactAgentStatuses((transaction) => + projectStatus(transaction, target, null, 'live', null) + ) + ) + await act(async () => vi.advanceTimersByTime(250)) + expect(screen.queryByTestId('tab1')).toBeNull() + await act(async () => + useAppStore + .getState() + .transactAgentStatuses((transaction) => + projectStatus(transaction, target, null, 'live', Date.now()) + ) + ) + await act(async () => vi.advanceTimersByTime(250)) + expect(within(screen.getByTestId('tab1')).getByLabelText('Failed')).toBeTruthy() + // Queue a trailing update, close before it fires, then recover current truth on reopen. + build.mockClear() + publish.mockClear() + await act(async () => update(target, 'working')) + await act(async () => openChanged(false)) + await act(async () => update(target, 'idle', 'cancellation')) + await act(async () => vi.advanceTimersByTime(250)) + expect(build).not.toHaveBeenCalled() + expect(publish).not.toHaveBeenCalled() + await act(async () => openChanged(true)) + expect(build).toHaveBeenCalledOnce() + expect(publish).toHaveBeenCalledOnce() + expect(within(screen.getByTestId('tab1')).getByLabelText('Interrupted')).toBeTruthy() + mounted.unmount() + build.mockClear() + publish.mockClear() + await act(async () => update(target, 'working')) + await act(async () => vi.advanceTimersByTime(250)) + expect(build).not.toHaveBeenCalled() + expect(publish).not.toHaveBeenCalled() + } + ) + + it.each([100, 1000])( + 'keeps accepted-key discovery proportional to real mutations at %i rows', + async (count) => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + vi.setSystemTime(NOW) + useAppStore.setState(initialStoreState, true) + const tabs = Array.from({ length: count }, (_, i) => tab(`scale-${i}`, 'w1')) + const entries = Object.fromEntries( + tabs.map((t) => { + const paneKey = makePaneKey(t.id, LEAF_1) + return [paneKey, entry(paneKey, t.id, 'w1')] + }) + ) + useAppStore.setState({ + ...baseState(), + tabsByWorktree: { w1: tabs }, + agentStatusByPaneKey: entries, + terminalLayoutsByTabId: Object.fromEntries( + tabs.map((t) => [ + t.id, + { + root: { type: 'leaf' as const, leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: t.ptyId! } + } + ]) + ), + ptyIdsByTabId: Object.fromEntries(tabs.map((t) => [t.id, [t.ptyId!]])), + refreshGitHubForWorktreeIfStale: async () => {}, + setGeneratedTabTitleFromAgentPrompt: () => {} + }) + const paneKey = makePaneKey(tabs[0].id, LEAF_1) + const measure = async () => { + let reads = 0 + let enumerations = 0 + const started = performance.now() + for (let i = 0; i < 10; i++) { + const rows = useAppStore.getState().agentStatusByPaneKey + useAppStore.setState({ + agentStatusByPaneKey: new Proxy(rows, { + get(target, key, receiver) { + if (typeof key === 'string') { + reads++ + } + // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver. + return Reflect.get(target, key, receiver) + }, + ownKeys(target) { + enumerations++ + return Reflect.ownKeys(target) + } + }) + }) + await act(async () => + useAppStore.getState().transactAgentStatuses((transaction) => { + transaction.apply({ + paneKey, + payload: { state: 'working', prompt: 'fixed', agentType: 'claude' }, + timing: { updatedAt: NOW + i + 1 } + }) + }) + ) + } + return { reads, enumerations, milliseconds: performance.now() - started } + } + const baseline = await measure() + useAppStore.setState({ agentStatusByPaneKey: entries }) + const build = vi.spyOn(snapshotBuilder, 'buildDashboardSnapshot') + const publish = vi.fn(async () => {}) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + dashboard: { + onRevealAgent: () => noop, + onAckAgent: () => noop, + onPopoutOpenChanged: () => noop, + onSnapshotRequested: () => noop, + getPopoutOpen: async () => true, + publishSnapshot: publish + } + } + }) + function Publisher() { + useDashboardPopoutBridge(true) + return null + } + const mounted = render(createElement(Publisher)) + await act(async () => {}) + expect(build.mock.results[0].value.cards).toHaveLength(count) + build.mockClear() + publish.mockClear() + const accepted = vi.fn() + const dispose = useAppStore.getState().subscribeAgentStatusChanges(accepted) + const observed = await measure() + expect(observed.enumerations).toBe(baseline.enumerations) + expect(observed.reads - baseline.reads).toBe(10) + expect(accepted).toHaveBeenCalledTimes(10) + expect( + accepted.mock.calls.every( + ([changes]) => changes.length === 1 && changes[0].paneKey === paneKey + ) + ).toBe(true) + await act(async () => vi.advanceTimersByTime(250)) + expect(build).not.toHaveBeenCalled() + expect(publish).not.toHaveBeenCalled() + const publisherBuilds = build.mock.calls.length + const snapshotStarted = performance.now() + const snapshot = buildDashboardSnapshot( + { ...useAppStore.getState(), chatNames: new Map() }, + NOW + ) + const snapshotMs = performance.now() - snapshotStarted + expect(snapshot.cards).toHaveLength(count) + console.info('accepted status production trigger benchmark', { + rows: count, + updates: 10, + baseline, + observed, + addedDiscoveryReads: observed.reads - baseline.reads, + acceptedKeys: 10, + publisherBuilds, + publications: publish.mock.calls.length, + snapshotCards: snapshot.cards.length, + snapshotMs + }) + dispose() + mounted.unmount() + } + ) +}) diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index ce4aaeb1d45..d939b51a2c5 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -1,3 +1,4 @@ +import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict' import type { StructuredChatTabNames } from '@/runtime/structured-chat-tab-name' import type { AgentLaunchRouteView } from '@/lib/agent-launch-route-input' import type { AppState } from '@/store/types' @@ -208,6 +209,7 @@ export function buildDashboardSnapshot( agentType: row.agentType, bucket, dotState, + verdict: agentVerdictDisplayMark(row.entry) ?? undefined, ...(workingMode ? { workingMode } : {}), task: isTitleDerived ? '' : rowTask(row), repoId: workspace.projectId, diff --git a/src/renderer/src/components/dashboard/dashboard-card-bucket.ts b/src/renderer/src/components/dashboard/dashboard-card-bucket.ts index b02b6d7d0f7..4cc7da66c3c 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-bucket.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-bucket.ts @@ -1,9 +1,9 @@ import type { DashboardBucket, - DashboardCardDisplayState + DashboardCardBaseDisplayState } from '../../../../shared/dashboard-snapshot' -export function dashboardBucketForDotState(state: DashboardCardDisplayState): DashboardBucket { +export function dashboardBucketForDotState(state: DashboardCardBaseDisplayState): DashboardBucket { switch (state) { case 'working': case 'monitoring': diff --git a/src/renderer/src/components/dashboard/dashboard-row-bucket.ts b/src/renderer/src/components/dashboard/dashboard-row-bucket.ts index 855b75e3f33..99a37977499 100644 --- a/src/renderer/src/components/dashboard/dashboard-row-bucket.ts +++ b/src/renderer/src/components/dashboard/dashboard-row-bucket.ts @@ -1,6 +1,6 @@ import type { DashboardAgentRow } from './useDashboardData' import { - dashboardCardDisplayState, + dashboardCardBaseDisplayState, type DashboardBucket, type DashboardCardDotState } from '../../../../shared/dashboard-snapshot' @@ -51,7 +51,7 @@ export function dashboardRowBucketProjection( : undefined const unseen = isAgentSubjectUnread(turnRecords, row.paneKey) const bucket = dashboardBucketForDotState( - dashboardCardDisplayState({ dotState, workingMode, unseen }) + dashboardCardBaseDisplayState({ dotState, workingMode, unseen }) ) return { isTitleDerived, dotState, workingMode, unseen, bucket } diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx index 7bf660a702a..7198d25779a 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ acknowledgeAgents: vi.fn(), + subscribeAgentStatusChanges: vi.fn(() => vi.fn()), setActiveWorktree: vi.fn(), subscribeStore: vi.fn((_listener: (state: unknown, previousState: unknown) => void) => vi.fn()), onRevealAgent: vi.fn(), @@ -31,6 +32,7 @@ vi.mock('@/store', () => ({ getState: () => ({ ...makeSnapshotWatchState(), acknowledgeAgents: mocks.acknowledgeAgents, + subscribeAgentStatusChanges: mocks.subscribeAgentStatusChanges, setActiveWorktree: mocks.setActiveWorktree }), subscribe: mocks.subscribeStore diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index 70a0603be7a..7ed949123b0 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -190,7 +190,22 @@ export function useDashboardPopoutBridge(enabled: boolean): void { open = next if (open) { if (!unsubscribeStore) { - unsubscribeStore = watchSnapshotInputs(publishThrottled) + const stopInputs = watchSnapshotInputs(publishThrottled) + const stopStatuses = useAppStore.getState().subscribeAgentStatusChanges((changes) => { + if ( + changes.some( + ({ previous, current }) => + previous?.terminalResumeEligible === false || + current?.terminalResumeEligible === false + ) + ) { + publishThrottled() + } + }) + unsubscribeStore = () => { + stopInputs() + stopStatuses() + } } publishNow(true) } else { diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx index 939a29cc8df..a4da7f82891 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.test.tsx @@ -9,8 +9,16 @@ import type { AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' import { searchHit } from '../../../../shared/ai-vault-search-test-fixture' import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types' import type { AiVaultSubagentResumeActions } from './AiVaultSessionSubagents' +import { findAiVaultSessionLiveState } from './ai-vault-original-pane' import { VaultSessionRow } from './AiVaultSessionRow' +import type { AgentDotState } from '@/components/AgentStateDot' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { + buildAiVaultOriginalPaneIndex, + findAiVaultSessionLiveStateInIndex +} from './ai-vault-original-pane-index' + const session = { id: 'local:gemini:sess-1:/home/a/.gemini/s.json', executionHostId: 'local', @@ -59,6 +67,7 @@ afterEach(() => { function renderRow( overrides: { + liveState?: AgentDotState | null searchHit?: AiVaultSearchHit session?: AiVaultSession subagentResume?: AiVaultSubagentResumeActions @@ -78,7 +87,7 @@ function renderRow( session={overrides.session ?? session} searchHit={overrides.searchHit} subagentResume={overrides.subagentResume} - liveState={null} + liveState={overrides.liveState ?? null} resumeStartup={{ command: 'gemini --resume sess-1' }} realHomeResumeStartup={{ command: 'gemini --resume sess-1' }} worktreeInfo={overrides.worktreeInfo ?? null} @@ -106,6 +115,72 @@ function renderRow( ) } +it.each([true, false])( + 'renders the selected pane status through the %s provider lookup', + (direct) => { + const selectedSession = { ...session, title: 'Monitor the background job until it finishes' } + const selected: AgentStatusEntry = { + paneKey: 'tab:leaf', + state: 'working', + workingMode: 'monitoring', + agentType: session.agent, + prompt: selectedSession.title, + updatedAt: 100, + stateStartedAt: 100, + stateHistory: [], + ...(direct ? { providerSession: { key: 'session_id', id: session.sessionId } } : {}) + } + const lookup = (entries: AgentStatusEntry[]) => { + const state = { + agentStatusByPaneKey: Object.fromEntries(entries.map((entry, i) => [String(i), entry])), + retainedAgentsByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {}, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + } + const indexed = findAiVaultSessionLiveStateInIndex( + buildAiVaultOriginalPaneIndex(state), + selectedSession + ) + expect(findAiVaultSessionLiveState(state, selectedSession)).toBe(indexed) + return indexed + } + renderRow({ liveState: lookup([selected]) }) + expect(screen.getByLabelText('Monitoring background tasks')).toBeTruthy() + cleanup() + renderRow({ liveState: lookup([{ ...selected, workingMode: undefined }]) }) + expect(screen.getByLabelText('Working')).toBeTruthy() + cleanup() + renderRow({ + liveState: lookup([{ ...selected, state: 'done', workingMode: undefined, interrupted: true }]) + }) + expect(screen.getByLabelText('Interrupted')).toBeTruthy() + for (const [outcome, label] of [ + ['failure', 'Failed'], + ['unconfirmed', 'Couldn’t confirm'] + ] as const) { + cleanup() + renderRow({ + liveState: lookup([ + { + ...selected, + state: 'done', + workingMode: undefined, + mainAgent: { state: 'done', outcome, stateStartedAt: 100 } + } + ]) + }) + expect(screen.getByLabelText(label)).toBeTruthy() + } + cleanup() + renderRow({ liveState: lookup([{ ...selected, state: 'done', workingMode: undefined }]) }) + expect(screen.queryByLabelText('Done')).toBeNull() + if (!direct) { + expect(lookup([selected, selected])).toBeNull() + } + } +) + function expectAgentIdentity(): void { const metadata = screen.getByTestId('ai-vault-session-metadata') expect(metadata.querySelector('svg, img')).toBeTruthy() diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 20c58cb52c3..c46de35484e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -25,7 +25,7 @@ import { getSessionDetailsId, SessionMetadata } from './ai-vault-session-row-display' -import type { AgentStatusState } from '../../../../shared/agent-status-types' +import type { AgentDotState } from '@/components/AgentStateDot' import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types' import { AiVaultSearchEvidence } from './AiVaultSearchEvidence' import { useAiVaultSessionDisplayTitle } from './use-ai-vault-session-display-title' @@ -65,7 +65,7 @@ export function VaultSessionRow({ onPick }: { session: AiVaultSession - liveState: AgentStatusState | null + liveState: AgentDotState | null resumeStartup: AiVaultResumeStartup realHomeResumeStartup: AiVaultResumeStartup worktreeInfo: AiVaultSessionWorktreeInfo | null diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index 78766f92d0a..cb849e6db56 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -1,7 +1,7 @@ import { SubagentExpansionProvider } from './ai-vault-subagent-expansion' import { useVirtualizer } from '@tanstack/react-virtual' import { useCallback, useMemo, useRef, useState, type Ref } from 'react' -import type { AgentStatusState } from '../../../../shared/agent-status-types' +import type { AgentDotState } from '@/components/AgentStateDot' import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command' import { translate } from '@/i18n/i18n' @@ -88,7 +88,7 @@ export function AiVaultSessionVirtualList({ buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null isStructuredSessionOpen: (session: AiVaultSession) => boolean - getSessionLiveState: (session: AiVaultSession) => AgentStatusState | null + getSessionLiveState: (session: AiVaultSession) => AgentDotState | null getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions diff --git a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx index 8b127eac276..5df1c075000 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types' +import type { AgentDotState } from '@/components/AgentStateDot' import { AiVaultVirtualRow } from './AiVaultVirtualRow' const cliSession: AiVaultSession = { @@ -39,7 +40,8 @@ function renderSession( session: AiVaultSession, blocked = false, searchHit?: AiVaultSearchHit, - searchHitHostLabels?: ReadonlyMap + searchHitHostLabels?: ReadonlyMap, + liveState: AgentDotState | null = null ) { const buildResumeStartup = vi.fn(() => ({ command: session.resumeCommand })) const onCopyResume = vi.fn() @@ -61,7 +63,7 @@ function renderSession( buildResumeStartup={buildResumeStartup} getOriginalPaneTarget={() => null} isStructuredSessionOpen={() => false} - getSessionLiveState={() => null} + getSessionLiveState={() => liveState} getWorktreeInfo={() => null} getSessionResumeState={() => ({ blocked, @@ -94,6 +96,11 @@ function renderSession( return { buildResumeStartup, onCopyResume, onResume, onResumeInNewCli } } +it('passes semantic status through the virtual row to the real glyph', () => { + renderSession(cliSession, false, undefined, undefined, 'monitoring') + expect(screen.getByLabelText('Monitoring background tasks')).toBeTruthy() +}) + describe('AiVaultVirtualRow search hit source host', () => { it('names the host a hit came from when hits span several hosts', () => { renderSession(cliSession, false, undefined, new Map([['local', 'Local Mac']])) diff --git a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx index d95d036b8c4..53248f171ca 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx @@ -1,4 +1,4 @@ -import type { AgentStatusState } from '../../../../shared/agent-status-types' +import type { AgentDotState } from '@/components/AgentStateDot' import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin' import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command' @@ -81,7 +81,7 @@ export function AiVaultVirtualRow({ buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null isStructuredSessionOpen: (session: AiVaultSession) => boolean - getSessionLiveState: (session: AiVaultSession) => AgentStatusState | null + getSessionLiveState: (session: AiVaultSession) => AgentDotState | null getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts index 82725c0d425..746e47c784f 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts @@ -7,7 +7,7 @@ import { findFolderWorkspaceOwner } from '@/lib/folder-workspace-runtime-owner' import { useAppStore } from '@/store' import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session' import { findStructuredAgentSessionTab } from '@/lib/agent-session/structured-agent-session-tab-activation' -import type { AgentStatusState } from '../../../../shared/agent-status-types' +import type { AgentDotState } from '@/components/AgentStateDot' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { translate } from '@/i18n/i18n' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' @@ -22,7 +22,7 @@ export function useAiVaultOriginalPaneActions(): { getOriginalPaneTarget: ( session: AiVaultSession ) => ReturnType - getSessionLiveState: (session: AiVaultSession) => AgentStatusState | null + getSessionLiveState: (session: AiVaultSession) => AgentDotState | null isStructuredSessionOpen: (session: AiVaultSession) => boolean /** Settles false when the pane is gone and nothing was switched. */ jumpToOriginalPane: (session: AiVaultSession) => Promise diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.ts index 32ed2abfc73..9ba26cea5ef 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.ts @@ -1,4 +1,5 @@ -import type { AgentStatusState } from '../../../../shared/agent-status-types' +import { agentRowDisplayDotState } from '@/lib/agent-row-dot-state' +import type { AgentDotState } from '@/components/AgentStateDot' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { promptsMatchSession, @@ -173,15 +174,15 @@ export function findOriginalAiVaultSessionPaneInIndex( export function findAiVaultSessionLiveStateInIndex( index: AiVaultOriginalPaneIndex, session: AiVaultSession -): AgentStatusState | null { +): AgentDotState | null { const direct = index.liveByProvider.get(providerKey(session.agent, session.sessionId)) if (direct?.[0]) { - return direct[0].state + return agentRowDisplayDotState({ state: direct[0].state, entry: direct[0] }) } - const promptMatchedStates: AgentStatusState[] = [] + const promptMatchedStates: AgentDotState[] = [] for (const entry of index.liveWithoutProviderByAgent.get(session.agent) ?? []) { if (promptsMatchSession(session, entry)) { - promptMatchedStates.push(entry.state) + promptMatchedStates.push(agentRowDisplayDotState({ state: entry.state, entry })) } } return promptMatchedStates.length === 1 ? promptMatchedStates[0] : null diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts index fad0b5480b7..28834dd3dec 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts @@ -1,6 +1,7 @@ +import { agentRowDisplayDotState } from '@/lib/agent-row-dot-state' import type { AppState } from '@/store/types' import { resolveRuntimePaneTitleLeafId } from '../../../../shared/runtime-pane-title-leaf-id' -import type { AgentStatusState } from '../../../../shared/agent-status-types' +import type { AgentDotState } from '@/components/AgentStateDot' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import type { @@ -179,18 +180,18 @@ export function resolveOriginalPaneTarget(args: { export function findAiVaultSessionLiveState( state: Pick, session: AiVaultSession -): AgentStatusState | null { - const promptMatchedStates: AgentStatusState[] = [] +): AgentDotState | null { + const promptMatchedStates: AgentDotState[] = [] for (const entry of Object.values(state.agentStatusByPaneKey)) { if (!agentMatches(session, entry.agentType)) { continue } if (providerSessionMatches(session, entry.providerSession?.id)) { - return entry.state + return agentRowDisplayDotState({ state: entry.state, entry }) } if (entry.providerSession === undefined && promptsMatchSession(session, entry)) { - promptMatchedStates.push(entry.state) + promptMatchedStates.push(agentRowDisplayDotState({ state: entry.state, entry })) } } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx index f2bec8ba895..c78feed85bb 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx @@ -8,7 +8,7 @@ import { WorktreeHostContextBadge } from '@/components/sidebar/WorktreeHostConte import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' import { AgentStateDot } from '@/components/AgentStateDot' import { AgentIcon } from '@/lib/agent-catalog' -import type { AgentStatusState } from '../../../../shared/agent-status-types' +import type { AgentDotState } from '@/components/AgentStateDot' import { useRepoById } from '@/store/selectors' import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' @@ -41,7 +41,7 @@ export function SessionMetadata({ sourceHostLabel }: { session: AiVaultSession - liveState: AgentStatusState | null + liveState: AgentDotState | null updatedAt: string worktreeInfo: AiVaultSessionWorktreeInfo | null vaultScope: AiVaultScope diff --git a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts index c79e2c342d9..95d69974a2e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts @@ -335,6 +335,8 @@ it.each([10, 100])('counts real saved-startup cancellation map work at %i entrie agentStatusByPaneKey: counted(population('tab:leaf', status)), tabsByWorktree: { wt: tabs } }) + const accepted = vi.fn() + const disposeAccepted = store.getState().subscribeAgentStatusChanges(accepted) const context = captureTerminalLaunchContext(session, startup) const successor = { ...startup, resumeSource: { cancelled: false } } const successorRegistration = { ...registration, registeredAt: 2 } @@ -363,6 +365,13 @@ it.each([10, 100])('counts real saved-startup cancellation map work at %i entrie try { expect(applyTerminalLaunchResult(session, result, context, 'creator')).toBe(true) expect(source.cancelled).toBe(true) + if (newer) { + expect(accepted).not.toHaveBeenCalled() + } else { + expect(accepted).toHaveBeenCalledExactlyOnceWith([ + { paneKey: 'tab:leaf', previous: status, current: undefined } + ]) + } // Four independent whole-map spreads, once each; this is work per result, not a latency bound. expect([enumerated, copied, notifications, tabVisits]).toEqual( newer ? [0, 0, 0, 0] : [4 * size, 4 * size, 4, size] @@ -390,6 +399,7 @@ it.each([10, 100])('counts real saved-startup cancellation map work at %i entrie applyTerminalLaunchResult(session, result, context, 'creator') expect([enumerated, copied, notifications, tabVisits]).toEqual([0, 0, 0, 0]) } finally { + disposeAccepted() unsubscribe() } } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts index f995fadd0f2..1b88589f6af 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts @@ -70,11 +70,12 @@ export function installSavedResumeStartupCancellation(session: ConnectPanePtySes if (ownsPaneStartup) { const seeded = session.savedStartupStatus if (seeded?.source === source) { - useAppStore.setState((current) => { + useAppStore.getState().updateAgentStatusState((current, changedPaneKeys) => { if (current.agentStatusByPaneKey[session.cacheKey] !== seeded.entry) { return current } const next = { ...current.agentStatusByPaneKey } + changedPaneKeys.add(session.cacheKey) delete next[session.cacheKey] return { agentStatusByPaneKey: next } }) diff --git a/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts b/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts index e9cc5a5dd12..ce4bd11b672 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts @@ -94,6 +94,9 @@ it.each([10, 100])( ) ) }) + const accepted = vi.fn() + const disposeAccepted = store.getState().subscribeAgentStatusChanges(accepted) + const previous = store.getState().agentStatusByPaneKey['tab:leaf'] const notifications = vi.fn() const unsubscribe = store.subscribe(notifications) const pending = countQueue( @@ -132,6 +135,9 @@ it.each([10, 100])( copied = 0 try { clear({ paneKey: 'tab:leaf', refusedResume: refused }) + expect(accepted).toHaveBeenCalledExactlyOnceWith([ + { paneKey: 'tab:leaf', previous, current: undefined } + ]) const removed = size / 2 // Reverse splice shifts 1+…+m surviving entries in EACH queue, besides one predicate read per row. expect([reads, writes]).toEqual([3 * size + removed * (removed + 1), removed * (removed + 1)]) @@ -148,6 +154,7 @@ it.each([10, 100])( expect(copied).toBe(0) } finally { unsubs.forEach((dispose) => dispose()) + disposeAccepted() unsubscribe() onClear.mockRestore() } diff --git a/src/renderer/src/lib/forget-refused-pane-resume.ts b/src/renderer/src/lib/forget-refused-pane-resume.ts index 7372577bbce..184433f0bb4 100644 --- a/src/renderer/src/lib/forget-refused-pane-resume.ts +++ b/src/renderer/src/lib/forget-refused-pane-resume.ts @@ -49,7 +49,7 @@ export function forgetRefusedPaneResume( if (!identity) { return } - useAppStore.setState((current) => { + useAppStore.getState().updateAgentStatusState((current, changedPaneKeys) => { const live = current.agentStatusByPaneKey[paneKey] if ( !live || @@ -60,6 +60,7 @@ export function forgetRefusedPaneResume( return current } const next = { ...current.agentStatusByPaneKey } + changedPaneKeys.add(paneKey) delete next[paneKey] return { agentStatusByPaneKey: next } }) diff --git a/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts b/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts index 8bed9afc3bd..a210caefda0 100644 --- a/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts +++ b/src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts @@ -6,7 +6,7 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' vi.mock('../store', () => ({ useAppStore: { setState: vi.fn(), - getState: vi.fn(() => ({})), + getState: vi.fn(() => ({ updateAgentStatusState: vi.fn() })), subscribe: vi.fn(() => () => {}) } })) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts index c1922d4d926..400e2893e33 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts @@ -70,17 +70,24 @@ export function applyStructuredSessionTabSnapshots( const acceptedAgentSessions: { worktreeId: string; sessionId: string }[] = [] const drawnChatLabels: DrawnChatLabel[] = [] const settleStructuredSessionMirror = applyWebSessionTabsStorePatch( - (state) => - applyLocalStructuredSessionTabSnapshots(state, snapshots, owner, undefined, { - ...options, - onAcceptedAgentSession: (worktreeId, sessionId) => { - acceptedAgentSessions.push({ worktreeId, sessionId }) - options.onAcceptedAgentSession?.(worktreeId, sessionId) + (state, changedAgentPaneKeys) => + applyLocalStructuredSessionTabSnapshots( + state, + snapshots, + owner, + undefined, + { + ...options, + onAcceptedAgentSession: (worktreeId, sessionId) => { + acceptedAgentSessions.push({ worktreeId, sessionId }) + options.onAcceptedAgentSession?.(worktreeId, sessionId) + }, + // Switch point: main places the listed chats' tabs; its frames draw them. + framesDraw: ORCA_LAYOUT_OWNER_IS_MAIN ? framesDrawWorkspace(state) : undefined, + onDrawnChatLabel: (change) => drawnChatLabels.push(change) }, - // Switch point: main places the listed chats' tabs; its frames draw them. - framesDraw: ORCA_LAYOUT_OWNER_IS_MAIN ? framesDrawWorkspace(state) : undefined, - onDrawnChatLabel: (change) => drawnChatLabels.push(change) - }), + changedAgentPaneKeys + ), { frames: [] } ) settleStructuredSessionMirror() @@ -115,7 +122,8 @@ export function applyLocalStructuredSessionTabSnapshots< snapshots: readonly RuntimeMobileSessionTabsResult[], owner = LOCAL_STRUCTURED_SESSION_OWNER, now = Date.now(), - options: StructuredSessionSnapshotApplyOptions = {} + options: StructuredSessionSnapshotApplyOptions = {}, + changedAgentPaneKeys?: Set ): State { let next = state for (const snapshot of snapshots) { @@ -169,7 +177,8 @@ export function applyLocalStructuredSessionTabSnapshots< contentScope: 'agent-session', preserveLocalLayout: true, terminalPtyMode: 'local' - } + }, + changedAgentPaneKeys ) if (options.framesDraw?.(snapshot.worktree)) { // Main's frames own this workspace's tabs, so the patch (tab structure only here) is dropped. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts index 128e6771aeb..e278a0e22b9 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts @@ -14,6 +14,7 @@ import { import { stubBrowserCreateRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture' const mocks = vi.hoisted(() => ({ + changedAgentPaneKeys: new Set(), getState: vi.fn(), setState: vi.fn(), subscribe: vi.fn(), @@ -53,8 +54,10 @@ vi.mock('./web-session-tabs-sync/tracking-decisions', () => ({ decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot })) vi.mock('./web-session-tabs-sync/store-patch', () => ({ - applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { - mocks.setState(buildPatch) + applyWebSessionTabsStorePatch: ( + buildPatch: (state: unknown, changedAgentPaneKeys: Set) => unknown + ) => { + mocks.setState((state: unknown) => buildPatch(state, mocks.changedAgentPaneKeys)) return () => {} } })) @@ -144,7 +147,10 @@ describe('createWebRuntimeSessionBrowserTab', () => { expect(mocks.applyWebSessionTabsSnapshot).toHaveBeenCalledWith( { state: 'before', activeWorktreeId: WORKTREE_ID }, snapshot, - ENVIRONMENT_ID + ENVIRONMENT_ID, + undefined, + undefined, + mocks.changedAgentPaneKeys ) expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledWith( ENVIRONMENT_ID, @@ -421,7 +427,10 @@ describe('createWebRuntimeSessionBrowserTab', () => { expect(mocks.applyWebSessionTabsSnapshot).toHaveBeenCalledWith( { state: 'before-stage', activeWorktreeId: 'other-worktree' }, snapshot, - ENVIRONMENT_ID + ENVIRONMENT_ID, + undefined, + undefined, + mocks.changedAgentPaneKeys ) expect(setStateResults.at(-1)).toEqual({ state: 'after' }) }) diff --git a/src/renderer/src/runtime/web-runtime-session-snapshot.ts b/src/renderer/src/runtime/web-runtime-session-snapshot.ts index c32869e47e0..cae80cb6df8 100644 --- a/src/renderer/src/runtime/web-runtime-session-snapshot.ts +++ b/src/renderer/src/runtime/web-runtime-session-snapshot.ts @@ -157,10 +157,17 @@ export async function refreshWebRuntimeSessionTabsSnapshot( ? decideWebSessionTabsSnapshot(recovered, environmentId) : WEB_SESSION_TABS_FRAME_OUTRANKED const settleMirror = applyWebSessionTabsStorePatch( - (state) => { + (state, changedAgentPaneKeys) => { // Eager refresh can outlive workspace switches; update tabs without taking focus. const patch = decision.apply - ? applyWebSessionTabsSnapshot(state, recovered, environmentId) + ? applyWebSessionTabsSnapshot( + state, + recovered, + environmentId, + undefined, + undefined, + changedAgentPaneKeys + ) : state return patch === state ? state : patch }, diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts index 12b28fd3bb6..9bec55e4bf2 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts @@ -19,6 +19,7 @@ import { import { stubRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture' const mocks = vi.hoisted(() => ({ + changedAgentPaneKeys: new Set(), getState: vi.fn(), setState: vi.fn(), subscribe: vi.fn(), @@ -59,8 +60,10 @@ vi.mock('./web-session-tabs-sync/tracking-decisions', async (importOriginal) => decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot })) vi.mock('./web-session-tabs-sync/store-patch', () => ({ - applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { - mocks.setState(buildPatch) + applyWebSessionTabsStorePatch: ( + buildPatch: (state: unknown, changedAgentPaneKeys: Set) => unknown + ) => { + mocks.setState((state: unknown) => buildPatch(state, mocks.changedAgentPaneKeys)) return () => {} } })) @@ -396,7 +399,10 @@ describe('createWebRuntimeSessionTerminal', () => { expect(mocks.applyWebSessionTabsSnapshot).toHaveBeenCalledWith( { state: 'before', activeWorktreeId: WORKTREE_ID }, snapshot, - ENVIRONMENT_ID + ENVIRONMENT_ID, + undefined, + undefined, + mocks.changedAgentPaneKeys ) }) diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index e1f732c9afe..086303bf321 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -14,6 +14,7 @@ import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revisi import { stubRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture' const mocks = vi.hoisted(() => ({ + changedAgentPaneKeys: new Set(), getState: vi.fn(), setState: vi.fn(), subscribe: vi.fn(), @@ -57,8 +58,10 @@ vi.mock('./web-session-tabs-sync/tracking-decisions', () => ({ decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot })) vi.mock('./web-session-tabs-sync/store-patch', () => ({ - applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { - mocks.setState(buildPatch) + applyWebSessionTabsStorePatch: ( + buildPatch: (state: unknown, changedAgentPaneKeys: Set) => unknown + ) => { + mocks.setState((state: unknown) => buildPatch(state, mocks.changedAgentPaneKeys)) return () => {} } })) @@ -192,7 +195,14 @@ describe('refreshWebRuntimeSessionTabsSnapshot', () => { getCurrentState: expect.any(Function) } ) - expect(mocks.applyWebSessionTabsSnapshot).toHaveBeenCalledWith(state, recovered, ENVIRONMENT_ID) + expect(mocks.applyWebSessionTabsSnapshot).toHaveBeenCalledWith( + state, + recovered, + ENVIRONMENT_ID, + undefined, + undefined, + mocks.changedAgentPaneKeys + ) }) }) @@ -253,7 +263,10 @@ describe('activateWebRuntimeSessionWorktree', () => { expect(mocks.applyWebSessionTabsSnapshot).toHaveBeenCalledWith( { state: 'before' }, snapshot, - ENVIRONMENT_ID + ENVIRONMENT_ID, + undefined, + undefined, + mocks.changedAgentPaneKeys ) expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledWith( ENVIRONMENT_ID, diff --git a/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts b/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts index 3935a0208ec..4331cfa57ae 100644 --- a/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusChange } from '@/store/slices/agent-status/agent-status-contract' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { makePaneKey } from '../../../shared/stable-pane-id' import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' @@ -22,7 +23,11 @@ import { registerRendererOwnedAgentStatusPane, resetRendererOwnedAgentStatusPanesForTests } from '@/components/terminal-pane/renderer-owned-agent-status-registry' -import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api' +import { + applyWebSessionTabsSnapshot, + applyWebSessionTabsSnapshots, + applyWebSessionTabsSnapshotOperations +} from './web-session-tabs-sync/snapshot-api' import { applyWebSessionTabsStorePatch } from './web-session-tabs-sync/store-patch' import { decideWebSessionTabsSnapshot } from './web-session-tabs-sync/tracking-decisions' import { resetWebSessionTabsSnapshotFreshnessForTests } from './web-session-tabs-sync/tracking-lifecycle' @@ -78,7 +83,15 @@ function makeAgentSnapshot( function applySnapshot(snapshot: RuntimeMobileSessionTabsResult, live: boolean): void { const decision = decideWebSessionTabsSnapshot(snapshot, ENVIRONMENT_ID) applyWebSessionTabsStorePatch( - (state) => applyWebSessionTabsSnapshot(state, snapshot, ENVIRONMENT_ID, NOW), + (state, changedAgentPaneKeys) => + applyWebSessionTabsSnapshot( + state, + snapshot, + ENVIRONMENT_ID, + NOW, + undefined, + changedAgentPaneKeys + ), { frames: [{ environmentId: ENVIRONMENT_ID, worktreeId: snapshot.worktree, decision }] }, snapshot, live @@ -104,6 +117,67 @@ describe('paired session-tab agent completion notifications', () => { vi.useRealTimers() }) + it.each(['single', 'batch', 'operations'] as const)( + 'reports committed mirror keys through %s reconciliation and retraction', + (kind) => { + const observed = vi.fn((changes: readonly AgentStatusChange[]) => { + for (const change of changes) { + expect(useAppStore.getState().agentStatusByPaneKey[change.paneKey]).toBe(change.current) + } + }) + const dispose = useAppStore.getState().subscribeAgentStatusChanges(observed) + const paneKey = makePaneKey(toWebTerminalSurfaceTabId(HOST_TAB_ID), LEAF_ID) + const apply = (snapshot: RuntimeMobileSessionTabsResult) => { + const decision = decideWebSessionTabsSnapshot(snapshot, ENVIRONMENT_ID) + const settle = applyWebSessionTabsStorePatch( + (state, keys) => { + if (kind === 'batch') { + return applyWebSessionTabsSnapshots(state, [snapshot], ENVIRONMENT_ID, NOW, keys) + } + if (kind === 'operations') { + return applyWebSessionTabsSnapshotOperations( + state, + [{ snapshot, environmentId: ENVIRONMENT_ID, decision }], + keys + ) + } + return applyWebSessionTabsSnapshot( + state, + snapshot, + ENVIRONMENT_ID, + NOW, + undefined, + keys + ) + }, + { frames: [{ environmentId: ENVIRONMENT_ID, worktreeId: WORKTREE_ID, decision }] } + ) + settle() + } + try { + apply(makeAgentSnapshot(1, NOW)) + expect(observed).toHaveBeenCalledExactlyOnceWith([ + { + paneKey, + previous: undefined, + current: useAppStore.getState().agentStatusByPaneKey[paneKey] + } + ]) + const previous = useAppStore.getState().agentStatusByPaneKey[paneKey] + observed.mockClear() + apply({ ...makeAgentSnapshot(2, NOW + 1), tabs: [] }) + expect(observed).toHaveBeenCalledExactlyOnceWith([ + { paneKey, previous, current: undefined } + ]) + observed.mockClear() + apply({ ...makeAgentSnapshot(3, NOW + 2), tabs: [] }) + expect(observed).not.toHaveBeenCalled() + } finally { + dispose() + } + } + ) + it('forwards accepted working snapshots and live statuses', () => { applySnapshot(makeAgentSnapshot(1, NOW), false) expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts index da7d1c340aa..f1aa9e0dc84 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts @@ -28,7 +28,7 @@ import { acceptSessionTabsRuntimeId, getSessionTabsRuntimeIdFromResponse } from './publisher-identity-fences' -import { applyWebSessionTabsSnapshot } from './snapshot-api' +import { createWebSessionTabsSnapshotPatch } from './snapshot-api' import { applyWebSessionTabsStorePatch } from './store-patch' import { hostSessionMirrorSettleForPatchlessFrame, @@ -166,7 +166,7 @@ export function installActiveSessionTabsSubscription({ hostAffirmedWorktree ||= hostSnapshotAffirmsWorktreeContents(recovered) const replayed = isRuntimeSubscriptionReplayResponse(response) settle = applyWebSessionTabsStorePatch( - (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), + createWebSessionTabsSnapshotPatch(recovered, environmentId), { frames: [ { diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts index 4df5ac3ba58..e72b33217b2 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts @@ -134,6 +134,7 @@ export function updateBatchAgentPaneKey( present: boolean, batchContext?: WebSessionTabsBatchContext ): void { + batchContext?.changedAgentPaneKeys?.add(paneKey) const tabId = parsePaneKey(paneKey)?.tabId const index = batchContext?.agentPaneKeysByTabId if (!tabId || !index) { @@ -209,6 +210,7 @@ export function buildRetractedMirroredTabSweepPatch( const paneKeys = scopedPaneKeysByTabId.get(tabId) const patch = buildRetiredTerminalTabStateSweepPatch(sweepState, [tabId], undefined, { preserveActivityClearedState: true, + changedAgentPaneKeys: batchContext?.changedAgentPaneKeys, ...(paneKeys ? { paneKeys } : {}) }) if (patch) { diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts index c981f5c4f6f..7aa57a703a7 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts @@ -16,7 +16,7 @@ import { isRetiredSessionTabsPublicationEpoch } from './publisher-identity-fences' import { scheduleRetiredEpochCensus } from './retired-epoch-census' -import { applyWebSessionTabsSnapshot } from './snapshot-api' +import { createWebSessionTabsSnapshotPatch } from './snapshot-api' import { applyWebSessionTabsStorePatch } from './store-patch' import { hostSessionMirrorSettleForPatchlessFrame, @@ -141,7 +141,7 @@ export function handleGlobalSessionEvent(args: GlobalSessionEventArgs): void { const decision = decideWebSessionTabsSnapshot(recovered, environmentId, runtimeId) if (decision.apply) { settleHydration = applyWebSessionTabsStorePatch( - (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), + createWebSessionTabsSnapshotPatch(recovered, environmentId), { frames: [ { diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts index 33ff997caa5..ad7eca83586 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts @@ -131,7 +131,14 @@ export function handleGlobalSessionInventoryEvent({ decisions[index]!.apply ? [snapshot] : [] ) settleHydration = applyWebSessionTabsStorePatch( - (state) => applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId), + (state, changedAgentPaneKeys) => + applyWebSessionTabsSnapshots( + state, + freshSnapshots, + environmentId, + undefined, + changedAgentPaneKeys + ), { frames: applicable.map(({ snapshot }, index) => ({ environmentId, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts b/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts index f468bb9072b..1bbcd81f03b 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts @@ -128,7 +128,14 @@ export function loadInitialWebSessionTabs({ (latestReceivedSessionTabsInventoryFrameByEnvironment.get(environmentId) ?? 0) <= requestReceivedFrame settleHydration = applyWebSessionTabsStorePatch( - (state) => applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId), + (state, changedAgentPaneKeys) => + applyWebSessionTabsSnapshots( + state, + freshSnapshots, + environmentId, + undefined, + changedAgentPaneKeys + ), { frames: applicable.map((snapshot, index) => ({ environmentId, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/snapshot-api.ts b/src/renderer/src/runtime/web-session-tabs-sync/snapshot-api.ts index 575bb8bfee8..7e549997c66 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/snapshot-api.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/snapshot-api.ts @@ -16,8 +16,19 @@ export function applyWebSessionTabsSnapshot( snapshot: RuntimeMobileSessionTabsResult, environmentId: string, now = Date.now(), - options?: WebSessionTabsSnapshotApplyOptions + options?: WebSessionTabsSnapshotApplyOptions, + changedAgentPaneKeys?: Set ): WebSessionTabsSyncState | Partial { + if (changedAgentPaneKeys) { + return applyWebSessionTabsSnapshots( + state, + [snapshot], + environmentId, + now, + changedAgentPaneKeys, + options + ) + } return applyWebSessionTabsSnapshotWithContext( state, snapshot, @@ -28,14 +39,25 @@ export function applyWebSessionTabsSnapshot( ) } +export function createWebSessionTabsSnapshotPatch( + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string +) { + return (state: WebSessionTabsSyncState, keys: Set) => + applyWebSessionTabsSnapshot(state, snapshot, environmentId, undefined, undefined, keys) +} + export function applyWebSessionTabsSnapshots( state: WebSessionTabsSyncState, snapshots: readonly RuntimeMobileSessionTabsResult[], environmentId: string, - now = Date.now() + now = Date.now(), + changedAgentPaneKeys?: Set, + options?: WebSessionTabsSnapshotApplyOptions ): WebSessionTabsSyncState | Partial { const nextState = { ...state } const batchContext: WebSessionTabsBatchContext = { + changedAgentPaneKeys, agentPaneKeysByTabId: null, changedRecords: new Set(), openFilesIndex: null @@ -47,7 +69,8 @@ export function applyWebSessionTabsSnapshots( snapshot, environmentId, now, - batchContext + batchContext, + options ) if (patch === nextState) { continue @@ -111,7 +134,8 @@ export function decideWebSessionTabsSnapshotOperations( export function applyWebSessionTabsSnapshotOperations( state: WebSessionTabsSyncState, - operations: readonly DecidedWebSessionTabsSnapshotOperation[] + operations: readonly DecidedWebSessionTabsSnapshotOperation[], + changedAgentPaneKeys?: Set ): WebSessionTabsSyncState | Partial { let nextState = state let mergedPatch: Partial = {} @@ -119,7 +143,14 @@ export function applyWebSessionTabsSnapshotOperations( if (!decision.apply) { continue } - const patch = applyWebSessionTabsSnapshot(nextState, snapshot, environmentId) + const patch = applyWebSessionTabsSnapshot( + nextState, + snapshot, + environmentId, + undefined, + undefined, + changedAgentPaneKeys + ) if (patch === nextState) { continue } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index 96e44482406..fc5ea00dfbc 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -278,6 +278,7 @@ export type WebSessionOpenFilesIndex = { byWorktree: Map } export type WebSessionTabsBatchContext = { + changedAgentPaneKeys?: Set retractionPaneKeysByRecord?: WeakMap>> agentPaneKeysByTabId: Map> | null changedRecords: Set diff --git a/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts index 3a7aac14e2d..682ce6064dc 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts @@ -31,7 +31,10 @@ import { /** Commit a reconciliation patch and return a receipt for its host evidence. */ export function applyWebSessionTabsStorePatch( - buildPatch: (state: AppState) => WebSessionTabsSyncState | Partial, + buildPatch: ( + state: AppState, + changedAgentPaneKeys: Set + ) => WebSessionTabsSyncState | Partial, hostMirrorVerdict: HostSessionMirrorPatchVerdict, agentStatusSnapshots?: RuntimeMobileSessionTabsResult | readonly RuntimeMobileSessionTabsResult[], allowCompletionNotification = false @@ -52,9 +55,10 @@ export function applyWebSessionTabsStorePatch( }[] = [] const runStorePatch = ( - state: AppState + state: AppState, + changedAgentPaneKeys: Set ): WebSessionTabsSyncState | Partial => { - const patch = buildPatch(state) + const patch = buildPatch(state, changedAgentPaneKeys) mirroredAgentStatusChanged = patch !== state && Object.hasOwn(patch, 'agentStatusByPaneKey') if (agentStatusSnapshots) { const nextAgentStatuses = patch.agentStatusByPaneKey ?? state.agentStatusByPaneKey @@ -182,7 +186,7 @@ export function applyWebSessionTabsStorePatch( } try { - useAppStore.setState(runStorePatch) + useAppStore.getState().updateAgentStatusState(runStorePatch) } catch (error) { if (!patchCommitted) { throw error diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts index 42d1e20c773..7bb5eafb9ba 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts @@ -15,7 +15,8 @@ export function applyVisibilityResumeRepairs( } const decided = decideWebSessionTabsSnapshotOperations(operations) const settle = applyWebSessionTabsStorePatch( - (state) => applyWebSessionTabsSnapshotOperations(state, decided), + (state, changedAgentPaneKeys) => + applyWebSessionTabsSnapshotOperations(state, decided, changedAgentPaneKeys), { frames: decided.map(({ environmentId, snapshot, decision }) => ({ environmentId, diff --git a/src/renderer/src/store/host-twin-worktree-purge.test.ts b/src/renderer/src/store/host-twin-worktree-purge.test.ts index 23adee86b19..66db1b6f269 100644 --- a/src/renderer/src/store/host-twin-worktree-purge.test.ts +++ b/src/renderer/src/store/host-twin-worktree-purge.test.ts @@ -58,7 +58,23 @@ describe('purgeWorktreeTerminalState with a surviving twin', () => { const f = twin() const key = f.workspaceKey + const previous = { + paneKey: f.remote.paneKey, + state: 'done' as const, + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + terminalResumeEligible: false as const + } + f.store.setState({ agentStatusByPaneKey: { [f.remote.paneKey]: previous } }) + const accepted = vi.fn() + const dispose = f.store.getState().subscribeAgentStatusChanges(accepted) f.store.getState().purgeWorktreeTerminalState([hostWorktreeTarget({ id: key, ...f.remote })]) + expect(accepted).toHaveBeenCalledExactlyOnceWith([ + { paneKey: f.remote.paneKey, previous, current: undefined } + ]) + dispose() const s = f.store.getState() expect(s.tabsByWorktree[key]?.map((tab) => tab.id)).toEqual([ diff --git a/src/renderer/src/store/slices/agent-status/agent-status-authority-actions.ts b/src/renderer/src/store/slices/agent-status/agent-status-authority-actions.ts index 00007892c75..41643ce2a95 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-authority-actions.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-authority-actions.ts @@ -56,8 +56,11 @@ export function createAgentStatusAuthorityActions( } let hadLive = false settleAttentionRemovedBy(set, get, () => - set((s) => { + set((s, changedPaneKeys) => { const retiredLivePaneKeys = retiredPaneKeys.filter((key) => key in s.agentStatusByPaneKey) + for (const key of retiredLivePaneKeys) { + changedPaneKeys.add(key) + } hadLive = retiredLivePaneKeys.length > 0 let nextRetentionSuppressedPaneKeys = removePaneKeys( s.retentionSuppressedPaneKeys, @@ -180,55 +183,71 @@ export function createAgentStatusAuthorityActions( rendererAgentStatusObservations.rebind(to) const targetTabId = getTabIdFromPaneKey(to) ?? undefined const targetLeafId = getLeafIdFromPaneKey(to) ?? undefined - set((s) => ({ - agentStatusByPaneKey: movePaneKeyedRecord(s.agentStatusByPaneKey, from, to, (entry) => ({ - ...entry, - paneKey: to, - tabId: targetTabId - })), - // Bump epoch on live pane-key moves so retention/sidebar consumers observe the remap. - ...(from in s.agentStatusByPaneKey - ? { agentStatusEpoch: s.agentStatusEpoch + 1, sortEpoch: s.sortEpoch + 1 } - : {}), - runtimeAgentOrchestrationByPaneKey: movePaneKeyedRecord( - s.runtimeAgentOrchestrationByPaneKey, - from, - to - ), - retainedAgentsByPaneKey: movePaneKeyedRecord( - s.retainedAgentsByPaneKey, - from, - to, - (retained) => ({ - ...retained, - entry: { ...retained.entry, paneKey: to, tabId: targetTabId }, - tab: targetTabId ? { ...retained.tab, id: targetTabId } : retained.tab - }) - ), - sleepingAgentSessionsByPaneKey: movePaneKeyedRecord( - s.sleepingAgentSessionsByPaneKey, - from, - to, - (record) => ({ ...record, paneKey: to, tabId: targetTabId }) - ), - agentLaunchConfigByPaneKey: movePaneKeyedRecord( - s.agentLaunchConfigByPaneKey, - from, - to, - (entry) => ({ + set((s, changedPaneKeys) => { + changedPaneKeys.add(from) + changedPaneKeys.add(to) + return { + agentStatusByPaneKey: movePaneKeyedRecord(s.agentStatusByPaneKey, from, to, (entry) => ({ ...entry, - identity: { ...entry.identity, tabId: targetTabId, leafId: targetLeafId } - }) - ), - acknowledgedAgentsByPaneKey: movePaneKeyedRecord(s.acknowledgedAgentsByPaneKey, from, to), - activityClearedAtByPaneKey: movePaneKeyedRecord(s.activityClearedAtByPaneKey, from, to), - manuallyUnreadTurnsByPaneKey: movePaneKeyedRecord(s.manuallyUnreadTurnsByPaneKey, from, to), - paneForegroundAgentByPaneKey: movePaneKeyedRecord(s.paneForegroundAgentByPaneKey, from, to), - ...movePaneAttention(s, from, to), - lastTerminalInputAtByPaneKey: movePaneKeyedRecord(s.lastTerminalInputAtByPaneKey, from, to), - cacheTimerByKey: movePaneKeyedRecord(s.cacheTimerByKey, from, to), - retentionSuppressedPaneKeys: movePaneKeyedRecord(s.retentionSuppressedPaneKeys, from, to) - })) + paneKey: to, + tabId: targetTabId + })), + // Bump epoch on live pane-key moves so retention/sidebar consumers observe the remap. + ...(from in s.agentStatusByPaneKey + ? { agentStatusEpoch: s.agentStatusEpoch + 1, sortEpoch: s.sortEpoch + 1 } + : {}), + runtimeAgentOrchestrationByPaneKey: movePaneKeyedRecord( + s.runtimeAgentOrchestrationByPaneKey, + from, + to + ), + retainedAgentsByPaneKey: movePaneKeyedRecord( + s.retainedAgentsByPaneKey, + from, + to, + (retained) => ({ + ...retained, + entry: { ...retained.entry, paneKey: to, tabId: targetTabId }, + tab: targetTabId ? { ...retained.tab, id: targetTabId } : retained.tab + }) + ), + sleepingAgentSessionsByPaneKey: movePaneKeyedRecord( + s.sleepingAgentSessionsByPaneKey, + from, + to, + (record) => ({ ...record, paneKey: to, tabId: targetTabId }) + ), + agentLaunchConfigByPaneKey: movePaneKeyedRecord( + s.agentLaunchConfigByPaneKey, + from, + to, + (entry) => ({ + ...entry, + identity: { ...entry.identity, tabId: targetTabId, leafId: targetLeafId } + }) + ), + acknowledgedAgentsByPaneKey: movePaneKeyedRecord(s.acknowledgedAgentsByPaneKey, from, to), + activityClearedAtByPaneKey: movePaneKeyedRecord(s.activityClearedAtByPaneKey, from, to), + manuallyUnreadTurnsByPaneKey: movePaneKeyedRecord( + s.manuallyUnreadTurnsByPaneKey, + from, + to + ), + paneForegroundAgentByPaneKey: movePaneKeyedRecord( + s.paneForegroundAgentByPaneKey, + from, + to + ), + ...movePaneAttention(s, from, to), + lastTerminalInputAtByPaneKey: movePaneKeyedRecord( + s.lastTerminalInputAtByPaneKey, + from, + to + ), + cacheTimerByKey: movePaneKeyedRecord(s.cacheTimerByKey, from, to), + retentionSuppressedPaneKeys: movePaneKeyedRecord(s.retentionSuppressedPaneKeys, from, to) + } + }) if (typeof window !== 'undefined') { window.api?.agentStatus?.transferPaneAuthority?.({ fromPaneKey: from, diff --git a/src/renderer/src/store/slices/agent-status/agent-status-batch.test.ts b/src/renderer/src/store/slices/agent-status/agent-status-batch.test.ts index a03a36ac6d4..9c686183eaf 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-batch.test.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-batch.test.ts @@ -1,10 +1,15 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../../shared/agent-status-types' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../../shared/agent-status-freshness' import type { AppState } from '../../types' import './agent-status' +import { resetAgentPaneAuthorityAliasesForTests } from '../agent-pane-authority' import { MAX_LIVE_AGENT_STATUSES } from './agent-status-capacity-eviction' -import type { AgentStatusBatchUpdate, RetainedAgentEntry } from './agent-status-contract' +import type { + AgentStatusBatchUpdate, + AgentStatusChange, + RetainedAgentEntry +} from './agent-status-contract' import type { GeneratedTabTitleUpdate } from '../terminal-tab-title-batch' import { createTestStore } from '../store-test-helpers' import { makeTab, makeUnifiedTab } from '../store-session-test-harness' @@ -527,3 +532,270 @@ describe('setAgentStatuses', () => { ).toBeUndefined() }) }) + +describe('committed agent status changes', () => { + beforeEach(() => resetAgentPaneAuthorityAliasesForTests()) + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + vi.unstubAllGlobals() + resetAgentPaneAuthorityAliasesForTests() + }) + const write = (store: ReturnType, paneKey = FIRST_PANE, native = true) => { + store + .getState() + .setAgentStatus( + paneKey, + { state: 'working', agentType: 'claude', prompt: 'fixed' }, + undefined, + { updatedAt: BASE_TIME, stateStartedAt: BASE_TIME }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + native ? { terminalResumeEligible: false } : undefined + ) + } + it('emits first-before/final-after once after the final transaction is readable', () => { + vi.useFakeTimers() + vi.setSystemTime(BASE_TIME) + const store = seedBatchStore() + write(store) + const previous = store.getState().agentStatusByPaneKey[FIRST_PANE] + const observed = vi.fn((changes: readonly AgentStatusChange[]) => { + for (const change of changes) { + expect(store.getState().agentStatusByPaneKey[change.paneKey]).toBe(change.current) + } + }) + store.getState().subscribeAgentStatusChanges(observed) + store.getState().transactAgentStatuses((transaction) => { + transaction.apply({ + paneKey: FIRST_PANE, + payload: { state: 'done', prompt: 'fixed', interrupted: true }, + timing: { updatedAt: BASE_TIME + 1 } + }) + store.getState().transactAgentStatuses((nested) => + nested.apply({ + paneKey: FIRST_PANE, + payload: { + state: 'done', + prompt: 'fixed', + mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: BASE_TIME } + }, + timing: { updatedAt: BASE_TIME + 2 } + }) + ) + write(store, SECOND_PANE) + store.getState().removeAgentStatus(SECOND_PANE) + expect(observed).not.toHaveBeenCalled() + }) + expect(observed).toHaveBeenCalledExactlyOnceWith([ + { paneKey: FIRST_PANE, previous, current: store.getState().agentStatusByPaneKey[FIRST_PANE] } + ]) + }) + it('suppresses rejected, missing, equal, net-equal and failed transaction mutations', () => { + vi.useFakeTimers() + vi.setSystemTime(BASE_TIME) + const store = seedBatchStore() + write(store) + const observed = vi.fn() + store.getState().subscribeAgentStatusChanges(observed) + store.getState().setAgentStatus(FIRST_PANE, { state: 'done', prompt: 'fixed' }, undefined, { + updatedAt: BASE_TIME - 1 + }) + store.getState().removeAgentStatus('missing') + store.getState().setAgentProgramStatus(FIRST_PANE, null) + store.getState().transactAgentStatuses(() => { + store.getState().setAgentProgramStatus(FIRST_PANE, { + state: 'working', + progress: 42, + observedAt: BASE_TIME + }) + store.getState().setAgentProgramStatus(FIRST_PANE, null) + }) + const before = store.getState().agentStatusByPaneKey + expect(() => + store.getState().transactAgentStatuses(() => { + store.getState().removeAgentStatus(FIRST_PANE) + throw new Error('abort') + }) + ).toThrow('abort') + expect(store.getState().agentStatusByPaneKey).toBe(before) + expect(() => + store.getState().updateAgentStatusState((_state, keys) => { + keys.add(FIRST_PANE) + return { + agentStatusByPaneKey: {}, + get editorDrafts(): Record { + throw new Error('precommit middleware') + } + } + }) + ).toThrow('precommit middleware') + expect(store.getState().agentStatusByPaneKey).toBe(before) + expect(observed).not.toHaveBeenCalled() + store.getState().removeAgentStatus(FIRST_PANE) + expect(observed).toHaveBeenCalledOnce() + }) + it('suppresses an equal accepted row rebuilt by remove and re-add in one transaction', () => { + vi.useFakeTimers() + vi.setSystemTime(BASE_TIME) + const store = seedBatchStore() + write(store, SECOND_PANE) + const previous = store.getState().agentStatusByPaneKey[SECOND_PANE] + const observed = vi.fn() + store.getState().subscribeAgentStatusChanges(observed) + store.getState().transactAgentStatuses(() => { + store.getState().removeAgentStatus(SECOND_PANE) + write(store, SECOND_PANE) + }) + const current = store.getState().agentStatusByPaneKey[SECOND_PANE] + expect(current).toEqual(previous) + expect(current.stateHistory).not.toBe(previous.stateHistory) + expect(observed).not.toHaveBeenCalled() + }) + it('isolates stores, disposes listeners, orders reentry and contains observer failures', () => { + vi.useFakeTimers() + vi.setSystemTime(BASE_TIME) + const store = seedBatchStore() + const other = seedBatchStore() + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const isolated = vi.fn() + other.getState().subscribeAgentStatusChanges(isolated) + const order: string[] = [] + let stopSecond = () => {} + store.getState().subscribeAgentStatusChanges((changes) => { + order.push(changes[0].paneKey) + if (changes[0].paneKey === FIRST_PANE) { + stopSecond() + write(store, SECOND_PANE) + } + }) + const second = vi.fn() + stopSecond = store.getState().subscribeAgentStatusChanges(second) + const stopBroken = store.getState().subscribeAgentStatusChanges(() => { + throw new Error('observer') + }) + expect(() => write(store)).not.toThrow() + expect(order).toEqual([FIRST_PANE, SECOND_PANE]) + expect(second).not.toHaveBeenCalled() + expect(isolated).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledTimes(2) + stopBroken() + }) + it.each([ + 'remove', + 'prefix', + 'drop', + 'drop-prefix', + 'worktree', + 'hibernate', + 'retire', + 'provider', + 'program', + 'orchestration', + 'authority', + 'connection', + 'marker' + ] as const)('reports the known accepted keys for %s', (action) => { + vi.useFakeTimers() + vi.setSystemTime(BASE_TIME) + const store = seedBatchStore() + write(store) + const previous = store.getState().agentStatusByPaneKey[FIRST_PANE] + const observed = vi.fn() + store.getState().subscribeAgentStatusChanges(observed) + const state = store.getState() + switch (action) { + case 'remove': + state.removeAgentStatus(FIRST_PANE) + break + case 'prefix': + state.removeAgentStatusByTabPrefix('tab-1') + break + case 'drop': + state.dropAgentStatus(FIRST_PANE) + break + case 'drop-prefix': + state.dropAgentStatusByTabPrefix('tab-1') + break + case 'worktree': + state.dropAgentStatusByWorktree('wt-1') + break + case 'hibernate': + state.dropHibernatedAgentStatusPane('wt-1', FIRST_PANE) + break + case 'retire': + state.retireAgentPaneAuthority(FIRST_PANE) + break + case 'provider': + state.recordAgentProviderSession( + FIRST_PANE, + 'claude', + { key: 'session_id', id: 'session' }, + { updatedAt: BASE_TIME + 1 }, + { worktreeId: 'wt-1' } + ) + break + case 'program': + state.setAgentProgramStatus(FIRST_PANE, { + state: 'working', + progress: 20, + observedAt: BASE_TIME + }) + break + case 'orchestration': + state.setRuntimeAgentOrchestrationByPaneKey({ + [FIRST_PANE]: { taskId: 'task', dispatchId: 'dispatch' } + }) + break + case 'authority': + state.transferAgentPaneAuthority({ fromPaneKey: FIRST_PANE, toPaneKey: SECOND_PANE }) + break + case 'connection': + store.setState({ + agentStatusByPaneKey: { [FIRST_PANE]: { ...previous, connectionId: 'server' } } + }) + state.clearTransientAgentStatuses('server', BASE_TIME) + break + case 'marker': + write(store, FIRST_PANE, false) + break + } + expect(observed).toHaveBeenCalledOnce() + const changes: readonly AgentStatusChange[] = observed.mock.calls[0][0] + expect(changes.map((change) => change.paneKey)).toEqual( + action === 'authority' ? [FIRST_PANE, SECOND_PANE] : [FIRST_PANE] + ) + expect(changes[0].previous?.terminalResumeEligible).toBe(false) + for (const change of changes) { + expect(change.current).toBe(store.getState().agentStatusByPaneKey[change.paneKey]) + } + }) + it('includes evicted rows alongside the accepted replacement', () => { + vi.useFakeTimers() + vi.setSystemTime(BASE_TIME) + const store = seedBatchStore() + const rows: Record = {} + for (let i = 0; i < MAX_LIVE_AGENT_STATUSES; i++) { + rows[`orphan-${i}`] = { + paneKey: `orphan-${i}`, + state: 'done', + stateHistory: [], + prompt: '', + updatedAt: 0, + stateStartedAt: 0, + terminalResumeEligible: false + } + } + store.setState({ agentStatusByPaneKey: rows }) + const observed = vi.fn() + store.getState().subscribeAgentStatusChanges(observed) + write(store) + expect(observed).toHaveBeenCalledOnce() + const changes: readonly AgentStatusChange[] = observed.mock.calls[0][0] + expect(changes.map((change) => change.paneKey)).toEqual([FIRST_PANE, 'orphan-0']) + expect(changes[1]).toEqual({ + paneKey: 'orphan-0', + previous: rows['orphan-0'], + current: undefined + }) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status/agent-status-cleanup-actions.ts b/src/renderer/src/store/slices/agent-status/agent-status-cleanup-actions.ts index dc52484c0ce..cb994af246d 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-cleanup-actions.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-cleanup-actions.ts @@ -61,10 +61,11 @@ export function createAgentStatusCleanupActions( ) { return } - set((s) => { + set((s, changedPaneKeys) => { const hasLive = paneKey in s.agentStatusByPaneKey const next = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey if (hasLive) { + changedPaneKeys.add(paneKey) delete next[paneKey] } const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey @@ -127,9 +128,10 @@ export function createAgentStatusCleanupActions( ) { return } - set((s) => { + set((s, changedPaneKeys) => { const next = { ...s.agentStatusByPaneKey } for (const key of toRemove) { + changedPaneKeys.add(key) delete next[key] } const nextLaunchConfigs = { ...s.agentLaunchConfigByPaneKey } @@ -171,7 +173,7 @@ export function createAgentStatusCleanupActions( return } let removed = false - set((s) => { + set((s, changedPaneKeys) => { const worktreeIdsOnConnection = collectWorktreeIdsForConnection(s, connectionId) let next: Record | null = null for (const [paneKey, existing] of Object.entries(s.agentStatusByPaneKey)) { @@ -187,6 +189,7 @@ export function createAgentStatusCleanupActions( continue } next ??= { ...s.agentStatusByPaneKey } + changedPaneKeys.add(paneKey) delete next[paneKey] } const alreadyBlocked = connectionId in s.transientClearedAgentStatusConnectionIds diff --git a/src/renderer/src/store/slices/agent-status/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status/agent-status-contract.ts index b43fbd7c241..67869444f25 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-contract.ts @@ -160,3 +160,11 @@ export type AgentStatusBatchTransaction = { apply: (update: AgentStatusBatchUpdate) => boolean afterCommit: (effect: () => void) => void } + +/** Ephemeral accepted live-row mutations; missing references describe creation/deletion. */ +export type AgentStatusChange = { + paneKey: string + previous: AgentStatusEntry | undefined + current: AgentStatusEntry | undefined +} +export type AgentStatusChangeListener = (changes: readonly AgentStatusChange[]) => void diff --git a/src/renderer/src/store/slices/agent-status/agent-status-drop-actions.ts b/src/renderer/src/store/slices/agent-status/agent-status-drop-actions.ts index dbb3aeee737..87957222f6a 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-drop-actions.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-drop-actions.ts @@ -37,7 +37,7 @@ export function createAgentStatusDropActions( return { dropAgentStatus: (paneKey, opts?: DropAgentStatusOptions) => { let liveExisted = false - set((s) => { + set((s, changedPaneKeys) => { const hasLive = paneKey in s.agentStatusByPaneKey liveExisted = hasLive const hasRetained = paneKey in s.retainedAgentsByPaneKey @@ -77,6 +77,7 @@ export function createAgentStatusDropActions( } const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey if (hasLive) { + changedPaneKeys.add(paneKey) delete nextLive[paneKey] } const nextRetained = hasRetained @@ -120,7 +121,7 @@ export function createAgentStatusDropActions( dropAgentStatusByTabPrefix: (tabIdPrefix, opts?: DropAgentStatusByTabPrefixOptions) => { const retiredAliasPaneKeys = retireAgentPaneAuthorityAliasesByOwnerTab(tabIdPrefix) let hadLive = false - set((s) => { + set((s, changedPaneKeys) => { const dropped = buildAgentStatusTabPrefixDropPatch( s, tabIdPrefix, @@ -128,6 +129,9 @@ export function createAgentStatusDropActions( opts ) hadLive = dropped.hadLive + for (const key of dropped.removedPaneKeys) { + changedPaneKeys.add(key) + } return dropped.patch }) if (hadLive) { @@ -140,7 +144,7 @@ export function createAgentStatusDropActions( dropHibernatedAgentStatusPane: (worktreeId, paneKey, opts?: DropHibernatedAgentPaneOptions) => { let hadLive = false - set((s) => { + set((s, changedPaneKeys) => { const liveEntry = s.agentStatusByPaneKey[paneKey] const hasLive = liveEntry !== undefined const hasRetained = paneKey in s.retainedAgentsByPaneKey @@ -202,6 +206,7 @@ export function createAgentStatusDropActions( hadLive = hasLive const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey if (hasLive) { + changedPaneKeys.add(paneKey) delete nextLive[paneKey] } const nextLaunchConfigs = hasLaunchConfig diff --git a/src/renderer/src/store/slices/agent-status/agent-status-drop-reducer.ts b/src/renderer/src/store/slices/agent-status/agent-status-drop-reducer.ts index 3a743f7f8c7..96eda5a96f0 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-drop-reducer.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-drop-reducer.ts @@ -36,11 +36,12 @@ export function buildAgentStatusTabPrefixDropPatch( tabIdPrefix: string, retiredAliasPaneKeys: readonly string[], opts?: DropAgentStatusByTabPrefixOptions & { paneKeys?: ReadonlySet } -): { patch: Partial; hadLive: boolean } { +): { patch: Partial; hadLive: boolean; removedPaneKeys: string[] } { const prefix = `${tabIdPrefix}:` const matchesPane = (key: string): boolean => key.startsWith(prefix) && (!opts?.paneKeys || opts.paneKeys.has(key)) let hadLive = false + const removedPaneKeys: string[] = [] const buildPatch = (): Partial => { const completedOrphanKeys = opts?.paneKeys ? [] @@ -135,6 +136,7 @@ export function buildAgentStatusTabPrefixDropPatch( const nextLive = liveKeys.length > 0 ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey for (const key of liveKeys) { + removedPaneKeys.push(key) delete nextLive[key] } const nextLaunchConfigs = @@ -187,5 +189,5 @@ export function buildAgentStatusTabPrefixDropPatch( } } const patch = buildPatch() - return { patch, hadLive } + return { patch, hadLive, removedPaneKeys } } diff --git a/src/renderer/src/store/slices/agent-status/agent-status-live-actions.ts b/src/renderer/src/store/slices/agent-status/agent-status-live-actions.ts index 9fa4bb690e7..0ce74ccc64d 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-live-actions.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-live-actions.ts @@ -66,7 +66,7 @@ export function createAgentStatusLiveActions( } let built: AgentStatusLiveEntryBuild | AgentStatusLiveEntryRejection | null = null let liveEntryDelta: FreshnessLiveEntryDelta | null = null - set((state) => { + set((state, changedPaneKeys) => { const retirement = state.recentlyRetiredAgentStatusPaneKeys[paneKey] if ( (retirement !== undefined && @@ -106,6 +106,10 @@ export function createAgentStatusLiveActions( } const previousEntries = state.agentStatusByPaneKey const reduction = reduceAgentStatusLiveUpdate(state, built, updatedAt) + changedPaneKeys.add(built.entry.paneKey) + for (const evicted of reduction.evictedEntries) { + changedPaneKeys.add(evicted.paneKey) + } liveEntryDelta = { previousEntries, nextEntries: reduction.patch.agentStatusByPaneKey ?? previousEntries, diff --git a/src/renderer/src/store/slices/agent-status/agent-status-orchestration-actions.ts b/src/renderer/src/store/slices/agent-status/agent-status-orchestration-actions.ts index cf9fad39aef..4f6def6ecbc 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-orchestration-actions.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-orchestration-actions.ts @@ -22,7 +22,7 @@ export function createAgentStatusOrchestrationActions( entries: Record ) => { const generatedTitleUpdates: AgentStatusEntry[] = [] - set((s) => { + set((s, changedPaneKeys) => { const runtimeMapChanged = !orchestrationMapsEqual( s.runtimeAgentOrchestrationByPaneKey, entries @@ -45,6 +45,7 @@ export function createAgentStatusOrchestrationActions( liveChanged = true } const nextEntry = { ...liveEntry, orchestration: merged } + changedPaneKeys.add(paneKey) nextLive[paneKey] = nextEntry // Why: only replace titles when labels match the live dispatch taskId; sticky completed context must not rename a later turn. if ( diff --git a/src/renderer/src/store/slices/agent-status/agent-status-program-status-actions.ts b/src/renderer/src/store/slices/agent-status/agent-status-program-status-actions.ts index 09d9000eb09..8bd43802acb 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-program-status-actions.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-program-status-actions.ts @@ -14,7 +14,7 @@ export function createAgentStatusProgramStatusActions( setAgentProgramStatus: (paneKey: string, programStatus: ProgramStatusFacet | null) => { paneKey = resolveAgentPaneAuthorityKey(paneKey) let changed = false - set((s) => { + set((s, changedPaneKeys) => { const existing = s.agentStatusByPaneKey[paneKey] // Why no row creation: the facet rides an existing row and never decides one exists. if ( @@ -24,6 +24,7 @@ export function createAgentStatusProgramStatusActions( return s } changed = true + changedPaneKeys.add(paneKey) const { programStatus: _previous, ...rest } = existing // Why the entry alone: timing, history, sort and attention must not read a facet tick as news. return { diff --git a/src/renderer/src/store/slices/agent-status/agent-status-provider-session-actions.ts b/src/renderer/src/store/slices/agent-status/agent-status-provider-session-actions.ts index c497ddb95a2..2a56eee351b 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-provider-session-actions.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-provider-session-actions.ts @@ -51,7 +51,7 @@ export function createAgentStatusProviderSessionActions( } let removedLiveStatus = false settleAttentionRemovedBy(set, get, () => - set((s) => { + set((s, changedPaneKeys) => { const existingStatus = s.agentStatusByPaneKey[paneKey] const existingRecord = s.sleepingAgentSessionsByPaneKey[paneKey] if ( @@ -123,6 +123,7 @@ export function createAgentStatusProviderSessionActions( ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey if (removedLiveStatus) { + changedPaneKeys.add(paneKey) delete nextLive[paneKey] } const nextRetained = diff --git a/src/renderer/src/store/slices/agent-status/agent-status-runtime.ts b/src/renderer/src/store/slices/agent-status/agent-status-runtime.ts index 95853cf607e..43646194b00 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-runtime.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-runtime.ts @@ -1,6 +1,12 @@ +import { structuralValuesEqualIgnoringUndefined } from '../../../../../shared/structural-value-equality' import type { StateCreator } from 'zustand' import type { AppState } from '../../types' -import type { AgentStatusBatchTransaction, AgentStatusBatchUpdate } from './agent-status-contract' +import type { + AgentStatusBatchTransaction, + AgentStatusBatchUpdate, + AgentStatusChange, + AgentStatusChangeListener +} from './agent-status-contract' import type { AgentStatusSlice } from './agent-status-slice-contract' import type { GeneratedTabTitleUpdate } from '../terminal-tab-title-batch' import { createFreshnessScheduler } from './agent-status-freshness-scheduler' @@ -8,10 +14,11 @@ import { createFreshnessScheduler } from './agent-status-freshness-scheduler' export type AgentStatusStateUpdate = | AppState | Partial - | ((state: AppState) => AppState | Partial) + | ((state: AppState, changedPaneKeys: Set) => AppState | Partial) export type AgentStatusRuntime = { get: () => AppState + subscribeAgentStatusChanges: (listener: AgentStatusChangeListener) => () => void set: (update: AgentStatusStateUpdate) => void runAfterCommit: (effect: () => void) => void applyGeneratedTabTitleUpdate: (update: GeneratedTabTitleUpdate) => void @@ -31,6 +38,73 @@ export function createAgentStatusRuntime( 'setAgentStatus' | 'recordAgentProviderSession' | 'setAgentProgramStatus' > ): AgentStatusRuntime { + const listeners = new Set() + const pendingChanges: AgentStatusChange[][] = [] + let committing = 0 + let notifying = false + let batchedPaneKeys: Set | null = null + const subscribeAgentStatusChanges = (listener: AgentStatusChangeListener): (() => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + const flushChanges = (): void => { + if (committing || notifying) { + return + } + notifying = true + try { + for (const accepted of pendingChanges) { + if (!accepted?.length) { + continue + } + const currentListeners = [...listeners] + for (const listener of currentListeners) { + if (!listeners.has(listener)) { + continue + } + try { + listener(accepted) + } catch (error) { + console.error('Agent status observer failed', error) + } + } + } + } finally { + pendingChanges.length = 0 + notifying = false + } + } + const commit = ( + patch: AppState | Partial, + before: AppState, + paneKeys: ReadonlySet + ): void => { + if (!listeners.size) { + storeSet(patch, false) + return + } + const changes: AgentStatusChange[] = [] + const rows = patch.agentStatusByPaneKey ?? before.agentStatusByPaneKey + for (const paneKey of paneKeys) { + const previous = before.agentStatusByPaneKey[paneKey] + const current = rows[paneKey] + if (!structuralValuesEqualIgnoringUndefined(previous, current)) { + changes.push({ paneKey, previous, current }) + } + } + committing++ + pendingChanges.push(changes) + try { + storeSet(patch, false) + } finally { + // A middleware rejection before commit must never advertise accepted rows. + if (storeGet() === before) { + changes.length = 0 + } + committing-- + flushChanges() + } + } let batchedAgentStatusState: AppState | null = null let batchedAgentStatusTouchedKeys: Set | null = null // Staged in-place mutations cannot signal changes by identity; count every accepted write. @@ -43,11 +117,13 @@ export function createAgentStatusRuntime( // Omit replace from this write type so no slice call can request replacement the batch cannot express. const set = (update: AgentStatusStateUpdate): void => { const staged = batchedAgentStatusState + const before = staged ?? storeGet() + const paneKeys = batchedPaneKeys ?? new Set() + const nextState = typeof update === 'function' ? update(before, paneKeys) : update if (staged === null) { - storeSet(update, false) + commit(nextState, before, paneKeys) return } - const nextState = typeof update === 'function' ? update(staged) : update if (Object.is(nextState, staged)) { return } @@ -134,6 +210,8 @@ export function createAgentStatusRuntime( } const initialState = storeGet() const touchedKeys = new Set() + const paneKeys = new Set() + batchedPaneKeys = paneKeys const revisionAtStart = batchedAgentStatusRevision batchedAgentStatusState = { ...initialState } batchedAgentStatusTouchedKeys = touchedKeys @@ -146,13 +224,18 @@ export function createAgentStatusRuntime( const generatedTabTitleUpdates = batchedGeneratedTabTitleUpdates const freshnessRequested = batchedAgentStatusFreshnessRequested const hasStagedWrites = batchedAgentStatusRevision !== revisionAtStart + batchedPaneKeys = null batchedAgentStatusState = null batchedAgentStatusTouchedKeys = null batchedAgentStatusEffects = null batchedGeneratedTabTitleUpdates = null batchedAgentStatusFreshnessRequested = false if (hasStagedWrites) { - storeSet(buildAgentStatusBatchPatch(initialState, nextState, touchedKeys), false) + commit( + buildAgentStatusBatchPatch(initialState, nextState, touchedKeys), + initialState, + paneKeys + ) } if (generatedTabTitleUpdates.length > 0) { storeGet().setGeneratedTabTitlesFromAgentPrompts(generatedTabTitleUpdates) @@ -165,6 +248,7 @@ export function createAgentStatusRuntime( } return result } finally { + batchedPaneKeys = null batchedAgentStatusState = null batchedAgentStatusTouchedKeys = null batchedAgentStatusEffects = null @@ -208,6 +292,7 @@ export function createAgentStatusRuntime( } return { + subscribeAgentStatusChanges, get, set, runAfterCommit, diff --git a/src/renderer/src/store/slices/agent-status/agent-status-slice-contract.ts b/src/renderer/src/store/slices/agent-status/agent-status-slice-contract.ts index c1d9503a08d..b5b3b8e6697 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-slice-contract.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-slice-contract.ts @@ -6,6 +6,7 @@ import type { AgentProviderSessionTiming, AgentStatusBatchTransaction, AgentStatusBatchUpdate, + AgentStatusChangeListener, AgentStatusMetadata, AgentStatusPayload, AgentStatusRouting, @@ -28,11 +29,16 @@ import type { SleepingAgentLaunchConfig, SleepingAgentSessionRecord } from '../../../../../shared/agent-session-resume' +import type { AgentStatusStateUpdate } from './agent-status-runtime' import type { ProgramStatusFacet } from '../../../../../shared/program-status/facet' export type AgentStatusSlice = { /** Explicit agent status entries keyed by `${tabId}:${leafId}`; real-time only, not persisted. */ agentStatusByPaneKey: Record + /** Store-local committed row changes, using writer-known keys rather than map discovery. */ + subscribeAgentStatusChanges: (listener: AgentStatusChangeListener) => () => void + /** Route external atomic status patches through the same commit boundary. */ + updateAgentStatusState: (update: AgentStatusStateUpdate) => void /** Main-synced dispatch metadata for live panes that may only have title-derived status in the renderer. */ runtimeAgentOrchestrationByPaneKey: Record /** PTYs still reporting legacy numeric pane keys but with registry-backed UUID proof; stored separately from normal hook-reported status. */ diff --git a/src/renderer/src/store/slices/agent-status/agent-status-worktree-drop-actions.ts b/src/renderer/src/store/slices/agent-status/agent-status-worktree-drop-actions.ts index 906fcb5466f..ab46c0f43a8 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-worktree-drop-actions.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-worktree-drop-actions.ts @@ -20,7 +20,7 @@ export function createAgentStatusWorktreeDropActions( return { dropAgentStatusByWorktree: (worktreeId, opts?: DropAgentStatusByWorktreeOptions) => { let hadLive = false - set((s) => { + set((s, changedPaneKeys) => { const tabPrefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) // An open chat's row is its host's: the chat tab's projection removes it on close. const chatTabIds = openNonTerminalTabIds(s, worktreeId) @@ -118,6 +118,7 @@ export function createAgentStatusWorktreeDropActions( const nextLive = liveKeys.length > 0 ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey for (const key of liveKeys) { + changedPaneKeys.add(key) delete nextLive[key] } const nextLaunchConfigs = diff --git a/src/renderer/src/store/slices/agent-status/agent-status-worktree-purge-leak.test.ts b/src/renderer/src/store/slices/agent-status/agent-status-worktree-purge-leak.test.ts index 2505cd6bd6c..45c72898d80 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-worktree-purge-leak.test.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-worktree-purge-leak.test.ts @@ -106,10 +106,17 @@ describe('bulk worktree purge evicts pane-scoped agent/unread/input maps (leak r const store = createTestStore() seedPaneState(store) const epochBefore = store.getState().agentStatusEpoch + const previous = store.getState().agentStatusByPaneKey[PANE] + const accepted = vi.fn() + const dispose = store.getState().subscribeAgentStatusChanges(accepted) store.getState().purgeWorktreeTerminalState([WT]) const s = store.getState() + expect(accepted).toHaveBeenCalledExactlyOnceWith([ + { paneKey: PANE, previous, current: undefined } + ]) + dispose() expect(s.agentStatusByPaneKey[PANE]).toBeUndefined() expect(s.agentStatusEpoch).toBe(epochBefore + 1) expect(s.agentLaunchConfigByPaneKey[PANE]).toBeUndefined() diff --git a/src/renderer/src/store/slices/agent-status/agent-status.ts b/src/renderer/src/store/slices/agent-status/agent-status.ts index 6c6eeaa6c71..b52cc7b05a7 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status.ts @@ -58,6 +58,8 @@ export const createAgentStatusSlice: StateCreator { }) const epochBefore = store.getState().sortEpoch + const paneKey = `${TAB_A}:leaf` + const previous = { + paneKey, + state: 'done' as const, + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + terminalResumeEligible: false as const + } + store.setState({ agentStatusByPaneKey: { [paneKey]: previous } }) + const accepted = vi.fn() + const dispose = store.getState().subscribeAgentStatusChanges(accepted) store.getState().purgeStaleRuntimeHostState(['env-a']) + expect(accepted).toHaveBeenCalledExactlyOnceWith([{ paneKey, previous, current: undefined }]) + dispose() const s = store.getState() expect(s.repos.map((r) => r.id)).toEqual(['repo1', 'repoServing']) diff --git a/src/renderer/src/store/slices/retired-terminal-tab-state-sweep.ts b/src/renderer/src/store/slices/retired-terminal-tab-state-sweep.ts index 5e63d93867b..a282f870042 100644 --- a/src/renderer/src/store/slices/retired-terminal-tab-state-sweep.ts +++ b/src/renderer/src/store/slices/retired-terminal-tab-state-sweep.ts @@ -45,7 +45,11 @@ export function buildRetiredTerminalTabStateSweepPatch( state: RetiredTerminalTabSweepState, tabIds: readonly string[], worktreeId?: string | null, - opts?: { preserveActivityClearedState?: boolean; paneKeys?: ReadonlySet } + opts?: { + preserveActivityClearedState?: boolean + paneKeys?: ReadonlySet + changedAgentPaneKeys?: Set + } ): Partial | null { if (tabIds.length === 0) { return null @@ -56,7 +60,7 @@ export function buildRetiredTerminalTabStateSweepPatch( if (!opts?.paneKeys) { retireParkedTerminalTab(tabId) } - const { patch } = buildAgentStatusTabPrefixDropPatch( + const { patch, removedPaneKeys } = buildAgentStatusTabPrefixDropPatch( swept, tabId, opts?.paneKeys ? [] : retireAgentPaneAuthorityAliasesByOwnerTab(tabId), @@ -66,6 +70,9 @@ export function buildRetiredTerminalTabStateSweepPatch( ...(opts?.preserveActivityClearedState ? { preserveActivityClearedState: true } : {}) } ) + for (const key of removedPaneKeys) { + opts?.changedAgentPaneKeys?.add(key) + } const foreground = opts?.paneKeys ? { paneForegroundAgentByPaneKey: removePaneKeys( diff --git a/src/renderer/src/store/slices/worktrees-fetch-removal-purge.test.ts b/src/renderer/src/store/slices/worktrees-fetch-removal-purge.test.ts index 9e8bd30485b..2ae9a43be9f 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-removal-purge.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-removal-purge.test.ts @@ -69,7 +69,38 @@ describe('fetchWorktrees', () => { } } as Partial) + const paneKey = 'removed-tab:leaf' + const previous = { + paneKey, + state: 'done' as const, + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + terminalResumeEligible: false as const + } + store.setState({ + tabsByWorktree: { + [removed.id]: [ + { + id: 'removed-tab', + worktreeId: removed.id, + ptyId: null, + title: '', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + }, + agentStatusByPaneKey: { [paneKey]: previous } + }) + const accepted = vi.fn() + const dispose = store.getState().subscribeAgentStatusChanges(accepted) await store.getState().fetchWorktrees('repo1') + expect(accepted).toHaveBeenCalledExactlyOnceWith([{ paneKey, previous, current: undefined }]) + dispose() expect(store.getState().worktreesByRepo.repo1).toEqual([surviving]) expect(store.getState().rightSidebarTabByWorktree).toEqual({ [surviving.id]: 'checks' }) diff --git a/src/renderer/src/store/slices/worktrees-slice-test-harness.ts b/src/renderer/src/store/slices/worktrees-slice-test-harness.ts index ca727c6662b..de8b1e5d95a 100644 --- a/src/renderer/src/store/slices/worktrees-slice-test-harness.ts +++ b/src/renderer/src/store/slices/worktrees-slice-test-harness.ts @@ -1,3 +1,4 @@ +import { createAgentStatusSlice } from './agent-status/agent-status' import { vi, type Mock } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' @@ -135,6 +136,7 @@ export function createTestStore() { // Why: this test isolates the worktree slice, so it provides only the state surface createWorktreeSlice touches. ...createWorktreeSlice(...a), ...createWorkspaceAttentionSlice(...a), + ...createAgentStatusSlice(...a), trustedOrcaHooks: {}, sshConnectionStates: new Map([ [ diff --git a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts index 945e81e19b7..87166365303 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts @@ -137,7 +137,7 @@ export function preserveConcurrentDisplayName( } export function mergeFetchedWorktrees( - set: Parameters>[0], + _set: Parameters>[0], get: () => AppState, args: FencedWorktreeMergeArgs ): WorktreeListingMergeOutcome { @@ -158,7 +158,7 @@ export function mergeFetchedWorktrees( const decision: { outcome: WorktreeListingMergeOutcome } = { outcome: 'not-current' } let authoritativelyRemovedIds: readonly string[] = [] let authoritativelySeenIds: readonly string[] = [] - set((s) => { + get().updateAgentStatusState((s, changedPaneKeys) => { // Check live applied catalog before any row/detection/purge merge so in-flight listings cannot undo newer create/remove replies. const refusal = worktreeListingRefusal( s, @@ -285,7 +285,8 @@ export function mergeFetchedWorktrees( ...(removedIds.length > 0 ? buildWorktreePurgeState( s, - removedIds.map((id) => hostWorktreeTarget({ id, hostId: args.hostId })) + removedIds.map((id) => hostWorktreeTarget({ id, hostId: args.hostId })), + changedPaneKeys ) : {}) } diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts index 62d6e529635..125f80f0ea6 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts @@ -156,7 +156,9 @@ export function createPurgeWorktreeTerminalState( ) ) settleAttentionRemovedBy(set, get, () => - set((s) => buildWorktreePurgeState(s, purgeableWorktreeTargets)) + get().updateAgentStatusState((s, changedPaneKeys) => + buildWorktreePurgeState(s, purgeableWorktreeTargets, changedPaneKeys) + ) ) } } diff --git a/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts b/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts index 02a92afcba0..95e1df8294a 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts @@ -139,7 +139,8 @@ export function collectHostTwinPurgeScopes( export function buildHostTwinPurgeState( s: AppState, hostId: ExecutionHostId, - twinHostIds: ReadonlyMap + twinHostIds: ReadonlyMap, + changedPaneKeys?: Set ): Partial { const twinIds = new Set(twinHostIds.keys()) const removed = collectRemovedHostTwinState(s, twinIds, hostId, twinHostIds) @@ -241,10 +242,16 @@ export function buildHostTwinPurgeState( ...layout, // Why: a group may now show a surviving tab of another kind, so selection is derived again. ...(selectionRemoved ? buildActiveSurfacePatch({ ...s, ...layout }, active) : {}), - ...buildWorktreePurgeSurfacePatch(s, omitters, doomed.doomedTabIds, { - ...tabPrefixedSleeping, - ...omitSleepingAgentSessionsForWorktrees(tabPrefixedSleeping, new Set(), sshRecordPaneKeys) - }), + ...buildWorktreePurgeSurfacePatch( + s, + omitters, + doomed.doomedTabIds, + { + ...tabPrefixedSleeping, + ...omitSleepingAgentSessionsForWorktrees(tabPrefixedSleeping, new Set(), sshRecordPaneKeys) + }, + changedPaneKeys + ), ...twinSelectionPatch(s, twinHostIds, hostId) } } diff --git a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts index 580bff2797e..b8cf6142f6f 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts @@ -38,7 +38,7 @@ export function createPurgeStaleRuntimeHostState( set, get, () => - set((s) => { + get().updateAgentStatusState((s, changedPaneKeys) => { const repoIdsWithRemovedOwners = new Set() const survivingRepoIds = new Set() const repoIdsWithSurvivingOwners = new Set() @@ -215,7 +215,9 @@ export function createPurgeStaleRuntimeHostState( }) // Why full: survivor ids were dropped above unless the session partition proves the bare-id state is the removed host's. const purgeState = - purgeTargets.length > 0 ? buildFullWorktreePurgeState(s, purgeTargets) : {} + purgeTargets.length > 0 + ? buildFullWorktreePurgeState(s, purgeTargets, changedPaneKeys) + : {} const visitPurgeTargets = [ ...removedWorktreeTargets, ...purgeTargets.filter( diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-omitters.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-omitters.ts index 50c99128ee2..134b09d8b52 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-omitters.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-omitters.ts @@ -40,7 +40,10 @@ export function createWorktreePurgeOmitters( const omitByPtyId = (obj: Record): Record => omitRecordKeys(obj, doomedPtyIds) // Pane-scoped maps are keyed `${tabId}:${leafId}`; tabId never contains ":", so the prefix before the first ":" is the owning tab. - const omitByPaneKeyTabPrefix = (obj: Record): Record => { + const omitByPaneKeyTabPrefix = ( + obj: Record, + removedPaneKeys?: Set + ): Record => { // Null-tolerant like omitByTabId: some worktree-isolation callers omit these slices (production store always inits to {}). if (!obj) { return obj @@ -49,7 +52,11 @@ export function createWorktreePurgeOmitters( obj, Object.keys(obj).filter((paneKey) => { const sep = paneKey.indexOf(':') - return sep > 0 && doomedTabIds.has(paneKey.slice(0, sep)) + const removed = sep > 0 && doomedTabIds.has(paneKey.slice(0, sep)) + if (removed) { + removedPaneKeys?.add(paneKey) + } + return removed }) ) } diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 2d3ff1728b0..74bb7db60b3 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -30,20 +30,27 @@ import { composeWorktreeHostIdentity } from '../../../../../../shared/worktree/h */ export function buildWorktreePurgeState( s: AppState, - worktreeTargets: WorktreePurgeTargets + worktreeTargets: WorktreePurgeTargets, + changedPaneKeys?: Set ): Partial { const targets: WorktreePurgeTarget[] = worktreeTargets.map((target) => typeof target === 'string' ? { id: target } : target ) const twinScopes = collectHostTwinPurgeScopes(s, targets) if (twinScopes.size === 0) { - return buildFullWorktreePurgeState(s, targets) + return buildFullWorktreePurgeState(s, targets, changedPaneKeys) } const twinIds = new Set([...twinScopes.values()].flatMap((twins) => [...twins.keys()])) const fullTargets = targets.filter((target) => !target.hostId || !twinIds.has(target.id)) - let patch = fullTargets.length > 0 ? buildFullWorktreePurgeState(s, fullTargets) : {} + let patch = + fullTargets.length > 0 ? buildFullWorktreePurgeState(s, fullTargets, changedPaneKeys) : {} for (const [hostId, twinHostIds] of twinScopes) { - const twinPatch = buildHostTwinPurgeState({ ...s, ...patch }, hostId, twinHostIds) + const twinPatch = buildHostTwinPurgeState( + { ...s, ...patch }, + hostId, + twinHostIds, + changedPaneKeys + ) patch = { ...patch, ...twinPatch, @@ -77,7 +84,8 @@ export function buildWorktreePurgeState( /** Drops every bucket under the targets' ids; visit recency alone stays host-scoped. */ export function buildFullWorktreePurgeState( s: AppState, - normalizedTargets: readonly WorktreePurgeTarget[] + normalizedTargets: readonly WorktreePurgeTarget[], + changedPaneKeys?: Set ): Partial { const worktreeIdSet = new Set(normalizedTargets.map((target) => target.id)) forgetCombinedDiffViewsForWorktrees(worktreeIdSet) @@ -148,7 +156,13 @@ export function buildFullWorktreePurgeState( tabIds: doomed.doomedTabIds, activeFileCleared }), - ...buildWorktreePurgeSurfacePatch(s, omitters, doomed.doomedTabIds, nextSleeping), + ...buildWorktreePurgeSurfacePatch( + s, + omitters, + doomed.doomedTabIds, + nextSleeping, + changedPaneKeys + ), worktreeLineageById: omitByWorktree(s.worktreeLineageById), workspaceLineageByChildKey: omitWorkspaceLineageByWorktree(s.workspaceLineageByChildKey), deleteStateByWorktreeId: removeDeleteStatesForWorktreeIds( diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-surface-patch.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-surface-patch.ts index 8c89d213d20..521735ef324 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-surface-patch.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-surface-patch.ts @@ -15,7 +15,8 @@ export function buildWorktreePurgeSurfacePatch( s: AppState, omitters: WorktreePurgeOmitters, doomedTabIds: ReadonlySet, - sleeping: WorktreePurgeSleepingMaps + sleeping: WorktreePurgeSleepingMaps, + changedPaneKeys?: Set ): Partial { const { omitByTabId, @@ -25,7 +26,7 @@ export function buildWorktreePurgeSurfacePatch( omitByPageId, omitByFileId } = omitters - const nextAgentStatusByPaneKey = omitByPaneKeyTabPrefix(s.agentStatusByPaneKey) + const nextAgentStatusByPaneKey = omitByPaneKeyTabPrefix(s.agentStatusByPaneKey, changedPaneKeys) return { localOnlyScrollbackByTabId: omitByTabId(s.localOnlyScrollbackByTabId), runtimePaneTitlesByTabId: omitByTabId(s.runtimePaneTitlesByTabId), diff --git a/src/shared/dashboard-snapshot.ts b/src/shared/dashboard-snapshot.ts index 227425c0d69..78d2fd0f85c 100644 --- a/src/shared/dashboard-snapshot.ts +++ b/src/shared/dashboard-snapshot.ts @@ -1,3 +1,4 @@ +import type { agentVerdictDisplayMark } from './agent-main-agent-verdict' import type { AgentType, AgentWorkingMode } from './agent-status-types' import type { ExecutionHostId } from './execution-host' import type { RepoIcon } from './repo-icon' @@ -27,18 +28,27 @@ export const DASHBOARD_MAX_MAP_WORKSPACES = 2_000 /** Kept distinct from `bucket` so attention cards retain their precise dot state. */ export type DashboardCardDotState = 'working' | 'blocked' | 'waiting' | 'done' | 'idle' -export type DashboardCardDisplayState = DashboardCardDotState | 'monitoring' +export type DashboardCardVerdict = NonNullable> +export type DashboardCardBaseDisplayState = DashboardCardDotState | 'monitoring' +export type DashboardCardDisplayState = DashboardCardBaseDisplayState | DashboardCardVerdict /** Completed agents stay green until acknowledged, then settle into gray idle. */ -export function dashboardCardDisplayState( +export function dashboardCardBaseDisplayState( card: Pick -): DashboardCardDisplayState { +): DashboardCardBaseDisplayState { if (card.dotState === 'working' && card.workingMode === 'monitoring') { return 'monitoring' } return card.dotState === 'done' && !card.unseen ? 'idle' : card.dotState } +/** Turn verdicts survive row decay and acknowledgment without changing column membership. */ +export function dashboardCardDisplayState( + card: Pick +): DashboardCardDisplayState { + return card.verdict ?? dashboardCardBaseDisplayState(card) +} + export type DashboardCardReview = { number: number state: 'open' | 'closed' | 'merged' | 'draft' @@ -81,6 +91,7 @@ export type DashboardCard = { dotState: DashboardCardDotState /** Additive discriminator; older pop-outs render this as ordinary working. */ workingMode?: AgentWorkingMode + verdict?: DashboardCardVerdict task: string lastUserMessage?: string lastAgentMessage?: string diff --git a/tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts b/tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts index 303ee63d439..13074a765b6 100644 --- a/tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts +++ b/tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts @@ -5,7 +5,7 @@ import { TerminalHost } from '../../src/main/daemon/terminal-host' vi.mock('../../src/renderer/src/store', () => ({ useAppStore: { setState: vi.fn(), - getState: vi.fn(() => ({})), + getState: vi.fn(() => ({ updateAgentStatusState: vi.fn() })), subscribe: vi.fn(() => () => {}) } }))