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
This commit is contained in:
Brennan Benson
2026-10-28 13:11:13 -07:00
committed by GitHub
parent 1f45c87c50
commit f869c5580c
72 changed files with 1594 additions and 180 deletions
+4
View File
@@ -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",
@@ -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(
@@ -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) &&
@@ -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(
<TooltipProvider>
<AgentKanbanCard
card={{ ...initial, verdict }}
now={2000}
onOpenTerminal={onOpenTerminal}
/>
</TooltipProvider>
)
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 })
@@ -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 &&
@@ -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(<AgentTerminalPanel card={statusCard} onOpenChange={() => {}} 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(<AgentTerminalDialog card={card()} onOpenChange={() => {}} onReveal={() => {}} />)
@@ -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> = {}): 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())
@@ -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,
@@ -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> = {}
): 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> = {}): 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<typeof nativeTab>,
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()
}
)
})
@@ -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,
@@ -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':
@@ -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 }
@@ -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
@@ -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 {
@@ -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()
@@ -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
@@ -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
@@ -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<string, string>
searchHitHostLabels?: ReadonlyMap<string, string>,
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']]))
@@ -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
@@ -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<typeof findOriginalAiVaultSessionPane>
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<boolean>
@@ -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
@@ -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<AppState, 'agentStatusByPaneKey'>,
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 }))
}
}
@@ -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
@@ -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()
}
}
@@ -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 }
})
@@ -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()
}
@@ -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 }
})
@@ -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(() => () => {})
}
}))
@@ -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<string>
): 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.
@@ -14,6 +14,7 @@ import {
import { stubBrowserCreateRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture'
const mocks = vi.hoisted(() => ({
changedAgentPaneKeys: new Set<string>(),
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<string>) => 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' })
})
@@ -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
},
@@ -19,6 +19,7 @@ import {
import { stubRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture'
const mocks = vi.hoisted(() => ({
changedAgentPaneKeys: new Set<string>(),
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<string>) => 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
)
})
@@ -14,6 +14,7 @@ import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revisi
import { stubRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture'
const mocks = vi.hoisted(() => ({
changedAgentPaneKeys: new Set<string>(),
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<string>) => 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,
@@ -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)
@@ -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: [
{
@@ -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) {
@@ -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: [
{
@@ -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,
@@ -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,
@@ -16,8 +16,19 @@ export function applyWebSessionTabsSnapshot(
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string,
now = Date.now(),
options?: WebSessionTabsSnapshotApplyOptions
options?: WebSessionTabsSnapshotApplyOptions,
changedAgentPaneKeys?: Set<string>
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
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<string>) =>
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<string>,
options?: WebSessionTabsSnapshotApplyOptions
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
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<string>
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
let nextState = state
let mergedPatch: Partial<WebSessionTabsSyncState> = {}
@@ -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
}
@@ -278,6 +278,7 @@ export type WebSessionOpenFilesIndex = {
byWorktree: Map<string, OpenFile[]>
}
export type WebSessionTabsBatchContext = {
changedAgentPaneKeys?: Set<string>
retractionPaneKeysByRecord?: WeakMap<object, Map<string, Set<string>>>
agentPaneKeysByTabId: Map<string, Set<string>> | null
changedRecords: Set<WebSessionTabsBatchRecordKey>
@@ -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<WebSessionTabsSyncState>,
buildPatch: (
state: AppState,
changedAgentPaneKeys: Set<string>
) => WebSessionTabsSyncState | Partial<WebSessionTabsSyncState>,
hostMirrorVerdict: HostSessionMirrorPatchVerdict,
agentStatusSnapshots?: RuntimeMobileSessionTabsResult | readonly RuntimeMobileSessionTabsResult[],
allowCompletionNotification = false
@@ -52,9 +55,10 @@ export function applyWebSessionTabsStorePatch(
}[] = []
const runStorePatch = (
state: AppState
state: AppState,
changedAgentPaneKeys: Set<string>
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> => {
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
@@ -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,
@@ -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([
@@ -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,
@@ -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<typeof seedBatchStore>, 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<string, string> {
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<string, AgentStatusEntry> = {}
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
})
})
})
@@ -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<string, (typeof s.agentStatusByPaneKey)[string]> | 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
@@ -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
@@ -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
@@ -36,11 +36,12 @@ export function buildAgentStatusTabPrefixDropPatch(
tabIdPrefix: string,
retiredAliasPaneKeys: readonly string[],
opts?: DropAgentStatusByTabPrefixOptions & { paneKeys?: ReadonlySet<string> }
): { patch: Partial<AgentStatusTabPrefixDropState>; hadLive: boolean } {
): { patch: Partial<AgentStatusTabPrefixDropState>; 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<AgentStatusTabPrefixDropState> => {
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 }
}
@@ -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,
@@ -22,7 +22,7 @@ export function createAgentStatusOrchestrationActions(
entries: Record<string, AgentStatusOrchestrationContext>
) => {
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 (
@@ -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 {
@@ -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 =
@@ -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<AppState>
| ((state: AppState) => AppState | Partial<AppState>)
| ((state: AppState, changedPaneKeys: Set<string>) => AppState | Partial<AppState>)
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<AgentStatusChangeListener>()
const pendingChanges: AgentStatusChange[][] = []
let committing = 0
let notifying = false
let batchedPaneKeys: Set<string> | 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<AppState>,
before: AppState,
paneKeys: ReadonlySet<string>
): 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<keyof AppState> | 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<string>()
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<keyof AppState>()
const paneKeys = new Set<string>()
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,
@@ -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<string, AgentStatusEntry>
/** 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<string, AgentStatusOrchestrationContext>
/** PTYs still reporting legacy numeric pane keys but with registry-backed UUID proof; stored separately from normal hook-reported status. */
@@ -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 =
@@ -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()
@@ -58,6 +58,8 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
}
return {
...actions,
subscribeAgentStatusChanges: runtime.subscribeAgentStatusChanges,
updateAgentStatusState: runtime.set,
agentStatusByPaneKey: {},
runtimeAgentOrchestrationByPaneKey: {},
migrationUnsupportedByPtyId: {},
@@ -108,7 +108,22 @@ describe('purgeStaleRuntimeHostState', () => {
})
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'])
@@ -45,7 +45,11 @@ export function buildRetiredTerminalTabStateSweepPatch(
state: RetiredTerminalTabSweepState,
tabIds: readonly string[],
worktreeId?: string | null,
opts?: { preserveActivityClearedState?: boolean; paneKeys?: ReadonlySet<string> }
opts?: {
preserveActivityClearedState?: boolean
paneKeys?: ReadonlySet<string>
changedAgentPaneKeys?: Set<string>
}
): Partial<RetiredTerminalTabSweepState> | 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(
@@ -69,7 +69,38 @@ describe('fetchWorktrees', () => {
}
} as Partial<AppState>)
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' })
@@ -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([
[
@@ -137,7 +137,7 @@ export function preserveConcurrentDisplayName<T extends Worktree>(
}
export function mergeFetchedWorktrees(
set: Parameters<StateCreator<AppState, [], [], WorktreeSlice>>[0],
_set: Parameters<StateCreator<AppState, [], [], WorktreeSlice>>[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
)
: {})
}
@@ -156,7 +156,9 @@ export function createPurgeWorktreeTerminalState(
)
)
settleAttentionRemovedBy(set, get, () =>
set((s) => buildWorktreePurgeState(s, purgeableWorktreeTargets))
get().updateAgentStatusState((s, changedPaneKeys) =>
buildWorktreePurgeState(s, purgeableWorktreeTargets, changedPaneKeys)
)
)
}
}
@@ -139,7 +139,8 @@ export function collectHostTwinPurgeScopes(
export function buildHostTwinPurgeState(
s: AppState,
hostId: ExecutionHostId,
twinHostIds: ReadonlyMap<string, readonly ExecutionHostId[]>
twinHostIds: ReadonlyMap<string, readonly ExecutionHostId[]>,
changedPaneKeys?: Set<string>
): Partial<AppState> {
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)
}
}
@@ -38,7 +38,7 @@ export function createPurgeStaleRuntimeHostState(
set,
get,
() =>
set((s) => {
get().updateAgentStatusState((s, changedPaneKeys) => {
const repoIdsWithRemovedOwners = new Set<string>()
const survivingRepoIds = new Set<string>()
const repoIdsWithSurvivingOwners = new Set<string>()
@@ -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(
@@ -40,7 +40,10 @@ export function createWorktreePurgeOmitters(
const omitByPtyId = <T>(obj: Record<string, T>): Record<string, T> =>
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 = <T>(obj: Record<string, T>): Record<string, T> => {
const omitByPaneKeyTabPrefix = <T>(
obj: Record<string, T>,
removedPaneKeys?: Set<string>
): Record<string, T> => {
// 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
})
)
}
@@ -30,20 +30,27 @@ import { composeWorktreeHostIdentity } from '../../../../../../shared/worktree/h
*/
export function buildWorktreePurgeState(
s: AppState,
worktreeTargets: WorktreePurgeTargets
worktreeTargets: WorktreePurgeTargets,
changedPaneKeys?: Set<string>
): Partial<AppState> {
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<string>
): Partial<AppState> {
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(
@@ -15,7 +15,8 @@ export function buildWorktreePurgeSurfacePatch(
s: AppState,
omitters: WorktreePurgeOmitters,
doomedTabIds: ReadonlySet<string>,
sleeping: WorktreePurgeSleepingMaps
sleeping: WorktreePurgeSleepingMaps,
changedPaneKeys?: Set<string>
): Partial<AppState> {
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),
+14 -3
View File
@@ -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<ReturnType<typeof agentVerdictDisplayMark>>
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<DashboardCard, 'dotState' | 'workingMode' | 'unseen'>
): 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<DashboardCard, 'dotState' | 'workingMode' | 'unseen' | 'verdict'>
): 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
@@ -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(() => () => {})
}
}))