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