mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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
|
||||
|
||||
+10
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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),
|
||||
|
||||
@@ -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(() => () => {})
|
||||
}
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user