From 7d1103bf1815eb6d6643cfa3dd37adb1eeb120b2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:59:14 -0700 Subject: [PATCH 01/17] fix(native-chat): read a crash-cut reply like a finished turn, with one explanation A reply that an Orca crash or restart cut off said it failed three times: the turn bar read "Failed after N", the chat's notice row said Claude stopped, and the sidebar dot stayed red after the chat was read. The turn bar now reads "Worked for N" for a proven crash/restart cut, the notice row stays the one explanation, and the sidebar card's dot and agent rows read failed only until the user has visited the chat (the same acknowledgement that un-bolds the row), then read done. A failure, a user's Stop, a replaced turn and an unconfirmed end keep their labels. The stored outcome is unchanged. --- .../MobileNativeChatTurnStatus.test.ts | 5 +- ...ctured-agent-session-close-verdict.test.ts | 5 +- ...tured-agent-session-crash-turn-end.test.ts | 31 +++++++- ...agent-session-recovered-turn-clock.test.ts | 9 +-- ...d-agent-session-stop-event-restart.test.ts | 5 +- .../dashboard/DashboardAgentRow.test.tsx | 32 +++++++++ .../dashboard/DashboardAgentRow.tsx | 6 +- ...MessageList.interrupted-turn-fold.test.tsx | 5 +- .../components/sidebar/WorktreeCardAgents.tsx | 1 + .../sidebar/use-worktree-activity-statuses.ts | 1 + .../worktree-agent-activity-summary.test.ts | 72 +++++++++++++++++++ .../worktree-agent-activity-summary.ts | 16 ++++- .../worktree-card-agent-summary.test.ts | 48 +++++++++++++ .../sidebar/worktree-card-agent-summary.ts | 43 ++++++++--- .../worktree-card-compact-agent-row.tsx | 22 +++--- .../sidebar/worktree-card-compact-agents.tsx | 11 +-- .../src/lib/agent-pane-activity-flags.ts | 10 ++- src/renderer/src/lib/agent-row-dot-state.ts | 21 ++++-- .../src/lib/agent-verdict-status-line.ts | 12 ++-- ...gent-status-observation-neutrality.test.ts | 4 +- src/shared/agent-main-agent-verdict.test.ts | 20 +++++- src/shared/agent-main-agent-verdict.ts | 19 +++-- src/shared/native-chat-turn-status.test.ts | 22 +++++- src/shared/native-chat-turn-status.ts | 5 +- 24 files changed, 360 insertions(+), 65 deletions(-) diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts index d2069a134b24..a0393996cec3 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -78,14 +78,15 @@ describe('MobileNativeChatTurnStatus', () => { expect(onToggleExpanded).toHaveBeenCalledOnce() }) - it('heads a turn a crash cut off as failed, and a turn the user stopped as interrupted', () => { + // The crash's notice row explains the cut; the turn bar reads like any finished turn. + it('heads a turn a crash cut off as worked, and a turn the user stopped as interrupted', () => { const crashed = render({ startedAt: Date.now(), workedSeconds: 12, verdict: 'interruption', onToggleExpanded: vi.fn() }) - expect(labels(crashed.root)).toEqual(['Failed after 12s']) + expect(labels(crashed.root)).toEqual(['Worked for 12s']) act(() => crashed.unmount()) const stopped = render({ startedAt: Date.now(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 33ef8080c030..b7a3684d356c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -153,9 +153,10 @@ describe('a turn cut short by closing its provider', () => { const { turn, settled } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') + // News for the sidebar, but the turn itself reads like a finished one. expect( settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'failedAfter' }) + ).toMatchObject({ key: 'workedFor' }) }) it("records the user's close on a turn no adapter settled, through the host's fallback", async () => { @@ -250,7 +251,7 @@ describe('a turn cut short by closing its provider', () => { const { settled } = await settledTurn() expect( settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'failedAfter' }) + ).toMatchObject({ key: 'workedFor' }) }) it("keeps the user's cancellation when a close aborts after the provider settled, then retries", async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 7e2376150891..3c993c916253 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -216,6 +216,31 @@ describe('a turn a crash cut short mid-tool', () => { expect(completedStructuredAgentTurnSeconds(timing)).toBe(27) }) + // The turn bar reads like a finished turn, so this row is the one place the chat says why. + it('explains the cut once, with one notice row and a turn bar that does not repeat it', async () => { + openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) + + await host.restoreReadableSessions() + + const { items } = await host.journalSnapshot(SESSION) + const statusRows = items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) + expect(statusRows).toEqual([ + expect.objectContaining({ + text: 'Claude stopped while this response was in progress. You can continue in this conversation.', + failure: expect.objectContaining({ kind: 'providerExited' }), + tone: 'error' + }) + ]) + const [timing] = selectStructuredAgentTurnTimings(items).values() + expect( + describeNativeChatTurnStatus({ + elapsedSeconds: 0, + workedSeconds: completedStructuredAgentTurnSeconds(timing), + verdict: timing?.verdict + }) + ).toEqual({ key: 'workedFor', duration: '27s' }) + }) + it('ends at the pre-crash renewal when the child outlived Orca and recovery stopped it', async () => { // The orphan is alive at relaunch, but its output went nowhere: none of that is work shown. let alive = true @@ -298,7 +323,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () unsubscribe() }) - it('reports the revision to the status feed as an interruption, which the chat folds as failed', async () => { + it('reports the revision to the status feed as an interruption, which the chat folds as worked', async () => { const published: AgentSessionStatusSummary[] = [] openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }), @@ -314,7 +339,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () await host.reconcileRestartLeases() await drainSession() - // The sidebar's red Failed, then the folded "Failed after 27s". + // The sidebar's red Failed until seen; the turn folds as "Worked for 27s" beside its notice row. await vi.waitFor(() => expect(outcomes().at(-1)).toBe('interruption')) const [timing] = selectStructuredAgentTurnTimings( (await host.journalSnapshot(SESSION)).items @@ -325,7 +350,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () workedSeconds: completedStructuredAgentTurnSeconds(timing), verdict: timing?.verdict }) - ).toEqual({ key: 'failedAfter', duration: '27s' }) + ).toEqual({ key: 'workedFor', duration: '27s' }) }) it('revises nothing twice, whoever re-runs the settle', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index e8dfe9f7f5e6..b3cd3f63d680 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -183,8 +183,8 @@ describe('a turn recovery settled after its host went away', () => { } ) - // The chat's turn bar and the tab's mark read one verdict: a turn nobody stopped failed, and - // must never show the done tick of a finished turn. + // The tab's mark reads a turn nobody stopped as failed until the user sees it; the chat's turn + // bar reads it like a finished turn, since the chat's notice row says why it stopped. it.each([ [ 'a restart', @@ -208,7 +208,7 @@ describe('a turn recovery settled after its host went away', () => { ) ] ] as const)( - 'reads Failed after N, marked failed, for a turn cut off by %s', + 'reads Worked for N, marked failed until seen, for a turn cut off by %s', async (_label, cut) => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) @@ -217,11 +217,12 @@ describe('a turn recovery settled after its host went away', () => { const [row] = session.server.getStatusSnapshot() expect(row && agentVerdictDisplayMark(row)).toBe('failed') + expect(row && agentVerdictDisplayMark(row, { seen: true })).toBeNull() const [settled] = [ ...selectStructuredAgentSettledTurns(session.journal.snapshot().items).values() ] expect(settled && formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...settled })).toBe( - 'Failed after 1s' + 'Worked for 1s' ) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 4ae9f59a015a..50056eedd031 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -158,7 +158,8 @@ describe('a restart between a Stop and its turn end', () => { expect(errorRows).toEqual([]) }) - it('reads Failed after N, marked failed, when nobody stopped it', async () => { + // The notice row is the one explanation; the turn bar reads like a finished turn. + it('reads Worked for N, marked failed, when nobody stopped it', async () => { await runningTurn(CODEX_TURN) await restartAndSettle() @@ -166,7 +167,7 @@ describe('a restart between a Stop and its turn end', () => { const { turn, label, mark, errorRows } = settled() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - expect(label).toMatch(/^Failed after /) + expect(label).toMatch(/^Worked for /) expect(mark).toBe('failed') expect(errorRows).toEqual([ expect.stringContaining('stopped while this response was in progress') diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index 5ac748027972..150071e49a37 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -326,6 +326,38 @@ describe('DashboardAgentRow', () => { expect(markup).not.toContain('lucide-circle-check') }) + // Attention only while unseen: a visited crash-cut row reads like a finished one. + it('renders a visited crash-cut row as done, and a visited failure still as failed', () => { + const visited = (outcome: 'interruption' | 'failure') => + renderToStaticMarkup( + + + + ) + + expect(visited('interruption')).toContain('aria-label="Done"') + expect(visited('interruption')).not.toContain('aria-label="Failed"') + expect(visited('failure')).toContain('aria-label="Failed"') + }) + it.each([ ['recorded', { mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 2_000 } }], ["an old host's flag", { interrupted: true }] diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 2cacdfb5474b..87a0b647a8cb 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -85,7 +85,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ onDismiss, onActivate, now, - isUnvisited = false, + isUnvisited, stateDotSize = 'md', hideIdentityIcon = false, hideExpand = false, @@ -171,7 +171,9 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ : [formatAgentTypeLabel(agent.agentType), model].filter(Boolean).join(' · ') // Why: a stop or a failure is a terminal outcome, so surface it in the leading state dot; a // failure does so even while subagents still run. - const dotState: AgentDotState = agentRowDisplayDotState(agent) + // A crash-cut turn reads failed only until the user visits it, like the row's bold; a caller + // that does not say reads unvisited. + const dotState: AgentDotState = agentRowDisplayDotState(agent, { seen: isUnvisited === false }) const dotTooltipLabel = stateDotTooltipLabel(agent, dotState, now) // Why: the elapsed gap is the whole content of an `unverifiable` row, so it rides the // row's own timestamp slot rather than hiding in a hover tooltip. diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx index 13f98dd4249e..acce19bcf523 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx @@ -112,7 +112,8 @@ function session(): NativeChatLiveSession { } describe('NativeChatMessageList folded turn headers', () => { - it('says a turn a crash cut off failed and a stopped turn was interrupted, where a finished one worked', () => { + // A crash-cut turn reads like a finished one; the chat's notice row is what says it stopped. + it('says a crash-cut turn worked like a finished one and a stopped turn was interrupted', () => { render( { .map((button) => [button.textContent, button.getAttribute('aria-expanded')]) expect(headers).toEqual([ ['Worked for 12s', 'false'], - ['Failed after 12s', 'false'], + ['Worked for 12s', 'false'], ['Interrupted after 12s', 'false'] ]) // The turn's detail stays inside the fold. diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 391c45127cda..6d0271576492 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -378,6 +378,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ > { diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts index 388b1ae7d886..92273a7fae2b 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts @@ -22,6 +22,7 @@ type WorktreeActivityStatusState = Pick< | 'migrationUnsupportedByPtyId' | 'retainedAgentsByPaneKey' | 'runtimeAgentOrchestrationByPaneKey' + | 'acknowledgedAgentsByPaneKey' > export function selectWorktreeActivityStatuses( diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index 90b3274c4094..03da184918bf 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -232,6 +232,78 @@ describe('selectWorktreeAgentActivitySummary', () => { expect(summary).toMatchObject(flags) }) + // The card's dot reads red only until the user has seen the chat, by the same acknowledgement + // that un-bolds its agent row; then the crash-cut turn reads done, like a finished one. + it.each([ + ['unseen', 999, { hasFailed: true, hasLiveDone: false }], + ['seen', 1_000, { hasFailed: false, hasLiveDone: true }] + ] as const)('reads a %s crash-cut turn by its acknowledgement', (_label, ackAt, flags) => { + vi.spyOn(Date, 'now').mockReturnValue(2_000) + const paneKey = makePaneKey('tab-1', LEAF_ID) + const failedKey = makePaneKey('tab-2', LEAF_ID) + const state: AgentActivityInput = { + tabsByWorktree: { + 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')], + 'repo::/wt-2': [makeTab('tab-2', 'repo::/wt-2')] + }, + agentStatusEpoch: 2, + agentStatusByPaneKey: { + [paneKey]: makeAgentStatusEntry({ + paneKey, + state: 'done', + mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: 1_000 } + }), + [failedKey]: makeAgentStatusEntry({ + paneKey: failedKey, + state: 'done', + mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 1_000 } + }) + }, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {}, + acknowledgedAgentsByPaneKey: { [paneKey]: ackAt, [failedKey]: ackAt } + } + + expect(selectWorktreeAgentActivitySummary(state, 'repo::/wt-1')).toMatchObject(flags) + // A failure stays news after it is seen. + expect(selectWorktreeAgentActivitySummary(state, 'repo::/wt-2')).toMatchObject({ + hasFailed: true + }) + }) + + it('reads a departed crash-cut agent as done once seen, and failed before', () => { + vi.spyOn(Date, 'now').mockReturnValue(2_000) + const retained = (ackAt: number) => + selectWorktreeAgentActivitySummary( + { + tabsByWorktree: { 'repo::/wt-1': [] }, + agentStatusEpoch: 3, + agentStatusByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: { + 'tab-9:0': { + entry: makeAgentStatusEntry({ + paneKey: 'tab-9:0', + state: 'done', + mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: 1_000 } + }), + worktreeId: 'repo::/wt-1', + tab: makeTab('tab-9', 'repo::/wt-1'), + agentType: 'claude', + startedAt: 1_000 + } + }, + acknowledgedAgentsByPaneKey: { 'tab-9:0': ackAt } + }, + 'repo::/wt-1' + ) + + expect(retained(0)).toMatchObject({ hasRetainedFailed: true, hasRetainedDone: false }) + expect(retained(1_500)).toMatchObject({ hasRetainedFailed: false, hasRetainedDone: true }) + }) + it('separates a failed outcome from clean completion and from a cancellation', () => { vi.spyOn(Date, 'now').mockReturnValue(2_000) const paneKey = makePaneKey('tab-1', LEAF_ID) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index 795d297344c4..b6f30f573114 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -59,6 +59,8 @@ export type AgentActivityInput = Pick< > & { tabsByWorktree: AgentActivityTabsByWorktree runtimeAgentOrchestrationByPaneKey?: AppState['runtimeAgentOrchestrationByPaneKey'] + /** Absent reads every turn unseen. */ + acknowledgedAgentsByPaneKey?: AppState['acknowledgedAgentsByPaneKey'] } type AgentActivityCache = { @@ -67,6 +69,7 @@ type AgentActivityCache = { migrationUnsupportedByPtyId: AppState['migrationUnsupportedByPtyId'] retainedAgentsByPaneKey: AppState['retainedAgentsByPaneKey'] runtimeAgentOrchestrationByPaneKey: AppState['runtimeAgentOrchestrationByPaneKey'] | undefined + acknowledgedAgentsByPaneKey: AppState['acknowledgedAgentsByPaneKey'] | undefined summaries: Map } @@ -89,7 +92,8 @@ function getWorktreeAgentActivitySummaries( agentActivityCache.agentStatusEpoch === state.agentStatusEpoch && agentActivityCache.migrationUnsupportedByPtyId === state.migrationUnsupportedByPtyId && agentActivityCache.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey && - agentActivityCache.runtimeAgentOrchestrationByPaneKey === runtimeAgentOrchestrationByPaneKey + agentActivityCache.runtimeAgentOrchestrationByPaneKey === runtimeAgentOrchestrationByPaneKey && + agentActivityCache.acknowledgedAgentsByPaneKey === state.acknowledgedAgentsByPaneKey ) { return agentActivityCache.summaries } @@ -114,6 +118,10 @@ function getWorktreeAgentActivitySummaries( return summary } + // Same rule as WorktreeCardAgents' unvisitedByPaneKey, so the dot and the row's bold agree. + const seen = (paneKey: string, stateStartedAt: number): boolean => + (state.acknowledgedAgentsByPaneKey?.[paneKey] ?? 0) >= stateStartedAt + const now = Date.now() for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { const paneIdentity = parseAgentStatusPaneIdentity(paneKey) @@ -144,7 +152,7 @@ function getWorktreeAgentActivitySummaries( if (entry.state === 'done') { addParentPaneId(summary, orchestration, worktreeId, tabIdToWorktreeId) } - applyAgentPaneActivityFlags(summary, entry) + applyAgentPaneActivityFlags(summary, entry, { seen: seen(paneKey, entry.stateStartedAt) }) } for (const unsupported of Object.values(state.migrationUnsupportedByPtyId ?? {})) { @@ -158,7 +166,8 @@ function getWorktreeAgentActivitySummaries( for (const retained of Object.values(state.retainedAgentsByPaneKey ?? {})) { const summary = summaryForWorktree(retained.worktreeId) // Why: a failed agent is retained so its failure stays visible, not so it reads done. - if (agentVerdictDisplayMark(retained.entry) === 'failed') { + const retainedSeen = seen(retained.entry.paneKey, retained.entry.stateStartedAt) + if (agentVerdictDisplayMark(retained.entry, { seen: retainedSeen }) === 'failed') { summary.hasRetainedFailed = true } else { summary.hasRetainedDone = true @@ -192,6 +201,7 @@ function getWorktreeAgentActivitySummaries( migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId, retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, runtimeAgentOrchestrationByPaneKey, + acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey, summaries } return summaries diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts index 9f070c355bdd..81b808223d0f 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts @@ -156,6 +156,54 @@ describe('worktree card agent summary', () => { ) }) + // Same acknowledgement as the row's bold: once visited, the crash-cut row reads like a finished one. + it('reads a crash-cut row failed only until the user visits it', () => { + const done = monitoringAgent() + done.state = 'done' + done.entry.state = 'done' + done.entry.workingMode = undefined + done.entry.lastAssistantMessage = 'Halfway through the loop' + const crashCut = { + ...done, + entry: { + ...done.entry, + mainAgent: { state: 'done' as const, outcome: 'interruption' as const, stateStartedAt: 1 } + } + } + const failed = { + ...done, + paneKey: 'tab-1:leaf-3', + entry: { + ...done.entry, + paneKey: 'tab-1:leaf-3', + mainAgent: { state: 'done' as const, outcome: 'failure' as const, stateStartedAt: 1 } + } + } + + expect(getAgentDotState(crashCut, { seen: false })).toBe('failed') + expect(getAgentDotState(crashCut, { seen: true })).toBe('done') + expect(getCompactAgentSecondary(crashCut, 0, undefined, { seen: true })).toBe( + 'Halfway through the loop' + ) + expect(getAgentDotState(failed, { seen: true })).toBe('failed') + + const unvisited = { [crashCut.paneKey]: false, [failed.paneKey]: false } + expect(summarizeAgents([crashCut, failed], 'Agents', unvisited)).toBe( + 'Agents: 1 failed, 1 done' + ) + expect( + buildSummaryAgentGroups([crashCut, failed], unvisited).map((group) => group.state) + ).toEqual(['failed', 'done']) + // A row the map does not name has not been visited. + expect(summarizeAgents([crashCut], 'Agents', {})).toBe('Agents failed') + + const markup = (isUnvisited: boolean) => + renderCompactAgentRow({ agent: crashCut, now: 0, onActivate: vi.fn(), isUnvisited }) + expect(markup(true)).toContain('Failed') + expect(markup(false)).not.toContain('Failed') + expect(markup(false)).toContain('Halfway through the loop') + }) + it("lists a failed turn as failed, not done, ahead of a user's Stop", () => { const done = monitoringAgent() done.state = 'done' diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts index 072ff7f2ba0d..798fcc2aede2 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts @@ -2,6 +2,7 @@ import type { AgentDotState } from '@/components/AgentStateDot' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { formatAgentTypeLabel } from '@/lib/agent-status' import { agentRowDisplayDotState } from '@/lib/agent-row-dot-state' +import type { AgentVerdictViewing } from '../../../../shared/agent-main-agent-verdict' export type SummaryAgentGroup = { state: AgentDotState @@ -24,8 +25,22 @@ const SUMMARY_STATE_ORDER: AgentDotState[] = [ 'idle' ] -export function getAgentDotState(agent: DashboardAgentRowData): AgentDotState { - return agentRowDisplayDotState(agent) +/** Which of a card's agents the user has not visited yet, as WorktreeCardAgents derives it. */ +export type AgentUnvisitedByPaneKey = Readonly> + +export function getAgentDotState( + agent: DashboardAgentRowData, + viewing?: AgentVerdictViewing +): AgentDotState { + return agentRowDisplayDotState(agent, viewing) +} + +/** An agent the map does not name reads unseen. */ +function viewingFrom( + agent: DashboardAgentRowData, + unvisitedByPaneKey: AgentUnvisitedByPaneKey | undefined +): AgentVerdictViewing { + return { seen: unvisitedByPaneKey?.[agent.paneKey] === false } } export function formatSummaryStateLabel(state: AgentDotState): string { @@ -55,10 +70,13 @@ export function formatSummaryStateLabel(state: AgentDotState): string { } } -export function buildSummaryAgentGroups(agents: DashboardAgentRowData[]): SummaryAgentGroup[] { +export function buildSummaryAgentGroups( + agents: DashboardAgentRowData[], + unvisitedByPaneKey?: AgentUnvisitedByPaneKey +): SummaryAgentGroup[] { const groups = new Map() for (const agent of agents) { - const dotState = getAgentDotState(agent) + const dotState = getAgentDotState(agent, viewingFrom(agent, unvisitedByPaneKey)) const group = groups.get(dotState) if (group) { group.push(agent) @@ -72,10 +90,14 @@ export function buildSummaryAgentGroups(agents: DashboardAgentRowData[]): Summar }) } -export function summarizeAgents(agents: DashboardAgentRowData[], subjectLabel: string): string { +export function summarizeAgents( + agents: DashboardAgentRowData[], + subjectLabel: string, + unvisitedByPaneKey?: AgentUnvisitedByPaneKey +): string { const counts = new Map() for (const agent of agents) { - const dotState = getAgentDotState(agent) + const dotState = getAgentDotState(agent, viewingFrom(agent, unvisitedByPaneKey)) counts.set(dotState, (counts.get(dotState) ?? 0) + 1) } const parts = SUMMARY_STATE_ORDER.flatMap((state) => { @@ -95,11 +117,16 @@ export function summarizeAgents(agents: DashboardAgentRowData[], subjectLabel: s return `${subjectLabel}: ${parts.join(', ')}` } -export function summarizeAgentIdentities(agents: DashboardAgentRowData[]): string { +export function summarizeAgentIdentities( + agents: DashboardAgentRowData[], + unvisitedByPaneKey?: AgentUnvisitedByPaneKey +): string { return agents .map((agent) => { const agentLabel = formatAgentTypeLabel(agent.agentType) - const stateLabel = formatSummaryStateLabel(getAgentDotState(agent)) + const stateLabel = formatSummaryStateLabel( + getAgentDotState(agent, viewingFrom(agent, unvisitedByPaneKey)) + ) return `${agentLabel} ${stateLabel}` }) .join('; ') diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index 4f3a34a2b05a..d0cc7e0c376d 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -15,21 +15,24 @@ import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timesta import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer' import { formatShortTimeAgo } from '@/lib/short-time-ago' import { agentVerdictStatusLine } from '@/lib/agent-verdict-status-line' +import type { AgentVerdictViewing } from '../../../../shared/agent-main-agent-verdict' function getCompactAgentPrimary( agent: DashboardAgentRowData, - conversationName: string | null + conversationName: string | null, + viewing: AgentVerdictViewing ): string { const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry) - return prompt || agentStateLabel(getAgentDotState(agent)) + return prompt || agentStateLabel(getAgentDotState(agent, viewing)) } export function getCompactAgentSecondary( agent: DashboardAgentRowData, now: number, - lastAssistantMessageOverride?: string + lastAssistantMessageOverride?: string, + viewing?: AgentVerdictViewing ): string { - const verdictLine = agentVerdictStatusLine(agent.entry) + const verdictLine = agentVerdictStatusLine(agent.entry, viewing) if (verdictLine) { return verdictLine } @@ -98,7 +101,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ isFocusedPane = false, hideIdentityIcon = false, cacheTimerActive = true, - isUnvisited = false + isUnvisited }: CompactAgentRowProps) { const hasChildDisclosure = typeof childAgentCount === 'number' && @@ -108,9 +111,12 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ // agentType, which is not an iconable agent and would render the unknown // "?" glyph. Nesting under the parent already conveys identity. const hideIcon = hideIdentityIcon || agent.rowSource === 'subagent' - const dotState = getAgentDotState(agent) + // A crash-cut turn reads failed only until the user visits it, like the row's bold; a caller + // that does not say reads unvisited. + const viewing = { seen: isUnvisited === false } + const dotState = getAgentDotState(agent, viewing) const conversationName = useAgentRowConversationName(agent) - const primary = getCompactAgentPrimary(agent, conversationName) + const primary = getCompactAgentPrimary(agent, conversationName, viewing) const isLineageChild = agent.lineage?.depth === 1 // Keep a live row's last assistant line stable while status/tool payloads // briefly omit the hook-only field between updates. Committed in an effect so a @@ -131,7 +137,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ const held = heldMessageRef.current const stableMessage = turnHoldable && !currentMessage && held?.turn === turn ? held.message : undefined - const secondary = getCompactAgentSecondary(agent, now, stableMessage) + const secondary = getCompactAgentSecondary(agent, now, stableMessage, viewing) // Why: sidebar truncation must preserve the passive-vs-active distinction. const leadingText = dotState === 'monitoring' ? secondary : primary const trailingText = diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx index e77304a064d7..d821abb537c0 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx @@ -9,7 +9,8 @@ import { buildSummaryAgentGroups, selectSummaryGroupIconAgents, summarizeAgentIdentities, - summarizeAgents + summarizeAgents, + type AgentUnvisitedByPaneKey } from './worktree-card-agent-summary' import { translate } from '@/i18n/i18n' @@ -25,6 +26,7 @@ function stopActivationKeyPropagation(e: React.KeyboardEvent): void { type CompactAgentSummaryButtonProps = { agents: DashboardAgentRowData[] + unvisitedByPaneKey?: AgentUnvisitedByPaneKey subjectLabel: string expanded: boolean onToggle: () => void @@ -77,17 +79,18 @@ export function CompactAgentExpansion({ export function CompactAgentSummaryButton({ agents, + unvisitedByPaneKey, subjectLabel, expanded, onToggle }: CompactAgentSummaryButtonProps): React.JSX.Element { - const summary = summarizeAgents(agents, subjectLabel) - const groups = buildSummaryAgentGroups(agents) + const summary = summarizeAgents(agents, subjectLabel, unvisitedByPaneKey) + const groups = buildSummaryAgentGroups(agents, unvisitedByPaneKey) const visibleGroups = groups.slice(0, 3) const hiddenGroupAgentCount = groups .slice(visibleGroups.length) .reduce((count, group) => count + group.agents.length, 0) - const agentIdentitySummary = summarizeAgentIdentities(agents) + const agentIdentitySummary = summarizeAgentIdentities(agents, unvisitedByPaneKey) const stopPointerPropagation = useCallback((e: React.SyntheticEvent) => { e.stopPropagation() }, []) diff --git a/src/renderer/src/lib/agent-pane-activity-flags.ts b/src/renderer/src/lib/agent-pane-activity-flags.ts index ad70621bb4d9..e100820dfdeb 100644 --- a/src/renderer/src/lib/agent-pane-activity-flags.ts +++ b/src/renderer/src/lib/agent-pane-activity-flags.ts @@ -1,5 +1,8 @@ import type { AgentStatusEntry } from '../../../shared/agent-status-types' -import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' +import { + agentVerdictDisplayMark, + type AgentVerdictViewing +} from '../../../shared/agent-main-agent-verdict' /** What fresh agent panes contribute to a container's status (worktree card, terminal tab). */ export type AgentPaneActivityFlags = { @@ -15,9 +18,10 @@ export type AgentPaneActivityFlags = { /** Fold one fresh pane's entry into its container's flags; `resolveWorktreeStatus` ranks them. */ export function applyAgentPaneActivityFlags( flags: AgentPaneActivityFlags, - entry: Pick + entry: Pick, + viewing?: AgentVerdictViewing ): void { - const mark = agentVerdictDisplayMark(entry) + const mark = agentVerdictDisplayMark(entry, viewing) if (entry.state === 'blocked' || entry.state === 'waiting') { flags.hasPermission = true } diff --git a/src/renderer/src/lib/agent-row-dot-state.ts b/src/renderer/src/lib/agent-row-dot-state.ts index bf2b7b1e5320..92617de5c5ea 100644 --- a/src/renderer/src/lib/agent-row-dot-state.ts +++ b/src/renderer/src/lib/agent-row-dot-state.ts @@ -1,6 +1,9 @@ import type { AgentDotState } from '@/components/AgentStateDot' import type { AgentChildRowModel } from '../../../shared/agent-child-row-model' -import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' +import { + agentVerdictDisplayMark, + type AgentVerdictViewing +} from '../../../shared/agent-main-agent-verdict' import type { AgentStatusEntry, AgentWorkingMode } from '../../../shared/agent-status-types' import type { AgentRowState } from './agent-row-decay-state' @@ -29,15 +32,19 @@ export function agentRowDotState( * The dot an agent row renders: a child row's own, else the main agent's verdict (a stop or a * failure, which a failure shows even while subagents still run), else its state. */ -export function agentRowDisplayDotState(agent: { - state: AgentRowState - entry: Pick - childRow?: Pick -}): AgentDotState { +export function agentRowDisplayDotState( + agent: { + state: AgentRowState + entry: Pick + childRow?: Pick + }, + viewing?: AgentVerdictViewing +): AgentDotState { if (agent.childRow) { return agent.childRow.displayState } return ( - agentVerdictDisplayMark(agent.entry) ?? agentRowDotState(agent.state, agent.entry.workingMode) + agentVerdictDisplayMark(agent.entry, viewing) ?? + agentRowDotState(agent.state, agent.entry.workingMode) ) } diff --git a/src/renderer/src/lib/agent-verdict-status-line.ts b/src/renderer/src/lib/agent-verdict-status-line.ts index 2c7aecefd4a5..90ff5e2509c4 100644 --- a/src/renderer/src/lib/agent-verdict-status-line.ts +++ b/src/renderer/src/lib/agent-verdict-status-line.ts @@ -1,14 +1,18 @@ import { agentMainAgentVerdict, agentVerdictDisplayMark, - type AgentMainAgentVerdictSource + type AgentMainAgentVerdictSource, + type AgentVerdictViewing } from '../../../shared/agent-main-agent-verdict' /** The line an agent row shows in place of its preview once its verdict marks it. A user's Stop * says so; a turn a newer request replaced reads plainly interrupted, since no one is named; a - * turn cut off by a crash reads failed, as a failure does. */ -export function agentVerdictStatusLine(entry: AgentMainAgentVerdictSource): string | null { - switch (agentVerdictDisplayMark(entry)) { + * turn cut off by a crash reads failed, as a failure does, until the user has seen it. */ +export function agentVerdictStatusLine( + entry: AgentMainAgentVerdictSource, + viewing?: AgentVerdictViewing +): string | null { + switch (agentVerdictDisplayMark(entry, viewing)) { case 'failed': return 'Failed' case 'interrupted': diff --git a/src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts b/src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts index 97fdd8666fbe..e6b572abceac 100644 --- a/src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts +++ b/src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts @@ -118,7 +118,9 @@ describe('agent status observation is behavior-neutral', () => { expect(stampedRows.map((row) => ({ ...row, entry: withoutObservation(row.entry) }))).toEqual( unstampedRows.map((row) => ({ ...row, entry: withoutObservation(row.entry) })) ) - expect(stampedRows.map(getAgentDotState)).toEqual(unstampedRows.map(getAgentDotState)) + expect(stampedRows.map((row) => getAgentDotState(row))).toEqual( + unstampedRows.map((row) => getAgentDotState(row)) + ) expect(resolveAttention([{ kind: 'hook', entry: stamped, hasLivePty: false }], at)).toEqual( resolveAttention([{ kind: 'hook', entry: unstamped, hasLivePty: false }], at) diff --git a/src/shared/agent-main-agent-verdict.test.ts b/src/shared/agent-main-agent-verdict.test.ts index d2b1bd185f59..c8419cdf51b4 100644 --- a/src/shared/agent-main-agent-verdict.test.ts +++ b/src/shared/agent-main-agent-verdict.test.ts @@ -114,7 +114,7 @@ describe('agentMainAgentVerdict', () => { expect(agentVerdictDisplayMark({ state: 'done', interrupted: true })).toBe('interrupted') }) - it('reads a crash-cut turn as failed and an unproven end as unconfirmed, neither a stop', () => { + it('reads an unseen crash-cut turn as failed and an unproven end as unconfirmed, neither a stop', () => { for (const [outcome, mark] of [ ['interruption', 'failed'], ['unconfirmed', 'unconfirmed'] @@ -127,6 +127,24 @@ describe('agentMainAgentVerdict', () => { } }) + // Attention only while unseen: once read, a crash-cut turn reads like a finished one, and its + // notice row in the chat is what still says it stopped. + it('drops the failed mark from a crash-cut turn the user has seen, and from nothing else', () => { + for (const [outcome, unseen, seen] of [ + ['interruption', 'failed', null], + ['failure', 'failed', 'failed'], + ['cancellation', 'interrupted', 'interrupted'], + ['superseded', 'interrupted', 'interrupted'], + ['unconfirmed', 'unconfirmed', 'unconfirmed'], + ['success', null, null] + ] as const) { + const row = { state: 'done' as const, mainAgent: { state: 'done' as const, outcome } } + expect(agentVerdictDisplayMark(row), outcome).toBe(unseen) + expect(agentVerdictDisplayMark(row, { seen: false }), outcome).toBe(unseen) + expect(agentVerdictDisplayMark(row, { seen: true }), outcome).toBe(seen) + } + }) + it('reads an arm from a newer host as no verdict, so the row reads done as it did', () => { const row = { state: 'done' as const, diff --git a/src/shared/agent-main-agent-verdict.ts b/src/shared/agent-main-agent-verdict.ts index 929b1d2748cf..faae536c7bf5 100644 --- a/src/shared/agent-main-agent-verdict.ts +++ b/src/shared/agent-main-agent-verdict.ts @@ -46,20 +46,29 @@ export function agentMainAgentVerdict(row: AgentMainAgentVerdictSource): AgentTu return row.state === 'done' && row.interrupted === true ? 'cancellation' : null } +/** What a display knows of the user having seen the agent's current turn. */ +export type AgentVerdictViewing = { + /** The user acknowledged this turn (the rule that un-bolds a sidebar row). Absent reads unseen. */ + seen?: boolean +} + /** * What the verdict marks on the agent's own display. A fault, whether the turn failed or something * other than the user cut it short, reads failed and outranks every combined state: it is news the - * user must see even while subagents still run. A user's Stop, or a turn a newer request replaced, - * reads interrupted, and an unproven end unconfirmed, only on a row that is itself done, so live - * child work still reads working. + * user must see even while subagents still run. A turn a crash or restart cut off is news only + * until seen; after that it reads like a finished turn, since the chat's notice row says why. A + * user's Stop, or a turn a newer request replaced, reads interrupted, and an unproven end + * unconfirmed, only on a row that is itself done, so live child work still reads working. */ export function agentVerdictDisplayMark( - row: AgentMainAgentVerdictSource + row: AgentMainAgentVerdictSource, + viewing: AgentVerdictViewing = {} ): 'failed' | 'interrupted' | 'unconfirmed' | null { switch (agentMainAgentVerdict(row)) { case 'failure': - case 'interruption': return 'failed' + case 'interruption': + return viewing.seen ? null : 'failed' case 'cancellation': case 'superseded': return row.state === 'done' ? 'interrupted' : null diff --git a/src/shared/native-chat-turn-status.test.ts b/src/shared/native-chat-turn-status.test.ts index 3b70e57bdafb..08557aef9a0a 100644 --- a/src/shared/native-chat-turn-status.test.ts +++ b/src/shared/native-chat-turn-status.test.ts @@ -42,9 +42,9 @@ describe('describeNativeChatTurnStatus', () => { it.each([ ['cancellation', 'interruptedAfter'], ['superseded', 'interruptedAfter'], - // A turn anything but the user cut short reads as a failure does. - ['interruption', 'failedAfter'], ['failure', 'failedAfter'], + // A crash or restart cut it off: the turn reads finished and the chat's notice row says why. + ['interruption', 'workedFor'], ['success', 'workedFor'], ['unconfirmed', 'workedFor'], [undefined, 'workedFor'] @@ -108,6 +108,24 @@ describe('formatNativeChatTurnStatusLabel', () => { 'Worked for 3m 4s' ) }) + + // The phone's turn bar renders through this function, so it reads the same as desktop. + it('reads a crash-cut turn as worked and a Stop as interrupted in English', () => { + expect( + formatNativeChatTurnStatusLabel({ + workedSeconds: 222, + elapsedSeconds: 0, + verdict: 'interruption' + }) + ).toBe('Worked for 3m 42s') + expect( + formatNativeChatTurnStatusLabel({ + workedSeconds: 222, + elapsedSeconds: 0, + verdict: 'cancellation' + }) + ).toBe('Interrupted after 3m 42s') + }) }) describe('reduceNativeChatTurnTiming', () => { diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index f1dbea00947d..54df2b2ad3e5 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -42,7 +42,8 @@ export function describeNativeChatTurnStatus({ workedSeconds?: number | null elapsedSeconds: number /** How the settled turn ended: a user's Stop or a newer request's replacement reads interrupted, - * and a failure or a turn anything else cut short reads failed. */ + * and a failure reads failed. A turn a crash or restart cut off reads like a finished one: the + * chat's own notice row is what says it stopped. */ verdict?: AgentTurnOutcome }): { key: 'workingFor' | 'workedFor' | 'interruptedAfter' | 'failedAfter' @@ -61,9 +62,9 @@ function settledTurnStatusKey( case 'superseded': return 'interruptedAfter' case 'failure': - case 'interruption': return 'failedAfter' case 'success': + case 'interruption': case 'unconfirmed': case undefined: return 'workedFor' From b0b71e36705dc06aef9c58e1fdca56eae4249a0b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:09:53 -0700 Subject: [PATCH 02/17] fix(native-chat): explain a turn a quit or eviction cut, once A turn cut off by quitting Orca, an idle eviction or a teardown recorded the same outcome as a crash-cut turn but wrote no notice row, so with the turn bar now reading "Worked for N" nothing in the chat said it stopped. The host stop's settle now writes the existing providerExited notice for the latest turn when it ends as news (no person's Stop decided it): in the same write as the host's own turn end, or right after the adapter's. It is keyed by the turn, and skipped when an error row already explains that turn, so a retry or an earlier exit row never leaves two. --- ...ctured-agent-session-close-verdict.test.ts | 73 ++++++++++++---- ...gent-session-dead-generation-settlement.ts | 7 +- .../structured-agent-session-host-lifetime.ts | 32 ++++++- ...ent-session-restart-failure-filing.test.ts | 3 +- ...ssion-restart-interruption-test-harness.ts | 8 ++ ...t-session-restart-offer-withdrawal.test.ts | 5 +- ...ed-agent-session-restart-ownership.test.ts | 3 +- ...tructured-agent-session-stop-cut-notice.ts | 84 +++++++++++++++++++ 8 files changed, 193 insertions(+), 22 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-cut-notice.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index b7a3684d356c..21e2b82e95a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -11,6 +11,7 @@ import { } from '../../../shared/agent-session-journal-types' import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { describeNativeChatTurnStatus } from '../../../shared/native-chat-turn-status' import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' @@ -121,9 +122,25 @@ async function settledTurn() { const { items } = await host.journalSnapshot(SESSION) const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) const [settled] = [...selectStructuredAgentSettledTurns(items).values()] - return { turn, settled } + const cutTurnItemId = items.find((item) => readAgentJournalTurn(item.body))?.itemId + // Every row that says why the turn stopped, and which turn it says it of. + const notices = items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' + ? [{ text: item.body.text, ofCutTurn: item.turnScope?.turnItemId === cutTurnItemId }] + : [] + ) + const label = settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }).key + return { turn, settled, notices, label } } +/** A turn nobody stopped reads like a finished one, with exactly one row saying it stopped. */ +const ONE_NOTICE = [ + { + text: 'Codex stopped while this response was in progress. You can continue in this conversation.', + ofCutTurn: true + } +] + function lastSummary(statuses: AgentSessionStatusEvent[]) { const last = statuses.at(-1) return last?.type === 'status' ? last.session : null @@ -136,11 +153,13 @@ describe('a turn cut short by closing its provider', () => { await host.close(SESSION, 'user-close') expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' }) - const { turn, settled } = await settledTurn() + const { turn, settled, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) expect( settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) ).toMatchObject({ key: 'interruptedAfter' }) + // The person's own close explains itself. + expect(notices).toEqual([]) }) it('leaves a close the user did not aim at this chat as news', async () => { @@ -150,13 +169,12 @@ describe('a turn cut short by closing its provider', () => { await host.close(SESSION, 'evict') expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'interruption' }) - const { turn, settled } = await settledTurn() + const { turn, notices, label } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - // News for the sidebar, but the turn itself reads like a finished one. - expect( - settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'workedFor' }) + // News for the sidebar; the turn reads like a finished one beside its one notice row. + expect(label).toBe('workedFor') + expect(notices).toEqual(ONE_NOTICE) }) it("records the user's close on a turn no adapter settled, through the host's fallback", async () => { @@ -248,10 +266,9 @@ describe('a turn cut short by closing its provider', () => { expect(statuses.findLast((event) => event.type === 'status')).toMatchObject({ session: { status: 'idle', turnOutcome: 'interruption' } }) - const { settled } = await settledTurn() - expect( - settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'workedFor' }) + const { label, notices } = await settledTurn() + expect(label).toBe('workedFor') + expect(notices).toEqual(ONE_NOTICE) }) it("keeps the user's cancellation when a close aborts after the provider settled, then retries", async () => { @@ -298,9 +315,11 @@ describe('a turn cut short by closing its provider', () => { await host.collaboratorsForTests().lifetime.idleSweep.tick() expect(closeCalls).toBe(1) - const { turn } = await settledTurn() + const { turn, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn?.outcome).toBe(verdict.outcome) + // The retry writes the notice once, and only for the cut nobody asked for. + expect(notices).toEqual(cause === 'evict' ? ONE_NOTICE : []) } ) @@ -310,9 +329,33 @@ describe('a turn cut short by closing its provider', () => { await host.flushAllStreamedEvents({ trigger: 'quit' }) - const { turn } = await settledTurn() + const { turn, label, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') + // The notice lands in the same write as the host's own turn end. + expect(label).toBe('workedFor') + expect(notices).toEqual(ONE_NOTICE) + }) + + it('adds no second notice to a turn an exit row already explains, then quit', async () => { + providerEnd = null + await runningTurn() + const events = hostTestState().acquire.mock.calls[0]?.[0].events + events?.appendItem( + { provider: 'orca', clientMessageId: 'exit-row' }, + { + kind: 'status', + text: 'Codex stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' + }, + { turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(CUT_TURN) } } + ) + await host.flushStreamedEvents(SESSION) + + await host.flushAllStreamedEvents({ trigger: 'quit' }) + + const { notices } = await settledTurn() + expect(notices).toEqual(ONE_NOTICE) }) it('leaves a crash the provider saw before the user closed the chat as news', async () => { @@ -320,9 +363,11 @@ describe('a turn cut short by closing its provider', () => { await runningTurn() // The provider reports its own exit, which it saw first, as it closes: no verdict. await host.close(SESSION, 'user-close') - const { turn } = await settledTurn() + const { turn, label, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') + expect(label).toBe('workedFor') + expect(notices).toEqual(ONE_NOTICE) }) it.each(['user-close', 'evict'] as const)( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 39ff1c7ba514..8ed0af27fec9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -124,11 +124,13 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** The provider never finished starting: the start that failed, keyed by the child's * generation. Its row is the one the delivery loop writes for the same start. */ exitedDuringStartup?: { generation: string | null } + /** A host stop nobody asked for (quit, eviction): the one notice row for the turn it cut. */ + stopCutNotice?: JournalLifecycleMutationInput | null }): Promise { try { const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal) const showUnexpectedExitOutcome = input.showUnexpectedExitOutcome ?? hasUnfinishedWork - if (!showUnexpectedExitOutcome && !hasUnfinishedWork) { + if (!showUnexpectedExitOutcome && !hasUnfinishedWork && !input.stopCutNotice) { return { ok: true } } // A queued message is the delivery loop's to settle: it was never handed to this child. A @@ -173,6 +175,9 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { turnScope: exitedRootTurnScope(items, input.verdict) }) } + if (input.stopCutNotice) { + mutations.push(input.stopCutNotice) + } for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) const body = terminalDeadGenerationBody(item) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 60c20cb768d9..eed61e3767e8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -11,7 +11,11 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' import { evictStructuredAgentSession, STRUCTURED_AGENT_SESSION_EVICTION_STEPS, @@ -33,6 +37,7 @@ import { } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' +import { structuredAgentSessionStopCutNotice } from './structured-agent-session-stop-cut-notice' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' export type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-stop-event' import { @@ -90,6 +95,15 @@ export async function abandonQueuedStructuredAgentSessionMessages( ) } +/** Who the stop-cut notice names. */ +function stopCutFailureTextContext( + context: StructuredAgentSessionLifetimeContext, + sessionId: string +): AgentSessionFailureWordsContext { + const record = context.deps.store.getRecord(sessionId) + return record ? { agentName: TUI_AGENT_DISPLAY_NAMES[record.provider] } : {} +} + /** The wind-down this host owes for the session's child. A live child always owes one, whatever a * previous childless eviction recorded: a remembered tombstone must never outrank the child in * front of it. */ @@ -184,6 +198,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( await recorded const fence = owed?.fence ?? structuredAgentSessionConversationFence(context.deps.store, sessionId) + const fallbackEndedAt = context.now() const settled = await settleStructuredAgentSessionDeadGeneration({ journal: session.journal, sessionId, @@ -192,8 +207,19 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( pendingSubmissionReason: 'provider_closed_before_acknowledgement', // Only a turn no adapter settled: one with no close, or whose settle threw. Whether it was // a person's Stop is its event's to say (`turnEndAfterStop`). - verdict: { state: 'interrupted', completedAt: context.now() }, - showUnexpectedExitOutcome: false + verdict: { state: 'interrupted', completedAt: fallbackEndedAt }, + showUnexpectedExitOutcome: false, + // In the same write as the fallback's turn end, or just after the adapter's. A host stop's + // row is the delivery loop's, and a person's Stop explains itself. + stopCutNotice: + (cause === 'evict' || cause === 'user-close') && owed + ? structuredAgentSessionStopCutNotice(sessionId, session.journal.snapshot().items, { + journal: session.journal, + fence: owed.fence, + fallbackEndedAt, + failureTextContext: stopCutFailureTextContext(context, sessionId) + }) + : null }) if (!settled.ok) { context.deps.logger.warn("settling a closed agent's work failed", { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index f1fe0a40b421..1228256bbe51 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -8,6 +8,7 @@ import { StructuredAgentSessionResumeAdmission } from './structured-agent-sessio import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring' import { interruptedRestart, + QUIT_CUT_NOTICE, statusNotes } from './structured-agent-session-restart-interruption-test-harness' import { CALLER, envelope } from './structured-agent-session-host-test-harness' @@ -42,7 +43,7 @@ it('files nothing for a chat the user moved on in before its attempt, and spends const capsule = new AgentSessionRecoveryCapsule(root) expect(await capsule.listFailed(NOW)).toEqual([]) expect(await capsule.list(NOW)).toEqual([]) - expect(await statusNotes(host)).toEqual([]) + expect(await statusNotes(host)).toEqual(QUIT_CUT_NOTICE) }) // The continuation is accepted and its agent then fails to start: the message is rejected with the diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 94746daf3ec6..d88185e10e59 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -160,6 +160,14 @@ export async function interruptedRestart( return { ...hostTestState(), host, store, log, closeSession, marker, clock } } +/** The one row the quit wrote for the turn it cut; a continuation that fails nothing adds none. */ +export const QUIT_CUT_NOTICE = [ + { + text: 'Codex stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' + } +] + export async function statusNotes(host: StructuredAgentSessionHost) { return (await host.journalSnapshot(SESSION)).items.flatMap((item) => item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts index b155674babf0..6ca9bbfe676e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -17,6 +17,7 @@ import { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { interruptedRestart, + QUIT_CUT_NOTICE, statusNotes } from './structured-agent-session-restart-interruption-test-harness' import { CALLER, envelope } from './structured-agent-session-host-test-harness' @@ -385,8 +386,8 @@ it("refuses an automatic continuation when the user's message was accepted first expect(result.continued).toMatchObject([{ outcome: 'refused', reason: SUPERSEDED }]) await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce(), COLD_START) expect(sentTexts(dispatch)).toEqual(['A new request']) - // Nothing the user did failed: no row reached the reader, and nothing is kept. - expect(await statusNotes(host)).toEqual([]) + // Nothing the user did failed: only the quit's own notice is there, and nothing is kept. + expect(await statusNotes(host)).toEqual(QUIT_CUT_NOTICE) expect( events.some( (event) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index a46551b4500e..7a855c6c4026 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -17,6 +17,7 @@ import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structur import { interruptedRestart, startAgent, + QUIT_CUT_NOTICE, statusNotes, supersededRefusal } from './structured-agent-session-restart-interruption-test-harness' @@ -361,7 +362,7 @@ it("refuses a continuation quietly when the user's own message was accepted firs failed: [] }) await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - expect(await statusNotes(host)).toEqual([]) + expect(await statusNotes(host)).toEqual(QUIT_CUT_NOTICE) expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cut-notice.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cut-notice.ts new file mode 100644 index 000000000000..a854d12cfe49 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cut-notice.ts @@ -0,0 +1,84 @@ +// The one notice a host stop owes a turn it cut short when nobody's Stop ended it: a quit, an idle +// eviction or a teardown. That turn reads like a finished one, so this row is what says it stopped. + +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +export type StructuredAgentSessionStopCut = { + journal: Pick + /** The stopped child's fence: an end with no verdict on its latest turn is this stop's cut. */ + fence: number + /** When the host's fallback ends a turn no adapter settled. */ + fallbackEndedAt: number + failureTextContext?: AgentSessionFailureWordsContext +} + +/** The latest root turn, when it ends as news: still running for the fallback to end, or ended by + * the stopped child's adapter with no verdict. Null when a person's Stop decides it. */ +function cutTurn( + items: readonly AgentJournalRenderItem[], + stop: StructuredAgentSessionStopCut +): AgentJournalRenderItem | null { + const latest = items.findLast( + (item) => isRootAgentJournalItem(item) && readAgentJournalTurn(item.body) !== null + ) + const turn = latest ? readAgentJournalTurn(latest.body) : null + if (!latest || !turn) { + return null + } + if (turn.state === 'running') { + return stop.journal.stopMarks.personStopDecides(turn.turnId, stop.fallbackEndedAt) + ? null + : latest + } + return turn.state === 'interrupted' && + turn.outcome === undefined && + stop.journal.itemFence(latest.itemId) === stop.fence + ? latest + : null +} + +/** The notice for the turn this stop cut, unless one already explains it (an exit row, or this + * notice from an earlier attempt): one explanation per cut. */ +export function structuredAgentSessionStopCutNotice( + sessionId: string, + items: readonly AgentJournalRenderItem[], + stop: StructuredAgentSessionStopCut +): JournalLifecycleMutationInput | null { + const turn = cutTurn(items, stop) + if (!turn) { + return null + } + const explained = items.some( + (item) => + item.body.kind === 'status' && + item.body.tone === 'error' && + item.turnScope?.kind === 'turn' && + item.turnScope.turnItemId === turn.itemId + ) + if (explained) { + return null + } + return { + kind: 'item', + // Named by the turn it explains, so a retried settle rewrites this row instead of adding one. + identity: { provider: 'orca', clientMessageId: `stop-cut:${sessionId}:${turn.itemId}` }, + body: { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + ...stop.failureTextContext, + surface: 'row' + }), + tone: 'error' + }, + turnScope: { kind: 'turn', turnItemId: turn.itemId } + } +} From 029b6e9c2850ffab196d5e912d4e4a9a2b8e5657 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:12:25 -0700 Subject: [PATCH 03/17] test(native-chat): narrow the turn scope and add the seen map in the crash-cut tests --- .../structured-agent-session-close-verdict.test.ts | 8 +++++++- .../sidebar/use-worktree-activity-statuses.test.ts | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 21e2b82e95a2..98e110e9a267 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -126,7 +126,13 @@ async function settledTurn() { // Every row that says why the turn stopped, and which turn it says it of. const notices = items.flatMap((item) => item.body.kind === 'status' && item.body.tone === 'error' - ? [{ text: item.body.text, ofCutTurn: item.turnScope?.turnItemId === cutTurnItemId }] + ? [ + { + text: item.body.text, + ofCutTurn: + item.turnScope?.kind === 'turn' && item.turnScope.turnItemId === cutTurnItemId + } + ] : [] ) const label = settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }).key diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts index c9b5092f76e2..da382cca8e34 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts @@ -15,7 +15,8 @@ function makeStatusState(): StatusState { agentStatusByPaneKey: {}, migrationUnsupportedByPtyId: {}, retainedAgentsByPaneKey: {}, - runtimeAgentOrchestrationByPaneKey: {} + runtimeAgentOrchestrationByPaneKey: {}, + acknowledgedAgentsByPaneKey: {} } } From 829a14b2706c6844a2a448a3eb5c9a6a470560f7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:39:08 -0700 Subject: [PATCH 04/17] fix(native-chat): derive a cut turn's one notice on read instead of writing it at stop time A turn cut short when the agent stopped without anyone asking now reads "Worked for N", so it needs a row saying it stopped. Writing that row only on the quit and eviction paths missed journals written before this change, a quit that died between its drain and its settle, and any future stop cause. The transcript now derives it from the journal on desktop and phone alike: a root turn that ended interrupted with no verdict and no row about the stop gets one notice in the provider-exit row's words. A stored exit row, matched by its exit fact or its writer's identity rather than its tone, stays the explanation, and so does the restart continuation's own outcome note, so a refused resume says it once. The host's stop path is back to what it was. --- ...red-agent-session.cut-turn-notice.test.tsx | 124 ++++++++ .../use-mobile-structured-agent-session.ts | 20 +- ...ctured-agent-session-close-verdict.test.ts | 65 +--- ...tured-agent-session-crash-turn-end.test.ts | 6 +- ...gent-session-dead-generation-settlement.ts | 7 +- .../structured-agent-session-host-lifetime.ts | 32 +- ...ent-session-restart-failure-filing.test.ts | 9 +- ...ssion-restart-interruption-test-harness.ts | 35 ++- ...t-session-restart-offer-withdrawal.test.ts | 5 +- ...ed-agent-session-restart-ownership.test.ts | 3 +- ...tructured-agent-session-stop-cut-notice.ts | 84 ------ ...red-agent-session.cut-turn-notice.test.tsx | 97 ++++++ .../use-structured-agent-session.ts | 14 +- .../native-chat-cut-turn-notice.test.ts | 279 ++++++++++++++++++ src/shared/native-chat-cut-turn-notice.ts | 173 +++++++++++ src/shared/native-chat-turn-status.ts | 4 +- 16 files changed, 762 insertions(+), 195 deletions(-) create mode 100644 mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-cut-notice.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx create mode 100644 src/shared/native-chat-cut-turn-notice.test.ts create mode 100644 src/shared/native-chat-cut-turn-notice.ts diff --git a/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx new file mode 100644 index 000000000000..b7d76b55f7a9 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx @@ -0,0 +1,124 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() } +})) + +const SCOPE = { kind: 'turn' as const, turnItemId: 'turn-1' } +const ITEMS: AgentJournalRenderItem[] = [ + { + itemId: 'user-1', + revision: 0, + sequence: 1, + observedAt: 1, + turnScope: { kind: 'thread' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] } + }, + { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 2, + turnScope: { kind: 'thread' }, + body: { + kind: 'turn', + turnId: 'turn-1', + userItemId: 'user-1', + state: 'interrupted', + startedAt: 2, + completedAt: 9 + } + }, + { + itemId: 'reply-1', + revision: 0, + sequence: 3, + observedAt: 3, + turnScope: SCOPE, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'half' }] } + } +] + +function snapshot(items: AgentJournalRenderItem[]): AgentSessionSubscribeEvent { + return { + type: 'snapshot', + sessionId: 'session-1', + fence: 3, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + fence: 3, + direction: 'tail', + items, + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-1', sequence: 1 }, + newest: { epoch: 'epoch-1', sequence: 3 }, + nextCursor: { epoch: 'epoch-1', sequence: 4 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 3 }, + hasOlder: false, + hasNewer: false + } + } +} + +describe('useMobileStructuredAgentSession transcript', () => { + let renderer: ReactTestRenderer | null = null + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('shows a cut turn no row explains with the one notice desktop shows, in that turn', async () => { + let listener: ((value: unknown) => void) | null = null + const client: Pick = { + sendRequest: vi.fn(async () => ({ ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } })), + subscribe: vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { + listener = onData + return () => {} + }) + } + let hook: ReturnType | null = null + function Harness(): null { + hook = useMobileStructuredAgentSession({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook calls only sendRequest and subscribe, both provided. + client: client as RpcClient, + sessionId: 'session-1', + enabled: true, + connected: true, + hostSupport: null, + agent: 'codex', + onSendError: vi.fn() + }) + return null + } + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + + act(() => listener?.(snapshot(ITEMS))) + + await vi.waitFor(() => + expect(hook?.session.messages.at(-1)).toMatchObject({ + role: 'system', + blocks: [ + { + text: 'Codex stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' + } + ] + }) + ) + // The turn bars place rows by the same items, so the notice joins the cut turn. + expect(hook?.turnJournal.items.at(-1)).toMatchObject({ turnScope: SCOPE }) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 653d7fbaaa1a..1dc10252d34a 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -2,6 +2,8 @@ import { useCallback, useMemo, useRef } from 'react' import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' +import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice' +import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' import { activeStructuredAgentSessionTurnId, @@ -157,12 +159,24 @@ export function useMobileStructuredAgentSession(args: { onSendError }) + // What the transcript reads, as desktop does: the journal plus the one notice a cut turn with no + // row gets. + const transcriptItems = useMemo( + () => + withNativeChatCutTurnNotices(state.items, { + agentName: TUI_AGENT_DISPLAY_NAMES[agent === 'codex' ? 'codex' : 'claude'] + }), + [agent, state.items] + ) const messages = useMemo( - () => projectStructuredAgentSessionMessages(state.items, [], state.submissions), - [state.items, state.submissions] + () => projectStructuredAgentSessionMessages(transcriptItems, [], state.submissions), + [transcriptItems, state.submissions] ) const turnId = activeStructuredAgentSessionTurnId(state.items) - const turnTiming = useMobileStructuredAgentTurnTiming(state, turnId) + const turnTiming = useMobileStructuredAgentTurnTiming( + { ...state, items: transcriptItems }, + turnId + ) const activityText = selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null const thinking = isStructuredAgentSessionThinking(state.items) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 98e110e9a267..b1922b913ed1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -11,8 +11,8 @@ import { } from '../../../shared/agent-session-journal-types' import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { describeNativeChatTurnStatus } from '../../../shared/native-chat-turn-status' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' @@ -122,29 +122,17 @@ async function settledTurn() { const { items } = await host.journalSnapshot(SESSION) const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) const [settled] = [...selectStructuredAgentSettledTurns(items).values()] - const cutTurnItemId = items.find((item) => readAgentJournalTurn(item.body))?.itemId - // Every row that says why the turn stopped, and which turn it says it of. - const notices = items.flatMap((item) => - item.body.kind === 'status' && item.body.tone === 'error' - ? [ - { - text: item.body.text, - ofCutTurn: - item.turnScope?.kind === 'turn' && item.turnScope.turnItemId === cutTurnItemId - } - ] - : [] - ) const label = settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }).key - return { turn, settled, notices, label } + // Every error row a reader's transcript shows, the cut turn's derived notice included. + const notices = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) + return { turn, settled, label, notices } } /** A turn nobody stopped reads like a finished one, with exactly one row saying it stopped. */ const ONE_NOTICE = [ - { - text: 'Codex stopped while this response was in progress. You can continue in this conversation.', - ofCutTurn: true - } + 'Codex stopped while this response was in progress. You can continue in this conversation.' ] function lastSummary(statuses: AgentSessionStatusEvent[]) { @@ -159,11 +147,9 @@ describe('a turn cut short by closing its provider', () => { await host.close(SESSION, 'user-close') expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' }) - const { turn, settled, notices } = await settledTurn() + const { turn, label, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - expect( - settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'interruptedAfter' }) + expect(label).toBe('interruptedAfter') // The person's own close explains itself. expect(notices).toEqual([]) }) @@ -175,10 +161,10 @@ describe('a turn cut short by closing its provider', () => { await host.close(SESSION, 'evict') expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'interruption' }) - const { turn, notices, label } = await settledTurn() + const { turn, label, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - // News for the sidebar; the turn reads like a finished one beside its one notice row. + // News for the sidebar; the turn reads like a finished one beside its one notice. expect(label).toBe('workedFor') expect(notices).toEqual(ONE_NOTICE) }) @@ -324,7 +310,6 @@ describe('a turn cut short by closing its provider', () => { const { turn, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn?.outcome).toBe(verdict.outcome) - // The retry writes the notice once, and only for the cut nobody asked for. expect(notices).toEqual(cause === 'evict' ? ONE_NOTICE : []) } ) @@ -335,32 +320,9 @@ describe('a turn cut short by closing its provider', () => { await host.flushAllStreamedEvents({ trigger: 'quit' }) - const { turn, label, notices } = await settledTurn() + const { turn, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - // The notice lands in the same write as the host's own turn end. - expect(label).toBe('workedFor') - expect(notices).toEqual(ONE_NOTICE) - }) - - it('adds no second notice to a turn an exit row already explains, then quit', async () => { - providerEnd = null - await runningTurn() - const events = hostTestState().acquire.mock.calls[0]?.[0].events - events?.appendItem( - { provider: 'orca', clientMessageId: 'exit-row' }, - { - kind: 'status', - text: 'Codex stopped while this response was in progress. You can continue in this conversation.', - tone: 'error' - }, - { turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(CUT_TURN) } } - ) - await host.flushStreamedEvents(SESSION) - - await host.flushAllStreamedEvents({ trigger: 'quit' }) - - const { notices } = await settledTurn() expect(notices).toEqual(ONE_NOTICE) }) @@ -369,10 +331,9 @@ describe('a turn cut short by closing its provider', () => { await runningTurn() // The provider reports its own exit, which it saw first, as it closes: no verdict. await host.close(SESSION, 'user-close') - const { turn, label, notices } = await settledTurn() + const { turn, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - expect(label).toBe('workedFor') expect(notices).toEqual(ONE_NOTICE) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 3c993c916253..d31a79e5f54b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -38,6 +38,7 @@ import { } from './structured-agent-session-adapter' import { resettleOpenStructuredAgentSessionConversation } from './structured-agent-session-conversation-open' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { @@ -223,7 +224,10 @@ describe('a turn a crash cut short mid-tool', () => { await host.restoreReadableSessions() const { items } = await host.journalSnapshot(SESSION) - const statusRows = items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) + // As a reader's transcript shows it: the stored row is the explanation, so none is derived. + const statusRows = withNativeChatCutTurnNotices(items, { agentName: 'Claude' }).flatMap( + (item) => (item.body.kind === 'status' ? [item.body] : []) + ) expect(statusRows).toEqual([ expect.objectContaining({ text: 'Claude stopped while this response was in progress. You can continue in this conversation.', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 8ed0af27fec9..39ff1c7ba514 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -124,13 +124,11 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** The provider never finished starting: the start that failed, keyed by the child's * generation. Its row is the one the delivery loop writes for the same start. */ exitedDuringStartup?: { generation: string | null } - /** A host stop nobody asked for (quit, eviction): the one notice row for the turn it cut. */ - stopCutNotice?: JournalLifecycleMutationInput | null }): Promise { try { const hasUnfinishedWork = hasUnfinishedStructuredAgentSessionWork(input.journal) const showUnexpectedExitOutcome = input.showUnexpectedExitOutcome ?? hasUnfinishedWork - if (!showUnexpectedExitOutcome && !hasUnfinishedWork && !input.stopCutNotice) { + if (!showUnexpectedExitOutcome && !hasUnfinishedWork) { return { ok: true } } // A queued message is the delivery loop's to settle: it was never handed to this child. A @@ -175,9 +173,6 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { turnScope: exitedRootTurnScope(items, input.verdict) }) } - if (input.stopCutNotice) { - mutations.push(input.stopCutNotice) - } for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) const body = terminalDeadGenerationBody(item) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index eed61e3767e8..60c20cb768d9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -11,11 +11,7 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { - agentSessionFailureWords, - type AgentSessionFailureWordsContext -} from '../../../shared/agent-session-failure-words' -import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { evictStructuredAgentSession, STRUCTURED_AGENT_SESSION_EVICTION_STEPS, @@ -37,7 +33,6 @@ import { } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' -import { structuredAgentSessionStopCutNotice } from './structured-agent-session-stop-cut-notice' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' export type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-stop-event' import { @@ -95,15 +90,6 @@ export async function abandonQueuedStructuredAgentSessionMessages( ) } -/** Who the stop-cut notice names. */ -function stopCutFailureTextContext( - context: StructuredAgentSessionLifetimeContext, - sessionId: string -): AgentSessionFailureWordsContext { - const record = context.deps.store.getRecord(sessionId) - return record ? { agentName: TUI_AGENT_DISPLAY_NAMES[record.provider] } : {} -} - /** The wind-down this host owes for the session's child. A live child always owes one, whatever a * previous childless eviction recorded: a remembered tombstone must never outrank the child in * front of it. */ @@ -198,7 +184,6 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( await recorded const fence = owed?.fence ?? structuredAgentSessionConversationFence(context.deps.store, sessionId) - const fallbackEndedAt = context.now() const settled = await settleStructuredAgentSessionDeadGeneration({ journal: session.journal, sessionId, @@ -207,19 +192,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( pendingSubmissionReason: 'provider_closed_before_acknowledgement', // Only a turn no adapter settled: one with no close, or whose settle threw. Whether it was // a person's Stop is its event's to say (`turnEndAfterStop`). - verdict: { state: 'interrupted', completedAt: fallbackEndedAt }, - showUnexpectedExitOutcome: false, - // In the same write as the fallback's turn end, or just after the adapter's. A host stop's - // row is the delivery loop's, and a person's Stop explains itself. - stopCutNotice: - (cause === 'evict' || cause === 'user-close') && owed - ? structuredAgentSessionStopCutNotice(sessionId, session.journal.snapshot().items, { - journal: session.journal, - fence: owed.fence, - fallbackEndedAt, - failureTextContext: stopCutFailureTextContext(context, sessionId) - }) - : null + verdict: { state: 'interrupted', completedAt: context.now() }, + showUnexpectedExitOutcome: false }) if (!settled.ok) { context.deps.logger.warn("settling a closed agent's work failed", { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index 1228256bbe51..42e4a91c5ae2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -9,6 +9,7 @@ import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structur import { interruptedRestart, QUIT_CUT_NOTICE, + readerNotes, statusNotes } from './structured-agent-session-restart-interruption-test-harness' import { CALLER, envelope } from './structured-agent-session-host-test-harness' @@ -43,7 +44,9 @@ it('files nothing for a chat the user moved on in before its attempt, and spends const capsule = new AgentSessionRecoveryCapsule(root) expect(await capsule.listFailed(NOW)).toEqual([]) expect(await capsule.list(NOW)).toEqual([]) - expect(await statusNotes(host)).toEqual(QUIT_CUT_NOTICE) + expect(await statusNotes(host)).toEqual([]) + // No note took over, so the cut turn keeps its one notice. + expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) }) // The continuation is accepted and its agent then fails to start: the message is rejected with the @@ -52,6 +55,8 @@ it('says so in the chat when the agent cannot start for the continuation', async const { host, acquire } = await interruptedRestart() await host.restartResume.list() acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + // Before the action, the quit's cut reads with its one notice. + expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) await host.restartResume.continueAfterRestart([SESSION], 'modal') @@ -62,6 +67,8 @@ it('says so in the chat when the agent cannot start for the continuation', async text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, tone: 'error' }) + // The refusal note is now the one explanation: the cut is not said a second time beside it. + expect(await readerNotes(host)).toEqual(await statusNotes(host)) }) // A send that throws after Orca may have taken it cannot be proven undelivered: filed unconfirmed, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index d88185e10e59..783ff1f7555c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -1,4 +1,8 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' // A chat interrupted mid-turn by a restart, rebuilt on a fresh host over the same store, for the // restart-resume ownership and failure tests. @@ -160,20 +164,31 @@ export async function interruptedRestart( return { ...hostTestState(), host, store, log, closeSession, marker, clock } } -/** The one row the quit wrote for the turn it cut; a continuation that fails nothing adds none. */ -export const QUIT_CUT_NOTICE = [ - { - text: 'Codex stopped while this response was in progress. You can continue in this conversation.', - tone: 'error' - } -] - export async function statusNotes(host: StructuredAgentSessionHost) { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + return notesOf((await host.journalSnapshot(SESSION)).items) +} + +/** The notes a reader's transcript shows, the cut turn's derived notice included. */ +export async function readerNotes(host: StructuredAgentSessionHost) { + return notesOf( + withNativeChatCutTurnNotices((await host.journalSnapshot(SESSION)).items, { + agentName: 'Codex' + }) + ) +} + +function notesOf(items: readonly AgentJournalRenderItem[]) { + return items.flatMap((item) => item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : [] ) } +/** The one row the quit's cut turn reads with when nothing else explains it. */ +export const QUIT_CUT_NOTICE = { + text: 'Codex stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' +} + /** A continuation the host refuses because the user's own message was accepted first: another * client's send lands after the action reserved the offer, just before the continuation is * accepted. `userAnswers` instead has the user send before or after the whole attempt. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts index 6ca9bbfe676e..b155674babf0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -17,7 +17,6 @@ import { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { interruptedRestart, - QUIT_CUT_NOTICE, statusNotes } from './structured-agent-session-restart-interruption-test-harness' import { CALLER, envelope } from './structured-agent-session-host-test-harness' @@ -386,8 +385,8 @@ it("refuses an automatic continuation when the user's message was accepted first expect(result.continued).toMatchObject([{ outcome: 'refused', reason: SUPERSEDED }]) await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce(), COLD_START) expect(sentTexts(dispatch)).toEqual(['A new request']) - // Nothing the user did failed: only the quit's own notice is there, and nothing is kept. - expect(await statusNotes(host)).toEqual(QUIT_CUT_NOTICE) + // Nothing the user did failed: no row reached the reader, and nothing is kept. + expect(await statusNotes(host)).toEqual([]) expect( events.some( (event) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 7a855c6c4026..a46551b4500e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -17,7 +17,6 @@ import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structur import { interruptedRestart, startAgent, - QUIT_CUT_NOTICE, statusNotes, supersededRefusal } from './structured-agent-session-restart-interruption-test-harness' @@ -362,7 +361,7 @@ it("refuses a continuation quietly when the user's own message was accepted firs failed: [] }) await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - expect(await statusNotes(host)).toEqual(QUIT_CUT_NOTICE) + expect(await statusNotes(host)).toEqual([]) expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cut-notice.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cut-notice.ts deleted file mode 100644 index a854d12cfe49..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cut-notice.ts +++ /dev/null @@ -1,84 +0,0 @@ -// The one notice a host stop owes a turn it cut short when nobody's Stop ended it: a quit, an idle -// eviction or a teardown. That turn reads like a finished one, so this row is what says it stopped. - -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { - agentSessionFailureWords, - type AgentSessionFailureWordsContext -} from '../../../shared/agent-session-failure-words' -import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' -import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' - -export type StructuredAgentSessionStopCut = { - journal: Pick - /** The stopped child's fence: an end with no verdict on its latest turn is this stop's cut. */ - fence: number - /** When the host's fallback ends a turn no adapter settled. */ - fallbackEndedAt: number - failureTextContext?: AgentSessionFailureWordsContext -} - -/** The latest root turn, when it ends as news: still running for the fallback to end, or ended by - * the stopped child's adapter with no verdict. Null when a person's Stop decides it. */ -function cutTurn( - items: readonly AgentJournalRenderItem[], - stop: StructuredAgentSessionStopCut -): AgentJournalRenderItem | null { - const latest = items.findLast( - (item) => isRootAgentJournalItem(item) && readAgentJournalTurn(item.body) !== null - ) - const turn = latest ? readAgentJournalTurn(latest.body) : null - if (!latest || !turn) { - return null - } - if (turn.state === 'running') { - return stop.journal.stopMarks.personStopDecides(turn.turnId, stop.fallbackEndedAt) - ? null - : latest - } - return turn.state === 'interrupted' && - turn.outcome === undefined && - stop.journal.itemFence(latest.itemId) === stop.fence - ? latest - : null -} - -/** The notice for the turn this stop cut, unless one already explains it (an exit row, or this - * notice from an earlier attempt): one explanation per cut. */ -export function structuredAgentSessionStopCutNotice( - sessionId: string, - items: readonly AgentJournalRenderItem[], - stop: StructuredAgentSessionStopCut -): JournalLifecycleMutationInput | null { - const turn = cutTurn(items, stop) - if (!turn) { - return null - } - const explained = items.some( - (item) => - item.body.kind === 'status' && - item.body.tone === 'error' && - item.turnScope?.kind === 'turn' && - item.turnScope.turnItemId === turn.itemId - ) - if (explained) { - return null - } - return { - kind: 'item', - // Named by the turn it explains, so a retried settle rewrites this row instead of adding one. - identity: { provider: 'orca', clientMessageId: `stop-cut:${sessionId}:${turn.itemId}` }, - body: { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { - ...stop.failureTextContext, - surface: 'row' - }), - tone: 'error' - }, - turnScope: { kind: 'turn', turnItemId: turn.itemId } - } -} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx new file mode 100644 index 000000000000..fcb474f2acc6 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const SCOPE = { kind: 'turn' as const, turnItemId: 'turn-1' } +const items: AgentJournalRenderItem[] = [ + { + itemId: 'user-1', + revision: 0, + sequence: 1, + observedAt: 1, + turnScope: { kind: 'thread' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] } + }, + { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 2, + turnScope: { kind: 'thread' }, + body: { + kind: 'turn', + turnId: 'turn-1', + userItemId: 'user-1', + state: 'interrupted', + startedAt: 2, + completedAt: 9 + } + }, + { + itemId: 'reply-1', + revision: 0, + sequence: 3, + observedAt: 3, + turnScope: SCOPE, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'half' }] } + } +] + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(async () => null), + supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false) +})) +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { + fence: 3, + items, + submissions: [], + status: 'ready', + error: null, + hasOlder: false, + handoff: null + }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) +vi.mock('./use-structured-agent-session-outbox', () => ({ + structuredSessionOperationId: vi.fn(), + useStructuredAgentSessionOutbox: () => ({ + outbox: [], + error: null, + send: vi.fn(), + retry: vi.fn() + }) +})) + +import { useStructuredAgentSession } from './use-structured-agent-session' + +describe('useStructuredAgentSession transcript', () => { + it('shows a cut turn no row explains with its one notice, placed in that turn', () => { + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'codex', + target: { kind: 'local' }, + isVisible: true + }) + ) + + expect(result.current.messages.at(-1)).toMatchObject({ + role: 'system', + blocks: [ + { + text: 'Codex stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' + } + ] + }) + // The list places rows by the same items, so the notice joins the cut turn. + expect(result.current.journalItems.at(-1)).toMatchObject({ turnScope: SCOPE }) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 96ad805560d9..e95702b7a57e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -38,6 +38,8 @@ import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent- import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards' import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' +import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' +import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-names' export type { StructuredPromptItem } from './structured-agent-session-message-projection' @@ -163,8 +165,16 @@ export function useStructuredAgentSession(args: { }), [isWorking, outbox, queueCapability, queueFollowUps, queuedMessageIds] ) + // What the transcript reads: the journal plus the one notice a cut turn with no row gets. + const transcriptItems = useMemo( + () => + withNativeChatCutTurnNotices(transportState.journalItems, { + agentName: TUI_AGENT_DISPLAY_NAMES[agent === 'codex' ? 'codex' : 'claude'] + }), + [agent, transportState.journalItems] + ) const messages = useStructuredAgentSessionMessages( - transportState.journalItems, + transcriptItems, transcriptOutbox, transportState.submissions ) @@ -198,7 +208,7 @@ export function useStructuredAgentSession(args: { { command } ) }), - journalItems: transportState.journalItems, + journalItems: transcriptItems, subagentRoster: transportState.subagentRoster, messages, status: transportEnabled ? state.status : 'ready', diff --git a/src/shared/native-chat-cut-turn-notice.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts new file mode 100644 index 000000000000..759235c7ed4d --- /dev/null +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from './agent-session-failure' +import { agentSessionFailureWords } from './agent-session-failure-words' +import { agentJournalItemKey } from './agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnOutcome, + AgentJournalTurnScope +} from './agent-session-journal-types' +import { withNativeChatCutTurnNotices } from './native-chat-cut-turn-notice' +import { hostStatesTurnScopes, nativeChatTurnMembership } from './native-chat-turn-membership' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +const THREAD: AgentJournalTurnScope = { kind: 'thread' } +const NOTICE = + 'Codex stopped while this response was in progress. You can continue in this conversation.' +let sequence = 0 + +function item( + itemId: string, + body: AgentJournalItemBody, + /** Null for a host that predates scopes. */ + turnScope: AgentJournalTurnScope | null = THREAD +): AgentJournalRenderItem { + sequence += 1 + return { + itemId, + revision: 0, + sequence, + observedAt: sequence, + body, + ...(turnScope ? { turnScope } : {}) + } +} + +const inTurn = (turnItemId: string): AgentJournalTurnScope => ({ kind: 'turn', turnItemId }) +const user = (itemId: string, scope: AgentJournalTurnScope | null = THREAD) => + item(itemId, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: itemId }] }, scope) +const reply = (itemId: string, scope: AgentJournalTurnScope | null) => + item( + itemId, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: itemId }] }, + scope + ) + +type Ending = { + state: 'running' | 'completed' | 'interrupted' | 'unverifiable' + outcome?: AgentJournalTurnOutcome +} +const CUT: Ending = { state: 'interrupted' } + +function turn( + itemId: string, + userItemId: string, + ending: Ending, + scope: AgentJournalTurnScope | null = THREAD +) { + return item( + itemId, + { + kind: 'turn', + turnId: itemId, + userItemId, + startedAt: 1_000, + ...(ending.state === 'running' ? {} : { completedAt: 2_000 }), + ...ending + }, + scope + ) +} + +/** A row the host wrote, by the identity its writer gives it. */ +function hostRow( + clientMessageId: string, + body: AgentJournalItemBody, + scope: AgentJournalTurnScope | null +) { + return item(agentJournalItemKey({ provider: 'orca', clientMessageId }), body, scope) +} + +/** What the crash path's exit row says, from its fact. */ +const exitWords = (): AgentJournalItemBody => ({ + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + agentName: 'Codex', + surface: 'row' + }), + tone: 'error' +}) +const exitRow = (clientMessageId: string, scope: AgentJournalTurnScope | null) => + hostRow(clientMessageId, exitWords(), scope) + +function notices(items: readonly AgentJournalRenderItem[]) { + return withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).flatMap((entry, index) => + entry.itemId.includes('cut-turn-notice') + ? [ + { + index, + text: entry.body.kind === 'status' ? entry.body.text : null, + scope: entry.turnScope + } + ] + : [] + ) +} + +describe('withNativeChatCutTurnNotices', () => { + it('gives a cut turn no row explains one notice in the exit row words, after its last row', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('u2'), + turn('t2', 'u2', { state: 'completed', outcome: 'success' }) + ] + + const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + const notice = derived[3]! + expect(notice.body).toMatchObject({ tone: 'error', failure: { kind: 'providerExited' } }) + // Placed by journal position too, so any reader that sorts keeps it under the turn. + expect(notice.sequence).toBe(items[2]!.sequence) + expect(notice.sequenceIndex).toBeGreaterThan(0) + }) + + it('adds nothing where the crash path already wrote its exit row, scoped or about the conversation', () => { + for (const [id, scope] of [ + ['stale-session:s:death-3-2000', inTurn('t1')], + ['provider-exit:s:3:gen', inTurn('t1')], + ['stale-session:s:death-3-2000', THREAD] + ] as const) { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + exitRow(id, scope) + ] + expect(withNativeChatCutTurnNotices(items), id).toBe(items) + } + }) + + it('counts an exit row by its exit fact, whatever wrote it', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + item('other-writer', exitWords(), inTurn('t1')) + ] + expect(withNativeChatCutTurnNotices(items)).toBe(items) + }) + + it("still explains a cut whose only error row is the agent's own, not the stop's", () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + hostRow( + 'provider-frame:permission_denied', + { kind: 'status', text: 'Permission denied: Bash', tone: 'error' }, + inTurn('t1') + ) + ] + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + }) + + it('says nothing of a Stop, a replaced turn, a failure, an unproven end, a finished or a running turn', () => { + for (const ending of [ + { state: 'interrupted', outcome: 'cancellation' }, + { state: 'interrupted', outcome: 'superseded' }, + { state: 'interrupted', outcome: 'failure' }, + { state: 'completed', outcome: 'failure' }, + { state: 'unverifiable' }, + { state: 'completed', outcome: 'success' }, + { state: 'completed' }, + { state: 'running' } + ] satisfies Ending[]) { + const items = [user('u1'), turn('t1', 'u1', ending), reply('a1', inTurn('t1'))] + expect(withNativeChatCutTurnNotices(items), JSON.stringify(ending)).toBe(items) + } + }) + + it("says nothing of a subagent's cut turn: the conversation's own turn speaks for it", () => { + const items = [ + user('u1'), + turn('t1', 'u1', { state: 'completed', outcome: 'success' }), + { ...turn('sub', 'u1', CUT, inTurn('t1')), agentId: 'sub-agent' } + ] + expect(withNativeChatCutTurnNotices(items)).toBe(items) + }) + + it('explains every cut turn in the history no row explains, each once', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('u2'), + turn('t2', 'u2', CUT), + reply('a2', inTurn('t2')), + exitRow('provider-exit:s:4:gen', inTurn('t2')), + user('u3'), + turn('t3', 'u3', CUT) + ] + expect(notices(items)).toEqual([ + { index: 3, text: NOTICE, scope: inTurn('t1') }, + // A turn with no rows of its own takes the notice after its record. + { index: 10, text: NOTICE, scope: inTurn('t3') } + ]) + }) + + it("takes the restart continuation's outcome note as the one explanation of the cut it follows", () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + hostRow( + 'restart-continuation:s:5000', + { + kind: 'status', + text: "Orca couldn't continue this chat after the restart. Send a message to continue it.", + tone: 'error' + }, + THREAD + ) + ] + expect(withNativeChatCutTurnNotices(items)).toBe(items) + }) + + it('places the notice in its turn, for desktop and phone alike', () => { + const items = [user('u1'), turn('t1', 'u1', CUT), reply('a1', inTurn('t1')), user('u2')] + const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + const messages = projectStructuredAgentSessionMessages(derived, [], []) + const { turnKeys } = nativeChatTurnMembership(messages, { items: derived, submissions: [] }) + + const notice = messages.findIndex((message) => message.id.includes('cut-turn-notice')) + expect(messages[notice]?.blocks).toEqual([ + expect.objectContaining({ type: 'text', text: NOTICE, tone: 'error' }) + ]) + expect(turnKeys[notice]).toBe('u1') + }) + + it('reads a journal from a host that states no scope, without making it look like one that does', () => { + const items = [ + user('u1', null), + turn('t1', 'u1', CUT, null), + reply('a1', null), + user('u2', null) + ] + + const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: undefined }]) + expect(hostStatesTurnScopes(derived)).toBe(false) + }) + + it('reads the older status-row turn record, and keeps each notice the same object across reads', () => { + const record = item( + 't1', + { + kind: 'status', + text: '', + turnLifecycle: { + turnId: 't1', + userItemId: 'u1', + state: 'interrupted', + startedAt: 1_000, + completedAt: 2_000 + } + }, + null + ) + const items = [user('u1', null), record] + + const first = withNativeChatCutTurnNotices(items) + const second = withNativeChatCutTurnNotices([...items]) + + expect(first).toHaveLength(3) + expect(second[2]).toBe(first[2]) + }) +}) diff --git a/src/shared/native-chat-cut-turn-notice.ts b/src/shared/native-chat-cut-turn-notice.ts new file mode 100644 index 000000000000..67b6d3b17593 --- /dev/null +++ b/src/shared/native-chat-cut-turn-notice.ts @@ -0,0 +1,173 @@ +// The one row that says a turn was cut short when the agent stopped without anyone asking (a crash, +// a quit, an eviction, a teardown). Derived on read from the journal, never stored: the turn record +// is the durable fact, so a journal written before this rule, a quit that died mid-settle and any +// future stop cause all read the same. A row the host already wrote about the stop stays the +// explanation, so nothing is said twice. Shared by desktop and mobile, whose transcripts must agree. + +import { agentSessionFailureFact, readAgentSessionFailureFact } from './agent-session-failure' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from './agent-session-failure-words' +import { agentJournalItemKey, parseAgentJournalItemKey } from './agent-session-journal-item-key' +import { isRootAgentJournalItem } from './agent-session-journal-producer' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' +import { agentTurnVerdict } from './agent-turn-outcome' +import { hostStatesTurnScopes } from './native-chat-turn-membership' + +/** Host rows that already say why a turn stopped: a provider exit, a dead owner found on reopen, and + * the restart continuation's own outcome note, which a quit's cut is followed by. */ +const STOP_EXPLAINING_ROWS = ['provider-exit:', 'stale-session:', 'restart-continuation:'] + +const CUT_TURN_NOTICE_ROW = 'cut-turn-notice:' + +type CutTurnNoticeContext = Pick + +/** Root turns that ended interrupted with no verdict, so nobody asked for the stop. */ +function isCutRootTurn(item: AgentJournalRenderItem): boolean { + const turn = readAgentJournalTurn(item.body) + return ( + turn !== null && + isRootAgentJournalItem(item) && + agentTurnVerdict({ state: turn.state, outcome: readAgentJournalTurnOutcome(turn) }) === + 'interruption' + ) +} + +/** Matched by what the row states or who wrote it, never its tone: an agent's own error row in the + * turn (a denied permission, a refusal) says nothing about the stop. */ +function explainsAStop(item: AgentJournalRenderItem): boolean { + if (item.body.kind !== 'status' || readAgentJournalTurn(item.body)) { + return false + } + if (readAgentSessionFailureFact(item.body.failure)?.kind === 'providerExited') { + return true + } + const identity = parseAgentJournalItemKey(item.itemId) + return ( + identity?.provider === 'orca' && + STOP_EXPLAINING_ROWS.some((prefix) => identity.clientMessageId.startsWith(prefix)) + ) +} + +/** The cut turns some row already explains: one scoped to the turn, or one about the conversation + * (or from a host that states no scope), which explains the newest cut turn before it. */ +function explainedCutTurns(items: readonly AgentJournalRenderItem[]): Set { + const explained = new Set() + let newestCutTurn: string | null = null + for (const item of items) { + if (isCutRootTurn(item)) { + newestCutTurn = item.itemId + } else if (explainsAStop(item)) { + const scope = item.turnScope + if (scope?.kind === 'turn') { + explained.add(scope.turnItemId) + } else if (newestCutTurn !== null) { + explained.add(newestCutTurn) + } + } + } + return explained +} + +/** The turn's last row, which the notice follows: one scoped to it, or by journal order on a host + * that states no scope. A new root turn ends the order-read run. */ +function lastRowOfTurn( + items: readonly AgentJournalRenderItem[], + turnIndex: number, + statesScopes: boolean +): number { + const turnItemId = items[turnIndex]!.itemId + let last = turnIndex + for (let index = turnIndex + 1; index < items.length; index += 1) { + const item = items[index]! + if (statesScopes) { + if (item.turnScope?.kind === 'turn' && item.turnScope.turnItemId === turnItemId) { + last = index + } + continue + } + if (readAgentJournalTurn(item.body) && isRootAgentJournalItem(item)) { + break + } + if (!(item.body.kind === 'message' && item.body.role === 'user')) { + last = index + } + } + return last +} + +const noticeCache = new WeakMap< + AgentJournalRenderItem, + { + after: AgentJournalRenderItem + statesScopes: boolean + agentName: string | undefined + notice: AgentJournalRenderItem + } +>() + +function cutTurnNotice( + turnItem: AgentJournalRenderItem, + after: AgentJournalRenderItem, + statesScopes: boolean, + context: CutTurnNoticeContext +): AgentJournalRenderItem { + const cached = noticeCache.get(turnItem) + if ( + cached?.after === after && + cached.statesScopes === statesScopes && + cached.agentName === context.agentName + ) { + return cached.notice + } + const notice: AgentJournalRenderItem = { + itemId: agentJournalItemKey({ + provider: 'orca', + clientMessageId: `${CUT_TURN_NOTICE_ROW}${turnItem.itemId}` + }), + revision: 0, + body: { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + ...context, + surface: 'row' + }), + tone: 'error' + }, + // Just after the turn's last row and before anything the journal wrote next. + sequence: after.sequence, + sequenceIndex: (after.sequenceIndex ?? 0) + 0.5, + observedAt: readAgentJournalTurn(turnItem.body)?.completedAt ?? after.observedAt, + // A scope on a journal that states none would change how every other row is placed. + ...(statesScopes ? { turnScope: { kind: 'turn' as const, turnItemId: turnItem.itemId } } : {}) + } + noticeCache.set(turnItem, { after, statesScopes, agentName: context.agentName, notice }) + return notice +} + +/** + * The journal as the transcript reads it: each root turn cut short with nobody asking, and no row + * saying so, gets one notice in the words of the provider-exit row, right after the turn's last row. + * Returns `items` itself when no turn needs one. + */ +export function withNativeChatCutTurnNotices( + items: readonly AgentJournalRenderItem[], + context: CutTurnNoticeContext = {} +): readonly AgentJournalRenderItem[] { + const explained = explainedCutTurns(items) + const statesScopes = hostStatesTurnScopes(items) + const noticesAfter = new Map() + items.forEach((item, index) => { + if (isCutRootTurn(item) && !explained.has(item.itemId)) { + const last = lastRowOfTurn(items, index, statesScopes) + const notice = cutTurnNotice(item, items[last]!, statesScopes, context) + noticesAfter.set(last, [...(noticesAfter.get(last) ?? []), notice]) + } + }) + if (noticesAfter.size === 0) { + return items + } + return items.flatMap((item, index) => [item, ...(noticesAfter.get(index) ?? [])]) +} diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index 54df2b2ad3e5..446c489c350c 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -42,8 +42,8 @@ export function describeNativeChatTurnStatus({ workedSeconds?: number | null elapsedSeconds: number /** How the settled turn ended: a user's Stop or a newer request's replacement reads interrupted, - * and a failure reads failed. A turn a crash or restart cut off reads like a finished one: the - * chat's own notice row is what says it stopped. */ + * and a failure reads failed. A turn cut short when the agent stopped without anyone asking (a + * crash, a quit, an eviction) reads like a finished one: the chat's notice row says it stopped. */ verdict?: AgentTurnOutcome }): { key: 'workingFor' | 'workedFor' | 'interruptedAfter' | 'failedAfter' From 06da7cc0f33185ef5af094a028503caab11681e3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:55:30 -0700 Subject: [PATCH 05/17] fix(sidebar): read a cut-short turn's red mark from its acknowledgement on every surface A turn cut short with nobody asking reads failed only until the user has seen it. The previous revision passed an optional "seen" flag to each caller, so any surface that did not pass it, the chat's own tab dot among them, stayed red beside a sidebar that read Done. The verdict's display now takes the acknowledgement as part of what it reads: the entry's stateStartedAt beside the time the user last acknowledged it, both required, judged by one shared rule. Each surface joins it once where it builds its rows: the sidebar's agent rows (and so the notes send menu), the workspace card summary, the terminal tab bar, Cmd-J recent rows, and Activity threads. Failures, Stops, replaced turns and unproven ends keep their marks as before; the phone, which has no acknowledgement record, keeps the unseen reading. --- mobile/src/worktree/agent-row-display.test.ts | 3 +- mobile/src/worktree/agent-row-display.ts | 8 +- ...agent-session-recovered-turn-clock.test.ts | 13 ++- ...d-agent-session-stop-event-restart.test.ts | 7 +- .../ActivityPrototypePage-test-fixtures.ts | 3 +- .../activity/ActivityPrototypePage.test.ts | 9 +- ...ivityPrototypePage.thread-grouping.test.ts | 18 +++- .../activity-clear-completed-hmr.test.ts | 1 + ...ity-clear-completed-state-lifetime.test.ts | 1 + .../activity/activity-clear-completed.test.ts | 1 + ...ivity-event-builder.identity-reuse.test.ts | 9 +- .../activity-event-builder.live-cap.test.ts | 2 +- .../activity/activity-scope-filter.test.ts | 1 + .../activity-status-group-header.test.ts | 37 ++++++- .../activity/activity-thread-actions.test.ts | 1 + .../activity/activity-thread-builder.ts | 16 ++- .../activity-thread-child-agent.test.ts | 1 + ...ivity-thread-grouping.search-cache.test.ts | 1 + .../activity-thread-hover-card.test.tsx | 1 + ...vity-thread-list-pane-collapsible.test.tsx | 1 + ...y-thread-list-pane.virtualization.test.tsx | 1 + .../activity-thread-presentation.test.ts | 1 + .../activity/activity-thread-presentation.ts | 14 ++- .../activity/activity-thread-types.ts | 2 + .../activity-thread-virtual-items.test.ts | 1 + .../activity/use-agent-pane-threads.ts | 9 +- .../agent-child-row-parity.test.tsx | 13 ++- .../components/cmd-j/palette-live-status.tsx | 21 ++-- .../DashboardAgentChildDisclosure.test.tsx | 5 +- .../dashboard/DashboardAgentRow.test.tsx | 79 +++++++------- .../dashboard/DashboardAgentRow.tsx | 14 ++- .../dashboard/agent-finished-timestamp.ts | 14 ++- .../editor/ReviewNotesSendMenuContent.tsx | 10 +- .../structured-child-records-switch.test.tsx | 7 +- ...rdAgents.acknowledgement-emphasis.test.tsx | 5 +- .../sidebar/WorktreeCardAgents.test.tsx | 5 +- .../components/sidebar/WorktreeCardAgents.tsx | 26 ++--- .../stale-agent-row-unverifiable.test.ts | 15 ++- ...ubagent-child-row-fallback-pixels.test.tsx | 9 +- .../sidebar/useWorktreeAgentRows.ts | 13 ++- .../worktree-agent-activity-summary.test.ts | 16 +++ .../worktree-agent-activity-summary.ts | 28 ++--- .../worktree-card-agent-summary.test.ts | 100 +++++++++--------- .../sidebar/worktree-card-agent-summary.ts | 57 +++------- ...-compact-agent-row.stable-message.test.tsx | 2 +- .../worktree-card-compact-agent-row.tsx | 32 +++--- .../sidebar/worktree-card-compact-agents.tsx | 15 ++- .../src/components/tab-bar/SortableTab.tsx | 1 + .../terminal-tab-activity-status.test.ts | 46 ++++++++ .../tab-bar/terminal-tab-activity-status.ts | 24 ++++- .../terminal-tab-spinner-launch-agent.test.ts | 3 + .../use-worktree-jump-palette-recent-tabs.ts | 13 +-- .../use-worktree-jump-palette-store-state.ts | 4 +- ...tree-jump-palette-recent-inclusion.test.ts | 7 +- .../worktree-jump-palette-recent-inclusion.ts | 8 +- ...orktree-jump-palette-status-inputs.test.ts | 3 +- .../worktree-jump-palette-status-inputs.ts | 8 +- .../src/lib/activity-thread-display.test.ts | 32 ++++++ .../src/lib/activity-thread-display.ts | 7 +- .../lib/agent-entry-acknowledgement.test.ts | 59 +++++++++++ .../src/lib/agent-entry-acknowledgement.ts | 43 ++++++++ .../src/lib/agent-pane-activity-flags.ts | 12 +-- src/renderer/src/lib/agent-row-dot-state.ts | 23 ++-- .../src/lib/agent-verdict-status-line.ts | 12 +-- .../src/lib/recent-workspace-tab-rows.test.ts | 40 ++++++- .../src/lib/recent-workspace-tab-rows.ts | 18 +++- ...paired-agent-row-turn-outcome-arms.test.ts | 4 +- ...gent-status-observation-neutrality.test.ts | 9 +- src/shared/agent-main-agent-verdict.test.ts | 34 +++--- src/shared/agent-main-agent-verdict.ts | 26 ++--- src/shared/agent-turn-acknowledgement.ts | 20 ++++ 71 files changed, 747 insertions(+), 357 deletions(-) create mode 100644 src/renderer/src/lib/agent-entry-acknowledgement.test.ts create mode 100644 src/renderer/src/lib/agent-entry-acknowledgement.ts create mode 100644 src/shared/agent-turn-acknowledgement.ts diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index c3fa8c84b6de..e7df2936cbca 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -121,8 +121,9 @@ describe('agentDotState', () => { expect(agentRowVerdict(agentRow), JSON.stringify(agentRow)).toBe( agentMainAgentVerdict(agentRow) ) + // The phone has no acknowledgement record: it reads every turn as desktop reads one unseen. expect(agentRowVerdictMark(agentRow), JSON.stringify(agentRow)).toBe( - agentVerdictDisplayMark(agentRow) + agentVerdictDisplayMark({ ...agentRow, stateStartedAt: 1, acknowledgedAt: undefined }) ) } } diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index 87b07abfbfb7..ea5f957e8334 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -36,8 +36,10 @@ const AGENT_TURN_OUTCOMES: readonly AgentTurnOutcome[] = [ ] // Mirrors desktop agentMainAgentVerdict and agentVerdictDisplayMark -// (src/shared/agent-main-agent-verdict.ts); a parity test runs both over one table. `mainAgent` is -// the main agent's own status, sent also while subagents hold the row working; an old host sends none. +// (src/shared/agent-main-agent-verdict.ts) as desktop reads a turn the user has not seen; a parity +// test runs both over one table. Desktop drops the failed mark from a turn cut short once the user +// acknowledges it; the phone has no acknowledgement record, so it keeps the unseen reading. `mainAgent` +// is the main agent's own status, sent also while subagents hold the row working; an old host sends none. export function agentRowVerdict(row: AgentRowVerdictSource): AgentTurnOutcome | null { if (row.mainAgent && row.mainAgent.state !== 'done') { return null @@ -49,7 +51,7 @@ export function agentRowVerdict(row: AgentRowVerdictSource): AgentTurnOutcome | return row.state === 'done' && row.interrupted ? 'cancellation' : null } -// A fault (a failure, or a turn cut short by something other than the user) reads failed and +// A failure, or a turn cut short when the agent stopped without anyone asking, reads failed and // outranks every state; a user's Stop, a turn a newer request replaced, and an unproven end mark // only a row that is itself done. export function agentRowVerdictMark( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index b3cd3f63d680..82315a910805 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -176,7 +176,7 @@ describe('a turn recovery settled after its host went away', () => { // Nobody stopped it: the flag older readers take as a user's stop stays down. expect(row?.interrupted ?? false).toBe(false) // The sidebar and tab read the published row, with no user action in between. - expect(row && agentVerdictDisplayMark(row)).toBe(mark) + expect(row && agentVerdictDisplayMark({ ...row, acknowledgedAt: undefined })).toBe(mark) expect(row && agentTurnEndedOnPurpose(row)).toBe(false) // The dot and the OS notification come only from a completion event, and none is sent. expect(session.completionEvents).toEqual([]) @@ -216,8 +216,11 @@ describe('a turn recovery settled after its host went away', () => { session.publish() const [row] = session.server.getStatusSnapshot() - expect(row && agentVerdictDisplayMark(row)).toBe('failed') - expect(row && agentVerdictDisplayMark(row, { seen: true })).toBeNull() + expect(row && agentVerdictDisplayMark({ ...row, acknowledgedAt: undefined })).toBe('failed') + // Once the user has seen it, the chat's notice row is what still says it stopped. + expect( + row && agentVerdictDisplayMark({ ...row, acknowledgedAt: row.stateStartedAt }) + ).toBeNull() const [settled] = [ ...selectStructuredAgentSettledTurns(session.journal.snapshot().items).values() ] @@ -251,7 +254,9 @@ describe('a turn recovery settled after its host went away', () => { stateStartedAt: RECOVERED, mainAgent: { state: 'done', outcome: 'unconfirmed' } }) - expect(row && agentVerdictDisplayMark(row)).toBe('unconfirmed') + expect(row && agentVerdictDisplayMark({ ...row, acknowledgedAt: undefined })).toBe( + 'unconfirmed' + ) expect(session.completionEvents).toEqual([]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 50056eedd031..dd36abc9c8e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -111,7 +111,12 @@ function settled(turnId?: string) { turn, label: timing ? formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...timing }) : null, mark: verdict - ? agentVerdictDisplayMark({ state: 'done', mainAgent: { state: 'done', outcome: verdict } }) + ? agentVerdictDisplayMark({ + state: 'done', + mainAgent: { state: 'done', outcome: verdict }, + stateStartedAt: 1, + acknowledgedAt: undefined + }) : null, errorRows: items.flatMap((item) => item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] diff --git a/src/renderer/src/components/activity/ActivityPrototypePage-test-fixtures.ts b/src/renderer/src/components/activity/ActivityPrototypePage-test-fixtures.ts index 181f96869a27..25b94ab867df 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage-test-fixtures.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage-test-fixtures.ts @@ -165,6 +165,7 @@ export function makeThreads(result: ReturnType) { return buildAgentPaneThreads({ events: result.events, liveAgentByPaneKey: result.liveAgentByPaneKey, - paneEntryByPaneKey: result.paneEntryByPaneKey + paneEntryByPaneKey: result.paneEntryByPaneKey, + acknowledgedAgentsByPaneKey: {} }) } diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts index 2a960cb464e5..9c841532d47f 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts @@ -73,7 +73,11 @@ describe('buildActivityEvents', () => { acknowledgedAgentsByPaneKey: {}, now: 100_000 }) - const threads = buildAgentPaneThreads({ events, liveAgentByPaneKey }) + const threads = buildAgentPaneThreads({ + events, + liveAgentByPaneKey, + acknowledgedAgentsByPaneKey: {} + }) expect(events).toHaveLength(80) expect(threads).toHaveLength(18) @@ -445,7 +449,8 @@ describe('buildActivityEvents', () => { const groups = buildActivityThreadGroups( buildAgentPaneThreads({ events: result.events, - liveAgentByPaneKey: result.liveAgentByPaneKey + liveAgentByPaneKey: result.liveAgentByPaneKey, + acknowledgedAgentsByPaneKey: {} }), 'status' ) diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts index 42f45f1683d2..f40fadfe856a 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts @@ -120,7 +120,11 @@ describe('activity thread grouping', () => { acknowledgedAgentsByPaneKey: {}, now: 3_000 }) - const threads = buildAgentPaneThreads({ events, liveAgentByPaneKey }) + const threads = buildAgentPaneThreads({ + events, + liveAgentByPaneKey, + acknowledgedAgentsByPaneKey: {} + }) const groups = buildActivityThreadGroups(threads, 'status') expect(groups).toHaveLength(2) @@ -154,7 +158,11 @@ describe('activity thread grouping', () => { acknowledgedAgentsByPaneKey: {}, now: 1_000 }) - const threads = buildAgentPaneThreads({ events, liveAgentByPaneKey }) + const threads = buildAgentPaneThreads({ + events, + liveAgentByPaneKey, + acknowledgedAgentsByPaneKey: {} + }) const group = getActivityThreadGroup(threads[0], 'project') expect(group).toEqual({ key: 'project:unknown', label: 'Unknown project' }) @@ -228,7 +236,11 @@ describe('activity thread grouping', () => { acknowledgedAgentsByPaneKey: {}, now: 3_000 }) - const threads = buildAgentPaneThreads({ events, liveAgentByPaneKey }) + const threads = buildAgentPaneThreads({ + events, + liveAgentByPaneKey, + acknowledgedAgentsByPaneKey: {} + }) const groups = buildActivityThreadGroups(threads, 'worktree') expect(groups.map((group) => group.key)).toEqual(['worktree:wt-a', 'worktree:wt-b']) diff --git a/src/renderer/src/components/activity/activity-clear-completed-hmr.test.ts b/src/renderer/src/components/activity/activity-clear-completed-hmr.test.ts index 1ea5d3b2c949..5af2134e260a 100644 --- a/src/renderer/src/components/activity/activity-clear-completed-hmr.test.ts +++ b/src/renderer/src/components/activity/activity-clear-completed-hmr.test.ts @@ -67,6 +67,7 @@ import { function makeThread(paneKey: string, overrides: Partial = {}): AgentPaneThread { return { + acknowledgedAt: undefined, paneKey, tab: makeTab(), worktree: makeWorktree(), diff --git a/src/renderer/src/components/activity/activity-clear-completed-state-lifetime.test.ts b/src/renderer/src/components/activity/activity-clear-completed-state-lifetime.test.ts index 8bbfbe03ee2c..eb45c6d2e4f7 100644 --- a/src/renderer/src/components/activity/activity-clear-completed-state-lifetime.test.ts +++ b/src/renderer/src/components/activity/activity-clear-completed-state-lifetime.test.ts @@ -45,6 +45,7 @@ function clearWithOpenDraft(clear: boolean = true) { const retained = makeRetainedDoneEntry(tab) const paneKey = retained.entry.paneKey const thread: AgentPaneThread = { + acknowledgedAt: undefined, paneKey, tab, worktree: makeWorktree(), diff --git a/src/renderer/src/components/activity/activity-clear-completed.test.ts b/src/renderer/src/components/activity/activity-clear-completed.test.ts index 79191ce32520..116cb3407b68 100644 --- a/src/renderer/src/components/activity/activity-clear-completed.test.ts +++ b/src/renderer/src/components/activity/activity-clear-completed.test.ts @@ -65,6 +65,7 @@ import { activityThreadStatusId } from './activity-thread-presentation' function makeThread(paneKey: string, overrides: Partial = {}): AgentPaneThread { return { + acknowledgedAt: undefined, paneKey, tab: makeTab(), worktree: makeWorktree(), diff --git a/src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts b/src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts index 4cf8fb2927eb..663ec2988153 100644 --- a/src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts +++ b/src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts @@ -67,7 +67,11 @@ function buildBoth( ) { const result = buildActivityEvents(args, eventCache) const threads = buildAgentPaneThreads( - { events: result.events, liveAgentByPaneKey: result.liveAgentByPaneKey }, + { + events: result.events, + liveAgentByPaneKey: result.liveAgentByPaneKey, + acknowledgedAgentsByPaneKey: {} + }, threadCache ) return { ...result, threads } @@ -240,7 +244,8 @@ describe('activity build identity reuse', () => { const cold = buildActivityEvents(scenario) const coldThreads = buildAgentPaneThreads({ events: cold.events, - liveAgentByPaneKey: cold.liveAgentByPaneKey + liveAgentByPaneKey: cold.liveAgentByPaneKey, + acknowledgedAgentsByPaneKey: {} }) expect(cached.events).toEqual(cold.events) expect(cached.liveAgentByPaneKey).toEqual(cold.liveAgentByPaneKey) diff --git a/src/renderer/src/components/activity/activity-event-builder.live-cap.test.ts b/src/renderer/src/components/activity/activity-event-builder.live-cap.test.ts index 643fc6b4e081..c9bc249e9926 100644 --- a/src/renderer/src/components/activity/activity-event-builder.live-cap.test.ts +++ b/src/renderer/src/components/activity/activity-event-builder.live-cap.test.ts @@ -41,7 +41,7 @@ describe('live activity capacity', () => { acknowledgedAgentsByPaneKey: {}, now: 3_000 }) - const threads = buildAgentPaneThreads(result) + const threads = buildAgentPaneThreads({ ...result, acknowledgedAgentsByPaneKey: {} }) expect(threads).toHaveLength(82) expect( diff --git a/src/renderer/src/components/activity/activity-scope-filter.test.ts b/src/renderer/src/components/activity/activity-scope-filter.test.ts index 45817e50a7b5..f86ff6f2127c 100644 --- a/src/renderer/src/components/activity/activity-scope-filter.test.ts +++ b/src/renderer/src/components/activity/activity-scope-filter.test.ts @@ -20,6 +20,7 @@ const SSH_HOST = 'ssh:devbox' as ExecutionHostId function makeThread(overrides: Partial = {}): AgentPaneThread { const worktree = makeWorktree() return { + acknowledgedAt: undefined, paneKey: PANE_KEY, paneTitle: 'Test Agent', agentType: 'claude', diff --git a/src/renderer/src/components/activity/activity-status-group-header.test.ts b/src/renderer/src/components/activity/activity-status-group-header.test.ts index cbcf468b900d..15163f176bc7 100644 --- a/src/renderer/src/components/activity/activity-status-group-header.test.ts +++ b/src/renderer/src/components/activity/activity-status-group-header.test.ts @@ -12,8 +12,9 @@ import { PANE_KEY, PANE_KEY_2 } from './ActivityPrototypePage-test-fixtures' +import { activityThreadStatusId } from './activity-thread-presentation' -type Ending = 'cancellation' | 'interruption' +type Ending = 'cancellation' | 'interruption' | 'failure' function doneEntry(paneKey: string, outcome: Ending, at: number): AgentStatusEntry { return { @@ -30,8 +31,12 @@ function doneEntry(paneKey: string, outcome: Ending, at: number): AgentStatusEnt } } -/** Status groups for two ended panes, `newer` the most recent. */ -function statusGroups(newer: Ending, older: Ending) { +/** The threads for two ended panes, `newer` the most recent. */ +function endedThreads( + newer: Ending, + older: Ending, + acknowledgedAgentsByPaneKey: Record = {} +) { const repo = makeRepo() const worktree = makeWorktree() const { events, liveAgentByPaneKey } = buildActivityEvents({ @@ -45,10 +50,15 @@ function statusGroups(newer: Ending, older: Ending) { }, worktreeMap: new Map([[worktree.id, worktree]]), repoMap: new Map([[repo.id, repo]]), - acknowledgedAgentsByPaneKey: {}, + acknowledgedAgentsByPaneKey, now: 3_000 }) - return buildActivityThreadGroups(buildAgentPaneThreads({ events, liveAgentByPaneKey }), 'status') + return buildAgentPaneThreads({ events, liveAgentByPaneKey, acknowledgedAgentsByPaneKey }) +} + +/** Status groups for two ended panes, `newer` the most recent. */ +function statusGroups(newer: Ending, older: Ending) { + return buildActivityThreadGroups(endedThreads(newer, older), 'status') } describe('the status group headers', () => { @@ -75,3 +85,20 @@ describe('the status group headers', () => { expect(groups[0]).toMatchObject({ key: 'interrupted', state: 'interrupted' }) }) }) + +describe('a thread cut short with nobody asking', () => { + // The same acknowledgement as the sidebar: news until seen, then it reads like a finished turn. + it('leaves Failed, and its "Failed" line, once the user has seen it; a failure stays', () => { + const unseen = endedThreads('interruption', 'failure') + const seen = endedThreads('interruption', 'failure', { [PANE_KEY]: 3_000, [PANE_KEY_2]: 2_000 }) + const cut = (threads: typeof seen) => threads.find((thread) => thread.paneKey === PANE_KEY) + const failed = (threads: typeof seen) => threads.find((thread) => thread.paneKey === PANE_KEY_2) + + expect(activityThreadStatusId(cut(unseen)!)).toBe('failed') + expect(cut(unseen)?.responsePreview).toBe('Failed') + expect(activityThreadStatusId(cut(seen)!)).toBe('done') + expect(cut(seen)?.responsePreview).not.toBe('Failed') + expect(activityThreadStatusId(failed(seen)!)).toBe('failed') + expect(failed(seen)?.responsePreview).toBe('Failed') + }) +}) diff --git a/src/renderer/src/components/activity/activity-thread-actions.test.ts b/src/renderer/src/components/activity/activity-thread-actions.test.ts index ce546f036970..303bb99c4bc5 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.test.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.test.ts @@ -34,6 +34,7 @@ const REMOTE_HOST = 'ssh:devbox' as const function makeRemoteThread(): AgentPaneThread { const worktree = { ...makeWorktree(), hostId: REMOTE_HOST } return { + acknowledgedAt: undefined, paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', paneTitle: 'Remote agent', agentType: 'claude', diff --git a/src/renderer/src/components/activity/activity-thread-builder.ts b/src/renderer/src/components/activity/activity-thread-builder.ts index d0f198dbebab..776a46ea892a 100644 --- a/src/renderer/src/components/activity/activity-thread-builder.ts +++ b/src/renderer/src/components/activity/activity-thread-builder.ts @@ -4,6 +4,7 @@ import { statusPreviewForEntry } from './activity-thread-presentation' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { acknowledgedAgentEntry } from '@/lib/agent-entry-acknowledgement' import type { ActivityEvent, ActivityLiveAgentSnapshot, @@ -52,6 +53,7 @@ function reuseThreadIfEqual( previous.agentType === next.agentType && previous.currentAgentState === next.currentAgentState && previous.currentAgentEntry === next.currentAgentEntry && + previous.acknowledgedAt === next.acknowledgedAt && previous.paneEntry === next.paneEntry && previous.responsePreview === next.responsePreview && previous.latestTimestamp === next.latestTimestamp && @@ -70,12 +72,16 @@ export function buildAgentPaneThreads( events: ActivityEvent[] liveAgentByPaneKey: Record paneEntryByPaneKey?: Record + acknowledgedAgentsByPaneKey: Readonly> generatedTitlesEnabled?: boolean }, reuseCache?: AgentPaneThreadReuseCache ): AgentPaneThread[] { const generatedTitlesEnabled = args.generatedTitlesEnabled === true const byPaneKey = new Map() + // Why: the thread's line follows its mark, so a seen cut-short turn drops "Failed" as the dot does. + const seen = (entry: AgentStatusEntry) => + acknowledgedAgentEntry(entry, args.acknowledgedAgentsByPaneKey[entry.paneKey]) for (const event of args.events) { const paneKey = event.entry.paneKey const existing = byPaneKey.get(paneKey) @@ -89,8 +95,9 @@ export function buildAgentPaneThreads( agentType: event.agentType, currentAgentState: null, currentAgentEntry: null, + acknowledgedAt: args.acknowledgedAgentsByPaneKey[paneKey], paneEntry: args.paneEntryByPaneKey?.[paneKey], - responsePreview: statusPreviewForEntry(event.entry, event.state), + responsePreview: statusPreviewForEntry(seen(event.entry), event.state), latestTimestamp: event.timestamp, latestEvent: event, events: [event], @@ -111,7 +118,7 @@ export function buildAgentPaneThreads( existing.agentType = event.agentType existing.tab = event.tab existing.responsePreview = statusPreviewForEntry( - event.entry, + seen(event.entry), event.state, existing.responsePreview ) @@ -130,8 +137,9 @@ export function buildAgentPaneThreads( agentType: liveAgent.agentType, currentAgentState: liveAgent.state, currentAgentEntry: liveAgent.entry, + acknowledgedAt: args.acknowledgedAgentsByPaneKey[paneKey], paneEntry: args.paneEntryByPaneKey?.[paneKey], - responsePreview: statusPreviewForEntry(liveAgent.entry, liveAgent.entry.state), + responsePreview: statusPreviewForEntry(seen(liveAgent.entry), liveAgent.entry.state), latestTimestamp: liveAgent.timestamp, latestEvent: null, events: [], @@ -148,7 +156,7 @@ export function buildAgentPaneThreads( existing.currentAgentState = liveAgent.state existing.currentAgentEntry = liveAgent.entry existing.responsePreview = statusPreviewForEntry( - liveAgent.entry, + seen(liveAgent.entry), liveAgent.entry.state, existing.responsePreview ) diff --git a/src/renderer/src/components/activity/activity-thread-child-agent.test.ts b/src/renderer/src/components/activity/activity-thread-child-agent.test.ts index 214e0e559cd0..39faf3131f72 100644 --- a/src/renderer/src/components/activity/activity-thread-child-agent.test.ts +++ b/src/renderer/src/components/activity/activity-thread-child-agent.test.ts @@ -32,6 +32,7 @@ function makeTestThread( ): AgentPaneThread { const worktree = makeWorktree() return { + acknowledgedAt: undefined, paneKey, paneTitle: 'Test Agent', agentType: 'claude', diff --git a/src/renderer/src/components/activity/activity-thread-grouping.search-cache.test.ts b/src/renderer/src/components/activity/activity-thread-grouping.search-cache.test.ts index 4c5b003eac4b..dbb7ed10d1a9 100644 --- a/src/renderer/src/components/activity/activity-thread-grouping.search-cache.test.ts +++ b/src/renderer/src/components/activity/activity-thread-grouping.search-cache.test.ts @@ -8,6 +8,7 @@ import { makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures' function makeThread(paneKey: string, paneTitle: string): AgentPaneThread { return { + acknowledgedAt: undefined, paneKey, tab: makeTab(), worktree: makeWorktree(), diff --git a/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx b/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx index ef1f109d50ad..3f475f6bc308 100644 --- a/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx +++ b/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx @@ -45,6 +45,7 @@ function createTestThread(overrides: Partial = {}): AgentPaneTh const tab = makeTab() return { + acknowledgedAt: undefined, paneKey: 'tab-1:leaf-1', tab, worktree, diff --git a/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx b/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx index 6db25785dc65..a16496dda706 100644 --- a/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx +++ b/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx @@ -12,6 +12,7 @@ import { makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures' globalThis.IS_REACT_ACT_ENVIRONMENT = true const mockThread: AgentPaneThread = { + acknowledgedAt: undefined, paneKey: 'tab-1:agent-1', tab: makeTab(), worktree: makeWorktree(), diff --git a/src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx b/src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx index 5b531570444d..297f4c38bf2b 100644 --- a/src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx +++ b/src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx @@ -18,6 +18,7 @@ const THREAD_COUNT = 300 function makeThread(index: number): AgentPaneThread { return { + acknowledgedAt: undefined, paneKey: `tab-${index}:leaf-${index}`, tab: makeTab(), worktree: makeWorktree(), diff --git a/src/renderer/src/components/activity/activity-thread-presentation.test.ts b/src/renderer/src/components/activity/activity-thread-presentation.test.ts index 6aa3efc1a322..c7a2c392c57d 100644 --- a/src/renderer/src/components/activity/activity-thread-presentation.test.ts +++ b/src/renderer/src/components/activity/activity-thread-presentation.test.ts @@ -12,6 +12,7 @@ import { function makeThread(overrides: Partial = {}): AgentPaneThread { const worktree = makeWorktree() return { + acknowledgedAt: undefined, paneKey: PANE_KEY, paneTitle: 'low hanging issues', agentType: 'codex', diff --git a/src/renderer/src/components/activity/activity-thread-presentation.ts b/src/renderer/src/components/activity/activity-thread-presentation.ts index 4614b192d1b3..900fd8dbf1e4 100644 --- a/src/renderer/src/components/activity/activity-thread-presentation.ts +++ b/src/renderer/src/components/activity/activity-thread-presentation.ts @@ -14,6 +14,10 @@ import { import { formatUiRelativeTime } from '@/i18n/relative-time-format' import { translate } from '@/i18n/i18n' import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' +import { + acknowledgedAgentEntry, + type AcknowledgedAgentStatusEntry +} from '@/lib/agent-entry-acknowledgement' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { isHistoricalActivityState } from './activity-event-state' import type { @@ -139,7 +143,7 @@ export function paneTitleForEvent(event: ActivityEvent, generatedTitlesEnabled: } export function statusPreviewForEntry( - entry: AgentStatusEntry, + entry: AcknowledgedAgentStatusEntry, agentState?: AgentStatusState | null, previousPreview?: string ): string { @@ -151,13 +155,17 @@ export type ActivityThreadStatusId = AgentDotState /** Single classifier behind grouping, labels, and clear-completed; the only place the * verdict predicate is spelled. */ export function activityThreadStatusId(thread: AgentPaneThread): ActivityThreadStatusId { + const seen = (entry: AgentStatusEntry) => acknowledgedAgentEntry(entry, thread.acknowledgedAt) // Why: a failed main agent outranks the subagent work still holding its row live. - if (thread.currentAgentEntry && agentVerdictDisplayMark(thread.currentAgentEntry) === 'failed') { + if ( + thread.currentAgentEntry && + agentVerdictDisplayMark(seen(thread.currentAgentEntry)) === 'failed' + ) { return 'failed' } const state = threadCurrentState(thread) ?? 'done' const verdictEntry = threadVerdictEntry(thread) - const verdictDot = verdictEntry ? agentVerdictDisplayMark(verdictEntry) : null + const verdictDot = verdictEntry ? agentVerdictDisplayMark(seen(verdictEntry)) : null if (!thread.currentAgentState && state === 'done' && verdictDot) { return verdictDot } diff --git a/src/renderer/src/components/activity/activity-thread-types.ts b/src/renderer/src/components/activity/activity-thread-types.ts index 5d0b9e11acb3..9b2eab592928 100644 --- a/src/renderer/src/components/activity/activity-thread-types.ts +++ b/src/renderer/src/components/activity/activity-thread-types.ts @@ -54,6 +54,8 @@ export type AgentPaneThread = { agentType: AgentType currentAgentState: ActivityLiveAgentState | null currentAgentEntry: AgentStatusEntry | null + /** When the user last acknowledged the pane: a turn cut short reads failed only until then. */ + acknowledgedAt: number | undefined /** The pane's own status row, live or not; its state outranks the newest event's. */ paneEntry?: AgentStatusEntry responsePreview: string diff --git a/src/renderer/src/components/activity/activity-thread-virtual-items.test.ts b/src/renderer/src/components/activity/activity-thread-virtual-items.test.ts index 041841bcad47..53ce68923afc 100644 --- a/src/renderer/src/components/activity/activity-thread-virtual-items.test.ts +++ b/src/renderer/src/components/activity/activity-thread-virtual-items.test.ts @@ -10,6 +10,7 @@ import { makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures' function makeThread(paneKey: string): AgentPaneThread { return { + acknowledgedAt: undefined, paneKey, tab: makeTab(), worktree: makeWorktree(), diff --git a/src/renderer/src/components/activity/use-agent-pane-threads.ts b/src/renderer/src/components/activity/use-agent-pane-threads.ts index f9575f02a766..ba7a76b4d503 100644 --- a/src/renderer/src/components/activity/use-agent-pane-threads.ts +++ b/src/renderer/src/components/activity/use-agent-pane-threads.ts @@ -162,11 +162,18 @@ export function useAgentPaneThreads(args: { events: allEvents, liveAgentByPaneKey, paneEntryByPaneKey, + acknowledgedAgentsByPaneKey: storeData.acknowledgedAgentsByPaneKey, generatedTitlesEnabled: storeData.generatedTitlesEnabled }, threadReuseCacheRef.current ), - [allEvents, liveAgentByPaneKey, paneEntryByPaneKey, storeData.generatedTitlesEnabled] + [ + allEvents, + liveAgentByPaneKey, + paneEntryByPaneKey, + storeData.acknowledgedAgentsByPaneKey, + storeData.generatedTitlesEnabled + ] ) const selectedPaneKeyIsLive = diff --git a/src/renderer/src/components/agent-child-row-parity.test.tsx b/src/renderer/src/components/agent-child-row-parity.test.tsx index eb7721d2f2f1..dd429bfe28b6 100644 --- a/src/renderer/src/components/agent-child-row-parity.test.tsx +++ b/src/renderer/src/components/agent-child-row-parity.test.tsx @@ -16,6 +16,7 @@ import { buildBackgroundTaskGroupsFromViews } from '@/components/native-chat/bac import { CompactAgentRow } from '@/components/sidebar/worktree-card-compact-agent-row' import { buildSubagentChildRows } from '@/components/sidebar/worktree-subagent-child-rows' import { TooltipProvider } from '@/components/ui/tooltip' +import { acknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' vi.mock('@/components/dashboard/use-agent-row-conversation-name', () => ({ useAgentRowConversationName: () => null @@ -125,7 +126,11 @@ function sidebarRows(parent: AgentStatusEntry, parentIsFresh = true): RenderedRo mount( renderToStaticMarkup( - {}} /> + {}} + /> ) ), @@ -176,7 +181,7 @@ function fullRow(parent: AgentStatusEntry): { labels: string[]; text: string } { renderToStaticMarkup( {}} onDismiss={() => {}} @@ -343,7 +348,7 @@ describe('a child reads the same in the sidebar and the chat strip', () => { renderToStaticMarkup( {}} onDismiss={() => {}} @@ -473,7 +478,7 @@ describe('a mirrored parent and its children read one silence', () => { renderToStaticMarkup( {}} onDismiss={() => {}} diff --git a/src/renderer/src/components/cmd-j/palette-live-status.tsx b/src/renderer/src/components/cmd-j/palette-live-status.tsx index 34840d50cc5a..57cf0c9ef36e 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.tsx @@ -5,10 +5,7 @@ import { AgentStateDot } from '@/components/AgentStateDot' import { StateIndicatorTooltip } from '@/components/StateIndicatorTooltip' import StatusIndicator from '@/components/sidebar/StatusIndicator' import { FilledBellIcon } from '@/components/sidebar/WorktreeCardHelpers' -import { - buildExplicitEntriesByTabId, - type TabPaneInputSources -} from '@/components/sidebar/smart-attention' +import { buildExplicitEntriesByTabId } from '@/components/sidebar/smart-attention' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state' import { @@ -18,6 +15,7 @@ import { } from '@/lib/worktree-status' import { resolveRecentWorkspaceTabStatus, + type RecentWorkspaceTabPaneSources, type RecentWorkspaceTabRow } from '@/lib/recent-workspace-tab-rows' import { @@ -42,7 +40,7 @@ type PaletteLiveStatus = { liveAgentStatusByWorktreeId: ReadonlyMap agentStatusPaneIdsByTabId: Record> stalePaneIdsByTabId: Record> - paneSources: TabPaneInputSources + paneSources: RecentWorkspaceTabPaneSources tabsByWorktree: Record browserTabsByWorktree: Record unreadTerminalTabs: Record @@ -72,7 +70,8 @@ export function PaletteLiveStatusProvider({ browserTabsByWorktree, migrationUnsupportedByPtyId, unreadTerminalTabs, - unreadAgentCompletionPanes + unreadAgentCompletionPanes, + acknowledgedAgentsByPaneKey } = useAppStore( useShallow((s) => active @@ -85,7 +84,8 @@ export function PaletteLiveStatusProvider({ browserTabsByWorktree: s.browserTabsByWorktree, migrationUnsupportedByPtyId: s.migrationUnsupportedByPtyId, unreadTerminalTabs: s.unreadTerminalTabs, - unreadAgentCompletionPanes: s.unreadAgentCompletionPanes + unreadAgentCompletionPanes: s.unreadAgentCompletionPanes, + acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey } : EMPTY_LIVE_INPUTS ) @@ -112,7 +112,8 @@ export function PaletteLiveStatusProvider({ entriesByTabId, ptyIdsByTabId, runtimePaneTitlesByTabId, - terminalLayoutsByTabId + terminalLayoutsByTabId, + acknowledgedAgentsByPaneKey }, tabsByWorktree, browserTabsByWorktree, @@ -122,6 +123,7 @@ export function PaletteLiveStatusProvider({ now } }, [ + acknowledgedAgentsByPaneKey, agentStatusByPaneKey, browserTabsByWorktree, migrationUnsupportedByPtyId, @@ -186,7 +188,8 @@ const EMPTY_LIVE_INPUTS = Object.freeze({ browserTabsByWorktree: {}, migrationUnsupportedByPtyId: {}, unreadTerminalTabs: {}, - unreadAgentCompletionPanes: {} + unreadAgentCompletionPanes: {}, + acknowledgedAgentsByPaneKey: {} }) function useLiveStatus(): PaletteLiveStatus | null { diff --git a/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.test.tsx index 6d0994eb7604..d17c8709c19b 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.test.tsx @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { CompactAgentRow } from '../sidebar/worktree-card-compact-agent-row' import { TooltipProvider } from '../ui/tooltip' import DashboardAgentRow from './DashboardAgentRow' -import type { DashboardAgentRow as AgentRow } from './useDashboardData' +import type { AcknowledgedAgentRow as AgentRow } from '@/lib/agent-entry-acknowledgement' import { DashboardAgentChildDisclosure } from './DashboardAgentChildDisclosure' vi.mock('./use-agent-row-conversation-name', () => ({ useAgentRowConversationName: () => null })) @@ -26,7 +26,8 @@ const agent: AgentRow = { prompt: 'Review the change', updatedAt: 60000, stateStartedAt: 60000, - stateHistory: [] + stateHistory: [], + acknowledgedAt: undefined }, tab: { id: 'tab', diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index 150071e49a37..4c053ea999fd 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -5,13 +5,17 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { TooltipProvider } from '../ui/tooltip' import DashboardAgentRow from './DashboardAgentRow' -import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' +import { + acknowledgedAgentRow, + type AcknowledgedAgentRow as DashboardAgentRowData +} from '@/lib/agent-entry-acknowledgement' const NOW = 120_000 function makeAgent( - overrides: Partial = {}, - entryOverrides: Partial = {} + overrides: Partial> = {}, + entryOverrides: Partial = {}, + acknowledgedAt?: number ): DashboardAgentRowData { const paneKey = overrides.paneKey ?? 'tab-1:leaf-1' const tab: TerminalTab = { @@ -35,15 +39,18 @@ function makeAgent( ...entryOverrides } - return { - paneKey, - entry, - tab, - agentType: entry.agentType ?? 'codex', - state: entry.state, - startedAt: entry.stateStartedAt, - ...overrides - } + return acknowledgedAgentRow( + { + paneKey, + entry, + tab, + agentType: entry.agentType ?? 'codex', + state: entry.state, + startedAt: entry.stateStartedAt, + ...overrides + }, + acknowledgedAt + ) } function renderRow(agent: DashboardAgentRowData): string { @@ -326,36 +333,28 @@ describe('DashboardAgentRow', () => { expect(markup).not.toContain('lucide-circle-check') }) - // Attention only while unseen: a visited crash-cut row reads like a finished one. - it('renders a visited crash-cut row as done, and a visited failure still as failed', () => { - const visited = (outcome: 'interruption' | 'failure') => - renderToStaticMarkup( - - - + // Attention only while unseen: a visited cut-short row reads like a finished one. + it('renders a visited cut-short row as done, and a visited failure still as failed', () => { + const visited = (outcome: 'interruption' | 'failure', acknowledgedAt: number) => + renderRow( + makeAgent( + { state: 'done', startedAt: 1_000 }, + { + state: 'done', + prompt: 'Give me a quick update', + updatedAt: 2_000, + stateStartedAt: 2_000, + stateHistory: [], + mainAgent: { state: 'done', outcome, stateStartedAt: 2_000 } + }, + acknowledgedAt + ) ) - expect(visited('interruption')).toContain('aria-label="Done"') - expect(visited('interruption')).not.toContain('aria-label="Failed"') - expect(visited('failure')).toContain('aria-label="Failed"') + expect(visited('interruption', 1_999)).toContain('aria-label="Failed"') + expect(visited('interruption', 2_000)).toContain('aria-label="Done"') + expect(visited('interruption', 2_000)).not.toContain('aria-label="Failed"') + expect(visited('failure', 2_000)).toContain('aria-label="Failed"') }) it.each([ diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 87a0b647a8cb..722992586a84 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -15,7 +15,7 @@ import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict' import { agentVerdictStatusLine } from '@/lib/agent-verdict-status-line' -import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' +import type { AcknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' import { useAgentRowConversationName } from './use-agent-row-conversation-name' import { lastEnteredDoneAt } from './agent-finished-timestamp' @@ -33,14 +33,14 @@ function formatTimeAgo(ts: number, now: number): string { } // A child row's silence is the model's, on the clock its compact row and the strip read. -function rowNoUpdateLabel(agent: DashboardAgentRowData, now: number): string { +function rowNoUpdateLabel(agent: AcknowledgedAgentRow, now: number): string { return agent.childRow ? agentChildRowNoUpdateLabel(agent.childRow, now) : agentNoUpdateLabel(agent.entry, now) } function stateDotTooltipLabel( - agent: DashboardAgentRowData, + agent: AcknowledgedAgentRow, dotState: AgentDotState, now: number ): string { @@ -53,7 +53,7 @@ function stateDotTooltipLabel( } type Props = { - agent: DashboardAgentRowData + agent: AcknowledgedAgentRow onDismiss: (paneKey: string) => void /** Navigate to this agent's tab; paneKey lets the caller mark-visit the exact clicked row. */ onActivate: (tabId: string, paneKey: string) => void @@ -85,7 +85,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ onDismiss, onActivate, now, - isUnvisited, + isUnvisited = false, stateDotSize = 'md', hideIdentityIcon = false, hideExpand = false, @@ -171,9 +171,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ : [formatAgentTypeLabel(agent.agentType), model].filter(Boolean).join(' · ') // Why: a stop or a failure is a terminal outcome, so surface it in the leading state dot; a // failure does so even while subagents still run. - // A crash-cut turn reads failed only until the user visits it, like the row's bold; a caller - // that does not say reads unvisited. - const dotState: AgentDotState = agentRowDisplayDotState(agent, { seen: isUnvisited === false }) + const dotState: AgentDotState = agentRowDisplayDotState(agent) const dotTooltipLabel = stateDotTooltipLabel(agent, dotState, now) // Why: the elapsed gap is the whole content of an `unverifiable` row, so it rides the // row's own timestamp slot rather than hiding in a hover tooltip. diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts index 2031569ff6d8..290939f0c380 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts @@ -1,7 +1,7 @@ import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' import { - agentTurnEndedOnPurpose, - agentVerdictDisplayMark + agentMainAgentVerdict, + agentTurnEndedOnPurpose } from '../../../../shared/agent-main-agent-verdict' import type { DashboardAgentRow } from './useDashboardData' @@ -30,8 +30,14 @@ export function lastEnteredDoneAt( if (entry.state === 'done' && agentTurnEndedOnPurpose(entry) && entry.sessionBoundary !== true) { return entry.stateStartedAt } - // Why: a failed main agent reads failed while its subagents run, so it shows when it failed. - if (entry.state !== 'done' && entry.mainAgent && agentVerdictDisplayMark(entry) === 'failed') { + // Why: a main agent that failed or was cut short ended its turn while its subagents run, so it + // shows when that turn ended. + const verdict = agentMainAgentVerdict(entry) + if ( + entry.state !== 'done' && + entry.mainAgent && + (verdict === 'failure' || verdict === 'interruption') + ) { return entry.mainAgent.stateStartedAt } for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) { diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index 688113e7a010..0eea46f2f19f 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -27,7 +27,7 @@ import { } from '@/lib/agent-status' import { track } from '@/lib/telemetry' import { useNow } from '@/hooks/use-now' -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { AcknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp' import { selectLivePtyIdsForWorktree } from '@/components/sidebar/worktree-card-status-inputs' import { useWorktreeAgentRows } from '@/components/sidebar/useWorktreeAgentRows' @@ -37,7 +37,7 @@ import { translate } from '@/i18n/i18n' type OrderedSendTarget = { target: NotesSendAgentTarget - agent: DashboardAgentRowData | null + agent: AcknowledgedAgentRow | null } export function ReviewNotesSendMenuContent({ @@ -245,7 +245,7 @@ function AgentTargetMenuItem({ onSend }: { target: NotesSendAgentTarget - agent: DashboardAgentRowData | null + agent: AcknowledgedAgentRow | null now: number disabled: boolean onSend: (target: NotesSendAgentTarget) => void @@ -292,7 +292,7 @@ function AgentTargetMenuItem({ function orderSendTargetsByWorktreeAgentRows( sendTargets: NotesSendAgentTarget[], - agentRows: DashboardAgentRowData[] + agentRows: AcknowledgedAgentRow[] ): OrderedSendTarget[] { const targetsByPaneKey = new Map(sendTargets.map((target) => [target.paneKey, target])) const usedPaneKeys = new Set() @@ -316,7 +316,7 @@ function orderSendTargetsByWorktreeAgentRows( return ordered } -function formatAgentRelativeTime(agent: DashboardAgentRowData, now: number): string | null { +function formatAgentRelativeTime(agent: AcknowledgedAgentRow, now: number): string | null { const doneAt = lastEnteredDoneAt(agent) if (doneAt !== null) { return `${formatTimeAgo(doneAt, now)}` diff --git a/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx b/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx index a5b4b911793c..aa2e1f8ef530 100644 --- a/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx +++ b/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx @@ -81,6 +81,7 @@ import { CompactAgentRow } from '@/components/sidebar/worktree-card-compact-agen import { buildSubagentChildRows } from '@/components/sidebar/worktree-subagent-child-rows' import { TooltipProvider } from '@/components/ui/tooltip' import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' +import { acknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' const MINUTE = 60_000 const tab = { @@ -177,7 +178,11 @@ function sidebarRows(): RenderedRow[] { const container = document.createElement('div') container.innerHTML = renderToStaticMarkup( - {}} /> + {}} + /> ) return readRow(container, ' - ') diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx index 1459e7f1bb30..4e9efcdbf484 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx @@ -10,6 +10,7 @@ import { seedWorktreeAgentExpansionStateForTests } from './worktree-card-agents-expansion-state' import WorktreeCardAgents from './WorktreeCardAgents' +import { acknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' const WORKSPACE = 'acknowledgement-workspace' const PANE_A = makePaneKey('tab-a', '11111111-1111-4111-8111-111111111111') @@ -81,9 +82,11 @@ function agentRow( function renderLabels(agents: DashboardAgentRow[]): HTMLElement[] { const container = document.createElement('div') + // The rows as useWorktreeAgentRows hands them over: joined with the store's acknowledgements. + const rows = agents.map((agent) => acknowledgedAgentRow(agent, acknowledgements[agent.paneKey])) container.innerHTML = renderToStaticMarkup( - + ) return [ diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index 73b9a430bd76..f989e67f76f2 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -1,7 +1,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { AcknowledgedAgentRow as DashboardAgentRowData } from '@/lib/agent-entry-acknowledgement' import { makePaneKey } from '../../../../shared/stable-pane-id' const LEAF_A = '11111111-1111-4111-8111-111111111111' @@ -59,7 +59,8 @@ function mockAgent({ stateStartedAt, stateHistory: prompt === undefined ? undefined : [], terminalHandle, - orchestration + orchestration, + acknowledgedAt: undefined }, lineage } diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 6d0271576492..541eb442e265 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -12,7 +12,8 @@ import { } from './worktree-card-send-target-inputs' import { useWorktreeAgentRows } from './useWorktreeAgentRows' import { cn } from '@/lib/utils' -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { AcknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' +import { isAgentTurnAcknowledged } from '../../../../shared/agent-turn-acknowledgement' import { parsePaneKey } from '../../../../shared/stable-pane-id' import { dismissStaleAgentRowByKey } from '../terminal-pane/stale-agent-row' import { useFocusedAgentPaneKey } from './focused-agent-row-highlight' @@ -27,7 +28,6 @@ import { revealElementInScrollContainer } from './worktree-sidebar-reveal' import { useWorktreeAgentExpansionState } from './worktree-card-agents-expansion-state' import { translate } from '@/i18n/i18n' import { activateStructuredAgentSessionTab } from '@/lib/structured-agent-session-tab-activation' -import { selectAcknowledgedAgentTimes } from './worktree-card-agent-ack-inputs' export const SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT = 'orca-suppress-worktree-list-scroll-adjustment' @@ -47,7 +47,7 @@ function revealCompactAgentCard(agentListRoot: HTMLElement | null): void { type Props = { worktreeId: string - agents?: DashboardAgentRowData[] + agents?: AcknowledgedAgentRow[] /** Spacing from the card body above; parent decides whether a divider is appropriate. */ className?: string } @@ -69,7 +69,7 @@ const WorktreeCardAgents = React.memo(function WorktreeCardAgents({ type BodyProps = { worktreeId: string - agents: DashboardAgentRowData[] + agents: AcknowledgedAgentRow[] className?: string } @@ -91,19 +91,14 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ const focusedAgentPaneKey = useFocusedAgentPaneKey(worktreeId) const compactAgentListRootRef = useRef(null) - // Why: acknowledgement writes are app-global; project only this card's rows - // so unrelated worktree activity does not rerender every agent body. - const acknowledgedAgentTimes = useAppStore( - useShallow((s) => selectAcknowledgedAgentTimes(s, agents)) - ) + // Why: the rows carry their acknowledgement, joined where they are built. const unvisitedByPaneKey = useMemo(() => { const out: Record = {} - for (const [index, agent] of agents.entries()) { - const ackAt = acknowledgedAgentTimes[index] ?? 0 - out[agent.paneKey] = ackAt < agent.entry.stateStartedAt + for (const agent of agents) { + out[agent.paneKey] = !isAgentTurnAcknowledged(agent.entry) } return out - }, [agents, acknowledgedAgentTimes]) + }, [agents]) const handleDismissAgent = useCallback( (paneKey: string) => { @@ -236,7 +231,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ }, []) const renderAgentBranch = ( - agent: DashboardAgentRowData, + agent: AcknowledgedAgentRow, ancestorPaneKeys: ReadonlySet = new Set() ): React.ReactNode => { if (ancestorPaneKeys.has(agent.paneKey)) { @@ -295,7 +290,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ } const renderCompactAgentBranch = ( - agent: DashboardAgentRowData, + agent: AcknowledgedAgentRow, ancestorPaneKeys: ReadonlySet = new Set(), cacheTimerActive = true ): React.ReactNode => { @@ -378,7 +373,6 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ > { diff --git a/src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts b/src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts index 052adc752b95..05de9eaeb2a6 100644 --- a/src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts +++ b/src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts @@ -22,6 +22,7 @@ import { buildWorktreeAgentRows } from './worktree-agent-rows' import { getAgentDotState } from './worktree-card-agent-summary' import { getCompactAgentSecondary } from './worktree-card-compact-agent-row' import { resolveAttention } from './smart-attention' +import { acknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' const NOW = new Date('2026-05-04T12:00:00.000Z').getTime() const TAB_ID = 'tab-1' @@ -73,10 +74,10 @@ describe('a stale entry on a pane Orca still holds', () => { now: NOW }) expect(rows[0].state).not.toBe('done') - expect(getAgentDotState(rows[0])).not.toBe('done') + expect(getAgentDotState(acknowledgedAgentRow(rows[0], undefined))).not.toBe('done') // Nor the working spinner: Orca has no current evidence of work either. - expect(getAgentDotState(rows[0])).not.toBe('working') - expect(getAgentDotState(rows[0])).toBe('unverifiable') + expect(getAgentDotState(acknowledgedAgentRow(rows[0], undefined))).not.toBe('working') + expect(getAgentDotState(acknowledgedAgentRow(rows[0], undefined))).toBe('unverifiable') }) it('reports the observed gap rather than a verdict on the agent', () => { @@ -87,7 +88,9 @@ describe('a stale entry on a pane Orca still holds', () => { ptyIdsByTabId: LIVE, now: NOW }) - expect(getCompactAgentSecondary(rows[0], NOW)).toBe('No update in 34m') + expect(getCompactAgentSecondary(acknowledgedAgentRow(rows[0], undefined), NOW)).toBe( + 'No update in 34m' + ) }) it('measures the gap from the observation clock, not the delivery clock', () => { @@ -101,7 +104,9 @@ describe('a stale entry on a pane Orca still holds', () => { now: NOW }) expect(rows[0].state).toBe('unverifiable') - expect(getCompactAgentSecondary(rows[0], NOW)).toBe('No update in 34m') + expect(getCompactAgentSecondary(acknowledgedAgentRow(rows[0], undefined), NOW)).toBe( + 'No update in 34m' + ) }) }) diff --git a/src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx b/src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx index 3307d2f9dce8..f5d5afdf17ac 100644 --- a/src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx +++ b/src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx @@ -9,6 +9,7 @@ import { NativeChatBackgroundTasksStatus } from '@/components/native-chat/Native import { TooltipProvider } from '@/components/ui/tooltip' import { CompactAgentRow } from './worktree-card-compact-agent-row' import { buildSubagentChildRows } from './worktree-subagent-child-rows' +import { acknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' vi.mock('@/components/dashboard/use-agent-row-conversation-name', () => ({ useAgentRowConversationName: () => null @@ -79,7 +80,11 @@ function compactMarkup(parent: AgentStatusEntry, parentIsFresh: boolean): string normalize( renderToStaticMarkup( - {}} /> + {}} + /> ) ) @@ -92,7 +97,7 @@ function fullMarkup(parent: AgentStatusEntry, parentIsFresh: boolean): string[] renderToStaticMarkup( {}} onDismiss={() => {}} diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index 1fd1d8af452f..1f01ffada000 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -26,6 +26,8 @@ import { } from './worktree-agent-row-selectors' import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from './worktree-agent-orchestration-index' import { EMPTY_TABS } from './WorktreeCardHelpers' +import { selectAcknowledgedAgentTimes } from './worktree-card-agent-ack-inputs' +import { acknowledgedAgentRow, type AcknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' import { createWorktreeAgentFreshnessSelector, EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE @@ -52,7 +54,7 @@ export { * store slice and then shared by every visible card, avoiding O(cards × agents) * selector work on high-frequency agent status pings. */ -export function useWorktreeAgentRows(worktreeId: string, active = true): DashboardAgentRow[] { +export function useWorktreeAgentRows(worktreeId: string, active = true): AcknowledgedAgentRow[] { const selectAgentFreshness = useMemo( () => createWorktreeAgentFreshnessSelector(worktreeId), [worktreeId] @@ -112,7 +114,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa active ? selectAgentFreshness(s) : EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE ) - return useMemo(() => { + const rows = useMemo(() => { if (!active) { return EMPTY_AGENT_ROWS } @@ -156,4 +158,11 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa paneForegroundAgentByPaneKey, agentFreshnessSignature ]) + // Why: joined here, once, so every surface built from these rows reads one seen-ness; only this + // card's acknowledgements are projected, so other cards' visits do not re-render it. + const acknowledgedTimes = useAppStore(useShallow((s) => selectAcknowledgedAgentTimes(s, rows))) + return useMemo( + () => rows.map((row, index) => acknowledgedAgentRow(row, acknowledgedTimes[index])), + [rows, acknowledgedTimes] + ) } diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index 03da184918bf..b14a2f52f3a7 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -77,6 +77,7 @@ describe('selectWorktreeAgentActivitySummary', () => { agentStatusByPaneKey: { [firstPaneKey]: makeAgentStatusEntry({ paneKey: firstPaneKey, state: 'working' }) }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: { @@ -116,6 +117,7 @@ describe('selectWorktreeAgentActivitySummary', () => { agentStatusByPaneKey: { [paneKey]: entry }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey @@ -154,6 +156,7 @@ describe('selectWorktreeAgentActivitySummary', () => { agentStatusByPaneKey: { [paneKey]: makeAgentStatusEntry({ paneKey, state: 'working' }) }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey @@ -191,6 +194,7 @@ describe('selectWorktreeAgentActivitySummary', () => { workingMode: 'monitoring' }) }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -222,6 +226,7 @@ describe('selectWorktreeAgentActivitySummary', () => { mainAgent: { state: 'done', outcome, stateStartedAt: 1_000 } }) }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -318,6 +323,7 @@ describe('selectWorktreeAgentActivitySummary', () => { mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 1_000 } }) }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -343,6 +349,7 @@ describe('selectWorktreeAgentActivitySummary', () => { mainAgent: { state: 'done', outcome, stateStartedAt: 1_000 } }) }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -389,6 +396,7 @@ describe('selectWorktreeAgentActivitySummary', () => { tabsByWorktree: { [worktreeId]: [liveTab, retainedTab] }, agentStatusEpoch: epoch++, agentStatusByPaneKey, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey @@ -448,6 +456,7 @@ describe('selectWorktreeAgentActivitySummary', () => { restoredUnconfirmed: true }) }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -474,6 +483,7 @@ describe('selectWorktreeAgentActivitySummary', () => { const changedPaneKey = makePaneKey('tab-11', LEAF_ID) const baseInputs = { tabsByWorktree, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -517,6 +527,7 @@ describe('selectWorktreeAgentActivitySummary', () => { const secondPaneKey = makePaneKey('tab-2', LEAF_ID) const replacementPaneKey = makePaneKey('tab-3', LEAF_ID) const sharedInputs = { + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -587,6 +598,7 @@ describe('selectWorktreeAgentActivitySummary', () => { worktreeId: 'repo::/wt-1' }) }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -614,6 +626,7 @@ describe('selectWorktreeAgentActivitySummary', () => { parentPaneKey }) }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -639,6 +652,7 @@ describe('selectWorktreeAgentActivitySummary', () => { worktreeId: 'repo::/wt-1' }) }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: { [childPaneKey]: { @@ -666,6 +680,7 @@ describe('selectWorktreeAgentActivitySummary', () => { tabsByWorktree: { 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] }, agentStatusEpoch: 0, agentStatusByPaneKey: { [paneKey]: entry }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -692,6 +707,7 @@ describe('selectWorktreeAgentActivitySummary', () => { tabsByWorktree: { 'repo::/wt-1': [tab] }, agentStatusEpoch: 0, agentStatusByPaneKey: { [paneKey]: entry }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index b6f30f573114..753504906949 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -12,6 +12,7 @@ import { } from '../../../../shared/agent-status-types' import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict' import { applyAgentPaneActivityFlags } from '@/lib/agent-pane-activity-flags' +import { acknowledgedAgentEntry } from '@/lib/agent-entry-acknowledgement' export type WorktreeAgentActivitySummary = { hasPermission: boolean @@ -56,11 +57,10 @@ export type AgentActivityInput = Pick< | 'agentStatusByPaneKey' | 'migrationUnsupportedByPtyId' | 'retainedAgentsByPaneKey' + | 'acknowledgedAgentsByPaneKey' > & { tabsByWorktree: AgentActivityTabsByWorktree runtimeAgentOrchestrationByPaneKey?: AppState['runtimeAgentOrchestrationByPaneKey'] - /** Absent reads every turn unseen. */ - acknowledgedAgentsByPaneKey?: AppState['acknowledgedAgentsByPaneKey'] } type AgentActivityCache = { @@ -68,8 +68,8 @@ type AgentActivityCache = { agentStatusEpoch: number migrationUnsupportedByPtyId: AppState['migrationUnsupportedByPtyId'] retainedAgentsByPaneKey: AppState['retainedAgentsByPaneKey'] + acknowledgedAgentsByPaneKey: AppState['acknowledgedAgentsByPaneKey'] runtimeAgentOrchestrationByPaneKey: AppState['runtimeAgentOrchestrationByPaneKey'] | undefined - acknowledgedAgentsByPaneKey: AppState['acknowledgedAgentsByPaneKey'] | undefined summaries: Map } @@ -92,8 +92,8 @@ function getWorktreeAgentActivitySummaries( agentActivityCache.agentStatusEpoch === state.agentStatusEpoch && agentActivityCache.migrationUnsupportedByPtyId === state.migrationUnsupportedByPtyId && agentActivityCache.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey && - agentActivityCache.runtimeAgentOrchestrationByPaneKey === runtimeAgentOrchestrationByPaneKey && - agentActivityCache.acknowledgedAgentsByPaneKey === state.acknowledgedAgentsByPaneKey + agentActivityCache.acknowledgedAgentsByPaneKey === state.acknowledgedAgentsByPaneKey && + agentActivityCache.runtimeAgentOrchestrationByPaneKey === runtimeAgentOrchestrationByPaneKey ) { return agentActivityCache.summaries } @@ -118,10 +118,6 @@ function getWorktreeAgentActivitySummaries( return summary } - // Same rule as WorktreeCardAgents' unvisitedByPaneKey, so the dot and the row's bold agree. - const seen = (paneKey: string, stateStartedAt: number): boolean => - (state.acknowledgedAgentsByPaneKey?.[paneKey] ?? 0) >= stateStartedAt - const now = Date.now() for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { const paneIdentity = parseAgentStatusPaneIdentity(paneKey) @@ -152,7 +148,10 @@ function getWorktreeAgentActivitySummaries( if (entry.state === 'done') { addParentPaneId(summary, orchestration, worktreeId, tabIdToWorktreeId) } - applyAgentPaneActivityFlags(summary, entry, { seen: seen(paneKey, entry.stateStartedAt) }) + applyAgentPaneActivityFlags( + summary, + acknowledgedAgentEntry(entry, state.acknowledgedAgentsByPaneKey[paneKey]) + ) } for (const unsupported of Object.values(state.migrationUnsupportedByPtyId ?? {})) { @@ -166,8 +165,11 @@ function getWorktreeAgentActivitySummaries( for (const retained of Object.values(state.retainedAgentsByPaneKey ?? {})) { const summary = summaryForWorktree(retained.worktreeId) // Why: a failed agent is retained so its failure stays visible, not so it reads done. - const retainedSeen = seen(retained.entry.paneKey, retained.entry.stateStartedAt) - if (agentVerdictDisplayMark(retained.entry, { seen: retainedSeen }) === 'failed') { + const entry = acknowledgedAgentEntry( + retained.entry, + state.acknowledgedAgentsByPaneKey[retained.entry.paneKey] + ) + if (agentVerdictDisplayMark(entry) === 'failed') { summary.hasRetainedFailed = true } else { summary.hasRetainedDone = true @@ -200,8 +202,8 @@ function getWorktreeAgentActivitySummaries( agentStatusEpoch: state.agentStatusEpoch, migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId, retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, - runtimeAgentOrchestrationByPaneKey, acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey, + runtimeAgentOrchestrationByPaneKey, summaries } return summaries diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts index 81b808223d0f..d1590fd9b08b 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts @@ -2,7 +2,7 @@ import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { AcknowledgedAgentRow as DashboardAgentRowData } from '@/lib/agent-entry-acknowledgement' import { CompactAgentRow, getCompactAgentSecondary } from './worktree-card-compact-agent-row' import { buildSummaryAgentGroups, @@ -10,6 +10,7 @@ import { summarizeAgents } from './worktree-card-agent-summary' import { buildSubagentChildRows } from './worktree-subagent-child-rows' +import { acknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' function monitoringAgent(): DashboardAgentRowData { return { @@ -34,7 +35,8 @@ function monitoringAgent(): DashboardAgentRowData { updatedAt: 1, stateStartedAt: 1, stateHistory: [], - paneKey: 'tab-1:leaf-1' + paneKey: 'tab-1:leaf-1', + acknowledgedAt: undefined } } } @@ -156,54 +158,6 @@ describe('worktree card agent summary', () => { ) }) - // Same acknowledgement as the row's bold: once visited, the crash-cut row reads like a finished one. - it('reads a crash-cut row failed only until the user visits it', () => { - const done = monitoringAgent() - done.state = 'done' - done.entry.state = 'done' - done.entry.workingMode = undefined - done.entry.lastAssistantMessage = 'Halfway through the loop' - const crashCut = { - ...done, - entry: { - ...done.entry, - mainAgent: { state: 'done' as const, outcome: 'interruption' as const, stateStartedAt: 1 } - } - } - const failed = { - ...done, - paneKey: 'tab-1:leaf-3', - entry: { - ...done.entry, - paneKey: 'tab-1:leaf-3', - mainAgent: { state: 'done' as const, outcome: 'failure' as const, stateStartedAt: 1 } - } - } - - expect(getAgentDotState(crashCut, { seen: false })).toBe('failed') - expect(getAgentDotState(crashCut, { seen: true })).toBe('done') - expect(getCompactAgentSecondary(crashCut, 0, undefined, { seen: true })).toBe( - 'Halfway through the loop' - ) - expect(getAgentDotState(failed, { seen: true })).toBe('failed') - - const unvisited = { [crashCut.paneKey]: false, [failed.paneKey]: false } - expect(summarizeAgents([crashCut, failed], 'Agents', unvisited)).toBe( - 'Agents: 1 failed, 1 done' - ) - expect( - buildSummaryAgentGroups([crashCut, failed], unvisited).map((group) => group.state) - ).toEqual(['failed', 'done']) - // A row the map does not name has not been visited. - expect(summarizeAgents([crashCut], 'Agents', {})).toBe('Agents failed') - - const markup = (isUnvisited: boolean) => - renderCompactAgentRow({ agent: crashCut, now: 0, onActivate: vi.fn(), isUnvisited }) - expect(markup(true)).toContain('Failed') - expect(markup(false)).not.toContain('Failed') - expect(markup(false)).toContain('Halfway through the loop') - }) - it("lists a failed turn as failed, not done, ahead of a user's Stop", () => { const done = monitoringAgent() done.state = 'done' @@ -273,6 +227,50 @@ describe('worktree card agent summary', () => { }) expect(getAgentDotState(parent)).toBe('failed') - expect(getAgentDotState(child)).toBe('working') + expect(getAgentDotState(acknowledgedAgentRow(child, undefined))).toBe('working') + }) + + // The row's own acknowledgement, the one that un-bolds it: once visited, the cut-short row reads + // like a finished one, here and in the compact summary; a failure stays failed. + it('reads a cut-short row failed only until the user visits it', () => { + const done = monitoringAgent() + done.state = 'done' + done.entry = { + ...done.entry, + state: 'done', + workingMode: undefined, + lastAssistantMessage: 'Halfway through the loop' + } + const withVerdict = ( + outcome: 'interruption' | 'failure', + acknowledgedAt: number | undefined + ): DashboardAgentRowData => ({ + ...done, + paneKey: `tab-1:${outcome}`, + entry: { + ...done.entry, + mainAgent: { state: 'done', outcome, stateStartedAt: 1 }, + acknowledgedAt + } + }) + + expect(getAgentDotState(withVerdict('interruption', 0))).toBe('failed') + expect(getAgentDotState(withVerdict('interruption', 1))).toBe('done') + expect(getAgentDotState(withVerdict('failure', 1))).toBe('failed') + expect(getCompactAgentSecondary(withVerdict('interruption', 1), 0)).toBe( + 'Halfway through the loop' + ) + expect( + summarizeAgents([withVerdict('interruption', 1), withVerdict('failure', 1)], 'Agents') + ).toBe('Agents: 1 failed, 1 done') + const markup = (acknowledgedAt: number | undefined) => + renderCompactAgentRow({ + agent: withVerdict('interruption', acknowledgedAt), + now: 0, + onActivate: vi.fn() + }) + expect(markup(undefined)).toContain('Failed') + expect(markup(1)).not.toContain('Failed') + expect(markup(1)).toContain('Halfway through the loop') }) }) diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts index 798fcc2aede2..8dbc87c4b4df 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts @@ -1,12 +1,11 @@ import type { AgentDotState } from '@/components/AgentStateDot' -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { AcknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' import { formatAgentTypeLabel } from '@/lib/agent-status' import { agentRowDisplayDotState } from '@/lib/agent-row-dot-state' -import type { AgentVerdictViewing } from '../../../../shared/agent-main-agent-verdict' export type SummaryAgentGroup = { state: AgentDotState - agents: DashboardAgentRowData[] + agents: AcknowledgedAgentRow[] } const SUMMARY_STATE_ORDER: AgentDotState[] = [ @@ -25,22 +24,8 @@ const SUMMARY_STATE_ORDER: AgentDotState[] = [ 'idle' ] -/** Which of a card's agents the user has not visited yet, as WorktreeCardAgents derives it. */ -export type AgentUnvisitedByPaneKey = Readonly> - -export function getAgentDotState( - agent: DashboardAgentRowData, - viewing?: AgentVerdictViewing -): AgentDotState { - return agentRowDisplayDotState(agent, viewing) -} - -/** An agent the map does not name reads unseen. */ -function viewingFrom( - agent: DashboardAgentRowData, - unvisitedByPaneKey: AgentUnvisitedByPaneKey | undefined -): AgentVerdictViewing { - return { seen: unvisitedByPaneKey?.[agent.paneKey] === false } +export function getAgentDotState(agent: AcknowledgedAgentRow): AgentDotState { + return agentRowDisplayDotState(agent) } export function formatSummaryStateLabel(state: AgentDotState): string { @@ -70,13 +55,10 @@ export function formatSummaryStateLabel(state: AgentDotState): string { } } -export function buildSummaryAgentGroups( - agents: DashboardAgentRowData[], - unvisitedByPaneKey?: AgentUnvisitedByPaneKey -): SummaryAgentGroup[] { - const groups = new Map() +export function buildSummaryAgentGroups(agents: AcknowledgedAgentRow[]): SummaryAgentGroup[] { + const groups = new Map() for (const agent of agents) { - const dotState = getAgentDotState(agent, viewingFrom(agent, unvisitedByPaneKey)) + const dotState = getAgentDotState(agent) const group = groups.get(dotState) if (group) { group.push(agent) @@ -90,14 +72,10 @@ export function buildSummaryAgentGroups( }) } -export function summarizeAgents( - agents: DashboardAgentRowData[], - subjectLabel: string, - unvisitedByPaneKey?: AgentUnvisitedByPaneKey -): string { +export function summarizeAgents(agents: AcknowledgedAgentRow[], subjectLabel: string): string { const counts = new Map() for (const agent of agents) { - const dotState = getAgentDotState(agent, viewingFrom(agent, unvisitedByPaneKey)) + const dotState = getAgentDotState(agent) counts.set(dotState, (counts.get(dotState) ?? 0) + 1) } const parts = SUMMARY_STATE_ORDER.flatMap((state) => { @@ -117,26 +95,21 @@ export function summarizeAgents( return `${subjectLabel}: ${parts.join(', ')}` } -export function summarizeAgentIdentities( - agents: DashboardAgentRowData[], - unvisitedByPaneKey?: AgentUnvisitedByPaneKey -): string { +export function summarizeAgentIdentities(agents: AcknowledgedAgentRow[]): string { return agents .map((agent) => { const agentLabel = formatAgentTypeLabel(agent.agentType) - const stateLabel = formatSummaryStateLabel( - getAgentDotState(agent, viewingFrom(agent, unvisitedByPaneKey)) - ) + const stateLabel = formatSummaryStateLabel(getAgentDotState(agent)) return `${agentLabel} ${stateLabel}` }) .join('; ') } export function selectSummaryGroupIconAgents( - agents: DashboardAgentRowData[], + agents: AcknowledgedAgentRow[], maxCount: number -): DashboardAgentRowData[] { - const groups = new Map() +): AcknowledgedAgentRow[] { + const groups = new Map() agents.forEach((agent, index) => { const key = agent.agentType ?? 'unknown' const group = groups.get(key) @@ -149,7 +122,7 @@ export function selectSummaryGroupIconAgents( const sortedGroups = [...groups.values()].sort( (a, b) => b.agents.length - a.agents.length || a.firstIndex - b.firstIndex ) - const selected: DashboardAgentRowData[] = [] + const selected: AcknowledgedAgentRow[] = [] for (const group of sortedGroups) { if (selected.length >= maxCount) { break diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx index c9510cfef4fa..e747f6c8a235 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx @@ -2,7 +2,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { AcknowledgedAgentRow as DashboardAgentRowData } from '@/lib/agent-entry-acknowledgement' import { TooltipProvider } from '@/components/ui/tooltip' import { CompactAgentRow } from './worktree-card-compact-agent-row' diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index d0cc7e0c376d..de99970028a5 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef } from 'react' import { DashboardAgentChildDisclosure } from '@/components/dashboard/DashboardAgentChildDisclosure' import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' import { AgentChildRowContent } from '@/components/AgentChildRowContent' -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { AcknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { cn } from '@/lib/utils' @@ -15,24 +15,21 @@ import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timesta import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer' import { formatShortTimeAgo } from '@/lib/short-time-ago' import { agentVerdictStatusLine } from '@/lib/agent-verdict-status-line' -import type { AgentVerdictViewing } from '../../../../shared/agent-main-agent-verdict' function getCompactAgentPrimary( - agent: DashboardAgentRowData, - conversationName: string | null, - viewing: AgentVerdictViewing + agent: AcknowledgedAgentRow, + conversationName: string | null ): string { const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry) - return prompt || agentStateLabel(getAgentDotState(agent, viewing)) + return prompt || agentStateLabel(getAgentDotState(agent)) } export function getCompactAgentSecondary( - agent: DashboardAgentRowData, + agent: AcknowledgedAgentRow, now: number, - lastAssistantMessageOverride?: string, - viewing?: AgentVerdictViewing + lastAssistantMessageOverride?: string ): string { - const verdictLine = agentVerdictStatusLine(agent.entry, viewing) + const verdictLine = agentVerdictStatusLine(agent.entry) if (verdictLine) { return verdictLine } @@ -61,7 +58,7 @@ export function getCompactAgentSecondary( return formatAgentTypeLabel(agent.agentType) } -function getCompactAgentTime(agent: DashboardAgentRowData, now: number): string | null { +function getCompactAgentTime(agent: AcknowledgedAgentRow, now: number): string | null { const doneAt = lastEnteredDoneAt(agent) if (doneAt !== null) { return formatShortTimeAgo(doneAt, now) @@ -71,7 +68,7 @@ function getCompactAgentTime(agent: DashboardAgentRowData, now: number): string } type CompactAgentRowProps = { - agent: DashboardAgentRowData + agent: AcknowledgedAgentRow now: number onActivate: (tabId: string, paneKey: string) => void // Why: send-popover target mode temporarily turns compact sidebar rows into @@ -101,7 +98,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ isFocusedPane = false, hideIdentityIcon = false, cacheTimerActive = true, - isUnvisited + isUnvisited = false }: CompactAgentRowProps) { const hasChildDisclosure = typeof childAgentCount === 'number' && @@ -111,12 +108,9 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ // agentType, which is not an iconable agent and would render the unknown // "?" glyph. Nesting under the parent already conveys identity. const hideIcon = hideIdentityIcon || agent.rowSource === 'subagent' - // A crash-cut turn reads failed only until the user visits it, like the row's bold; a caller - // that does not say reads unvisited. - const viewing = { seen: isUnvisited === false } - const dotState = getAgentDotState(agent, viewing) + const dotState = getAgentDotState(agent) const conversationName = useAgentRowConversationName(agent) - const primary = getCompactAgentPrimary(agent, conversationName, viewing) + const primary = getCompactAgentPrimary(agent, conversationName) const isLineageChild = agent.lineage?.depth === 1 // Keep a live row's last assistant line stable while status/tool payloads // briefly omit the hook-only field between updates. Committed in an effect so a @@ -137,7 +131,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ const held = heldMessageRef.current const stableMessage = turnHoldable && !currentMessage && held?.turn === turn ? held.message : undefined - const secondary = getCompactAgentSecondary(agent, now, stableMessage, viewing) + const secondary = getCompactAgentSecondary(agent, now, stableMessage) // Why: sidebar truncation must preserve the passive-vs-active distinction. const leadingText = dotState === 'monitoring' ? secondary : primary const trailingText = diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx index d821abb537c0..c7cf8cb026db 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useRef } from 'react' import { ChevronDown } from 'lucide-react' import { AgentStateDot } from '@/components/AgentStateDot' -import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import type { AcknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent } from '@/lib/agent-status' import { cn } from '@/lib/utils' @@ -9,8 +9,7 @@ import { buildSummaryAgentGroups, selectSummaryGroupIconAgents, summarizeAgentIdentities, - summarizeAgents, - type AgentUnvisitedByPaneKey + summarizeAgents } from './worktree-card-agent-summary' import { translate } from '@/i18n/i18n' @@ -25,8 +24,7 @@ function stopActivationKeyPropagation(e: React.KeyboardEvent): void { } type CompactAgentSummaryButtonProps = { - agents: DashboardAgentRowData[] - unvisitedByPaneKey?: AgentUnvisitedByPaneKey + agents: AcknowledgedAgentRow[] subjectLabel: string expanded: boolean onToggle: () => void @@ -79,18 +77,17 @@ export function CompactAgentExpansion({ export function CompactAgentSummaryButton({ agents, - unvisitedByPaneKey, subjectLabel, expanded, onToggle }: CompactAgentSummaryButtonProps): React.JSX.Element { - const summary = summarizeAgents(agents, subjectLabel, unvisitedByPaneKey) - const groups = buildSummaryAgentGroups(agents, unvisitedByPaneKey) + const summary = summarizeAgents(agents, subjectLabel) + const groups = buildSummaryAgentGroups(agents) const visibleGroups = groups.slice(0, 3) const hiddenGroupAgentCount = groups .slice(visibleGroups.length) .reduce((count, group) => count + group.agents.length, 0) - const agentIdentitySummary = summarizeAgentIdentities(agents, unvisitedByPaneKey) + const agentIdentitySummary = summarizeAgentIdentities(agents) const stopPointerPropagation = useCallback((e: React.SyntheticEvent) => { e.stopPropagation() }, []) diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 1707f3efee34..5467ffb89714 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -105,6 +105,7 @@ export default function SortableTab({ tab, agentStatusByPaneKey: s.agentStatusByPaneKey, agentStatusEpoch: s.agentStatusEpoch, + acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey, runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, ptyIdsByTabId: s.ptyIdsByTabId, terminalLayout: s.terminalLayoutsByTabId?.[tab.id] diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index 7a18342bb4ba..8d06f1ed47d7 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -68,6 +68,7 @@ describe('resolveTerminalTabActivityStatus', () => { }) expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: { id: TAB_ID, title: 'Codex - action required' }, agentStatusByPaneKey: { [done.paneKey]: done }, ptyIdsByTabId: LIVE_PTY @@ -79,6 +80,7 @@ describe('resolveTerminalTabActivityStatus', () => { const working = entry(FIRST_LEAF_ID, 'working') expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [working.paneKey]: working }, ptyIdsByTabId: LIVE_PTY @@ -95,6 +97,7 @@ describe('resolveTerminalTabActivityStatus', () => { }) expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: { id: TAB_ID, title: '✋ Gemini CLI' }, agentStatusByPaneKey: { [stale.paneKey]: stale }, ptyIdsByTabId: LIVE_PTY, @@ -109,6 +112,7 @@ describe('resolveTerminalTabActivityStatus', () => { const working = entry(SECOND_LEAF_ID, 'working') expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [monitoring.paneKey]: monitoring }, ptyIdsByTabId: LIVE_PTY @@ -116,6 +120,7 @@ describe('resolveTerminalTabActivityStatus', () => { ).toBe('monitoring') expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [monitoring.paneKey]: monitoring, @@ -131,6 +136,7 @@ describe('resolveTerminalTabActivityStatus', () => { const waiting = entry(SECOND_LEAF_ID, 'waiting') expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [working.paneKey]: working, @@ -145,6 +151,7 @@ describe('resolveTerminalTabActivityStatus', () => { const done = entry(FIRST_LEAF_ID, 'done') expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [done.paneKey]: done }, ptyIdsByTabId: LIVE_PTY @@ -165,6 +172,7 @@ describe('resolveTerminalTabActivityStatus', () => { }) expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [ended.paneKey]: ended }, ptyIdsByTabId: LIVE_PTY @@ -172,10 +180,36 @@ describe('resolveTerminalTabActivityStatus', () => { ).toBe(status) }) + // The chat's own tab agrees with the sidebar beside it: a cut-short turn is news only until seen. + it.each([ + ['interruption', 'failed', 'done'], + ['failure', 'failed', 'failed'], + ['cancellation', 'interrupted', 'interrupted'], + ['superseded', 'interrupted', 'interrupted'], + ['unconfirmed', 'unconfirmed', 'unconfirmed'] + ] as const)( + 'reads a %s done as %s before the user sees it and %s after', + (outcome, unseen, seen) => { + const ended = entry(FIRST_LEAF_ID, 'done', { + mainAgent: { state: 'done', outcome, stateStartedAt: NOW } + }) + const statusWith = (acknowledgedAt: number) => + resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: { [ended.paneKey]: acknowledgedAt }, + tab: TAB, + agentStatusByPaneKey: { [ended.paneKey]: ended }, + ptyIdsByTabId: LIVE_PTY + }) + expect(statusWith(NOW - 1)).toBe(unseen) + expect(statusWith(NOW)).toBe(seen) + } + ) + it("reads an old host's user-stop flag as interrupted", () => { const stopped = entry(FIRST_LEAF_ID, 'done', { interrupted: true }) expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [stopped.paneKey]: stopped }, ptyIdsByTabId: LIVE_PTY @@ -190,6 +224,7 @@ describe('resolveTerminalTabActivityStatus', () => { const finished = entry(SECOND_LEAF_ID, 'done') expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [failed.paneKey]: failed, [finished.paneKey]: finished }, ptyIdsByTabId: LIVE_PTY @@ -203,6 +238,7 @@ describe('resolveTerminalTabActivityStatus', () => { mainAgent: { state: 'done', outcome, stateStartedAt: NOW } }) return resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [held.paneKey]: held }, ptyIdsByTabId: LIVE_PTY @@ -223,6 +259,7 @@ describe('resolveTerminalTabActivityStatus', () => { const finished = entry(SECOND_LEAF_ID, 'done') expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [interrupted.paneKey]: interrupted, @@ -238,6 +275,7 @@ describe('resolveTerminalTabActivityStatus', () => { vi.setSystemTime(31 * 60 * 1000) expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: { id: TAB_ID, title: 'Codex working' }, agentStatusByPaneKey: { [stale.paneKey]: stale }, ptyIdsByTabId: LIVE_PTY @@ -249,6 +287,7 @@ describe('resolveTerminalTabActivityStatus', () => { const restored = entry(FIRST_LEAF_ID, 'working', { restoredUnconfirmed: true }) expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: { id: TAB_ID, title: 'Codex working' }, agentStatusByPaneKey: { [restored.paneKey]: restored }, ptyIdsByTabId: LIVE_PTY @@ -260,6 +299,7 @@ describe('resolveTerminalTabActivityStatus', () => { const restored = entry(FIRST_LEAF_ID, 'working', { restoredUnconfirmed: true }) expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [restored.paneKey]: restored }, runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex working', 2: 'Claude working' } }, @@ -286,6 +326,7 @@ describe('resolveTerminalTabActivityStatus', () => { const agentStatusByPaneKey = { [working.paneKey]: working } expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey, agentStatusEpoch: 0, @@ -297,6 +338,7 @@ describe('resolveTerminalTabActivityStatus', () => { // Same map reference, bumped epoch — the entry is now stale. expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey, agentStatusEpoch: 1, @@ -308,6 +350,7 @@ describe('resolveTerminalTabActivityStatus', () => { it('does not treat a preserved title on a sleeping tab as activity', () => { expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: { id: TAB_ID, title: 'Codex working' }, runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex working' } }, ptyIdsByTabId: { [TAB_ID]: [] } @@ -319,6 +362,7 @@ describe('resolveTerminalTabActivityStatus', () => { const blocked = entry(FIRST_LEAF_ID, 'blocked') expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [blocked.paneKey]: blocked }, ptyIdsByTabId: LIVE_PTY @@ -330,6 +374,7 @@ describe('resolveTerminalTabActivityStatus', () => { const working = entry(FIRST_LEAF_ID, 'working', { paneKey: `${TAB_ID}:3` }) expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [working.paneKey]: working }, ptyIdsByTabId: LIVE_PTY @@ -340,6 +385,7 @@ describe('resolveTerminalTabActivityStatus', () => { it('reports a live shell with no agent as active (no activity glyph)', () => { expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: { id: TAB_ID, title: 'zsh' }, ptyIdsByTabId: LIVE_PTY }) diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts index 9c92d90c884d..809485e4b8d5 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -14,6 +14,7 @@ import { type AgentPaneActivityFlags } from '@/lib/agent-pane-activity-flags' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import { acknowledgedAgentEntry } from '@/lib/agent-entry-acknowledgement' // Why: a terminal tab is a container of panes, exactly like a worktree card is // a container of tabs. Reuse the WorktreeCard status vocabulary and resolver so @@ -31,6 +32,7 @@ type TerminalTabActivityFlags = AgentPaneActivityFlags & { type FlagsCache = { agentStatusByPaneKey: Record | undefined agentStatusEpoch: number | undefined + acknowledgedAgentsByPaneKey: Record flagsByTabId: Map } @@ -42,7 +44,8 @@ let flagsCache: FlagsCache | null = null function getTerminalTabActivityFlags( agentStatusByPaneKey: Record | undefined, - agentStatusEpoch: number | undefined + agentStatusEpoch: number | undefined, + acknowledgedAgentsByPaneKey: Record ): Map { // Why: freshness is time-based, so the store bumps agentStatusEpoch without // replacing the map at the 30m stale boundary (createFreshnessScheduler). @@ -52,7 +55,8 @@ function getTerminalTabActivityFlags( if ( flagsCache && flagsCache.agentStatusByPaneKey === agentStatusByPaneKey && - flagsCache.agentStatusEpoch === agentStatusEpoch + flagsCache.agentStatusEpoch === agentStatusEpoch && + flagsCache.acknowledgedAgentsByPaneKey === acknowledgedAgentsByPaneKey ) { return flagsCache.flagsByTabId } @@ -81,10 +85,13 @@ function getTerminalTabActivityFlags( const flags = getOrCreateTerminalTabActivityFlags(flagsByTabId, identity.tabId) flags.paneIds.add(identity.paneId) - applyAgentPaneActivityFlags(flags, entry) + applyAgentPaneActivityFlags( + flags, + acknowledgedAgentEntry(entry, acknowledgedAgentsByPaneKey[entry.paneKey || paneKey]) + ) } - flagsCache = { agentStatusByPaneKey, agentStatusEpoch, flagsByTabId } + flagsCache = { agentStatusByPaneKey, agentStatusEpoch, acknowledgedAgentsByPaneKey, flagsByTabId } return flagsByTabId } @@ -133,6 +140,8 @@ type TerminalTabActivityInput = { // Why: the store bumps this at the 30m stale boundary without replacing the // pane-status map; it is the flag cache's invalidation key (see above). agentStatusEpoch?: number + /** The user's acknowledgements: a turn cut short reads failed only until seen, as on the card. */ + acknowledgedAgentsByPaneKey: Record runtimePaneTitlesByTabId?: Record> ptyIdsByTabId?: Record terminalLayout?: TerminalLayoutSnapshot @@ -148,11 +157,16 @@ export function resolveTerminalTabActivityStatus({ tab, agentStatusByPaneKey, agentStatusEpoch, + acknowledgedAgentsByPaneKey, runtimePaneTitlesByTabId, ptyIdsByTabId, terminalLayout }: TerminalTabActivityInput): TerminalTabActivityStatus { - const flags = getTerminalTabActivityFlags(agentStatusByPaneKey, agentStatusEpoch).get(tab.id) + const flags = getTerminalTabActivityFlags( + agentStatusByPaneKey, + agentStatusEpoch, + acknowledgedAgentsByPaneKey + ).get(tab.id) return resolveWorktreeStatus({ tabs: [tab], browserTabs: [], diff --git a/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts index caa14841166d..2bb8cc0021c6 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts @@ -16,6 +16,7 @@ describe('#9040 terminal tab dot attributes spinner titles to the launched agent it('reports working for a Claude spinner title on a live tab', () => { const status = resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: { id: 'tab-1', title: '⠋ implementing the feature', @@ -30,6 +31,7 @@ describe('#9040 terminal tab dot attributes spinner titles to the launched agent // Control: the named-provider path this must stay at parity with. it('reports working for a named-provider title', () => { const status = resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: { id: 'tab-1', title: 'claude [working]' } as TerminalTab, ptyIdsByTabId: { 'tab-1': ['pty-0'] } }) @@ -39,6 +41,7 @@ describe('#9040 terminal tab dot attributes spinner titles to the launched agent it('stays out of working for a spinner title with no launch identity', () => { const status = resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: { id: 'tab-1', title: '⠐ Review branch for regressions' } as TerminalTab, ptyIdsByTabId: { 'tab-1': ['pty-0'] } }) diff --git a/src/renderer/src/components/use-worktree-jump-palette-recent-tabs.ts b/src/renderer/src/components/use-worktree-jump-palette-recent-tabs.ts index 129114ab4d11..b861cc1aba7d 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-recent-tabs.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-recent-tabs.ts @@ -1,10 +1,8 @@ import { useLayoutEffect, useMemo, useRef, useState } from 'react' -import { - buildExplicitEntriesByTabId, - type TabPaneInputSources -} from '@/components/sidebar/smart-attention' +import { buildExplicitEntriesByTabId } from '@/components/sidebar/smart-attention' import { orderRecentWorkspaceTabs, + type RecentWorkspaceTabPaneSources, type RecentWorkspaceTabRow } from '@/lib/recent-workspace-tab-rows' import type { TerminalTab } from '../../../shared/terminal-tab-types' @@ -45,6 +43,7 @@ const EMPTY_RECENT_TAB_SNAPSHOT: RecentTabOrderSnapshot = { export function useWorktreeJumpPaletteRecentTabs({ tabsByWorktree, agentStatusByPaneKey, + acknowledgedAgentsByPaneKey, migrationUnsupportedByPtyId, ptyIdsByTabId, runtimePaneTitlesByTabId, @@ -99,7 +98,7 @@ export function useWorktreeJumpPaletteRecentTabs({ } return byWorktree }, [tabsByWorktree]) - const recentTabPaneSources = useMemo( + const recentTabPaneSources = useMemo( () => ({ entriesByTabId: buildExplicitEntriesByTabId( agentStatusByPaneKey, @@ -107,9 +106,11 @@ export function useWorktreeJumpPaletteRecentTabs({ ), ptyIdsByTabId, runtimePaneTitlesByTabId, - terminalLayoutsByTabId + terminalLayoutsByTabId, + acknowledgedAgentsByPaneKey }), [ + acknowledgedAgentsByPaneKey, agentStatusByPaneKey, migrationUnsupportedByPtyId, ptyIdsByTabId, diff --git a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts index 21b0bc67f42c..27e73a754e46 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts @@ -72,7 +72,8 @@ export function useWorktreeJumpPaletteStoreState({ agentStatusByPaneKey, runtimePaneTitlesByTabId, unreadTerminalTabs, - unreadAgentCompletionPanes + unreadAgentCompletionPanes, + acknowledgedAgentsByPaneKey } = paletteIndexStatus const openFiles = useAppStore((state) => state.openFiles) const activeGroupIdByWorktree = useAppStore((state) => state.activeGroupIdByWorktree) @@ -159,6 +160,7 @@ export function useWorktreeJumpPaletteStoreState({ runtimePaneTitlesByTabId, unreadTerminalTabs, unreadAgentCompletionPanes, + acknowledgedAgentsByPaneKey, openFiles, activeGroupIdByWorktree, groupsByWorktree, diff --git a/src/renderer/src/components/worktree-jump-palette-recent-inclusion.test.ts b/src/renderer/src/components/worktree-jump-palette-recent-inclusion.test.ts index 654324782323..f95ffc32afdf 100644 --- a/src/renderer/src/components/worktree-jump-palette-recent-inclusion.test.ts +++ b/src/renderer/src/components/worktree-jump-palette-recent-inclusion.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { WorkspaceTabPaletteSearchResult } from '@/lib/workspace-tab-palette-search' -import type { TabPaneInputSources } from '@/components/sidebar/smart-attention' +import type { RecentWorkspaceTabPaneSources } from '@/lib/recent-workspace-tab-rows' import type { AgentJournalTurnOutcome } from '../../../shared/agent-turn-outcome' import type { AgentStatusEntry } from '../../../shared/agent-status-types' import { shouldIncludeOpenTabInRecentSection } from './worktree-jump-palette-recent-inclusion' @@ -41,10 +41,11 @@ function currentTabResult(): WorkspaceTabPaletteSearchResult { } function includesCurrentTabWith(entry: AgentStatusEntry): boolean { - const paneSources: TabPaneInputSources = { + const paneSources: RecentWorkspaceTabPaneSources = { entriesByTabId: new Map([[TAB_ID, [entry]]]), ptyIdsByTabId: {}, - runtimePaneTitlesByTabId: {} + runtimePaneTitlesByTabId: {}, + acknowledgedAgentsByPaneKey: {} } return shouldIncludeOpenTabInRecentSection({ item: { id: 'item-1', type: 'workspace-tab', result: currentTabResult() }, diff --git a/src/renderer/src/components/worktree-jump-palette-recent-inclusion.ts b/src/renderer/src/components/worktree-jump-palette-recent-inclusion.ts index dfaed9635e5c..f654ae5777f6 100644 --- a/src/renderer/src/components/worktree-jump-palette-recent-inclusion.ts +++ b/src/renderer/src/components/worktree-jump-palette-recent-inclusion.ts @@ -3,8 +3,10 @@ import { resolveTerminalTabAttentionBadge, terminalTabHasUnreadActivity } from '@/components/tab-bar/terminal-tab-activity-status' -import { resolveRecentWorkspaceTabStatus } from '@/lib/recent-workspace-tab-rows' -import type { TabPaneInputSources } from '@/components/sidebar/smart-attention' +import { + resolveRecentWorkspaceTabStatus, + type RecentWorkspaceTabPaneSources +} from '@/lib/recent-workspace-tab-rows' import type { OpenTabPaletteItem, OpenTabRecentRow } from './worktree-jump-palette-model' import type { Worktree } from '../../../shared/worktree/types' @@ -24,7 +26,7 @@ export function shouldIncludeOpenTabInRecentSection({ item: OpenTabPaletteItem worktree: Worktree row: OpenTabRecentRow['row'] - paneSources: TabPaneInputSources + paneSources: RecentWorkspaceTabPaneSources unreadTerminalTabs: Record unreadAgentCompletionPanes: Record now: number diff --git a/src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts b/src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts index 67685c941648..9b2293c51c30 100644 --- a/src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts +++ b/src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts @@ -14,7 +14,8 @@ const BASE: PaletteStatusInputsState = { terminalLayoutsByTabId: {}, tabsByWorktree: {}, unreadTerminalTabs: {}, - unreadAgentCompletionPanes: {} + unreadAgentCompletionPanes: {}, + acknowledgedAgentsByPaneKey: {} } describe('selectPaletteStatusInputs', () => { diff --git a/src/renderer/src/components/worktree-jump-palette-status-inputs.ts b/src/renderer/src/components/worktree-jump-palette-status-inputs.ts index 24e915081c16..d4977f1bda32 100644 --- a/src/renderer/src/components/worktree-jump-palette-status-inputs.ts +++ b/src/renderer/src/components/worktree-jump-palette-status-inputs.ts @@ -9,6 +9,7 @@ export type PaletteStatusInputsState = Pick< | 'tabsByWorktree' | 'unreadTerminalTabs' | 'unreadAgentCompletionPanes' + | 'acknowledgedAgentsByPaneKey' > export type PaletteStatusInputs = Pick< @@ -23,13 +24,15 @@ export type PaletteIndexStatusSnapshot = Pick< | 'runtimePaneTitlesByTabId' | 'unreadTerminalTabs' | 'unreadAgentCompletionPanes' + | 'acknowledgedAgentsByPaneKey' > const EMPTY_PALETTE_INDEX_STATUS: PaletteIndexStatusSnapshot = Object.freeze({ agentStatusByPaneKey: {}, runtimePaneTitlesByTabId: {}, unreadTerminalTabs: {}, - unreadAgentCompletionPanes: {} + unreadAgentCompletionPanes: {}, + acknowledgedAgentsByPaneKey: {} }) /** @@ -49,7 +52,8 @@ export function selectPaletteIndexStatusSnapshot( agentStatusByPaneKey: s.agentStatusByPaneKey, runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, unreadTerminalTabs: s.unreadTerminalTabs, - unreadAgentCompletionPanes: s.unreadAgentCompletionPanes + unreadAgentCompletionPanes: s.unreadAgentCompletionPanes, + acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey } } diff --git a/src/renderer/src/lib/activity-thread-display.test.ts b/src/renderer/src/lib/activity-thread-display.test.ts index 0f491dca0d54..9ad4e26bb941 100644 --- a/src/renderer/src/lib/activity-thread-display.test.ts +++ b/src/renderer/src/lib/activity-thread-display.test.ts @@ -164,6 +164,8 @@ describe('getActivityThreadStatusPreview', () => { it('shows tool activity while working and assistant replies otherwise', () => { expect( getActivityThreadStatusPreview({ + stateStartedAt: 1, + acknowledgedAt: undefined, state: 'working', toolName: 'Bash', toolInput: 'pnpm test', @@ -174,6 +176,8 @@ describe('getActivityThreadStatusPreview', () => { expect( getActivityThreadStatusPreview( { + stateStartedAt: 1, + acknowledgedAt: undefined, state: 'done', prompt: 'yes', lastAssistantMessage: 'Implemented the skill creator port.' @@ -186,6 +190,8 @@ describe('getActivityThreadStatusPreview', () => { it('rejects hook previews that echo the live user prompt', () => { expect( getActivityThreadStatusPreview({ + stateStartedAt: 1, + acknowledgedAt: undefined, state: 'working', prompt: 'yes', lastAssistantMessage: 'yes' @@ -196,6 +202,8 @@ describe('getActivityThreadStatusPreview', () => { it('surfaces interrupted sessions explicitly', () => { expect( getActivityThreadStatusPreview({ + stateStartedAt: 1, + acknowledgedAt: undefined, state: 'done', interrupted: true, prompt: 'Ship it' @@ -206,6 +214,8 @@ describe('getActivityThreadStatusPreview', () => { it('surfaces a failed turn as failed, not as its last reply', () => { expect( getActivityThreadStatusPreview({ + stateStartedAt: 1, + acknowledgedAt: undefined, state: 'done', lastAssistantMessage: 'Done!', mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 1 }, @@ -219,6 +229,8 @@ describe('getActivityThreadStatusPreview', () => { // only thing that says what the user is being asked to approve (STA-3160). expect( getActivityThreadStatusPreview({ + stateStartedAt: 1, + acknowledgedAt: undefined, state: 'waiting', toolName: 'bash', toolInput: 'rm -rf build/', @@ -233,6 +245,8 @@ describe('getActivityThreadStatusPreview', () => { for (const state of ['done', 'blocked'] as const) { expect( getActivityThreadStatusPreview({ + stateStartedAt: 1, + acknowledgedAt: undefined, state, toolName: 'bash', toolInput: 'rm -rf build/', @@ -246,6 +260,8 @@ describe('getActivityThreadStatusPreview', () => { // Why: not every wait is an approval — a question-style wait must not borrow tool text. expect( getActivityThreadStatusPreview({ + stateStartedAt: 1, + acknowledgedAt: undefined, state: 'waiting', prompt: 'Pick a branch' }) @@ -255,6 +271,8 @@ describe('getActivityThreadStatusPreview', () => { it('surfaces the completed-turn reply on a finished row', () => { expect( getActivityThreadStatusPreview({ + stateStartedAt: 1, + acknowledgedAt: undefined, state: 'done', prompt: 'Audit the repo', lastCompletedAssistantMessage: 'Filed 8 issues from the audit.' @@ -265,6 +283,8 @@ describe('getActivityThreadStatusPreview', () => { it('does not show a prior completed reply while the agent is working', () => { expect( getActivityThreadStatusPreview({ + stateStartedAt: 1, + acknowledgedAt: undefined, state: 'working', prompt: 'Next turn', lastCompletedAssistantMessage: 'Filed 8 issues from the audit.' @@ -275,6 +295,8 @@ describe('getActivityThreadStatusPreview', () => { it('skips orchestration worker_done wrap-up so the card shows the reply', () => { expect( getActivityThreadStatusPreview({ + stateStartedAt: 1, + acknowledgedAt: undefined, state: 'done', prompt: 'On the m4air environment, use the terminal', lastAssistantMessage: @@ -286,6 +308,8 @@ describe('getActivityThreadStatusPreview', () => { it('unwraps worker_done wrap-up that uses ascii dashes or markdown verdict labels', () => { expect( getActivityThreadStatusPreview({ + stateStartedAt: 1, + acknowledgedAt: undefined, state: 'done', prompt: 'On the m4air environment, use the terminal', lastAssistantMessage: @@ -297,6 +321,8 @@ describe('getActivityThreadStatusPreview', () => { it('drops a worker_done report that has no assistant reply', () => { expect( getActivityThreadStatusPreview({ + stateStartedAt: 1, + acknowledgedAt: undefined, state: 'done', prompt: 'Fix checkout', lastAssistantMessage: 'Done — worker_done sent with outcome succeeded.' @@ -307,6 +333,8 @@ describe('getActivityThreadStatusPreview', () => { it('falls through to the completed-turn reply when the live preview is only worker_done', () => { expect( getActivityThreadStatusPreview({ + stateStartedAt: 1, + acknowledgedAt: undefined, state: 'done', prompt: 'Fix checkout', lastAssistantMessage: 'Done — worker_done sent with outcome succeeded.', @@ -321,6 +349,8 @@ describe('resolveActivityThreadStatusPreview', () => { expect( resolveActivityThreadStatusPreview( { + stateStartedAt: 1, + acknowledgedAt: undefined, state: 'working', prompt: 'yes', lastAssistantMessage: 'yes' @@ -335,6 +365,8 @@ describe('resolveActivityThreadStatusPreview', () => { expect( resolveActivityThreadStatusPreview( { + stateStartedAt: 1, + acknowledgedAt: undefined, state: 'done', prompt: 'hi', lastAssistantMessage: '' diff --git a/src/renderer/src/lib/activity-thread-display.ts b/src/renderer/src/lib/activity-thread-display.ts index 8177372e102a..17ac93c0df19 100644 --- a/src/renderer/src/lib/activity-thread-display.ts +++ b/src/renderer/src/lib/activity-thread-display.ts @@ -12,6 +12,7 @@ import { } from './agent-row-primary-text' import { formatAgentToolPreview } from './agent-row-tool-preview' import { agentVerdictStatusLine } from './agent-verdict-status-line' +import type { AgentTurnAcknowledgement } from '../../../shared/agent-turn-acknowledgement' // Why: follow-up replies ("yes", "ok proceed") are valid hook prompts but are // terrible scan labels for a cross-worktree agent list — treat them as non-titles. @@ -197,7 +198,8 @@ export function getActivityThreadStatusPreview( | 'interrupted' | 'mainAgent' | 'prompt' - >, + > & + AgentTurnAcknowledgement, agentState?: AgentStatusState | null ): string { const verdictLine = agentVerdictStatusLine(entry) @@ -233,7 +235,8 @@ export function resolveActivityThreadStatusPreview( | 'interrupted' | 'mainAgent' | 'prompt' - >, + > & + AgentTurnAcknowledgement, agentState: AgentStatusState | null | undefined, previousPreview?: string ): string { diff --git a/src/renderer/src/lib/agent-entry-acknowledgement.test.ts b/src/renderer/src/lib/agent-entry-acknowledgement.test.ts new file mode 100644 index 000000000000..20d9e43d9ef5 --- /dev/null +++ b/src/renderer/src/lib/agent-entry-acknowledgement.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { acknowledgedAgentEntry, acknowledgedAgentRow } from './agent-entry-acknowledgement' + +const entry: AgentStatusEntry = { + paneKey: 'tab-1:leaf-1', + state: 'done', + prompt: 'Run the tests', + updatedAt: 2_000, + stateStartedAt: 2_000, + stateHistory: [] +} + +const row: DashboardAgentRow = { + paneKey: 'tab-1:leaf-1', + entry, + tab: { + id: 'tab-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'Claude', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + }, + agentType: 'claude', + state: 'done', + startedAt: 1_000 +} + +describe('acknowledgedAgentEntry', () => { + it('carries the acknowledgement beside the entry', () => { + expect(acknowledgedAgentEntry(entry, 1_500)).toEqual({ ...entry, acknowledgedAt: 1_500 }) + expect(acknowledgedAgentEntry(entry, undefined)).toEqual({ + ...entry, + acknowledgedAt: undefined + }) + }) + + // Memoized rows and selectors compare by identity, so a rebuild must not hand them a new object. + it('keeps one object until the entry or its acknowledgement changes', () => { + const first = acknowledgedAgentEntry(entry, 1_500) + expect(acknowledgedAgentEntry(entry, 1_500)).toBe(first) + expect(acknowledgedAgentEntry(entry, 2_000)).not.toBe(first) + expect(acknowledgedAgentEntry({ ...entry }, 2_000)).not.toBe(first) + }) +}) + +describe('acknowledgedAgentRow', () => { + it('joins the row entry, keeping one object per row and acknowledgement', () => { + const first = acknowledgedAgentRow(row, 2_000) + expect(first.entry.acknowledgedAt).toBe(2_000) + expect(first.paneKey).toBe(row.paneKey) + expect(acknowledgedAgentRow(row, 2_000)).toBe(first) + expect(acknowledgedAgentRow(row, undefined).entry.acknowledgedAt).toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/agent-entry-acknowledgement.ts b/src/renderer/src/lib/agent-entry-acknowledgement.ts new file mode 100644 index 000000000000..8d85b42ef654 --- /dev/null +++ b/src/renderer/src/lib/agent-entry-acknowledgement.ts @@ -0,0 +1,43 @@ +// An agent's status joined with the user's acknowledgement of it, read from the store's one record +// (`acknowledgedAgentsByPaneKey`) where a surface builds its rows, so every display of the verdict +// reads the same seen-ness and none can leave it out. + +import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import type { AgentTurnAcknowledgement } from '../../../shared/agent-turn-acknowledgement' + +export type AcknowledgedAgentStatusEntry = AgentStatusEntry & AgentTurnAcknowledgement + +/** A row whose entry carries its acknowledgement. */ +export type AcknowledgedAgentRow = DashboardAgentRow & { entry: AcknowledgedAgentStatusEntry } + +// Why: a joined value keeps its identity until its entry or acknowledgement changes, so memoized +// rows and selectors do not re-render on every rebuild. +const joinedEntries = new WeakMap() +const joinedRows = new WeakMap() + +export function acknowledgedAgentEntry( + entry: AgentStatusEntry, + acknowledgedAt: number | undefined +): AcknowledgedAgentStatusEntry { + const cached = joinedEntries.get(entry) + if (cached && cached.acknowledgedAt === acknowledgedAt) { + return cached + } + const joined = { ...entry, acknowledgedAt } + joinedEntries.set(entry, joined) + return joined +} + +export function acknowledgedAgentRow( + row: DashboardAgentRow, + acknowledgedAt: number | undefined +): AcknowledgedAgentRow { + const cached = joinedRows.get(row) + if (cached && cached.entry.acknowledgedAt === acknowledgedAt) { + return cached + } + const joined = { ...row, entry: acknowledgedAgentEntry(row.entry, acknowledgedAt) } + joinedRows.set(row, joined) + return joined +} diff --git a/src/renderer/src/lib/agent-pane-activity-flags.ts b/src/renderer/src/lib/agent-pane-activity-flags.ts index e100820dfdeb..92aa72a6f076 100644 --- a/src/renderer/src/lib/agent-pane-activity-flags.ts +++ b/src/renderer/src/lib/agent-pane-activity-flags.ts @@ -1,8 +1,6 @@ import type { AgentStatusEntry } from '../../../shared/agent-status-types' -import { - agentVerdictDisplayMark, - type AgentVerdictViewing -} from '../../../shared/agent-main-agent-verdict' +import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' +import type { AgentTurnAcknowledgement } from '../../../shared/agent-turn-acknowledgement' /** What fresh agent panes contribute to a container's status (worktree card, terminal tab). */ export type AgentPaneActivityFlags = { @@ -18,10 +16,10 @@ export type AgentPaneActivityFlags = { /** Fold one fresh pane's entry into its container's flags; `resolveWorktreeStatus` ranks them. */ export function applyAgentPaneActivityFlags( flags: AgentPaneActivityFlags, - entry: Pick, - viewing?: AgentVerdictViewing + entry: Pick & + AgentTurnAcknowledgement ): void { - const mark = agentVerdictDisplayMark(entry, viewing) + const mark = agentVerdictDisplayMark(entry) if (entry.state === 'blocked' || entry.state === 'waiting') { flags.hasPermission = true } diff --git a/src/renderer/src/lib/agent-row-dot-state.ts b/src/renderer/src/lib/agent-row-dot-state.ts index 92617de5c5ea..064d975a3fd3 100644 --- a/src/renderer/src/lib/agent-row-dot-state.ts +++ b/src/renderer/src/lib/agent-row-dot-state.ts @@ -1,10 +1,8 @@ import type { AgentDotState } from '@/components/AgentStateDot' import type { AgentChildRowModel } from '../../../shared/agent-child-row-model' -import { - agentVerdictDisplayMark, - type AgentVerdictViewing -} from '../../../shared/agent-main-agent-verdict' +import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' import type { AgentStatusEntry, AgentWorkingMode } from '../../../shared/agent-status-types' +import type { AgentTurnAcknowledgement } from '../../../shared/agent-turn-acknowledgement' import type { AgentRowState } from './agent-row-decay-state' /** @@ -32,19 +30,16 @@ export function agentRowDotState( * The dot an agent row renders: a child row's own, else the main agent's verdict (a stop or a * failure, which a failure shows even while subagents still run), else its state. */ -export function agentRowDisplayDotState( - agent: { - state: AgentRowState - entry: Pick - childRow?: Pick - }, - viewing?: AgentVerdictViewing -): AgentDotState { +export function agentRowDisplayDotState(agent: { + state: AgentRowState + entry: Pick & + AgentTurnAcknowledgement + childRow?: Pick +}): AgentDotState { if (agent.childRow) { return agent.childRow.displayState } return ( - agentVerdictDisplayMark(agent.entry, viewing) ?? - agentRowDotState(agent.state, agent.entry.workingMode) + agentVerdictDisplayMark(agent.entry) ?? agentRowDotState(agent.state, agent.entry.workingMode) ) } diff --git a/src/renderer/src/lib/agent-verdict-status-line.ts b/src/renderer/src/lib/agent-verdict-status-line.ts index 90ff5e2509c4..21e5e3a5d144 100644 --- a/src/renderer/src/lib/agent-verdict-status-line.ts +++ b/src/renderer/src/lib/agent-verdict-status-line.ts @@ -1,18 +1,14 @@ import { agentMainAgentVerdict, agentVerdictDisplayMark, - type AgentMainAgentVerdictSource, - type AgentVerdictViewing + type AgentVerdictDisplaySource } from '../../../shared/agent-main-agent-verdict' /** The line an agent row shows in place of its preview once its verdict marks it. A user's Stop * says so; a turn a newer request replaced reads plainly interrupted, since no one is named; a - * turn cut off by a crash reads failed, as a failure does, until the user has seen it. */ -export function agentVerdictStatusLine( - entry: AgentMainAgentVerdictSource, - viewing?: AgentVerdictViewing -): string | null { - switch (agentVerdictDisplayMark(entry, viewing)) { + * turn cut short with nobody asking reads failed, as a failure does, until the user has seen it. */ +export function agentVerdictStatusLine(entry: AgentVerdictDisplaySource): string | null { + switch (agentVerdictDisplayMark(entry)) { case 'failed': return 'Failed' case 'interrupted': diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts index f9c091901047..b40bebf10e9f 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest' import { orderRecentWorkspaceTabs, resolveRecentWorkspaceTabStatus, + type RecentWorkspaceTabPaneSources, type RecentWorkspaceTabRow } from './recent-workspace-tab-rows' -import type { TabPaneInputSources } from '@/components/sidebar/smart-attention' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry, @@ -44,8 +44,8 @@ function row(id: string, overrides: Partial = {}): Recent function sources( entries: AgentStatusEntry[], - overrides: Partial = {} -): TabPaneInputSources { + overrides: Partial = {} +): RecentWorkspaceTabPaneSources { const entriesByTabId = new Map() for (const item of entries) { const tabId = item.paneKey.split(':')[0] @@ -56,6 +56,7 @@ function sources( ptyIdsByTabId: {}, runtimePaneTitlesByTabId: {}, terminalLayoutsByTabId: {}, + acknowledgedAgentsByPaneKey: {}, ...overrides } } @@ -166,6 +167,39 @@ describe('resolveRecentWorkspaceTabStatus', () => { expect(resolveRecentWorkspaceTabStatus(row('cut'), sources([cut]), NOW)).toBe('failed') }) + it('reads a cut-short turn the user has seen as done, as the sidebar does; a failure stays failed', () => { + const at = NOW - 1_000 + const cut = entry('cut', 'done', at, { + mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: at } + }) + const failed = entry('failed', 'done', at, { + mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: at } + }) + const seen = { [cut.paneKey]: at, [failed.paneKey]: at } + + expect( + resolveRecentWorkspaceTabStatus( + row('cut'), + sources([cut], { acknowledgedAgentsByPaneKey: seen }), + NOW + ) + ).toBe('done') + expect( + resolveRecentWorkspaceTabStatus( + row('cut'), + sources([cut], { acknowledgedAgentsByPaneKey: { [cut.paneKey]: at - 1 } }), + NOW + ) + ).toBe('failed') + expect( + resolveRecentWorkspaceTabStatus( + row('failed'), + sources([failed], { acknowledgedAgentsByPaneKey: seen }), + NOW + ) + ).toBe('failed') + }) + it("reads a user's Stop as interrupted though attention demotes it, whether recorded or an old host's flag", () => { const recorded = entry('stopped', 'done', NOW - 1_000, { mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: NOW - 1_000 } diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.ts b/src/renderer/src/lib/recent-workspace-tab-rows.ts index 124ab24535c8..2b7e68ebdf9f 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.ts @@ -8,6 +8,7 @@ import { } from '@/components/sidebar/smart-attention' import { tabHasLivePty } from './tab-has-live-pty' import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' +import { acknowledgedAgentEntry } from './agent-entry-acknowledgement' import { isExplicitAgentStatusFresh } from './pane-agent-evidence' import type { WorktreeStatus } from './worktree-status' import type { TerminalTab } from '../../../shared/terminal-tab-types' @@ -35,6 +36,12 @@ export type RecentWorkspaceTabRow = { lastFocusedAt?: number | null } +/** The panes a recent row reads, beside the user's acknowledgements, so its dot agrees with the + * sidebar's once the user has seen a turn cut short. */ +export type RecentWorkspaceTabPaneSources = TabPaneInputSources & { + acknowledgedAgentsByPaneKey: Readonly> +} + export type RecentWorkspaceTabOrderInputs = { rows: readonly RecentWorkspaceTabRow[] } @@ -66,7 +73,7 @@ export function resolveRecentWorkspaceTabAttention( /** Live status dot for a hero row — re-read on agent churn, unlike the frozen ordering. */ export function resolveRecentWorkspaceTabStatus( row: RecentWorkspaceTabRow, - paneSources: TabPaneInputSources, + paneSources: RecentWorkspaceTabPaneSources, now: number ): WorktreeStatus { if (!row.terminalTab) { @@ -82,7 +89,14 @@ export function resolveRecentWorkspaceTabStatus( panes.flatMap((pane) => pane.kind === 'hook' && isExplicitAgentStatusFresh(pane.entry, now, AGENT_STATUS_STALE_AFTER_MS) - ? [agentVerdictDisplayMark(pane.entry)] + ? [ + agentVerdictDisplayMark( + acknowledgedAgentEntry( + pane.entry, + paneSources.acknowledgedAgentsByPaneKey[pane.entry.paneKey] + ) + ) + ] : [] ) ) diff --git a/src/renderer/src/runtime/paired-agent-row-turn-outcome-arms.test.ts b/src/renderer/src/runtime/paired-agent-row-turn-outcome-arms.test.ts index 71a1400409e0..6e3f7bceedae 100644 --- a/src/renderer/src/runtime/paired-agent-row-turn-outcome-arms.test.ts +++ b/src/renderer/src/runtime/paired-agent-row-turn-outcome-arms.test.ts @@ -125,7 +125,7 @@ describe('a paired client mirroring a host verdict arm', () => { const [row, ...rest] = mirroredRows('from-a-newer-host') expect(rest).toEqual([]) expect(row?.state).toBe('done') - expect(row && agentVerdictDisplayMark(row.entry)).toBeNull() + expect(row && agentVerdictDisplayMark({ ...row.entry, acknowledgedAt: undefined })).toBeNull() }) it.each([ @@ -133,6 +133,6 @@ describe('a paired client mirroring a host verdict arm', () => { ['unconfirmed', 'unconfirmed'] ] as const)('reads a mirrored %s as its own mark', (outcome, mark) => { const [row] = mirroredRows(outcome) - expect(row && agentVerdictDisplayMark(row.entry)).toBe(mark) + expect(row && agentVerdictDisplayMark({ ...row.entry, acknowledgedAt: undefined })).toBe(mark) }) }) diff --git a/src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts b/src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts index e6b572abceac..1ac4fb568687 100644 --- a/src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts +++ b/src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts @@ -7,6 +7,7 @@ import { buildWorktreeAgentRows } from '@/components/sidebar/worktree-agent-rows import { getAgentDotState } from '@/components/sidebar/worktree-card-agent-summary' import { resolveAttention } from '@/components/sidebar/smart-attention' import { createTestStore, makeTab } from './store-test-helpers' +import { acknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' // The safety argument for STA-4293 step 1 in one file: an observation-stamped row and an // unstamped row must be indistinguishable to every consumer that reads status today. @@ -118,9 +119,9 @@ describe('agent status observation is behavior-neutral', () => { expect(stampedRows.map((row) => ({ ...row, entry: withoutObservation(row.entry) }))).toEqual( unstampedRows.map((row) => ({ ...row, entry: withoutObservation(row.entry) })) ) - expect(stampedRows.map((row) => getAgentDotState(row))).toEqual( - unstampedRows.map((row) => getAgentDotState(row)) - ) + const dotState = (row: (typeof stampedRows)[number]) => + getAgentDotState(acknowledgedAgentRow(row, undefined)) + expect(stampedRows.map(dotState)).toEqual(unstampedRows.map(dotState)) expect(resolveAttention([{ kind: 'hook', entry: stamped, hasLivePty: false }], at)).toEqual( resolveAttention([{ kind: 'hook', entry: unstamped, hasLivePty: false }], at) @@ -154,7 +155,7 @@ describe('agent status observation is behavior-neutral', () => { }) expect(rows).toHaveLength(1) expect(rows[0].entry.observation).toBeUndefined() - expect(getAgentDotState(rows[0])).toBe('blocked') + expect(getAgentDotState(acknowledgedAgentRow(rows[0], undefined))).toBe('blocked') }) it('does not carry a previous observation onto an unstamped write', () => { diff --git a/src/shared/agent-main-agent-verdict.test.ts b/src/shared/agent-main-agent-verdict.test.ts index c8419cdf51b4..95a4434cc3a4 100644 --- a/src/shared/agent-main-agent-verdict.test.ts +++ b/src/shared/agent-main-agent-verdict.test.ts @@ -10,6 +10,10 @@ import { import type { AgentStatusState } from './agent-status-types' import { AGENT_TURN_OUTCOMES, type AgentTurnOutcome } from './agent-turn-outcome' +/** The mark before the user has seen the turn, which every case below but the last reads. */ +const unseenMark = (row: AgentMainAgentVerdictSource) => + agentVerdictDisplayMark({ ...row, stateStartedAt: 1_000, acknowledgedAt: undefined }) + const STATES: AgentStatusState[] = ['working', 'blocked', 'waiting', 'done'] const OUTCOMES: (AgentTurnOutcome | undefined)[] = [undefined, ...AGENT_TURN_OUTCOMES] @@ -50,7 +54,7 @@ describe('agentMainAgentVerdict', () => { expect(agentTurnEndedOnPurpose(row), label).toBe( verdict === 'cancellation' || verdict === 'superseded' ) - expect(agentVerdictDisplayMark(row), label).toBe( + expect(unseenMark(row), label).toBe( verdict === 'failure' || verdict === 'interruption' ? 'failed' : row.state !== 'done' @@ -69,14 +73,14 @@ describe('agentMainAgentVerdict', () => { (outcome) => { const row = { state: 'working' as const, mainAgent: { state: 'done' as const, outcome } } expect(agentMainAgentVerdict(row)).toBe(outcome) - expect(agentVerdictDisplayMark(row)).toBe('failed') + expect(unseenMark(row)).toBe('failed') } ) it('keeps a success or a stop with live subagent work reading working', () => { for (const outcome of ['success', 'cancellation'] as const) { const row = { state: 'working' as const, mainAgent: { state: 'done' as const, outcome } } - expect(agentVerdictDisplayMark(row)).toBeNull() + expect(unseenMark(row)).toBeNull() } }) @@ -108,28 +112,28 @@ describe('agentMainAgentVerdict', () => { ['unconfirmed', 'unconfirmed'] ] as const) { const row = { state: 'done' as const, mainAgent: { state: 'done' as const, outcome } } - expect(agentVerdictDisplayMark(row), outcome).toBe(mark) + expect(unseenMark(row), outcome).toBe(mark) } // An old host's legacy flag is a user's Stop too. - expect(agentVerdictDisplayMark({ state: 'done', interrupted: true })).toBe('interrupted') + expect(unseenMark({ state: 'done', interrupted: true })).toBe('interrupted') }) - it('reads an unseen crash-cut turn as failed and an unproven end as unconfirmed, neither a stop', () => { + it('reads a crash-cut turn as failed and an unproven end as unconfirmed, neither a stop', () => { for (const [outcome, mark] of [ ['interruption', 'failed'], ['unconfirmed', 'unconfirmed'] ] as const) { const row = { state: 'done' as const, mainAgent: { state: 'done' as const, outcome } } - expect(agentVerdictDisplayMark(row)).toBe(mark) + expect(unseenMark(row)).toBe(mark) expect(agentTurnEndedUncleanly(row)).toBe(true) // Nobody asked for it, so attention ranks it as news, like a completion or a failure. expect(agentTurnEndedOnPurpose(row)).toBe(false) } }) - // Attention only while unseen: once read, a crash-cut turn reads like a finished one, and its - // notice row in the chat is what still says it stopped. - it('drops the failed mark from a crash-cut turn the user has seen, and from nothing else', () => { + // Attention only while unseen: once acknowledged, a turn cut short reads like a finished one, and + // the chat's notice row is what still says it stopped. Every other verdict keeps its mark. + it('drops the failed mark from a cut-short turn the user has seen, and from nothing else', () => { for (const [outcome, unseen, seen] of [ ['interruption', 'failed', null], ['failure', 'failed', 'failed'], @@ -139,9 +143,11 @@ describe('agentMainAgentVerdict', () => { ['success', null, null] ] as const) { const row = { state: 'done' as const, mainAgent: { state: 'done' as const, outcome } } - expect(agentVerdictDisplayMark(row), outcome).toBe(unseen) - expect(agentVerdictDisplayMark(row, { seen: false }), outcome).toBe(unseen) - expect(agentVerdictDisplayMark(row, { seen: true }), outcome).toBe(seen) + const markAfter = (acknowledgedAt: number | undefined) => + agentVerdictDisplayMark({ ...row, stateStartedAt: 1_000, acknowledgedAt }) + expect(markAfter(undefined), outcome).toBe(unseen) + expect(markAfter(999), outcome).toBe(unseen) + expect(markAfter(1_000), outcome).toBe(seen) } }) @@ -155,7 +161,7 @@ describe('agentMainAgentVerdict', () => { } } expect(agentMainAgentVerdict(row)).toBeNull() - expect(agentVerdictDisplayMark(row)).toBeNull() + expect(unseenMark(row)).toBeNull() expect(agentTurnEndedUncleanly(row)).toBe(false) }) diff --git a/src/shared/agent-main-agent-verdict.ts b/src/shared/agent-main-agent-verdict.ts index faae536c7bf5..716254e10a6b 100644 --- a/src/shared/agent-main-agent-verdict.ts +++ b/src/shared/agent-main-agent-verdict.ts @@ -1,6 +1,10 @@ import { isAgentTurnOutcome, type AgentTurnOutcome } from './agent-turn-outcome' import type { AgentStatusState } from './agent-status-types' import type { AgentMainAgentStatus } from './main-agent-status' +import { + isAgentTurnAcknowledged, + type AgentTurnAcknowledgement +} from './agent-turn-acknowledgement' export type AgentMainAgentVerdictSource = { state: AgentStatusState @@ -46,29 +50,25 @@ export function agentMainAgentVerdict(row: AgentMainAgentVerdictSource): AgentTu return row.state === 'done' && row.interrupted === true ? 'cancellation' : null } -/** What a display knows of the user having seen the agent's current turn. */ -export type AgentVerdictViewing = { - /** The user acknowledged this turn (the rule that un-bolds a sidebar row). Absent reads unseen. */ - seen?: boolean -} +/** A verdict as a display reads it: with whether the user has seen it. */ +export type AgentVerdictDisplaySource = AgentMainAgentVerdictSource & AgentTurnAcknowledgement /** - * What the verdict marks on the agent's own display. A fault, whether the turn failed or something - * other than the user cut it short, reads failed and outranks every combined state: it is news the - * user must see even while subagents still run. A turn a crash or restart cut off is news only - * until seen; after that it reads like a finished turn, since the chat's notice row says why. A - * user's Stop, or a turn a newer request replaced, reads interrupted, and an unproven end + * What the verdict marks on the agent's own display. A failure reads failed and outranks every + * combined state: it is news the user must see even while subagents still run. A turn cut short + * when the agent stopped without anyone asking reads failed the same way, but only until the user + * has seen it; then it reads like a finished turn, and the chat's notice row still says it stopped. + * A user's Stop, or a turn a newer request replaced, reads interrupted, and an unproven end * unconfirmed, only on a row that is itself done, so live child work still reads working. */ export function agentVerdictDisplayMark( - row: AgentMainAgentVerdictSource, - viewing: AgentVerdictViewing = {} + row: AgentVerdictDisplaySource ): 'failed' | 'interrupted' | 'unconfirmed' | null { switch (agentMainAgentVerdict(row)) { case 'failure': return 'failed' case 'interruption': - return viewing.seen ? null : 'failed' + return isAgentTurnAcknowledged(row) ? null : 'failed' case 'cancellation': case 'superseded': return row.state === 'done' ? 'interrupted' : null diff --git a/src/shared/agent-turn-acknowledgement.ts b/src/shared/agent-turn-acknowledgement.ts new file mode 100644 index 000000000000..d82d0807b96d --- /dev/null +++ b/src/shared/agent-turn-acknowledgement.ts @@ -0,0 +1,20 @@ +// Whether the user has seen an agent's current state: the one rule that un-bolds a sidebar row, +// lets auto-acknowledgement skip a pane, clears an Activity unread and, for a turn cut short with +// nobody asking, drops its red mark. + +/** An agent's current state beside the newest time the user acknowledged it, joined where the + * entry is read so no surface can leave the acknowledgement out. */ +export type AgentTurnAcknowledgement = { + /** When the agent entered its current state. */ + stateStartedAt: number + /** When the user last acknowledged the agent; undefined when never. */ + acknowledgedAt: number | undefined +} + +/** Compared with `stateStartedAt`, not the newest update, so a same-state ping is not new news. */ +export function isAgentTurnAcknowledged({ + stateStartedAt, + acknowledgedAt +}: AgentTurnAcknowledgement): boolean { + return (acknowledgedAt ?? 0) >= stateStartedAt +} From ba26cf3024682e28e2797fbabce2571fb2aa7390 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:55:30 -0700 Subject: [PATCH 06/17] refactor(attention): use the one acknowledgement rule where it was copied Auto-acknowledgement, the Activity unread count, dashboard row buckets and notification acknowledgement each spelled the same "acknowledged at or after the current state began" comparison; they now call the shared rule. --- .../attention/agent-attention-acknowledgement.ts | 16 +++++++++++----- .../activity/useActivityUnreadCount.ts | 3 ++- .../components/dashboard/dashboard-row-bucket.ts | 7 ++++++- ...i-slice-agent-notification-acknowledgement.ts | 6 +++++- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/attention/agent-attention-acknowledgement.ts b/src/renderer/src/attention/agent-attention-acknowledgement.ts index c4e1d7f5f073..6ef85c298cab 100644 --- a/src/renderer/src/attention/agent-attention-acknowledgement.ts +++ b/src/renderer/src/attention/agent-attention-acknowledgement.ts @@ -3,6 +3,7 @@ import { type AgentAttentionRemainder, type ReadableAgentAttentionUnread } from './agent-attention-contract' +import { isAgentTurnAcknowledged } from '../../../shared/agent-turn-acknowledgement' /** Subject-keyed turn bookkeeping the acknowledgement policy reads; no surface shape here. */ export type AgentAttentionTurnRecords = { @@ -33,8 +34,7 @@ export function readAgentAttentionTurnStartedAt( /** * Subjects on the viewed surface whose current turn has not been acknowledged yet. * - * Why compare stateStartedAt (not updatedAt): same-state pings must not re-trigger an ack, - * matching the is-unvisited rule the workspace card uses. + * The is-unvisited rule every surface reads (`isAgentTurnAcknowledged`). */ export function computeAgentAcknowledgementTargets( records: AgentAttentionTurnRecords, @@ -44,13 +44,19 @@ export function computeAgentAcknowledgementTargets( return [] } const targets: string[] = [] - const acknowledgedAt = records.acknowledgedTurnStartedAt[subjectKey] ?? 0 + const acknowledgedAt = records.acknowledgedTurnStartedAt[subjectKey] const liveTurn = records.liveTurns[subjectKey] - if (liveTurn && acknowledgedAt < liveTurn.stateStartedAt) { + if ( + liveTurn && + !isAgentTurnAcknowledged({ stateStartedAt: liveTurn.stateStartedAt, acknowledgedAt }) + ) { targets.push(subjectKey) } const retainedTurn = records.retainedTurns[subjectKey] - if (retainedTurn && acknowledgedAt < retainedTurn.entry.stateStartedAt) { + if ( + retainedTurn && + !isAgentTurnAcknowledged({ stateStartedAt: retainedTurn.entry.stateStartedAt, acknowledgedAt }) + ) { targets.push(subjectKey) } return targets diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.ts b/src/renderer/src/components/activity/useActivityUnreadCount.ts index e727ef969e21..0017dbb6a7d9 100644 --- a/src/renderer/src/components/activity/useActivityUnreadCount.ts +++ b/src/renderer/src/components/activity/useActivityUnreadCount.ts @@ -5,6 +5,7 @@ import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsuppor import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { isAgentTurnAcknowledged } from '../../../../shared/agent-turn-acknowledgement' import { freshActivityLiveAgentState, isHistoricalActivityState } from './activity-event-state' @@ -45,7 +46,7 @@ export function countActivityUnread(source: ActivityUnreadCountSource, now = Dat (isHistoricalActivityState(entry.state) || (live && freshActivityLiveAgentState(entry, now) === 'working')) && entry.sessionBoundary !== true && - mutedAt < entry.stateStartedAt + !isAgentTurnAcknowledged({ stateStartedAt: entry.stateStartedAt, acknowledgedAt: mutedAt }) ) { count += 1 } diff --git a/src/renderer/src/components/dashboard/dashboard-row-bucket.ts b/src/renderer/src/components/dashboard/dashboard-row-bucket.ts index 0b4377ac9aa0..3ada3074a35a 100644 --- a/src/renderer/src/components/dashboard/dashboard-row-bucket.ts +++ b/src/renderer/src/components/dashboard/dashboard-row-bucket.ts @@ -6,6 +6,7 @@ import { } from '../../../../shared/dashboard-snapshot' import { dashboardBucketForDotState } from './dashboard-card-bucket' import type { AgentRowState } from '@/lib/agent-row-decay-state' +import { isAgentTurnAcknowledged } from '../../../../shared/agent-turn-acknowledgement' /** * Project a row state onto the published card vocabulary. @@ -39,7 +40,11 @@ export function dashboardRowBucketProjection( ? row.entry.workingMode : undefined const unseen = - !isTitleDerived && (acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt + !isTitleDerived && + !isAgentTurnAcknowledged({ + stateStartedAt: row.entry.stateStartedAt, + acknowledgedAt: acknowledgedAgentsByPaneKey?.[row.paneKey] + }) const bucket = dashboardBucketForDotState( dashboardCardDisplayState({ dotState, workingMode, unseen }) ) diff --git a/src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts b/src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts index e7b600a2ec84..5c2f0f33df32 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts @@ -1,6 +1,7 @@ import type { AppState } from '../../types' import { buildAgentNotificationId } from '../../../../../shared/agent-notification-id' import { parsePaneKey } from '../../../../../shared/stable-pane-id' +import { isAgentTurnAcknowledged } from '../../../../../shared/agent-turn-acknowledgement' export function resolvePaneKeyWorktreeIdFromTabs(state: AppState, paneKey: string): string | null { const parsed = parsePaneKey(paneKey) @@ -28,7 +29,10 @@ export function collectAcknowledgedAgentNotificationId({ stateStartedAt: number | null | undefined previousAckAt: number }): void { - if (typeof stateStartedAt !== 'number' || previousAckAt >= stateStartedAt) { + if ( + typeof stateStartedAt !== 'number' || + isAgentTurnAcknowledged({ stateStartedAt, acknowledgedAt: previousAckAt }) + ) { return } const id = buildAgentNotificationId({ worktreeId, paneKey, stateStartedAt }) From b35c7041ea5aae45c989ae10ee50e51d6a0b9199 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:57:15 -0700 Subject: [PATCH 07/17] test(mobile): type the cut-turn notice test's client and hook holder --- ...ured-agent-session.cut-turn-notice.test.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx index b7d76b55f7a9..37a29458c07d 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' vi.mock('@react-native-async-storage/async-storage', () => ({ @@ -80,15 +81,22 @@ describe('useMobileStructuredAgentSession transcript', () => { it('shows a cut turn no row explains with the one notice desktop shows, in that turn', async () => { let listener: ((value: unknown) => void) | null = null const client: Pick = { - sendRequest: vi.fn(async () => ({ ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } })), + sendRequest: vi.fn(async (): Promise => ({ + id: 'response-1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + })), subscribe: vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { listener = onData return () => {} }) } - let hook: ReturnType | null = null + const rendered: { hook: ReturnType | null } = { + hook: null + } function Harness(): null { - hook = useMobileStructuredAgentSession({ + rendered.hook = useMobileStructuredAgentSession({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook calls only sendRequest and subscribe, both provided. client: client as RpcClient, sessionId: 'session-1', @@ -108,7 +116,7 @@ describe('useMobileStructuredAgentSession transcript', () => { act(() => listener?.(snapshot(ITEMS))) await vi.waitFor(() => - expect(hook?.session.messages.at(-1)).toMatchObject({ + expect(rendered.hook?.session.messages.at(-1)).toMatchObject({ role: 'system', blocks: [ { @@ -119,6 +127,6 @@ describe('useMobileStructuredAgentSession transcript', () => { }) ) // The turn bars place rows by the same items, so the notice joins the cut turn. - expect(hook?.turnJournal.items.at(-1)).toMatchObject({ turnScope: SCOPE }) + expect(rendered.hook?.turnJournal.items.at(-1)).toMatchObject({ turnScope: SCOPE }) }) }) From 54ec8b08d5d36490f869c0b06b188e471721b741 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:58:01 -0700 Subject: [PATCH 08/17] test: pin an older host's exit row by its writer and the sidebar rows' acknowledgement join --- ...WorktreeAgentRows.acknowledgement.test.tsx | 65 +++++++++++++++++++ .../native-chat-cut-turn-notice.test.ts | 12 ++++ 2 files changed, 77 insertions(+) create mode 100644 src/renderer/src/components/sidebar/useWorktreeAgentRows.acknowledgement.test.tsx diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.acknowledgement.test.tsx b/src/renderer/src/components/sidebar/useWorktreeAgentRows.acknowledgement.test.tsx new file mode 100644 index 000000000000..8a6f3678dae5 --- /dev/null +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.acknowledgement.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { useWorktreeAgentRows } from './useWorktreeAgentRows' + +const WORKTREE = 'repo::/wt-ack' +const PANE_KEY = makePaneKey('tab-ack', '11111111-1111-4111-8111-111111111111') +const initial = useAppStore.getState() + +afterEach(() => { + act(() => useAppStore.setState(initial, true)) +}) + +describe('useWorktreeAgentRows', () => { + // Joined where the rows are built, so the card, its summary and the notes send menu read one + // seen-ness and keep each row object while nothing about it changes. + it("carries each row's acknowledgement on its entry", () => { + const now = Date.now() + const entry: AgentStatusEntry = { + paneKey: PANE_KEY, + state: 'done', + prompt: 'Run the tests', + updatedAt: now, + stateStartedAt: now, + stateHistory: [], + agentType: 'claude', + mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: now } + } + act(() => + useAppStore.setState({ + tabsByWorktree: { + [WORKTREE]: [ + { + id: 'tab-ack', + ptyId: null, + worktreeId: WORKTREE, + title: 'Claude', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + agentStatusByPaneKey: { [PANE_KEY]: entry }, + acknowledgedAgentsByPaneKey: {} + }) + ) + const { result } = renderHook(() => useWorktreeAgentRows(WORKTREE)) + const unseen = result.current[0] + expect(unseen?.entry.acknowledgedAt).toBe(0) + + act(() => useAppStore.setState({ acknowledgedAgentsByPaneKey: { [PANE_KEY]: now } })) + const seen = result.current[0] + expect(seen?.entry.acknowledgedAt).toBe(now) + expect(seen).not.toBe(unseen) + + act(() => useAppStore.setState({ acknowledgedAgentsByPaneKey: { [PANE_KEY]: now, other: 1 } })) + expect(result.current[0]).toBe(seen) + }) +}) diff --git a/src/shared/native-chat-cut-turn-notice.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts index 759235c7ed4d..eebc5d68f00a 100644 --- a/src/shared/native-chat-cut-turn-notice.test.ts +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -141,6 +141,18 @@ describe('withNativeChatCutTurnNotices', () => { } }) + // A host from before failure facts wrote the same rows with only their words. + it("counts an older host's exit row by its writer, with no exit fact", () => { + for (const id of ['provider-exit:s:3:gen', 'stale-session:s:death-3-2000']) { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + hostRow(id, { kind: 'status', text: NOTICE, tone: 'error' }, inTurn('t1')) + ] + expect(withNativeChatCutTurnNotices(items), id).toBe(items) + } + }) + it('counts an exit row by its exit fact, whatever wrote it', () => { const items = [ user('u1'), From a9f35d6ae351a125185c89ec19e2e1b541681ddb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:58:48 -0700 Subject: [PATCH 09/17] test(sidebar): re-read the card and the tab when only an acknowledgement changes --- .../worktree-agent-activity-summary.test.ts | 32 +++++++++++++++++++ .../terminal-tab-activity-status.test.ts | 4 ++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index b14a2f52f3a7..5e87b8e8ca0d 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -277,6 +277,38 @@ describe('selectWorktreeAgentActivitySummary', () => { }) }) + it('re-reads the card when only an acknowledgement changes', () => { + vi.spyOn(Date, 'now').mockReturnValue(2_000) + const paneKey = makePaneKey('tab-1', LEAF_ID) + const base = { + tabsByWorktree: { 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] }, + agentStatusEpoch: 4, + agentStatusByPaneKey: { + [paneKey]: makeAgentStatusEntry({ + paneKey, + state: 'done', + mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: 1_000 } + }) + }, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + } + + expect( + selectWorktreeAgentActivitySummary( + { ...base, acknowledgedAgentsByPaneKey: {} }, + 'repo::/wt-1' + ) + ).toMatchObject({ hasFailed: true }) + expect( + selectWorktreeAgentActivitySummary( + { ...base, acknowledgedAgentsByPaneKey: { [paneKey]: 1_000 } }, + 'repo::/wt-1' + ) + ).toMatchObject({ hasFailed: false, hasLiveDone: true }) + }) + it('reads a departed crash-cut agent as done once seen, and failed before', () => { vi.spyOn(Date, 'now').mockReturnValue(2_000) const retained = (ackAt: number) => diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index 8d06f1ed47d7..d0eafad90aa0 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -193,11 +193,13 @@ describe('resolveTerminalTabActivityStatus', () => { const ended = entry(FIRST_LEAF_ID, 'done', { mainAgent: { state: 'done', outcome, stateStartedAt: NOW } }) + // One status map throughout: an acknowledgement alone must re-read the tab. + const agentStatusByPaneKey = { [ended.paneKey]: ended } const statusWith = (acknowledgedAt: number) => resolveTerminalTabActivityStatus({ acknowledgedAgentsByPaneKey: { [ended.paneKey]: acknowledgedAt }, tab: TAB, - agentStatusByPaneKey: { [ended.paneKey]: ended }, + agentStatusByPaneKey, ptyIdsByTabId: LIVE_PTY }) expect(statusWith(NOW - 1)).toBe(unseen) From b8abab0a695a86a3b360f060b25a613da0f131a7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:22:39 -0700 Subject: [PATCH 10/17] fix(native-chat): keep a cut turn's notice through a resume that carries on A resume after a quit writes its "asked this agent to continue" note after its own message, so counting that note as the cut's explanation removed the notice once the resume went on, and made it flash away under automatic resume. Only the notes that say the chat was not carried on (refused, not connected, not confirmed) stand in for the notice now. A row about the whole conversation now explains a cut turn only when no other turn lies between them, and a failed start's row, which is about a start, never does. The host writers and the rule share the row identity prefixes, with the contract that a new row explaining a stop carries the provider-exited fact or one of them. --- ...gent-session-dead-generation-settlement.ts | 5 +- ...ured-agent-session-restart-continuation.ts | 6 +- ...ent-session-restart-failure-filing.test.ts | 17 +++++ ...tructured-agent-session-unexpected-exit.ts | 3 +- src/shared/agent-session-stop-row-identity.ts | 12 +++ .../native-chat-cut-turn-notice.test.ts | 60 ++++++++++++--- src/shared/native-chat-cut-turn-notice.ts | 73 ++++++++++++++----- 7 files changed, 143 insertions(+), 33 deletions(-) create mode 100644 src/shared/agent-session-stop-row-identity.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 39ff1c7ba514..f0372c8e58de 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -4,6 +4,7 @@ import { type SubmissionRejectionFact } from '../../../shared/agent-session-failure' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -229,7 +230,7 @@ export async function settleStaleStructuredAgentSessionState(input: { ) // Per attempt: a retry re-partitions only what is left, and a reused chunk id would skip it. const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}` - const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}` + const settlementId = `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:${input.fence}:${generation}` const mutations: JournalLifecycleMutationInput[] = [] for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) @@ -261,7 +262,7 @@ export async function settleStaleStructuredAgentSessionState(input: { // Named by the death it explains, so a retry after a partly written settle adds no second row. identity: { provider: 'orca', - clientMessageId: `stale-session:${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}` + clientMessageId: `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}` }, // The death evidence is Orca's log text, never a sentence for a person: the row says only // that the provider stopped. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 27c09bc2bbe1..c748748ccd41 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -22,6 +22,7 @@ import { type AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { RESTART_CONTINUATION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, @@ -120,7 +121,10 @@ function restartNoteWriter( return } await session.journal.appendItem( - { provider: 'orca', clientMessageId: `restart-continuation:${sessionId}:${host.now()}` }, + { + provider: 'orca', + clientMessageId: `${RESTART_CONTINUATION_ROW_PREFIX}${sessionId}:${host.now()}` + }, { kind: 'status', text, ...(tone ? { tone } : {}) }, // About the conversation, not any turn in it. { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index 42e4a91c5ae2..156c713ca656 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { + AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE } from '../../../shared/agent-session-restart-continuation' @@ -71,6 +72,22 @@ it('says so in the chat when the agent cannot start for the continuation', async expect(await readerNotes(host)).toEqual(await statusNotes(host)) }) +// A continuation that carries on, chosen in the prompt or automatically at launch, does not stand in +// for the cut: its note is written after its own message, so the cut keeps its notice throughout. +it("keeps the quit's notice through a continuation that carries the chat on", async () => { + const { host } = await interruptedRestart() + await host.restartResume.list() + expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) + + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(result.continued).toMatchObject([{ outcome: 'continued' }]) + expect(await readerNotes(host)).toEqual([ + QUIT_CUT_NOTICE, + { text: AGENT_SESSION_RESTART_CONTINUATION_NOTE, tone: undefined } + ]) +}) + // A send that throws after Orca may have taken it cannot be proven undelivered: filed unconfirmed, // and it stays on record while the agent that may be carrying on keeps running. it('keeps an unconfirmed failure while the agent the continuation started keeps running', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 232b101e6035..5a0b2f6de30a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -1,4 +1,5 @@ import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' @@ -193,5 +194,5 @@ function logExitFailure( } function providerExitSettlementId(event: UnexpectedExitLifecycleEvent): string { - return `provider-exit:${event.sessionId}:${event.fence}:${event.acquisitionGeneration}` + return `${PROVIDER_EXIT_ROW_PREFIX}${event.sessionId}:${event.fence}:${event.acquisitionGeneration}` } diff --git a/src/shared/agent-session-stop-row-identity.ts b/src/shared/agent-session-stop-row-identity.ts new file mode 100644 index 000000000000..d314003a3f99 --- /dev/null +++ b/src/shared/agent-session-stop-row-identity.ts @@ -0,0 +1,12 @@ +// The identities of the rows a host writes about a stop, shared with the readers that must not say +// the same stop twice (`native-chat-cut-turn-notice`). +// +// Contract: a new row that explains a stop must carry the providerExited fact or one of these +// prefixes, or a client derives a second notice beside it. + +/** The exit row the unexpected-exit settle writes, keyed by the child that exited. */ +export const PROVIDER_EXIT_ROW_PREFIX = 'provider-exit:' +/** The exit row a reopen or acquire writes for a generation found dead. */ +export const STALE_SESSION_ROW_PREFIX = 'stale-session:' +/** The note the restart continuation leaves about its outcome. */ +export const RESTART_CONTINUATION_ROW_PREFIX = 'restart-continuation:' diff --git a/src/shared/native-chat-cut-turn-notice.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts index eebc5d68f00a..17a63db4820d 100644 --- a/src/shared/native-chat-cut-turn-notice.test.ts +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -11,6 +11,13 @@ import type { import { withNativeChatCutTurnNotices } from './native-chat-cut-turn-notice' import { hostStatesTurnScopes, nativeChatTurnMembership } from './native-chat-turn-membership' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +import { + AGENT_SESSION_RESTART_CONTINUATION_NOTE, + AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, + AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE +} from './agent-session-restart-continuation' +import { structuredAgentSessionStartFailureRowIdentity } from './structured-agent-session-start-failure-row-key' const THREAD: AgentJournalTurnScope = { kind: 'thread' } const NOTICE = @@ -90,6 +97,9 @@ const exitWords = (): AgentJournalItemBody => ({ }) const exitRow = (clientMessageId: string, scope: AgentJournalTurnScope | null) => hostRow(clientMessageId, exitWords(), scope) +/** A note the restart continuation leaves about its outcome, about the conversation. */ +const restartNote = (text: string) => + hostRow(`restart-continuation:s:${text.length}`, { kind: 'status', text, tone: 'error' }, THREAD) function notices(items: readonly AgentJournalRenderItem[]) { return withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).flatMap((entry, index) => @@ -219,22 +229,54 @@ describe('withNativeChatCutTurnNotices', () => { ]) }) - it("takes the restart continuation's outcome note as the one explanation of the cut it follows", () => { + // A quit, then a resume that did not carry the chat on: the note says the cut was not continued, + // which is what the notice would say, so it stands alone. + it.each([ + AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, + AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE + ])('takes the restart note "%s" as the one explanation of the cut it follows', (text) => { + const items = [user('u1'), turn('t1', 'u1', CUT), reply('a1', inTurn('t1')), restartNote(text)] + expect(withNativeChatCutTurnNotices(items)).toBe(items) + }) + + // The note of a continuation that went on lands after its own message and turn. The cut keeps its + // notice before and after it, so nothing flashes away under an automatic resume. + it('keeps the notice once a continuation carries the chat on', () => { + const cut = [user('u1'), turn('t1', 'u1', CUT), reply('a1', inTurn('t1'))] + const continuing = [...cut, user('u2'), turn('t2', 'u2', { state: 'running' })] + const continued = [...continuing, restartNote(AGENT_SESSION_RESTART_CONTINUATION_NOTE)] + + for (const items of [cut, continuing, continued]) { + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + } + }) + + // A row about the conversation speaks for the cut it follows, never across a turn that finished. + it('lets a later exit row about the conversation explain no cut a finished turn came after', () => { const items = [ user('u1'), turn('t1', 'u1', CUT), reply('a1', inTurn('t1')), - hostRow( - 'restart-continuation:s:5000', - { - kind: 'status', - text: "Orca couldn't continue this chat after the restart. Send a message to continue it.", - tone: 'error' - }, + user('u2'), + turn('t2', 'u2', { state: 'completed', outcome: 'success' }), + exitRow('provider-exit:s:5:gen', THREAD) + ] + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + }) + + it("does not take a failed start's row as the explanation of an earlier cut", () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + item( + agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('gen-2')), + exitWords(), THREAD ) ] - expect(withNativeChatCutTurnNotices(items)).toBe(items) + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) }) it('places the notice in its turn, for desktop and phone alike', () => { diff --git a/src/shared/native-chat-cut-turn-notice.ts b/src/shared/native-chat-cut-turn-notice.ts index 67b6d3b17593..463fc8cc8022 100644 --- a/src/shared/native-chat-cut-turn-notice.ts +++ b/src/shared/native-chat-cut-turn-notice.ts @@ -14,57 +14,90 @@ import { isRootAgentJournalItem } from './agent-session-journal-producer' import type { AgentJournalRenderItem } from './agent-session-journal-types' import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' import { agentTurnVerdict } from './agent-turn-outcome' +import { + AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, + AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE +} from './agent-session-restart-continuation' +import { + PROVIDER_EXIT_ROW_PREFIX, + RESTART_CONTINUATION_ROW_PREFIX, + STALE_SESSION_ROW_PREFIX +} from './agent-session-stop-row-identity' +import { isStructuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row-key' import { hostStatesTurnScopes } from './native-chat-turn-membership' -/** Host rows that already say why a turn stopped: a provider exit, a dead owner found on reopen, and - * the restart continuation's own outcome note, which a quit's cut is followed by. */ -const STOP_EXPLAINING_ROWS = ['provider-exit:', 'stale-session:', 'restart-continuation:'] +/** Host rows that already say why a turn stopped: a provider exit, and a dead owner found on reopen. */ +const STOP_EXPLAINING_ROWS = [PROVIDER_EXIT_ROW_PREFIX, STALE_SESSION_ROW_PREFIX] + +/** The restart continuation's notes that say the cut was not carried on, which is what the notice + * would say. A continuation that went on writes its note after its own message, so the cut keeps + * its notice, as it would beside any stored exit row. */ +const RESTART_NOT_CONTINUED_NOTES: readonly string[] = [ + AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, + AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE +] const CUT_TURN_NOTICE_ROW = 'cut-turn-notice:' type CutTurnNoticeContext = Pick +function rootTurnVerdict( + item: AgentJournalRenderItem +): ReturnType | 'none' { + const turn = readAgentJournalTurn(item.body) + return turn && isRootAgentJournalItem(item) + ? agentTurnVerdict({ state: turn.state, outcome: readAgentJournalTurnOutcome(turn) }) + : 'none' +} + /** Root turns that ended interrupted with no verdict, so nobody asked for the stop. */ function isCutRootTurn(item: AgentJournalRenderItem): boolean { - const turn = readAgentJournalTurn(item.body) - return ( - turn !== null && - isRootAgentJournalItem(item) && - agentTurnVerdict({ state: turn.state, outcome: readAgentJournalTurnOutcome(turn) }) === - 'interruption' - ) + return rootTurnVerdict(item) === 'interruption' } /** Matched by what the row states or who wrote it, never its tone: an agent's own error row in the - * turn (a denied permission, a refusal) says nothing about the stop. */ + * turn (a denied permission, a refusal) says nothing about the stop. A failed start's row says why + * a start failed, not why an earlier turn stopped. */ function explainsAStop(item: AgentJournalRenderItem): boolean { - if (item.body.kind !== 'status' || readAgentJournalTurn(item.body)) { + if ( + item.body.kind !== 'status' || + readAgentJournalTurn(item.body) || + isStructuredAgentSessionStartFailureRow(item.itemId) + ) { return false } if (readAgentSessionFailureFact(item.body.failure)?.kind === 'providerExited') { return true } const identity = parseAgentJournalItemKey(item.itemId) + if (identity?.provider !== 'orca') { + return false + } + const { clientMessageId } = identity return ( - identity?.provider === 'orca' && - STOP_EXPLAINING_ROWS.some((prefix) => identity.clientMessageId.startsWith(prefix)) + STOP_EXPLAINING_ROWS.some((prefix) => clientMessageId.startsWith(prefix)) || + (clientMessageId.startsWith(RESTART_CONTINUATION_ROW_PREFIX) && + RESTART_NOT_CONTINUED_NOTES.includes(item.body.text)) ) } /** The cut turns some row already explains: one scoped to the turn, or one about the conversation - * (or from a host that states no scope), which explains the newest cut turn before it. */ + * (or from a host that states no scope) that follows the cut turn with no other root turn between. */ function explainedCutTurns(items: readonly AgentJournalRenderItem[]): Set { const explained = new Set() - let newestCutTurn: string | null = null + let precedingCutTurn: string | null = null for (const item of items) { - if (isCutRootTurn(item)) { - newestCutTurn = item.itemId + const verdict = rootTurnVerdict(item) + if (verdict !== 'none') { + precedingCutTurn = verdict === 'interruption' ? item.itemId : null } else if (explainsAStop(item)) { const scope = item.turnScope if (scope?.kind === 'turn') { explained.add(scope.turnItemId) - } else if (newestCutTurn !== null) { - explained.add(newestCutTurn) + } else if (precedingCutTurn !== null) { + explained.add(precedingCutTurn) } } } From 8a793cb7242770bd260f99d5c1f9240d4e5cc831 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:22:39 -0700 Subject: [PATCH 11/17] fix(sidebar): judge a cut as seen by the main agent's clock when a subagent holds the row A subagent can keep a row working after its main agent was cut, and the row's clock then predates the cut, so a look at the working chat counted as having seen the cut and no red mark showed. The mark now compares the acknowledgement with the later of the row's and the main agent's clocks; auto-acknowledgement of the chat on screen reads the same clock, and its stamp covers it, so looking at the chat still clears the mark. Bold rows, dashboard buckets, notifications and unread counts keep the row's clock. --- .../agent-attention-acknowledgement.test.ts | 41 +++++++++++++++++++ .../agent-attention-acknowledgement.ts | 22 +++++++--- .../ui-acknowledge-agents-clock-skew.test.ts | 19 +++++++++ ...lice-agent-notification-acknowledgement.ts | 7 +++- src/shared/agent-main-agent-verdict.test.ts | 15 +++++++ src/shared/agent-main-agent-verdict.ts | 10 ++++- src/shared/agent-turn-acknowledgement.ts | 20 +++++++-- 7 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/attention/agent-attention-acknowledgement.test.ts diff --git a/src/renderer/src/attention/agent-attention-acknowledgement.test.ts b/src/renderer/src/attention/agent-attention-acknowledgement.test.ts new file mode 100644 index 000000000000..991325979778 --- /dev/null +++ b/src/renderer/src/attention/agent-attention-acknowledgement.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { + computeAgentAcknowledgementTargets, + type AgentAttentionTurnRecords +} from './agent-attention-acknowledgement' + +const KEY = 'tab-1:leaf-1' + +describe('computeAgentAcknowledgementTargets', () => { + // The row has read `working` since 1_000 while a subagent runs; its main agent was cut at 3_000. + // An acknowledgement from before the cut must not keep an on-screen chat from clearing it. + const heldOpen = { + stateStartedAt: 1_000, + mainAgent: { stateStartedAt: 3_000 } + } + + it("acknowledges a viewed chat whose main agent was cut after the user's last look", () => { + const live: Omit = { + liveTurns: { [KEY]: heldOpen }, + retainedTurns: {} + } + const retained: Omit = { + liveTurns: {}, + retainedTurns: { [KEY]: { entry: heldOpen } } + } + for (const records of [live, retained]) { + expect( + computeAgentAcknowledgementTargets( + { ...records, acknowledgedTurnStartedAt: { [KEY]: 2_000 } }, + KEY + ) + ).toEqual([KEY]) + expect( + computeAgentAcknowledgementTargets( + { ...records, acknowledgedTurnStartedAt: { [KEY]: 3_000 } }, + KEY + ) + ).toEqual([]) + } + }) +}) diff --git a/src/renderer/src/attention/agent-attention-acknowledgement.ts b/src/renderer/src/attention/agent-attention-acknowledgement.ts index 6ef85c298cab..8cebf19dd673 100644 --- a/src/renderer/src/attention/agent-attention-acknowledgement.ts +++ b/src/renderer/src/attention/agent-attention-acknowledgement.ts @@ -3,13 +3,19 @@ import { type AgentAttentionRemainder, type ReadableAgentAttentionUnread } from './agent-attention-contract' -import { isAgentTurnAcknowledged } from '../../../shared/agent-turn-acknowledgement' +import { + agentAttentionStartedAt, + isAgentTurnAcknowledged +} from '../../../shared/agent-turn-acknowledgement' + +/** A turn's clocks: the row's, and the main agent's own when child work holds the row open. */ +type AgentAttentionTurn = { stateStartedAt: number; mainAgent?: { stateStartedAt?: number } } /** Subject-keyed turn bookkeeping the acknowledgement policy reads; no surface shape here. */ export type AgentAttentionTurnRecords = { - liveTurns: Record + liveTurns: Record /** Turns kept after their session ended, so a finished agent can still be acknowledged. */ - retainedTurns: Record + retainedTurns: Record acknowledgedTurnStartedAt: Record } @@ -34,7 +40,8 @@ export function readAgentAttentionTurnStartedAt( /** * Subjects on the viewed surface whose current turn has not been acknowledged yet. * - * The is-unvisited rule every surface reads (`isAgentTurnAcknowledged`). + * Judged on the attention clock the failed mark reads (`agentAttentionStartedAt`), so viewing a chat + * whose main agent was cut while child work held its row open clears the mark. */ export function computeAgentAcknowledgementTargets( records: AgentAttentionTurnRecords, @@ -48,14 +55,17 @@ export function computeAgentAcknowledgementTargets( const liveTurn = records.liveTurns[subjectKey] if ( liveTurn && - !isAgentTurnAcknowledged({ stateStartedAt: liveTurn.stateStartedAt, acknowledgedAt }) + !isAgentTurnAcknowledged({ stateStartedAt: agentAttentionStartedAt(liveTurn), acknowledgedAt }) ) { targets.push(subjectKey) } const retainedTurn = records.retainedTurns[subjectKey] if ( retainedTurn && - !isAgentTurnAcknowledged({ stateStartedAt: retainedTurn.entry.stateStartedAt, acknowledgedAt }) + !isAgentTurnAcknowledged({ + stateStartedAt: agentAttentionStartedAt(retainedTurn.entry), + acknowledgedAt + }) ) { targets.push(subjectKey) } diff --git a/src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts b/src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts index 669ebceb53ef..17cb2a4da209 100644 --- a/src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts +++ b/src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts @@ -67,6 +67,25 @@ describe('acknowledgeAgents with a clock-skewed execution host', () => { expect(ackAt < futureStartedAt).toBe(false) }) + // Child work can hold the row open past the main agent's cut; the failed mark reads the cut's clock. + it("stamps at least the main agent's own clock when it is newer than the row's", () => { + const store = createUIStore() + const cutAt = NOW + SKEW_MS + const heldOpen: Partial = { + agentStatusByPaneKey: { + [PANE_KEY]: makeAgentEntry({ + state: 'working', + mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: cutAt } + }) + } + } + store.setState(heldOpen) + + store.getState().acknowledgeAgents([PANE_KEY]) + + expect(store.getState().acknowledgedAgentsByPaneKey[PANE_KEY]).toBe(cutAt) + }) + it('stops rewriting the ack map once a future-stamped turn is acknowledged', () => { const store = createUIStore() const futureStartedAt = NOW + SKEW_MS diff --git a/src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts b/src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts index 5c2f0f33df32..6c983d268a83 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts @@ -49,8 +49,13 @@ export function usableTimestamp(value: unknown): number { export function latestAgentTurnTimestamp(entry: { stateStartedAt?: number stateHistory?: { startedAt?: number }[] + mainAgent?: { stateStartedAt?: number } }): number { - let latest = usableTimestamp(entry.stateStartedAt) + // Why the main agent's clock too: a cut it ended can postdate the row's, and the failed mark reads it. + let latest = Math.max( + usableTimestamp(entry.stateStartedAt), + usableTimestamp(entry.mainAgent?.stateStartedAt) + ) // Why history too: Activity renders one event per stateHistory entry, each with its own unread check. for (const history of entry.stateHistory ?? []) { latest = Math.max(latest, usableTimestamp(history.startedAt)) diff --git a/src/shared/agent-main-agent-verdict.test.ts b/src/shared/agent-main-agent-verdict.test.ts index 95a4434cc3a4..f5cd523f07b4 100644 --- a/src/shared/agent-main-agent-verdict.test.ts +++ b/src/shared/agent-main-agent-verdict.test.ts @@ -77,6 +77,21 @@ describe('agentMainAgentVerdict', () => { } ) + // A subagent holds the row working since 1_000; the main agent was cut at 3_000. An + // acknowledgement between the two saw the work, not the cut. + it('reads a cut seen only before it happened as failed while subagents hold the row open', () => { + const held = (outcome: 'interruption' | 'failure', acknowledgedAt: number) => + agentVerdictDisplayMark({ + state: 'working', + stateStartedAt: 1_000, + mainAgent: { state: 'done', outcome, stateStartedAt: 3_000 }, + acknowledgedAt + }) + expect(held('interruption', 2_000)).toBe('failed') + expect(held('interruption', 3_000)).toBeNull() + expect(held('failure', 3_000)).toBe('failed') + }) + it('keeps a success or a stop with live subagent work reading working', () => { for (const outcome of ['success', 'cancellation'] as const) { const row = { state: 'working' as const, mainAgent: { state: 'done' as const, outcome } } diff --git a/src/shared/agent-main-agent-verdict.ts b/src/shared/agent-main-agent-verdict.ts index 716254e10a6b..20186bf9e0d8 100644 --- a/src/shared/agent-main-agent-verdict.ts +++ b/src/shared/agent-main-agent-verdict.ts @@ -2,6 +2,7 @@ import { isAgentTurnOutcome, type AgentTurnOutcome } from './agent-turn-outcome' import type { AgentStatusState } from './agent-status-types' import type { AgentMainAgentStatus } from './main-agent-status' import { + agentAttentionStartedAt, isAgentTurnAcknowledged, type AgentTurnAcknowledgement } from './agent-turn-acknowledgement' @@ -9,7 +10,7 @@ import { export type AgentMainAgentVerdictSource = { state: AgentStatusState interrupted?: boolean - mainAgent?: { state: AgentStatusState; outcome?: AgentTurnOutcome } + mainAgent?: { state: AgentStatusState; outcome?: AgentTurnOutcome; stateStartedAt?: number } } /** The fields that carry the verdict. History entries, sleep records and `worktree ps` rows copy @@ -68,7 +69,12 @@ export function agentVerdictDisplayMark( case 'failure': return 'failed' case 'interruption': - return isAgentTurnAcknowledged(row) ? null : 'failed' + return isAgentTurnAcknowledged({ + stateStartedAt: agentAttentionStartedAt(row), + acknowledgedAt: row.acknowledgedAt + }) + ? null + : 'failed' case 'cancellation': case 'superseded': return row.state === 'done' ? 'interrupted' : null diff --git a/src/shared/agent-turn-acknowledgement.ts b/src/shared/agent-turn-acknowledgement.ts index d82d0807b96d..04afc70e9839 100644 --- a/src/shared/agent-turn-acknowledgement.ts +++ b/src/shared/agent-turn-acknowledgement.ts @@ -1,6 +1,8 @@ -// Whether the user has seen an agent's current state: the one rule that un-bolds a sidebar row, -// lets auto-acknowledgement skip a pane, clears an Activity unread and, for a turn cut short with -// nobody asking, drops its red mark. +// Whether the user has seen an agent's current state, by Orca's existing acknowledgement: the rule +// that un-bolds a sidebar row and that auto-acknowledgement, dashboard buckets, notification +// acknowledgement and the Activity unread count read beside an entry's state. A turn cut short with +// nobody asking reads failed only until this holds for its attention clock. Per-event Activity unread +// compares event times and keeps its own comparison. /** An agent's current state beside the newest time the user acknowledged it, joined where the * entry is read so no surface can leave the acknowledgement out. */ @@ -18,3 +20,15 @@ export function isAgentTurnAcknowledged({ }: AgentTurnAcknowledgement): boolean { return (acknowledgedAt ?? 0) >= stateStartedAt } + +/** + * When the agent's newest news began: the row's combined state, or the main agent's own state when + * that came later. Child work can hold the row open past the main agent's end, so a cut the user + * has not seen must not count as seen by an acknowledgement of the work before it. + */ +export function agentAttentionStartedAt(entry: { + stateStartedAt: number + mainAgent?: { stateStartedAt?: number } +}): number { + return Math.max(entry.stateStartedAt, entry.mainAgent?.stateStartedAt ?? 0) +} From f9223ea07ff63c431027cf1777d1d1fcb0f8100f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:34:48 -0700 Subject: [PATCH 12/17] fix(native-chat): tie a conversation-wide exit row to a cut only with no message sent since A send after a quit's cut whose new agent died before its turn opened leaves a provider-exit row about the whole conversation. That row is about the send, not the earlier cut, so the cut kept no explanation. An exit row now explains a preceding cut only when no message was sent in between; the restart notes, which follow the continuation's own message, still need only no turn between. The notes that say a resume did not carry the chat on are now told apart from the continued note by their tone ('error' or 'warning'), which every host that wrote them has set, instead of by their words. --- .../native-chat-cut-turn-notice.test.ts | 46 ++++++++--- src/shared/native-chat-cut-turn-notice.ts | 79 ++++++++++--------- 2 files changed, 79 insertions(+), 46 deletions(-) diff --git a/src/shared/native-chat-cut-turn-notice.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts index 17a63db4820d..483b5f689733 100644 --- a/src/shared/native-chat-cut-turn-notice.test.ts +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -98,8 +98,12 @@ const exitWords = (): AgentJournalItemBody => ({ const exitRow = (clientMessageId: string, scope: AgentJournalTurnScope | null) => hostRow(clientMessageId, exitWords(), scope) /** A note the restart continuation leaves about its outcome, about the conversation. */ -const restartNote = (text: string) => - hostRow(`restart-continuation:s:${text.length}`, { kind: 'status', text, tone: 'error' }, THREAD) +const restartNote = (text: string, tone?: 'error' | 'warning') => + hostRow( + `restart-continuation:s:${text.length}`, + { kind: 'status', text, ...(tone ? { tone } : {}) }, + THREAD + ) function notices(items: readonly AgentJournalRenderItem[]) { return withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).flatMap((entry, index) => @@ -232,13 +236,22 @@ describe('withNativeChatCutTurnNotices', () => { // A quit, then a resume that did not carry the chat on: the note says the cut was not continued, // which is what the notice would say, so it stands alone. it.each([ - AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, - AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, - AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE - ])('takes the restart note "%s" as the one explanation of the cut it follows', (text) => { - const items = [user('u1'), turn('t1', 'u1', CUT), reply('a1', inTurn('t1')), restartNote(text)] - expect(withNativeChatCutTurnNotices(items)).toBe(items) - }) + [AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, 'error'], + [AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, 'error'], + [AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, 'warning'] + ] as const)( + 'takes the restart note "%s" as the one explanation of the cut it follows', + (text, tone) => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('continuation'), + restartNote(text, tone) + ] + expect(withNativeChatCutTurnNotices(items)).toBe(items) + } + ) // The note of a continuation that went on lands after its own message and turn. The cut keeps its // notice before and after it, so nothing flashes away under an automatic resume. @@ -250,6 +263,21 @@ describe('withNativeChatCutTurnNotices', () => { for (const items of [cut, continuing, continued]) { expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) } + // Its note carries no tone, so even with no turn recorded before it, it does not stand in. + const noteOnly = [...cut, user('u2'), restartNote(AGENT_SESSION_RESTART_CONTINUATION_NOTE)] + expect(notices(noteOnly)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + }) + + // A send after the cut, whose new agent died before its turn opened: that exit is about the send. + it('lets no exit row about the conversation explain a cut a message was sent after', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('u2'), + exitRow('provider-exit:s:6:gen', THREAD) + ] + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) }) // A row about the conversation speaks for the cut it follows, never across a turn that finished. diff --git a/src/shared/native-chat-cut-turn-notice.ts b/src/shared/native-chat-cut-turn-notice.ts index 463fc8cc8022..e95e761fdc37 100644 --- a/src/shared/native-chat-cut-turn-notice.ts +++ b/src/shared/native-chat-cut-turn-notice.ts @@ -14,11 +14,6 @@ import { isRootAgentJournalItem } from './agent-session-journal-producer' import type { AgentJournalRenderItem } from './agent-session-journal-types' import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' import { agentTurnVerdict } from './agent-turn-outcome' -import { - AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, - AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, - AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE -} from './agent-session-restart-continuation' import { PROVIDER_EXIT_ROW_PREFIX, RESTART_CONTINUATION_ROW_PREFIX, @@ -30,15 +25,6 @@ import { hostStatesTurnScopes } from './native-chat-turn-membership' /** Host rows that already say why a turn stopped: a provider exit, and a dead owner found on reopen. */ const STOP_EXPLAINING_ROWS = [PROVIDER_EXIT_ROW_PREFIX, STALE_SESSION_ROW_PREFIX] -/** The restart continuation's notes that say the cut was not carried on, which is what the notice - * would say. A continuation that went on writes its note after its own message, so the cut keeps - * its notice, as it would beside any stored exit row. */ -const RESTART_NOT_CONTINUED_NOTES: readonly string[] = [ - AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, - AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, - AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE -] - const CUT_TURN_NOTICE_ROW = 'cut-turn-notice:' type CutTurnNoticeContext = Pick @@ -57,48 +43,67 @@ function isCutRootTurn(item: AgentJournalRenderItem): boolean { return rootTurnVerdict(item) === 'interruption' } -/** Matched by what the row states or who wrote it, never its tone: an agent's own error row in the - * turn (a denied permission, a refusal) says nothing about the stop. A failed start's row says why - * a start failed, not why an earlier turn stopped. */ -function explainsAStop(item: AgentJournalRenderItem): boolean { +/** What a row says about a stop, matched by what it states or who wrote it, never its tone alone: + * an agent's own error row in the turn (a denied permission, a refusal) says nothing about the stop. + * `exit`: the agent stopped. `not-continued`: a resume after a restart did not carry the chat on, + * which the restart note marks with a tone ('error' refused or not connected, 'warning' unconfirmed); + * a continuation that went on writes its note with none. A failed start's row is about a start. */ +function stopExplanation(item: AgentJournalRenderItem): 'exit' | 'not-continued' | null { if ( item.body.kind !== 'status' || readAgentJournalTurn(item.body) || isStructuredAgentSessionStartFailureRow(item.itemId) ) { - return false + return null } if (readAgentSessionFailureFact(item.body.failure)?.kind === 'providerExited') { - return true + return 'exit' } const identity = parseAgentJournalItemKey(item.itemId) if (identity?.provider !== 'orca') { - return false + return null } const { clientMessageId } = identity - return ( - STOP_EXPLAINING_ROWS.some((prefix) => clientMessageId.startsWith(prefix)) || - (clientMessageId.startsWith(RESTART_CONTINUATION_ROW_PREFIX) && - RESTART_NOT_CONTINUED_NOTES.includes(item.body.text)) - ) + if (STOP_EXPLAINING_ROWS.some((prefix) => clientMessageId.startsWith(prefix))) { + return 'exit' + } + const { tone } = item.body + return clientMessageId.startsWith(RESTART_CONTINUATION_ROW_PREFIX) && + (tone === 'error' || tone === 'warning') + ? 'not-continued' + : null } -/** The cut turns some row already explains: one scoped to the turn, or one about the conversation - * (or from a host that states no scope) that follows the cut turn with no other root turn between. */ +/** + * The cut turns some row already explains: one scoped to the turn, or one about the conversation + * (or from a host that states no scope) that follows the cut turn closely enough to be about it. + * An exit row is about the cut only with no message sent since, which a later start would be + * answering; a restart note follows the continuation's own message, so only a turn between counts. + */ function explainedCutTurns(items: readonly AgentJournalRenderItem[]): Set { const explained = new Set() - let precedingCutTurn: string | null = null + let cutBeforeExit: string | null = null + let cutBeforeRestartNote: string | null = null for (const item of items) { const verdict = rootTurnVerdict(item) if (verdict !== 'none') { - precedingCutTurn = verdict === 'interruption' ? item.itemId : null - } else if (explainsAStop(item)) { - const scope = item.turnScope - if (scope?.kind === 'turn') { - explained.add(scope.turnItemId) - } else if (precedingCutTurn !== null) { - explained.add(precedingCutTurn) - } + cutBeforeExit = cutBeforeRestartNote = verdict === 'interruption' ? item.itemId : null + continue + } + if (item.body.kind === 'message' && item.body.role === 'user' && isRootAgentJournalItem(item)) { + cutBeforeExit = null + continue + } + const explanation = stopExplanation(item) + if (explanation === null) { + continue + } + const scope = item.turnScope + const preceding = explanation === 'exit' ? cutBeforeExit : cutBeforeRestartNote + if (scope?.kind === 'turn') { + explained.add(scope.turnItemId) + } else if (preceding !== null) { + explained.add(preceding) } } return explained From 3b758d22ddf8cfe19d8540f86d87396bf95311a2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:42:08 -0700 Subject: [PATCH 13/17] fix(native-chat): keep an owner's proven death the explanation of its cut after a send A chat read before the startup reconcile settles its cut turn unverifiable; if the user then sends a message, the reconcile proves the old agent dead and writes its row about the conversation after that message. The row names the old owner's death, never the send, so a message since no longer detaches it from the cut. Only a provider-exit row, which a later start can write, still needs no message sent since. --- .../native-chat-cut-turn-notice.test.ts | 14 +++++++ src/shared/native-chat-cut-turn-notice.ts | 37 ++++++++++--------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/shared/native-chat-cut-turn-notice.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts index 483b5f689733..c69e0770c0d1 100644 --- a/src/shared/native-chat-cut-turn-notice.test.ts +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -293,6 +293,20 @@ describe('withNativeChatCutTurnNotices', () => { expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) }) + // Read before the startup reconcile, the cut settled unverifiable; the user sent u2; the reconcile + // then proved the old owner dead and wrote its row about the conversation after u2. That row is + // about the cut, not the send, so it stays the one explanation. + it("takes an owner's proven death as the explanation of the cut though a message was sent since", () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('u2'), + exitRow('stale-session:s:death-3-2000', THREAD) + ] + expect(withNativeChatCutTurnNotices(items)).toBe(items) + }) + it("does not take a failed start's row as the explanation of an earlier cut", () => { const items = [ user('u1'), diff --git a/src/shared/native-chat-cut-turn-notice.ts b/src/shared/native-chat-cut-turn-notice.ts index e95e761fdc37..ddf486363084 100644 --- a/src/shared/native-chat-cut-turn-notice.ts +++ b/src/shared/native-chat-cut-turn-notice.ts @@ -22,9 +22,6 @@ import { import { isStructuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row-key' import { hostStatesTurnScopes } from './native-chat-turn-membership' -/** Host rows that already say why a turn stopped: a provider exit, and a dead owner found on reopen. */ -const STOP_EXPLAINING_ROWS = [PROVIDER_EXIT_ROW_PREFIX, STALE_SESSION_ROW_PREFIX] - const CUT_TURN_NOTICE_ROW = 'cut-turn-notice:' type CutTurnNoticeContext = Pick @@ -45,10 +42,13 @@ function isCutRootTurn(item: AgentJournalRenderItem): boolean { /** What a row says about a stop, matched by what it states or who wrote it, never its tone alone: * an agent's own error row in the turn (a denied permission, a refusal) says nothing about the stop. - * `exit`: the agent stopped. `not-continued`: a resume after a restart did not carry the chat on, + * `exit`: the agent stopped. `owner-death`: a reopen proved the old agent dead, which is about the + * cut whatever was sent since. `not-continued`: a resume after a restart did not carry the chat on, * which the restart note marks with a tone ('error' refused or not connected, 'warning' unconfirmed); * a continuation that went on writes its note with none. A failed start's row is about a start. */ -function stopExplanation(item: AgentJournalRenderItem): 'exit' | 'not-continued' | null { +function stopExplanation( + item: AgentJournalRenderItem +): 'exit' | 'owner-death' | 'not-continued' | null { if ( item.body.kind !== 'status' || readAgentJournalTurn(item.body) || @@ -56,15 +56,15 @@ function stopExplanation(item: AgentJournalRenderItem): 'exit' | 'not-continued' ) { return null } - if (readAgentSessionFailureFact(item.body.failure)?.kind === 'providerExited') { - return 'exit' - } const identity = parseAgentJournalItemKey(item.itemId) - if (identity?.provider !== 'orca') { - return null + const clientMessageId = identity?.provider === 'orca' ? identity.clientMessageId : '' + if (clientMessageId.startsWith(STALE_SESSION_ROW_PREFIX)) { + return 'owner-death' } - const { clientMessageId } = identity - if (STOP_EXPLAINING_ROWS.some((prefix) => clientMessageId.startsWith(prefix))) { + if ( + readAgentSessionFailureFact(item.body.failure)?.kind === 'providerExited' || + clientMessageId.startsWith(PROVIDER_EXIT_ROW_PREFIX) + ) { return 'exit' } const { tone } = item.body @@ -78,20 +78,21 @@ function stopExplanation(item: AgentJournalRenderItem): 'exit' | 'not-continued' * The cut turns some row already explains: one scoped to the turn, or one about the conversation * (or from a host that states no scope) that follows the cut turn closely enough to be about it. * An exit row is about the cut only with no message sent since, which a later start would be - * answering; a restart note follows the continuation's own message, so only a turn between counts. + * answering. An owner's proven death, and a restart note that follows the continuation's own + * message, are about the cut until another turn begins. */ function explainedCutTurns(items: readonly AgentJournalRenderItem[]): Set { const explained = new Set() - let cutBeforeExit: string | null = null - let cutBeforeRestartNote: string | null = null + let cutNoSendSince: string | null = null + let cutNoTurnSince: string | null = null for (const item of items) { const verdict = rootTurnVerdict(item) if (verdict !== 'none') { - cutBeforeExit = cutBeforeRestartNote = verdict === 'interruption' ? item.itemId : null + cutNoSendSince = cutNoTurnSince = verdict === 'interruption' ? item.itemId : null continue } if (item.body.kind === 'message' && item.body.role === 'user' && isRootAgentJournalItem(item)) { - cutBeforeExit = null + cutNoSendSince = null continue } const explanation = stopExplanation(item) @@ -99,7 +100,7 @@ function explainedCutTurns(items: readonly AgentJournalRenderItem[]): Set Date: Sat, 3 Oct 2026 11:45:54 -0700 Subject: [PATCH 14/17] test(sidebar): give the activity-status store mock the acknowledgement map The card summary now reads acknowledgedAgentsByPaneKey; this test's hand-built store state lacked it, so every summary read threw. --- .../components/sidebar/use-worktree-activity-status.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx b/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx index 17ff8d18cd39..fda54bc7082e 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx @@ -20,6 +20,7 @@ type MockState = { runtimeAgentOrchestrationByPaneKey: Record> migrationUnsupportedByPtyId: Record retainedAgentsByPaneKey: Record + acknowledgedAgentsByPaneKey: Record } let mockState: MockState @@ -113,7 +114,8 @@ describe('useWorktreeActivityStatus', () => { agentStatusByPaneKey: {}, runtimeAgentOrchestrationByPaneKey: {}, migrationUnsupportedByPtyId: {}, - retainedAgentsByPaneKey: {} + retainedAgentsByPaneKey: {}, + acknowledgedAgentsByPaneKey: {} } }) From f1c2af2e3bac30fbef78822a98377affd604acef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:14:41 -0700 Subject: [PATCH 15/17] refactor: move the seen-gated red mark out of this change This change now keeps only the cut turn's label and its one derived notice. The red mark that clears once the user has seen a cut turn moves to its own change, so each can land alone; until it lands, the sidebar, tab bar, Cmd-J, Activity and the notes send menu read a cut turn as failed, as before. --- mobile/src/worktree/agent-row-display.test.ts | 3 +- mobile/src/worktree/agent-row-display.ts | 8 +- ...agent-session-recovered-turn-clock.test.ts | 18 +-- ...d-agent-session-stop-event-restart.test.ts | 7 +- .../agent-attention-acknowledgement.test.ts | 41 ------ .../agent-attention-acknowledgement.ts | 30 +---- .../ActivityPrototypePage-test-fixtures.ts | 3 +- .../activity/ActivityPrototypePage.test.ts | 9 +- ...ivityPrototypePage.thread-grouping.test.ts | 18 +-- .../activity-clear-completed-hmr.test.ts | 1 - ...ity-clear-completed-state-lifetime.test.ts | 1 - .../activity/activity-clear-completed.test.ts | 1 - ...ivity-event-builder.identity-reuse.test.ts | 9 +- .../activity-event-builder.live-cap.test.ts | 2 +- .../activity/activity-scope-filter.test.ts | 1 - .../activity-status-group-header.test.ts | 37 +----- .../activity/activity-thread-actions.test.ts | 1 - .../activity/activity-thread-builder.ts | 16 +-- .../activity-thread-child-agent.test.ts | 1 - ...ivity-thread-grouping.search-cache.test.ts | 1 - .../activity-thread-hover-card.test.tsx | 1 - ...vity-thread-list-pane-collapsible.test.tsx | 1 - ...y-thread-list-pane.virtualization.test.tsx | 1 - .../activity-thread-presentation.test.ts | 1 - .../activity/activity-thread-presentation.ts | 14 +- .../activity/activity-thread-types.ts | 2 - .../activity-thread-virtual-items.test.ts | 1 - .../activity/use-agent-pane-threads.ts | 9 +- .../activity/useActivityUnreadCount.ts | 3 +- .../agent-child-row-parity.test.tsx | 13 +- .../components/cmd-j/palette-live-status.tsx | 21 ++- .../DashboardAgentChildDisclosure.test.tsx | 5 +- .../dashboard/DashboardAgentRow.test.tsx | 55 ++------ .../dashboard/DashboardAgentRow.tsx | 8 +- .../dashboard/agent-finished-timestamp.ts | 14 +- .../dashboard/dashboard-row-bucket.ts | 7 +- .../editor/ReviewNotesSendMenuContent.tsx | 10 +- .../structured-child-records-switch.test.tsx | 7 +- ...rdAgents.acknowledgement-emphasis.test.tsx | 5 +- .../sidebar/WorktreeCardAgents.test.tsx | 5 +- .../components/sidebar/WorktreeCardAgents.tsx | 25 ++-- .../stale-agent-row-unverifiable.test.ts | 15 +-- ...ubagent-child-row-fallback-pixels.test.tsx | 9 +- .../use-worktree-activity-status.test.tsx | 4 +- .../use-worktree-activity-statuses.test.ts | 3 +- .../sidebar/use-worktree-activity-statuses.ts | 1 - ...WorktreeAgentRows.acknowledgement.test.tsx | 65 ---------- .../sidebar/useWorktreeAgentRows.ts | 13 +- .../worktree-agent-activity-summary.test.ts | 120 ------------------ .../worktree-agent-activity-summary.ts | 16 +-- .../worktree-card-agent-summary.test.ts | 52 +------- .../sidebar/worktree-card-agent-summary.ts | 22 ++-- ...-compact-agent-row.stable-message.test.tsx | 2 +- .../worktree-card-compact-agent-row.tsx | 10 +- .../sidebar/worktree-card-compact-agents.tsx | 4 +- .../src/components/tab-bar/SortableTab.tsx | 1 - .../terminal-tab-activity-status.test.ts | 48 ------- .../tab-bar/terminal-tab-activity-status.ts | 24 +--- .../terminal-tab-spinner-launch-agent.test.ts | 3 - .../use-worktree-jump-palette-recent-tabs.ts | 13 +- .../use-worktree-jump-palette-store-state.ts | 4 +- ...tree-jump-palette-recent-inclusion.test.ts | 7 +- .../worktree-jump-palette-recent-inclusion.ts | 8 +- ...orktree-jump-palette-status-inputs.test.ts | 3 +- .../worktree-jump-palette-status-inputs.ts | 8 +- .../src/lib/activity-thread-display.test.ts | 32 ----- .../src/lib/activity-thread-display.ts | 7 +- .../lib/agent-entry-acknowledgement.test.ts | 59 --------- .../src/lib/agent-entry-acknowledgement.ts | 43 ------- .../src/lib/agent-pane-activity-flags.ts | 4 +- src/renderer/src/lib/agent-row-dot-state.ts | 4 +- .../src/lib/agent-verdict-status-line.ts | 6 +- .../src/lib/recent-workspace-tab-rows.test.ts | 40 +----- .../src/lib/recent-workspace-tab-rows.ts | 18 +-- ...paired-agent-row-turn-outcome-arms.test.ts | 4 +- ...gent-status-observation-neutrality.test.ts | 7 +- .../ui-acknowledge-agents-clock-skew.test.ts | 19 --- ...lice-agent-notification-acknowledgement.ts | 13 +- src/shared/agent-main-agent-verdict.test.ts | 53 +------- src/shared/agent-main-agent-verdict.ts | 31 ++--- src/shared/agent-turn-acknowledgement.ts | 34 ----- 81 files changed, 190 insertions(+), 1055 deletions(-) delete mode 100644 src/renderer/src/attention/agent-attention-acknowledgement.test.ts delete mode 100644 src/renderer/src/components/sidebar/useWorktreeAgentRows.acknowledgement.test.tsx delete mode 100644 src/renderer/src/lib/agent-entry-acknowledgement.test.ts delete mode 100644 src/renderer/src/lib/agent-entry-acknowledgement.ts delete mode 100644 src/shared/agent-turn-acknowledgement.ts diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index e7df2936cbca..c3fa8c84b6de 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -121,9 +121,8 @@ describe('agentDotState', () => { expect(agentRowVerdict(agentRow), JSON.stringify(agentRow)).toBe( agentMainAgentVerdict(agentRow) ) - // The phone has no acknowledgement record: it reads every turn as desktop reads one unseen. expect(agentRowVerdictMark(agentRow), JSON.stringify(agentRow)).toBe( - agentVerdictDisplayMark({ ...agentRow, stateStartedAt: 1, acknowledgedAt: undefined }) + agentVerdictDisplayMark(agentRow) ) } } diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index ea5f957e8334..87b07abfbfb7 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -36,10 +36,8 @@ const AGENT_TURN_OUTCOMES: readonly AgentTurnOutcome[] = [ ] // Mirrors desktop agentMainAgentVerdict and agentVerdictDisplayMark -// (src/shared/agent-main-agent-verdict.ts) as desktop reads a turn the user has not seen; a parity -// test runs both over one table. Desktop drops the failed mark from a turn cut short once the user -// acknowledges it; the phone has no acknowledgement record, so it keeps the unseen reading. `mainAgent` -// is the main agent's own status, sent also while subagents hold the row working; an old host sends none. +// (src/shared/agent-main-agent-verdict.ts); a parity test runs both over one table. `mainAgent` is +// the main agent's own status, sent also while subagents hold the row working; an old host sends none. export function agentRowVerdict(row: AgentRowVerdictSource): AgentTurnOutcome | null { if (row.mainAgent && row.mainAgent.state !== 'done') { return null @@ -51,7 +49,7 @@ export function agentRowVerdict(row: AgentRowVerdictSource): AgentTurnOutcome | return row.state === 'done' && row.interrupted ? 'cancellation' : null } -// A failure, or a turn cut short when the agent stopped without anyone asking, reads failed and +// A fault (a failure, or a turn cut short by something other than the user) reads failed and // outranks every state; a user's Stop, a turn a newer request replaced, and an unproven end mark // only a row that is itself done. export function agentRowVerdictMark( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index 82315a910805..0857e3565d69 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -176,15 +176,15 @@ describe('a turn recovery settled after its host went away', () => { // Nobody stopped it: the flag older readers take as a user's stop stays down. expect(row?.interrupted ?? false).toBe(false) // The sidebar and tab read the published row, with no user action in between. - expect(row && agentVerdictDisplayMark({ ...row, acknowledgedAt: undefined })).toBe(mark) + expect(row && agentVerdictDisplayMark(row)).toBe(mark) expect(row && agentTurnEndedOnPurpose(row)).toBe(false) // The dot and the OS notification come only from a completion event, and none is sent. expect(session.completionEvents).toEqual([]) } ) - // The tab's mark reads a turn nobody stopped as failed until the user sees it; the chat's turn - // bar reads it like a finished turn, since the chat's notice row says why it stopped. + // The tab's mark reads a turn nobody stopped as failed; the chat's turn bar reads it like a + // finished turn, since the chat's notice row says why it stopped. it.each([ [ 'a restart', @@ -208,7 +208,7 @@ describe('a turn recovery settled after its host went away', () => { ) ] ] as const)( - 'reads Worked for N, marked failed until seen, for a turn cut off by %s', + 'reads Worked for N, marked failed, for a turn cut off by %s', async (_label, cut) => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) @@ -216,11 +216,7 @@ describe('a turn recovery settled after its host went away', () => { session.publish() const [row] = session.server.getStatusSnapshot() - expect(row && agentVerdictDisplayMark({ ...row, acknowledgedAt: undefined })).toBe('failed') - // Once the user has seen it, the chat's notice row is what still says it stopped. - expect( - row && agentVerdictDisplayMark({ ...row, acknowledgedAt: row.stateStartedAt }) - ).toBeNull() + expect(row && agentVerdictDisplayMark(row)).toBe('failed') const [settled] = [ ...selectStructuredAgentSettledTurns(session.journal.snapshot().items).values() ] @@ -254,9 +250,7 @@ describe('a turn recovery settled after its host went away', () => { stateStartedAt: RECOVERED, mainAgent: { state: 'done', outcome: 'unconfirmed' } }) - expect(row && agentVerdictDisplayMark({ ...row, acknowledgedAt: undefined })).toBe( - 'unconfirmed' - ) + expect(row && agentVerdictDisplayMark(row)).toBe('unconfirmed') expect(session.completionEvents).toEqual([]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index dd36abc9c8e4..50056eedd031 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -111,12 +111,7 @@ function settled(turnId?: string) { turn, label: timing ? formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...timing }) : null, mark: verdict - ? agentVerdictDisplayMark({ - state: 'done', - mainAgent: { state: 'done', outcome: verdict }, - stateStartedAt: 1, - acknowledgedAt: undefined - }) + ? agentVerdictDisplayMark({ state: 'done', mainAgent: { state: 'done', outcome: verdict } }) : null, errorRows: items.flatMap((item) => item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] diff --git a/src/renderer/src/attention/agent-attention-acknowledgement.test.ts b/src/renderer/src/attention/agent-attention-acknowledgement.test.ts deleted file mode 100644 index 991325979778..000000000000 --- a/src/renderer/src/attention/agent-attention-acknowledgement.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - computeAgentAcknowledgementTargets, - type AgentAttentionTurnRecords -} from './agent-attention-acknowledgement' - -const KEY = 'tab-1:leaf-1' - -describe('computeAgentAcknowledgementTargets', () => { - // The row has read `working` since 1_000 while a subagent runs; its main agent was cut at 3_000. - // An acknowledgement from before the cut must not keep an on-screen chat from clearing it. - const heldOpen = { - stateStartedAt: 1_000, - mainAgent: { stateStartedAt: 3_000 } - } - - it("acknowledges a viewed chat whose main agent was cut after the user's last look", () => { - const live: Omit = { - liveTurns: { [KEY]: heldOpen }, - retainedTurns: {} - } - const retained: Omit = { - liveTurns: {}, - retainedTurns: { [KEY]: { entry: heldOpen } } - } - for (const records of [live, retained]) { - expect( - computeAgentAcknowledgementTargets( - { ...records, acknowledgedTurnStartedAt: { [KEY]: 2_000 } }, - KEY - ) - ).toEqual([KEY]) - expect( - computeAgentAcknowledgementTargets( - { ...records, acknowledgedTurnStartedAt: { [KEY]: 3_000 } }, - KEY - ) - ).toEqual([]) - } - }) -}) diff --git a/src/renderer/src/attention/agent-attention-acknowledgement.ts b/src/renderer/src/attention/agent-attention-acknowledgement.ts index 8cebf19dd673..c4e1d7f5f073 100644 --- a/src/renderer/src/attention/agent-attention-acknowledgement.ts +++ b/src/renderer/src/attention/agent-attention-acknowledgement.ts @@ -3,19 +3,12 @@ import { type AgentAttentionRemainder, type ReadableAgentAttentionUnread } from './agent-attention-contract' -import { - agentAttentionStartedAt, - isAgentTurnAcknowledged -} from '../../../shared/agent-turn-acknowledgement' - -/** A turn's clocks: the row's, and the main agent's own when child work holds the row open. */ -type AgentAttentionTurn = { stateStartedAt: number; mainAgent?: { stateStartedAt?: number } } /** Subject-keyed turn bookkeeping the acknowledgement policy reads; no surface shape here. */ export type AgentAttentionTurnRecords = { - liveTurns: Record + liveTurns: Record /** Turns kept after their session ended, so a finished agent can still be acknowledged. */ - retainedTurns: Record + retainedTurns: Record acknowledgedTurnStartedAt: Record } @@ -40,8 +33,8 @@ export function readAgentAttentionTurnStartedAt( /** * Subjects on the viewed surface whose current turn has not been acknowledged yet. * - * Judged on the attention clock the failed mark reads (`agentAttentionStartedAt`), so viewing a chat - * whose main agent was cut while child work held its row open clears the mark. + * Why compare stateStartedAt (not updatedAt): same-state pings must not re-trigger an ack, + * matching the is-unvisited rule the workspace card uses. */ export function computeAgentAcknowledgementTargets( records: AgentAttentionTurnRecords, @@ -51,22 +44,13 @@ export function computeAgentAcknowledgementTargets( return [] } const targets: string[] = [] - const acknowledgedAt = records.acknowledgedTurnStartedAt[subjectKey] + const acknowledgedAt = records.acknowledgedTurnStartedAt[subjectKey] ?? 0 const liveTurn = records.liveTurns[subjectKey] - if ( - liveTurn && - !isAgentTurnAcknowledged({ stateStartedAt: agentAttentionStartedAt(liveTurn), acknowledgedAt }) - ) { + if (liveTurn && acknowledgedAt < liveTurn.stateStartedAt) { targets.push(subjectKey) } const retainedTurn = records.retainedTurns[subjectKey] - if ( - retainedTurn && - !isAgentTurnAcknowledged({ - stateStartedAt: agentAttentionStartedAt(retainedTurn.entry), - acknowledgedAt - }) - ) { + if (retainedTurn && acknowledgedAt < retainedTurn.entry.stateStartedAt) { targets.push(subjectKey) } return targets diff --git a/src/renderer/src/components/activity/ActivityPrototypePage-test-fixtures.ts b/src/renderer/src/components/activity/ActivityPrototypePage-test-fixtures.ts index 25b94ab867df..181f96869a27 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage-test-fixtures.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage-test-fixtures.ts @@ -165,7 +165,6 @@ export function makeThreads(result: ReturnType) { return buildAgentPaneThreads({ events: result.events, liveAgentByPaneKey: result.liveAgentByPaneKey, - paneEntryByPaneKey: result.paneEntryByPaneKey, - acknowledgedAgentsByPaneKey: {} + paneEntryByPaneKey: result.paneEntryByPaneKey }) } diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts index 9c841532d47f..2a960cb464e5 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts @@ -73,11 +73,7 @@ describe('buildActivityEvents', () => { acknowledgedAgentsByPaneKey: {}, now: 100_000 }) - const threads = buildAgentPaneThreads({ - events, - liveAgentByPaneKey, - acknowledgedAgentsByPaneKey: {} - }) + const threads = buildAgentPaneThreads({ events, liveAgentByPaneKey }) expect(events).toHaveLength(80) expect(threads).toHaveLength(18) @@ -449,8 +445,7 @@ describe('buildActivityEvents', () => { const groups = buildActivityThreadGroups( buildAgentPaneThreads({ events: result.events, - liveAgentByPaneKey: result.liveAgentByPaneKey, - acknowledgedAgentsByPaneKey: {} + liveAgentByPaneKey: result.liveAgentByPaneKey }), 'status' ) diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts index f40fadfe856a..42f45f1683d2 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts @@ -120,11 +120,7 @@ describe('activity thread grouping', () => { acknowledgedAgentsByPaneKey: {}, now: 3_000 }) - const threads = buildAgentPaneThreads({ - events, - liveAgentByPaneKey, - acknowledgedAgentsByPaneKey: {} - }) + const threads = buildAgentPaneThreads({ events, liveAgentByPaneKey }) const groups = buildActivityThreadGroups(threads, 'status') expect(groups).toHaveLength(2) @@ -158,11 +154,7 @@ describe('activity thread grouping', () => { acknowledgedAgentsByPaneKey: {}, now: 1_000 }) - const threads = buildAgentPaneThreads({ - events, - liveAgentByPaneKey, - acknowledgedAgentsByPaneKey: {} - }) + const threads = buildAgentPaneThreads({ events, liveAgentByPaneKey }) const group = getActivityThreadGroup(threads[0], 'project') expect(group).toEqual({ key: 'project:unknown', label: 'Unknown project' }) @@ -236,11 +228,7 @@ describe('activity thread grouping', () => { acknowledgedAgentsByPaneKey: {}, now: 3_000 }) - const threads = buildAgentPaneThreads({ - events, - liveAgentByPaneKey, - acknowledgedAgentsByPaneKey: {} - }) + const threads = buildAgentPaneThreads({ events, liveAgentByPaneKey }) const groups = buildActivityThreadGroups(threads, 'worktree') expect(groups.map((group) => group.key)).toEqual(['worktree:wt-a', 'worktree:wt-b']) diff --git a/src/renderer/src/components/activity/activity-clear-completed-hmr.test.ts b/src/renderer/src/components/activity/activity-clear-completed-hmr.test.ts index 5af2134e260a..1ea5d3b2c949 100644 --- a/src/renderer/src/components/activity/activity-clear-completed-hmr.test.ts +++ b/src/renderer/src/components/activity/activity-clear-completed-hmr.test.ts @@ -67,7 +67,6 @@ import { function makeThread(paneKey: string, overrides: Partial = {}): AgentPaneThread { return { - acknowledgedAt: undefined, paneKey, tab: makeTab(), worktree: makeWorktree(), diff --git a/src/renderer/src/components/activity/activity-clear-completed-state-lifetime.test.ts b/src/renderer/src/components/activity/activity-clear-completed-state-lifetime.test.ts index eb45c6d2e4f7..8bbfbe03ee2c 100644 --- a/src/renderer/src/components/activity/activity-clear-completed-state-lifetime.test.ts +++ b/src/renderer/src/components/activity/activity-clear-completed-state-lifetime.test.ts @@ -45,7 +45,6 @@ function clearWithOpenDraft(clear: boolean = true) { const retained = makeRetainedDoneEntry(tab) const paneKey = retained.entry.paneKey const thread: AgentPaneThread = { - acknowledgedAt: undefined, paneKey, tab, worktree: makeWorktree(), diff --git a/src/renderer/src/components/activity/activity-clear-completed.test.ts b/src/renderer/src/components/activity/activity-clear-completed.test.ts index 116cb3407b68..79191ce32520 100644 --- a/src/renderer/src/components/activity/activity-clear-completed.test.ts +++ b/src/renderer/src/components/activity/activity-clear-completed.test.ts @@ -65,7 +65,6 @@ import { activityThreadStatusId } from './activity-thread-presentation' function makeThread(paneKey: string, overrides: Partial = {}): AgentPaneThread { return { - acknowledgedAt: undefined, paneKey, tab: makeTab(), worktree: makeWorktree(), diff --git a/src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts b/src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts index 663ec2988153..4cf8fb2927eb 100644 --- a/src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts +++ b/src/renderer/src/components/activity/activity-event-builder.identity-reuse.test.ts @@ -67,11 +67,7 @@ function buildBoth( ) { const result = buildActivityEvents(args, eventCache) const threads = buildAgentPaneThreads( - { - events: result.events, - liveAgentByPaneKey: result.liveAgentByPaneKey, - acknowledgedAgentsByPaneKey: {} - }, + { events: result.events, liveAgentByPaneKey: result.liveAgentByPaneKey }, threadCache ) return { ...result, threads } @@ -244,8 +240,7 @@ describe('activity build identity reuse', () => { const cold = buildActivityEvents(scenario) const coldThreads = buildAgentPaneThreads({ events: cold.events, - liveAgentByPaneKey: cold.liveAgentByPaneKey, - acknowledgedAgentsByPaneKey: {} + liveAgentByPaneKey: cold.liveAgentByPaneKey }) expect(cached.events).toEqual(cold.events) expect(cached.liveAgentByPaneKey).toEqual(cold.liveAgentByPaneKey) diff --git a/src/renderer/src/components/activity/activity-event-builder.live-cap.test.ts b/src/renderer/src/components/activity/activity-event-builder.live-cap.test.ts index c9bc249e9926..643fc6b4e081 100644 --- a/src/renderer/src/components/activity/activity-event-builder.live-cap.test.ts +++ b/src/renderer/src/components/activity/activity-event-builder.live-cap.test.ts @@ -41,7 +41,7 @@ describe('live activity capacity', () => { acknowledgedAgentsByPaneKey: {}, now: 3_000 }) - const threads = buildAgentPaneThreads({ ...result, acknowledgedAgentsByPaneKey: {} }) + const threads = buildAgentPaneThreads(result) expect(threads).toHaveLength(82) expect( diff --git a/src/renderer/src/components/activity/activity-scope-filter.test.ts b/src/renderer/src/components/activity/activity-scope-filter.test.ts index f86ff6f2127c..45817e50a7b5 100644 --- a/src/renderer/src/components/activity/activity-scope-filter.test.ts +++ b/src/renderer/src/components/activity/activity-scope-filter.test.ts @@ -20,7 +20,6 @@ const SSH_HOST = 'ssh:devbox' as ExecutionHostId function makeThread(overrides: Partial = {}): AgentPaneThread { const worktree = makeWorktree() return { - acknowledgedAt: undefined, paneKey: PANE_KEY, paneTitle: 'Test Agent', agentType: 'claude', diff --git a/src/renderer/src/components/activity/activity-status-group-header.test.ts b/src/renderer/src/components/activity/activity-status-group-header.test.ts index 15163f176bc7..cbcf468b900d 100644 --- a/src/renderer/src/components/activity/activity-status-group-header.test.ts +++ b/src/renderer/src/components/activity/activity-status-group-header.test.ts @@ -12,9 +12,8 @@ import { PANE_KEY, PANE_KEY_2 } from './ActivityPrototypePage-test-fixtures' -import { activityThreadStatusId } from './activity-thread-presentation' -type Ending = 'cancellation' | 'interruption' | 'failure' +type Ending = 'cancellation' | 'interruption' function doneEntry(paneKey: string, outcome: Ending, at: number): AgentStatusEntry { return { @@ -31,12 +30,8 @@ function doneEntry(paneKey: string, outcome: Ending, at: number): AgentStatusEnt } } -/** The threads for two ended panes, `newer` the most recent. */ -function endedThreads( - newer: Ending, - older: Ending, - acknowledgedAgentsByPaneKey: Record = {} -) { +/** Status groups for two ended panes, `newer` the most recent. */ +function statusGroups(newer: Ending, older: Ending) { const repo = makeRepo() const worktree = makeWorktree() const { events, liveAgentByPaneKey } = buildActivityEvents({ @@ -50,15 +45,10 @@ function endedThreads( }, worktreeMap: new Map([[worktree.id, worktree]]), repoMap: new Map([[repo.id, repo]]), - acknowledgedAgentsByPaneKey, + acknowledgedAgentsByPaneKey: {}, now: 3_000 }) - return buildAgentPaneThreads({ events, liveAgentByPaneKey, acknowledgedAgentsByPaneKey }) -} - -/** Status groups for two ended panes, `newer` the most recent. */ -function statusGroups(newer: Ending, older: Ending) { - return buildActivityThreadGroups(endedThreads(newer, older), 'status') + return buildActivityThreadGroups(buildAgentPaneThreads({ events, liveAgentByPaneKey }), 'status') } describe('the status group headers', () => { @@ -85,20 +75,3 @@ describe('the status group headers', () => { expect(groups[0]).toMatchObject({ key: 'interrupted', state: 'interrupted' }) }) }) - -describe('a thread cut short with nobody asking', () => { - // The same acknowledgement as the sidebar: news until seen, then it reads like a finished turn. - it('leaves Failed, and its "Failed" line, once the user has seen it; a failure stays', () => { - const unseen = endedThreads('interruption', 'failure') - const seen = endedThreads('interruption', 'failure', { [PANE_KEY]: 3_000, [PANE_KEY_2]: 2_000 }) - const cut = (threads: typeof seen) => threads.find((thread) => thread.paneKey === PANE_KEY) - const failed = (threads: typeof seen) => threads.find((thread) => thread.paneKey === PANE_KEY_2) - - expect(activityThreadStatusId(cut(unseen)!)).toBe('failed') - expect(cut(unseen)?.responsePreview).toBe('Failed') - expect(activityThreadStatusId(cut(seen)!)).toBe('done') - expect(cut(seen)?.responsePreview).not.toBe('Failed') - expect(activityThreadStatusId(failed(seen)!)).toBe('failed') - expect(failed(seen)?.responsePreview).toBe('Failed') - }) -}) diff --git a/src/renderer/src/components/activity/activity-thread-actions.test.ts b/src/renderer/src/components/activity/activity-thread-actions.test.ts index 303bb99c4bc5..ce546f036970 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.test.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.test.ts @@ -34,7 +34,6 @@ const REMOTE_HOST = 'ssh:devbox' as const function makeRemoteThread(): AgentPaneThread { const worktree = { ...makeWorktree(), hostId: REMOTE_HOST } return { - acknowledgedAt: undefined, paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', paneTitle: 'Remote agent', agentType: 'claude', diff --git a/src/renderer/src/components/activity/activity-thread-builder.ts b/src/renderer/src/components/activity/activity-thread-builder.ts index 776a46ea892a..d0f198dbebab 100644 --- a/src/renderer/src/components/activity/activity-thread-builder.ts +++ b/src/renderer/src/components/activity/activity-thread-builder.ts @@ -4,7 +4,6 @@ import { statusPreviewForEntry } from './activity-thread-presentation' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import { acknowledgedAgentEntry } from '@/lib/agent-entry-acknowledgement' import type { ActivityEvent, ActivityLiveAgentSnapshot, @@ -53,7 +52,6 @@ function reuseThreadIfEqual( previous.agentType === next.agentType && previous.currentAgentState === next.currentAgentState && previous.currentAgentEntry === next.currentAgentEntry && - previous.acknowledgedAt === next.acknowledgedAt && previous.paneEntry === next.paneEntry && previous.responsePreview === next.responsePreview && previous.latestTimestamp === next.latestTimestamp && @@ -72,16 +70,12 @@ export function buildAgentPaneThreads( events: ActivityEvent[] liveAgentByPaneKey: Record paneEntryByPaneKey?: Record - acknowledgedAgentsByPaneKey: Readonly> generatedTitlesEnabled?: boolean }, reuseCache?: AgentPaneThreadReuseCache ): AgentPaneThread[] { const generatedTitlesEnabled = args.generatedTitlesEnabled === true const byPaneKey = new Map() - // Why: the thread's line follows its mark, so a seen cut-short turn drops "Failed" as the dot does. - const seen = (entry: AgentStatusEntry) => - acknowledgedAgentEntry(entry, args.acknowledgedAgentsByPaneKey[entry.paneKey]) for (const event of args.events) { const paneKey = event.entry.paneKey const existing = byPaneKey.get(paneKey) @@ -95,9 +89,8 @@ export function buildAgentPaneThreads( agentType: event.agentType, currentAgentState: null, currentAgentEntry: null, - acknowledgedAt: args.acknowledgedAgentsByPaneKey[paneKey], paneEntry: args.paneEntryByPaneKey?.[paneKey], - responsePreview: statusPreviewForEntry(seen(event.entry), event.state), + responsePreview: statusPreviewForEntry(event.entry, event.state), latestTimestamp: event.timestamp, latestEvent: event, events: [event], @@ -118,7 +111,7 @@ export function buildAgentPaneThreads( existing.agentType = event.agentType existing.tab = event.tab existing.responsePreview = statusPreviewForEntry( - seen(event.entry), + event.entry, event.state, existing.responsePreview ) @@ -137,9 +130,8 @@ export function buildAgentPaneThreads( agentType: liveAgent.agentType, currentAgentState: liveAgent.state, currentAgentEntry: liveAgent.entry, - acknowledgedAt: args.acknowledgedAgentsByPaneKey[paneKey], paneEntry: args.paneEntryByPaneKey?.[paneKey], - responsePreview: statusPreviewForEntry(seen(liveAgent.entry), liveAgent.entry.state), + responsePreview: statusPreviewForEntry(liveAgent.entry, liveAgent.entry.state), latestTimestamp: liveAgent.timestamp, latestEvent: null, events: [], @@ -156,7 +148,7 @@ export function buildAgentPaneThreads( existing.currentAgentState = liveAgent.state existing.currentAgentEntry = liveAgent.entry existing.responsePreview = statusPreviewForEntry( - seen(liveAgent.entry), + liveAgent.entry, liveAgent.entry.state, existing.responsePreview ) diff --git a/src/renderer/src/components/activity/activity-thread-child-agent.test.ts b/src/renderer/src/components/activity/activity-thread-child-agent.test.ts index 39faf3131f72..214e0e559cd0 100644 --- a/src/renderer/src/components/activity/activity-thread-child-agent.test.ts +++ b/src/renderer/src/components/activity/activity-thread-child-agent.test.ts @@ -32,7 +32,6 @@ function makeTestThread( ): AgentPaneThread { const worktree = makeWorktree() return { - acknowledgedAt: undefined, paneKey, paneTitle: 'Test Agent', agentType: 'claude', diff --git a/src/renderer/src/components/activity/activity-thread-grouping.search-cache.test.ts b/src/renderer/src/components/activity/activity-thread-grouping.search-cache.test.ts index dbb7ed10d1a9..4c5b003eac4b 100644 --- a/src/renderer/src/components/activity/activity-thread-grouping.search-cache.test.ts +++ b/src/renderer/src/components/activity/activity-thread-grouping.search-cache.test.ts @@ -8,7 +8,6 @@ import { makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures' function makeThread(paneKey: string, paneTitle: string): AgentPaneThread { return { - acknowledgedAt: undefined, paneKey, tab: makeTab(), worktree: makeWorktree(), diff --git a/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx b/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx index 3f475f6bc308..ef1f109d50ad 100644 --- a/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx +++ b/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx @@ -45,7 +45,6 @@ function createTestThread(overrides: Partial = {}): AgentPaneTh const tab = makeTab() return { - acknowledgedAt: undefined, paneKey: 'tab-1:leaf-1', tab, worktree, diff --git a/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx b/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx index a16496dda706..6db25785dc65 100644 --- a/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx +++ b/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx @@ -12,7 +12,6 @@ import { makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures' globalThis.IS_REACT_ACT_ENVIRONMENT = true const mockThread: AgentPaneThread = { - acknowledgedAt: undefined, paneKey: 'tab-1:agent-1', tab: makeTab(), worktree: makeWorktree(), diff --git a/src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx b/src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx index 297f4c38bf2b..5b531570444d 100644 --- a/src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx +++ b/src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx @@ -18,7 +18,6 @@ const THREAD_COUNT = 300 function makeThread(index: number): AgentPaneThread { return { - acknowledgedAt: undefined, paneKey: `tab-${index}:leaf-${index}`, tab: makeTab(), worktree: makeWorktree(), diff --git a/src/renderer/src/components/activity/activity-thread-presentation.test.ts b/src/renderer/src/components/activity/activity-thread-presentation.test.ts index c7a2c392c57d..6aa3efc1a322 100644 --- a/src/renderer/src/components/activity/activity-thread-presentation.test.ts +++ b/src/renderer/src/components/activity/activity-thread-presentation.test.ts @@ -12,7 +12,6 @@ import { function makeThread(overrides: Partial = {}): AgentPaneThread { const worktree = makeWorktree() return { - acknowledgedAt: undefined, paneKey: PANE_KEY, paneTitle: 'low hanging issues', agentType: 'codex', diff --git a/src/renderer/src/components/activity/activity-thread-presentation.ts b/src/renderer/src/components/activity/activity-thread-presentation.ts index 900fd8dbf1e4..4614b192d1b3 100644 --- a/src/renderer/src/components/activity/activity-thread-presentation.ts +++ b/src/renderer/src/components/activity/activity-thread-presentation.ts @@ -14,10 +14,6 @@ import { import { formatUiRelativeTime } from '@/i18n/relative-time-format' import { translate } from '@/i18n/i18n' import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' -import { - acknowledgedAgentEntry, - type AcknowledgedAgentStatusEntry -} from '@/lib/agent-entry-acknowledgement' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { isHistoricalActivityState } from './activity-event-state' import type { @@ -143,7 +139,7 @@ export function paneTitleForEvent(event: ActivityEvent, generatedTitlesEnabled: } export function statusPreviewForEntry( - entry: AcknowledgedAgentStatusEntry, + entry: AgentStatusEntry, agentState?: AgentStatusState | null, previousPreview?: string ): string { @@ -155,17 +151,13 @@ export type ActivityThreadStatusId = AgentDotState /** Single classifier behind grouping, labels, and clear-completed; the only place the * verdict predicate is spelled. */ export function activityThreadStatusId(thread: AgentPaneThread): ActivityThreadStatusId { - const seen = (entry: AgentStatusEntry) => acknowledgedAgentEntry(entry, thread.acknowledgedAt) // Why: a failed main agent outranks the subagent work still holding its row live. - if ( - thread.currentAgentEntry && - agentVerdictDisplayMark(seen(thread.currentAgentEntry)) === 'failed' - ) { + if (thread.currentAgentEntry && agentVerdictDisplayMark(thread.currentAgentEntry) === 'failed') { return 'failed' } const state = threadCurrentState(thread) ?? 'done' const verdictEntry = threadVerdictEntry(thread) - const verdictDot = verdictEntry ? agentVerdictDisplayMark(seen(verdictEntry)) : null + const verdictDot = verdictEntry ? agentVerdictDisplayMark(verdictEntry) : null if (!thread.currentAgentState && state === 'done' && verdictDot) { return verdictDot } diff --git a/src/renderer/src/components/activity/activity-thread-types.ts b/src/renderer/src/components/activity/activity-thread-types.ts index 9b2eab592928..5d0b9e11acb3 100644 --- a/src/renderer/src/components/activity/activity-thread-types.ts +++ b/src/renderer/src/components/activity/activity-thread-types.ts @@ -54,8 +54,6 @@ export type AgentPaneThread = { agentType: AgentType currentAgentState: ActivityLiveAgentState | null currentAgentEntry: AgentStatusEntry | null - /** When the user last acknowledged the pane: a turn cut short reads failed only until then. */ - acknowledgedAt: number | undefined /** The pane's own status row, live or not; its state outranks the newest event's. */ paneEntry?: AgentStatusEntry responsePreview: string diff --git a/src/renderer/src/components/activity/activity-thread-virtual-items.test.ts b/src/renderer/src/components/activity/activity-thread-virtual-items.test.ts index 53ce68923afc..041841bcad47 100644 --- a/src/renderer/src/components/activity/activity-thread-virtual-items.test.ts +++ b/src/renderer/src/components/activity/activity-thread-virtual-items.test.ts @@ -10,7 +10,6 @@ import { makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures' function makeThread(paneKey: string): AgentPaneThread { return { - acknowledgedAt: undefined, paneKey, tab: makeTab(), worktree: makeWorktree(), diff --git a/src/renderer/src/components/activity/use-agent-pane-threads.ts b/src/renderer/src/components/activity/use-agent-pane-threads.ts index 95dea2b21ffc..ce1f571cdbcc 100644 --- a/src/renderer/src/components/activity/use-agent-pane-threads.ts +++ b/src/renderer/src/components/activity/use-agent-pane-threads.ts @@ -162,18 +162,11 @@ export function useAgentPaneThreads(args: { events: allEvents, liveAgentByPaneKey, paneEntryByPaneKey, - acknowledgedAgentsByPaneKey: storeData.acknowledgedAgentsByPaneKey, generatedTitlesEnabled: storeData.generatedTitlesEnabled }, threadReuseCacheRef.current ), - [ - allEvents, - liveAgentByPaneKey, - paneEntryByPaneKey, - storeData.acknowledgedAgentsByPaneKey, - storeData.generatedTitlesEnabled - ] + [allEvents, liveAgentByPaneKey, paneEntryByPaneKey, storeData.generatedTitlesEnabled] ) const selectedPaneKeyIsLive = diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.ts b/src/renderer/src/components/activity/useActivityUnreadCount.ts index 0017dbb6a7d9..e727ef969e21 100644 --- a/src/renderer/src/components/activity/useActivityUnreadCount.ts +++ b/src/renderer/src/components/activity/useActivityUnreadCount.ts @@ -5,7 +5,6 @@ import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsuppor import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import { isAgentTurnAcknowledged } from '../../../../shared/agent-turn-acknowledgement' import { freshActivityLiveAgentState, isHistoricalActivityState } from './activity-event-state' @@ -46,7 +45,7 @@ export function countActivityUnread(source: ActivityUnreadCountSource, now = Dat (isHistoricalActivityState(entry.state) || (live && freshActivityLiveAgentState(entry, now) === 'working')) && entry.sessionBoundary !== true && - !isAgentTurnAcknowledged({ stateStartedAt: entry.stateStartedAt, acknowledgedAt: mutedAt }) + mutedAt < entry.stateStartedAt ) { count += 1 } diff --git a/src/renderer/src/components/agent-child-row-parity.test.tsx b/src/renderer/src/components/agent-child-row-parity.test.tsx index dd429bfe28b6..eb7721d2f2f1 100644 --- a/src/renderer/src/components/agent-child-row-parity.test.tsx +++ b/src/renderer/src/components/agent-child-row-parity.test.tsx @@ -16,7 +16,6 @@ import { buildBackgroundTaskGroupsFromViews } from '@/components/native-chat/bac import { CompactAgentRow } from '@/components/sidebar/worktree-card-compact-agent-row' import { buildSubagentChildRows } from '@/components/sidebar/worktree-subagent-child-rows' import { TooltipProvider } from '@/components/ui/tooltip' -import { acknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' vi.mock('@/components/dashboard/use-agent-row-conversation-name', () => ({ useAgentRowConversationName: () => null @@ -126,11 +125,7 @@ function sidebarRows(parent: AgentStatusEntry, parentIsFresh = true): RenderedRo mount( renderToStaticMarkup( - {}} - /> + {}} /> ) ), @@ -181,7 +176,7 @@ function fullRow(parent: AgentStatusEntry): { labels: string[]; text: string } { renderToStaticMarkup( {}} onDismiss={() => {}} @@ -348,7 +343,7 @@ describe('a child reads the same in the sidebar and the chat strip', () => { renderToStaticMarkup( {}} onDismiss={() => {}} @@ -478,7 +473,7 @@ describe('a mirrored parent and its children read one silence', () => { renderToStaticMarkup( {}} onDismiss={() => {}} diff --git a/src/renderer/src/components/cmd-j/palette-live-status.tsx b/src/renderer/src/components/cmd-j/palette-live-status.tsx index 57cf0c9ef36e..34840d50cc5a 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.tsx @@ -5,7 +5,10 @@ import { AgentStateDot } from '@/components/AgentStateDot' import { StateIndicatorTooltip } from '@/components/StateIndicatorTooltip' import StatusIndicator from '@/components/sidebar/StatusIndicator' import { FilledBellIcon } from '@/components/sidebar/WorktreeCardHelpers' -import { buildExplicitEntriesByTabId } from '@/components/sidebar/smart-attention' +import { + buildExplicitEntriesByTabId, + type TabPaneInputSources +} from '@/components/sidebar/smart-attention' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state' import { @@ -15,7 +18,6 @@ import { } from '@/lib/worktree-status' import { resolveRecentWorkspaceTabStatus, - type RecentWorkspaceTabPaneSources, type RecentWorkspaceTabRow } from '@/lib/recent-workspace-tab-rows' import { @@ -40,7 +42,7 @@ type PaletteLiveStatus = { liveAgentStatusByWorktreeId: ReadonlyMap agentStatusPaneIdsByTabId: Record> stalePaneIdsByTabId: Record> - paneSources: RecentWorkspaceTabPaneSources + paneSources: TabPaneInputSources tabsByWorktree: Record browserTabsByWorktree: Record unreadTerminalTabs: Record @@ -70,8 +72,7 @@ export function PaletteLiveStatusProvider({ browserTabsByWorktree, migrationUnsupportedByPtyId, unreadTerminalTabs, - unreadAgentCompletionPanes, - acknowledgedAgentsByPaneKey + unreadAgentCompletionPanes } = useAppStore( useShallow((s) => active @@ -84,8 +85,7 @@ export function PaletteLiveStatusProvider({ browserTabsByWorktree: s.browserTabsByWorktree, migrationUnsupportedByPtyId: s.migrationUnsupportedByPtyId, unreadTerminalTabs: s.unreadTerminalTabs, - unreadAgentCompletionPanes: s.unreadAgentCompletionPanes, - acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey + unreadAgentCompletionPanes: s.unreadAgentCompletionPanes } : EMPTY_LIVE_INPUTS ) @@ -112,8 +112,7 @@ export function PaletteLiveStatusProvider({ entriesByTabId, ptyIdsByTabId, runtimePaneTitlesByTabId, - terminalLayoutsByTabId, - acknowledgedAgentsByPaneKey + terminalLayoutsByTabId }, tabsByWorktree, browserTabsByWorktree, @@ -123,7 +122,6 @@ export function PaletteLiveStatusProvider({ now } }, [ - acknowledgedAgentsByPaneKey, agentStatusByPaneKey, browserTabsByWorktree, migrationUnsupportedByPtyId, @@ -188,8 +186,7 @@ const EMPTY_LIVE_INPUTS = Object.freeze({ browserTabsByWorktree: {}, migrationUnsupportedByPtyId: {}, unreadTerminalTabs: {}, - unreadAgentCompletionPanes: {}, - acknowledgedAgentsByPaneKey: {} + unreadAgentCompletionPanes: {} }) function useLiveStatus(): PaletteLiveStatus | null { diff --git a/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.test.tsx index d17c8709c19b..6d0994eb7604 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.test.tsx @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { CompactAgentRow } from '../sidebar/worktree-card-compact-agent-row' import { TooltipProvider } from '../ui/tooltip' import DashboardAgentRow from './DashboardAgentRow' -import type { AcknowledgedAgentRow as AgentRow } from '@/lib/agent-entry-acknowledgement' +import type { DashboardAgentRow as AgentRow } from './useDashboardData' import { DashboardAgentChildDisclosure } from './DashboardAgentChildDisclosure' vi.mock('./use-agent-row-conversation-name', () => ({ useAgentRowConversationName: () => null })) @@ -26,8 +26,7 @@ const agent: AgentRow = { prompt: 'Review the change', updatedAt: 60000, stateStartedAt: 60000, - stateHistory: [], - acknowledgedAt: undefined + stateHistory: [] }, tab: { id: 'tab', diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index 4c053ea999fd..5ac748027972 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -5,17 +5,13 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { TooltipProvider } from '../ui/tooltip' import DashboardAgentRow from './DashboardAgentRow' -import { - acknowledgedAgentRow, - type AcknowledgedAgentRow as DashboardAgentRowData -} from '@/lib/agent-entry-acknowledgement' +import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' const NOW = 120_000 function makeAgent( - overrides: Partial> = {}, - entryOverrides: Partial = {}, - acknowledgedAt?: number + overrides: Partial = {}, + entryOverrides: Partial = {} ): DashboardAgentRowData { const paneKey = overrides.paneKey ?? 'tab-1:leaf-1' const tab: TerminalTab = { @@ -39,18 +35,15 @@ function makeAgent( ...entryOverrides } - return acknowledgedAgentRow( - { - paneKey, - entry, - tab, - agentType: entry.agentType ?? 'codex', - state: entry.state, - startedAt: entry.stateStartedAt, - ...overrides - }, - acknowledgedAt - ) + return { + paneKey, + entry, + tab, + agentType: entry.agentType ?? 'codex', + state: entry.state, + startedAt: entry.stateStartedAt, + ...overrides + } } function renderRow(agent: DashboardAgentRowData): string { @@ -333,30 +326,6 @@ describe('DashboardAgentRow', () => { expect(markup).not.toContain('lucide-circle-check') }) - // Attention only while unseen: a visited cut-short row reads like a finished one. - it('renders a visited cut-short row as done, and a visited failure still as failed', () => { - const visited = (outcome: 'interruption' | 'failure', acknowledgedAt: number) => - renderRow( - makeAgent( - { state: 'done', startedAt: 1_000 }, - { - state: 'done', - prompt: 'Give me a quick update', - updatedAt: 2_000, - stateStartedAt: 2_000, - stateHistory: [], - mainAgent: { state: 'done', outcome, stateStartedAt: 2_000 } - }, - acknowledgedAt - ) - ) - - expect(visited('interruption', 1_999)).toContain('aria-label="Failed"') - expect(visited('interruption', 2_000)).toContain('aria-label="Done"') - expect(visited('interruption', 2_000)).not.toContain('aria-label="Failed"') - expect(visited('failure', 2_000)).toContain('aria-label="Failed"') - }) - it.each([ ['recorded', { mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 2_000 } }], ["an old host's flag", { interrupted: true }] diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 722992586a84..2cacdfb5474b 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -15,7 +15,7 @@ import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict' import { agentVerdictStatusLine } from '@/lib/agent-verdict-status-line' -import type { AcknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' +import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' import { useAgentRowConversationName } from './use-agent-row-conversation-name' import { lastEnteredDoneAt } from './agent-finished-timestamp' @@ -33,14 +33,14 @@ function formatTimeAgo(ts: number, now: number): string { } // A child row's silence is the model's, on the clock its compact row and the strip read. -function rowNoUpdateLabel(agent: AcknowledgedAgentRow, now: number): string { +function rowNoUpdateLabel(agent: DashboardAgentRowData, now: number): string { return agent.childRow ? agentChildRowNoUpdateLabel(agent.childRow, now) : agentNoUpdateLabel(agent.entry, now) } function stateDotTooltipLabel( - agent: AcknowledgedAgentRow, + agent: DashboardAgentRowData, dotState: AgentDotState, now: number ): string { @@ -53,7 +53,7 @@ function stateDotTooltipLabel( } type Props = { - agent: AcknowledgedAgentRow + agent: DashboardAgentRowData onDismiss: (paneKey: string) => void /** Navigate to this agent's tab; paneKey lets the caller mark-visit the exact clicked row. */ onActivate: (tabId: string, paneKey: string) => void diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts index 290939f0c380..2031569ff6d8 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts @@ -1,7 +1,7 @@ import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' import { - agentMainAgentVerdict, - agentTurnEndedOnPurpose + agentTurnEndedOnPurpose, + agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict' import type { DashboardAgentRow } from './useDashboardData' @@ -30,14 +30,8 @@ export function lastEnteredDoneAt( if (entry.state === 'done' && agentTurnEndedOnPurpose(entry) && entry.sessionBoundary !== true) { return entry.stateStartedAt } - // Why: a main agent that failed or was cut short ended its turn while its subagents run, so it - // shows when that turn ended. - const verdict = agentMainAgentVerdict(entry) - if ( - entry.state !== 'done' && - entry.mainAgent && - (verdict === 'failure' || verdict === 'interruption') - ) { + // Why: a failed main agent reads failed while its subagents run, so it shows when it failed. + if (entry.state !== 'done' && entry.mainAgent && agentVerdictDisplayMark(entry) === 'failed') { return entry.mainAgent.stateStartedAt } for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) { diff --git a/src/renderer/src/components/dashboard/dashboard-row-bucket.ts b/src/renderer/src/components/dashboard/dashboard-row-bucket.ts index 3ada3074a35a..0b4377ac9aa0 100644 --- a/src/renderer/src/components/dashboard/dashboard-row-bucket.ts +++ b/src/renderer/src/components/dashboard/dashboard-row-bucket.ts @@ -6,7 +6,6 @@ import { } from '../../../../shared/dashboard-snapshot' import { dashboardBucketForDotState } from './dashboard-card-bucket' import type { AgentRowState } from '@/lib/agent-row-decay-state' -import { isAgentTurnAcknowledged } from '../../../../shared/agent-turn-acknowledgement' /** * Project a row state onto the published card vocabulary. @@ -40,11 +39,7 @@ export function dashboardRowBucketProjection( ? row.entry.workingMode : undefined const unseen = - !isTitleDerived && - !isAgentTurnAcknowledged({ - stateStartedAt: row.entry.stateStartedAt, - acknowledgedAt: acknowledgedAgentsByPaneKey?.[row.paneKey] - }) + !isTitleDerived && (acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt const bucket = dashboardBucketForDotState( dashboardCardDisplayState({ dotState, workingMode, unseen }) ) diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index 0eea46f2f19f..688113e7a010 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -27,7 +27,7 @@ import { } from '@/lib/agent-status' import { track } from '@/lib/telemetry' import { useNow } from '@/hooks/use-now' -import type { AcknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp' import { selectLivePtyIdsForWorktree } from '@/components/sidebar/worktree-card-status-inputs' import { useWorktreeAgentRows } from '@/components/sidebar/useWorktreeAgentRows' @@ -37,7 +37,7 @@ import { translate } from '@/i18n/i18n' type OrderedSendTarget = { target: NotesSendAgentTarget - agent: AcknowledgedAgentRow | null + agent: DashboardAgentRowData | null } export function ReviewNotesSendMenuContent({ @@ -245,7 +245,7 @@ function AgentTargetMenuItem({ onSend }: { target: NotesSendAgentTarget - agent: AcknowledgedAgentRow | null + agent: DashboardAgentRowData | null now: number disabled: boolean onSend: (target: NotesSendAgentTarget) => void @@ -292,7 +292,7 @@ function AgentTargetMenuItem({ function orderSendTargetsByWorktreeAgentRows( sendTargets: NotesSendAgentTarget[], - agentRows: AcknowledgedAgentRow[] + agentRows: DashboardAgentRowData[] ): OrderedSendTarget[] { const targetsByPaneKey = new Map(sendTargets.map((target) => [target.paneKey, target])) const usedPaneKeys = new Set() @@ -316,7 +316,7 @@ function orderSendTargetsByWorktreeAgentRows( return ordered } -function formatAgentRelativeTime(agent: AcknowledgedAgentRow, now: number): string | null { +function formatAgentRelativeTime(agent: DashboardAgentRowData, now: number): string | null { const doneAt = lastEnteredDoneAt(agent) if (doneAt !== null) { return `${formatTimeAgo(doneAt, now)}` diff --git a/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx b/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx index aa2e1f8ef530..a5b4b911793c 100644 --- a/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx +++ b/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx @@ -81,7 +81,6 @@ import { CompactAgentRow } from '@/components/sidebar/worktree-card-compact-agen import { buildSubagentChildRows } from '@/components/sidebar/worktree-subagent-child-rows' import { TooltipProvider } from '@/components/ui/tooltip' import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' -import { acknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' const MINUTE = 60_000 const tab = { @@ -178,11 +177,7 @@ function sidebarRows(): RenderedRow[] { const container = document.createElement('div') container.innerHTML = renderToStaticMarkup( - {}} - /> + {}} /> ) return readRow(container, ' - ') diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx index 4e9efcdbf484..1459e7f1bb30 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx @@ -10,7 +10,6 @@ import { seedWorktreeAgentExpansionStateForTests } from './worktree-card-agents-expansion-state' import WorktreeCardAgents from './WorktreeCardAgents' -import { acknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' const WORKSPACE = 'acknowledgement-workspace' const PANE_A = makePaneKey('tab-a', '11111111-1111-4111-8111-111111111111') @@ -82,11 +81,9 @@ function agentRow( function renderLabels(agents: DashboardAgentRow[]): HTMLElement[] { const container = document.createElement('div') - // The rows as useWorktreeAgentRows hands them over: joined with the store's acknowledgements. - const rows = agents.map((agent) => acknowledgedAgentRow(agent, acknowledgements[agent.paneKey])) container.innerHTML = renderToStaticMarkup( - + ) return [ diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index f989e67f76f2..73b9a430bd76 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -1,7 +1,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { AcknowledgedAgentRow as DashboardAgentRowData } from '@/lib/agent-entry-acknowledgement' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { makePaneKey } from '../../../../shared/stable-pane-id' const LEAF_A = '11111111-1111-4111-8111-111111111111' @@ -59,8 +59,7 @@ function mockAgent({ stateStartedAt, stateHistory: prompt === undefined ? undefined : [], terminalHandle, - orchestration, - acknowledgedAt: undefined + orchestration }, lineage } diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 541eb442e265..391c45127cda 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -12,8 +12,7 @@ import { } from './worktree-card-send-target-inputs' import { useWorktreeAgentRows } from './useWorktreeAgentRows' import { cn } from '@/lib/utils' -import type { AcknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' -import { isAgentTurnAcknowledged } from '../../../../shared/agent-turn-acknowledgement' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { parsePaneKey } from '../../../../shared/stable-pane-id' import { dismissStaleAgentRowByKey } from '../terminal-pane/stale-agent-row' import { useFocusedAgentPaneKey } from './focused-agent-row-highlight' @@ -28,6 +27,7 @@ import { revealElementInScrollContainer } from './worktree-sidebar-reveal' import { useWorktreeAgentExpansionState } from './worktree-card-agents-expansion-state' import { translate } from '@/i18n/i18n' import { activateStructuredAgentSessionTab } from '@/lib/structured-agent-session-tab-activation' +import { selectAcknowledgedAgentTimes } from './worktree-card-agent-ack-inputs' export const SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT = 'orca-suppress-worktree-list-scroll-adjustment' @@ -47,7 +47,7 @@ function revealCompactAgentCard(agentListRoot: HTMLElement | null): void { type Props = { worktreeId: string - agents?: AcknowledgedAgentRow[] + agents?: DashboardAgentRowData[] /** Spacing from the card body above; parent decides whether a divider is appropriate. */ className?: string } @@ -69,7 +69,7 @@ const WorktreeCardAgents = React.memo(function WorktreeCardAgents({ type BodyProps = { worktreeId: string - agents: AcknowledgedAgentRow[] + agents: DashboardAgentRowData[] className?: string } @@ -91,14 +91,19 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ const focusedAgentPaneKey = useFocusedAgentPaneKey(worktreeId) const compactAgentListRootRef = useRef(null) - // Why: the rows carry their acknowledgement, joined where they are built. + // Why: acknowledgement writes are app-global; project only this card's rows + // so unrelated worktree activity does not rerender every agent body. + const acknowledgedAgentTimes = useAppStore( + useShallow((s) => selectAcknowledgedAgentTimes(s, agents)) + ) const unvisitedByPaneKey = useMemo(() => { const out: Record = {} - for (const agent of agents) { - out[agent.paneKey] = !isAgentTurnAcknowledged(agent.entry) + for (const [index, agent] of agents.entries()) { + const ackAt = acknowledgedAgentTimes[index] ?? 0 + out[agent.paneKey] = ackAt < agent.entry.stateStartedAt } return out - }, [agents]) + }, [agents, acknowledgedAgentTimes]) const handleDismissAgent = useCallback( (paneKey: string) => { @@ -231,7 +236,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ }, []) const renderAgentBranch = ( - agent: AcknowledgedAgentRow, + agent: DashboardAgentRowData, ancestorPaneKeys: ReadonlySet = new Set() ): React.ReactNode => { if (ancestorPaneKeys.has(agent.paneKey)) { @@ -290,7 +295,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ } const renderCompactAgentBranch = ( - agent: AcknowledgedAgentRow, + agent: DashboardAgentRowData, ancestorPaneKeys: ReadonlySet = new Set(), cacheTimerActive = true ): React.ReactNode => { diff --git a/src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts b/src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts index 05de9eaeb2a6..052adc752b95 100644 --- a/src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts +++ b/src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts @@ -22,7 +22,6 @@ import { buildWorktreeAgentRows } from './worktree-agent-rows' import { getAgentDotState } from './worktree-card-agent-summary' import { getCompactAgentSecondary } from './worktree-card-compact-agent-row' import { resolveAttention } from './smart-attention' -import { acknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' const NOW = new Date('2026-05-04T12:00:00.000Z').getTime() const TAB_ID = 'tab-1' @@ -74,10 +73,10 @@ describe('a stale entry on a pane Orca still holds', () => { now: NOW }) expect(rows[0].state).not.toBe('done') - expect(getAgentDotState(acknowledgedAgentRow(rows[0], undefined))).not.toBe('done') + expect(getAgentDotState(rows[0])).not.toBe('done') // Nor the working spinner: Orca has no current evidence of work either. - expect(getAgentDotState(acknowledgedAgentRow(rows[0], undefined))).not.toBe('working') - expect(getAgentDotState(acknowledgedAgentRow(rows[0], undefined))).toBe('unverifiable') + expect(getAgentDotState(rows[0])).not.toBe('working') + expect(getAgentDotState(rows[0])).toBe('unverifiable') }) it('reports the observed gap rather than a verdict on the agent', () => { @@ -88,9 +87,7 @@ describe('a stale entry on a pane Orca still holds', () => { ptyIdsByTabId: LIVE, now: NOW }) - expect(getCompactAgentSecondary(acknowledgedAgentRow(rows[0], undefined), NOW)).toBe( - 'No update in 34m' - ) + expect(getCompactAgentSecondary(rows[0], NOW)).toBe('No update in 34m') }) it('measures the gap from the observation clock, not the delivery clock', () => { @@ -104,9 +101,7 @@ describe('a stale entry on a pane Orca still holds', () => { now: NOW }) expect(rows[0].state).toBe('unverifiable') - expect(getCompactAgentSecondary(acknowledgedAgentRow(rows[0], undefined), NOW)).toBe( - 'No update in 34m' - ) + expect(getCompactAgentSecondary(rows[0], NOW)).toBe('No update in 34m') }) }) diff --git a/src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx b/src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx index f5d5afdf17ac..3307d2f9dce8 100644 --- a/src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx +++ b/src/renderer/src/components/sidebar/subagent-child-row-fallback-pixels.test.tsx @@ -9,7 +9,6 @@ import { NativeChatBackgroundTasksStatus } from '@/components/native-chat/Native import { TooltipProvider } from '@/components/ui/tooltip' import { CompactAgentRow } from './worktree-card-compact-agent-row' import { buildSubagentChildRows } from './worktree-subagent-child-rows' -import { acknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' vi.mock('@/components/dashboard/use-agent-row-conversation-name', () => ({ useAgentRowConversationName: () => null @@ -80,11 +79,7 @@ function compactMarkup(parent: AgentStatusEntry, parentIsFresh: boolean): string normalize( renderToStaticMarkup( - {}} - /> + {}} /> ) ) @@ -97,7 +92,7 @@ function fullMarkup(parent: AgentStatusEntry, parentIsFresh: boolean): string[] renderToStaticMarkup( {}} onDismiss={() => {}} diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx b/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx index fda54bc7082e..17ff8d18cd39 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx @@ -20,7 +20,6 @@ type MockState = { runtimeAgentOrchestrationByPaneKey: Record> migrationUnsupportedByPtyId: Record retainedAgentsByPaneKey: Record - acknowledgedAgentsByPaneKey: Record } let mockState: MockState @@ -114,8 +113,7 @@ describe('useWorktreeActivityStatus', () => { agentStatusByPaneKey: {}, runtimeAgentOrchestrationByPaneKey: {}, migrationUnsupportedByPtyId: {}, - retainedAgentsByPaneKey: {}, - acknowledgedAgentsByPaneKey: {} + retainedAgentsByPaneKey: {} } }) diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts index da382cca8e34..c9b5092f76e2 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts @@ -15,8 +15,7 @@ function makeStatusState(): StatusState { agentStatusByPaneKey: {}, migrationUnsupportedByPtyId: {}, retainedAgentsByPaneKey: {}, - runtimeAgentOrchestrationByPaneKey: {}, - acknowledgedAgentsByPaneKey: {} + runtimeAgentOrchestrationByPaneKey: {} } } diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts index 92273a7fae2b..388b1ae7d886 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts @@ -22,7 +22,6 @@ type WorktreeActivityStatusState = Pick< | 'migrationUnsupportedByPtyId' | 'retainedAgentsByPaneKey' | 'runtimeAgentOrchestrationByPaneKey' - | 'acknowledgedAgentsByPaneKey' > export function selectWorktreeActivityStatuses( diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.acknowledgement.test.tsx b/src/renderer/src/components/sidebar/useWorktreeAgentRows.acknowledgement.test.tsx deleted file mode 100644 index 8a6f3678dae5..000000000000 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.acknowledgement.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -// @vitest-environment happy-dom - -import { act, renderHook } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' -import { useAppStore } from '@/store' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import { useWorktreeAgentRows } from './useWorktreeAgentRows' - -const WORKTREE = 'repo::/wt-ack' -const PANE_KEY = makePaneKey('tab-ack', '11111111-1111-4111-8111-111111111111') -const initial = useAppStore.getState() - -afterEach(() => { - act(() => useAppStore.setState(initial, true)) -}) - -describe('useWorktreeAgentRows', () => { - // Joined where the rows are built, so the card, its summary and the notes send menu read one - // seen-ness and keep each row object while nothing about it changes. - it("carries each row's acknowledgement on its entry", () => { - const now = Date.now() - const entry: AgentStatusEntry = { - paneKey: PANE_KEY, - state: 'done', - prompt: 'Run the tests', - updatedAt: now, - stateStartedAt: now, - stateHistory: [], - agentType: 'claude', - mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: now } - } - act(() => - useAppStore.setState({ - tabsByWorktree: { - [WORKTREE]: [ - { - id: 'tab-ack', - ptyId: null, - worktreeId: WORKTREE, - title: 'Claude', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - agentStatusByPaneKey: { [PANE_KEY]: entry }, - acknowledgedAgentsByPaneKey: {} - }) - ) - const { result } = renderHook(() => useWorktreeAgentRows(WORKTREE)) - const unseen = result.current[0] - expect(unseen?.entry.acknowledgedAt).toBe(0) - - act(() => useAppStore.setState({ acknowledgedAgentsByPaneKey: { [PANE_KEY]: now } })) - const seen = result.current[0] - expect(seen?.entry.acknowledgedAt).toBe(now) - expect(seen).not.toBe(unseen) - - act(() => useAppStore.setState({ acknowledgedAgentsByPaneKey: { [PANE_KEY]: now, other: 1 } })) - expect(result.current[0]).toBe(seen) - }) -}) diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index 1f01ffada000..1fd1d8af452f 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -26,8 +26,6 @@ import { } from './worktree-agent-row-selectors' import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from './worktree-agent-orchestration-index' import { EMPTY_TABS } from './WorktreeCardHelpers' -import { selectAcknowledgedAgentTimes } from './worktree-card-agent-ack-inputs' -import { acknowledgedAgentRow, type AcknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' import { createWorktreeAgentFreshnessSelector, EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE @@ -54,7 +52,7 @@ export { * store slice and then shared by every visible card, avoiding O(cards × agents) * selector work on high-frequency agent status pings. */ -export function useWorktreeAgentRows(worktreeId: string, active = true): AcknowledgedAgentRow[] { +export function useWorktreeAgentRows(worktreeId: string, active = true): DashboardAgentRow[] { const selectAgentFreshness = useMemo( () => createWorktreeAgentFreshnessSelector(worktreeId), [worktreeId] @@ -114,7 +112,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Acknowl active ? selectAgentFreshness(s) : EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE ) - const rows = useMemo(() => { + return useMemo(() => { if (!active) { return EMPTY_AGENT_ROWS } @@ -158,11 +156,4 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Acknowl paneForegroundAgentByPaneKey, agentFreshnessSignature ]) - // Why: joined here, once, so every surface built from these rows reads one seen-ness; only this - // card's acknowledgements are projected, so other cards' visits do not re-render it. - const acknowledgedTimes = useAppStore(useShallow((s) => selectAcknowledgedAgentTimes(s, rows))) - return useMemo( - () => rows.map((row, index) => acknowledgedAgentRow(row, acknowledgedTimes[index])), - [rows, acknowledgedTimes] - ) } diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index 5e87b8e8ca0d..90b3274c4094 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -77,7 +77,6 @@ describe('selectWorktreeAgentActivitySummary', () => { agentStatusByPaneKey: { [firstPaneKey]: makeAgentStatusEntry({ paneKey: firstPaneKey, state: 'working' }) }, - acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: { @@ -117,7 +116,6 @@ describe('selectWorktreeAgentActivitySummary', () => { agentStatusByPaneKey: { [paneKey]: entry }, - acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey @@ -156,7 +154,6 @@ describe('selectWorktreeAgentActivitySummary', () => { agentStatusByPaneKey: { [paneKey]: makeAgentStatusEntry({ paneKey, state: 'working' }) }, - acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey @@ -194,7 +191,6 @@ describe('selectWorktreeAgentActivitySummary', () => { workingMode: 'monitoring' }) }, - acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -226,7 +222,6 @@ describe('selectWorktreeAgentActivitySummary', () => { mainAgent: { state: 'done', outcome, stateStartedAt: 1_000 } }) }, - acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -237,110 +232,6 @@ describe('selectWorktreeAgentActivitySummary', () => { expect(summary).toMatchObject(flags) }) - // The card's dot reads red only until the user has seen the chat, by the same acknowledgement - // that un-bolds its agent row; then the crash-cut turn reads done, like a finished one. - it.each([ - ['unseen', 999, { hasFailed: true, hasLiveDone: false }], - ['seen', 1_000, { hasFailed: false, hasLiveDone: true }] - ] as const)('reads a %s crash-cut turn by its acknowledgement', (_label, ackAt, flags) => { - vi.spyOn(Date, 'now').mockReturnValue(2_000) - const paneKey = makePaneKey('tab-1', LEAF_ID) - const failedKey = makePaneKey('tab-2', LEAF_ID) - const state: AgentActivityInput = { - tabsByWorktree: { - 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')], - 'repo::/wt-2': [makeTab('tab-2', 'repo::/wt-2')] - }, - agentStatusEpoch: 2, - agentStatusByPaneKey: { - [paneKey]: makeAgentStatusEntry({ - paneKey, - state: 'done', - mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: 1_000 } - }), - [failedKey]: makeAgentStatusEntry({ - paneKey: failedKey, - state: 'done', - mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 1_000 } - }) - }, - migrationUnsupportedByPtyId: {}, - runtimeAgentOrchestrationByPaneKey: {}, - retainedAgentsByPaneKey: {}, - acknowledgedAgentsByPaneKey: { [paneKey]: ackAt, [failedKey]: ackAt } - } - - expect(selectWorktreeAgentActivitySummary(state, 'repo::/wt-1')).toMatchObject(flags) - // A failure stays news after it is seen. - expect(selectWorktreeAgentActivitySummary(state, 'repo::/wt-2')).toMatchObject({ - hasFailed: true - }) - }) - - it('re-reads the card when only an acknowledgement changes', () => { - vi.spyOn(Date, 'now').mockReturnValue(2_000) - const paneKey = makePaneKey('tab-1', LEAF_ID) - const base = { - tabsByWorktree: { 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] }, - agentStatusEpoch: 4, - agentStatusByPaneKey: { - [paneKey]: makeAgentStatusEntry({ - paneKey, - state: 'done', - mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: 1_000 } - }) - }, - migrationUnsupportedByPtyId: {}, - runtimeAgentOrchestrationByPaneKey: {}, - retainedAgentsByPaneKey: {} - } - - expect( - selectWorktreeAgentActivitySummary( - { ...base, acknowledgedAgentsByPaneKey: {} }, - 'repo::/wt-1' - ) - ).toMatchObject({ hasFailed: true }) - expect( - selectWorktreeAgentActivitySummary( - { ...base, acknowledgedAgentsByPaneKey: { [paneKey]: 1_000 } }, - 'repo::/wt-1' - ) - ).toMatchObject({ hasFailed: false, hasLiveDone: true }) - }) - - it('reads a departed crash-cut agent as done once seen, and failed before', () => { - vi.spyOn(Date, 'now').mockReturnValue(2_000) - const retained = (ackAt: number) => - selectWorktreeAgentActivitySummary( - { - tabsByWorktree: { 'repo::/wt-1': [] }, - agentStatusEpoch: 3, - agentStatusByPaneKey: {}, - migrationUnsupportedByPtyId: {}, - runtimeAgentOrchestrationByPaneKey: {}, - retainedAgentsByPaneKey: { - 'tab-9:0': { - entry: makeAgentStatusEntry({ - paneKey: 'tab-9:0', - state: 'done', - mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: 1_000 } - }), - worktreeId: 'repo::/wt-1', - tab: makeTab('tab-9', 'repo::/wt-1'), - agentType: 'claude', - startedAt: 1_000 - } - }, - acknowledgedAgentsByPaneKey: { 'tab-9:0': ackAt } - }, - 'repo::/wt-1' - ) - - expect(retained(0)).toMatchObject({ hasRetainedFailed: true, hasRetainedDone: false }) - expect(retained(1_500)).toMatchObject({ hasRetainedFailed: false, hasRetainedDone: true }) - }) - it('separates a failed outcome from clean completion and from a cancellation', () => { vi.spyOn(Date, 'now').mockReturnValue(2_000) const paneKey = makePaneKey('tab-1', LEAF_ID) @@ -355,7 +246,6 @@ describe('selectWorktreeAgentActivitySummary', () => { mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 1_000 } }) }, - acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -381,7 +271,6 @@ describe('selectWorktreeAgentActivitySummary', () => { mainAgent: { state: 'done', outcome, stateStartedAt: 1_000 } }) }, - acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -428,7 +317,6 @@ describe('selectWorktreeAgentActivitySummary', () => { tabsByWorktree: { [worktreeId]: [liveTab, retainedTab] }, agentStatusEpoch: epoch++, agentStatusByPaneKey, - acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey @@ -488,7 +376,6 @@ describe('selectWorktreeAgentActivitySummary', () => { restoredUnconfirmed: true }) }, - acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -515,7 +402,6 @@ describe('selectWorktreeAgentActivitySummary', () => { const changedPaneKey = makePaneKey('tab-11', LEAF_ID) const baseInputs = { tabsByWorktree, - acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -559,7 +445,6 @@ describe('selectWorktreeAgentActivitySummary', () => { const secondPaneKey = makePaneKey('tab-2', LEAF_ID) const replacementPaneKey = makePaneKey('tab-3', LEAF_ID) const sharedInputs = { - acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -630,7 +515,6 @@ describe('selectWorktreeAgentActivitySummary', () => { worktreeId: 'repo::/wt-1' }) }, - acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -658,7 +542,6 @@ describe('selectWorktreeAgentActivitySummary', () => { parentPaneKey }) }, - acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -684,7 +567,6 @@ describe('selectWorktreeAgentActivitySummary', () => { worktreeId: 'repo::/wt-1' }) }, - acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: { [childPaneKey]: { @@ -712,7 +594,6 @@ describe('selectWorktreeAgentActivitySummary', () => { tabsByWorktree: { 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] }, agentStatusEpoch: 0, agentStatusByPaneKey: { [paneKey]: entry }, - acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -739,7 +620,6 @@ describe('selectWorktreeAgentActivitySummary', () => { tabsByWorktree: { 'repo::/wt-1': [tab] }, agentStatusEpoch: 0, agentStatusByPaneKey: { [paneKey]: entry }, - acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index 753504906949..795d297344c4 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -12,7 +12,6 @@ import { } from '../../../../shared/agent-status-types' import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict' import { applyAgentPaneActivityFlags } from '@/lib/agent-pane-activity-flags' -import { acknowledgedAgentEntry } from '@/lib/agent-entry-acknowledgement' export type WorktreeAgentActivitySummary = { hasPermission: boolean @@ -57,7 +56,6 @@ export type AgentActivityInput = Pick< | 'agentStatusByPaneKey' | 'migrationUnsupportedByPtyId' | 'retainedAgentsByPaneKey' - | 'acknowledgedAgentsByPaneKey' > & { tabsByWorktree: AgentActivityTabsByWorktree runtimeAgentOrchestrationByPaneKey?: AppState['runtimeAgentOrchestrationByPaneKey'] @@ -68,7 +66,6 @@ type AgentActivityCache = { agentStatusEpoch: number migrationUnsupportedByPtyId: AppState['migrationUnsupportedByPtyId'] retainedAgentsByPaneKey: AppState['retainedAgentsByPaneKey'] - acknowledgedAgentsByPaneKey: AppState['acknowledgedAgentsByPaneKey'] runtimeAgentOrchestrationByPaneKey: AppState['runtimeAgentOrchestrationByPaneKey'] | undefined summaries: Map } @@ -92,7 +89,6 @@ function getWorktreeAgentActivitySummaries( agentActivityCache.agentStatusEpoch === state.agentStatusEpoch && agentActivityCache.migrationUnsupportedByPtyId === state.migrationUnsupportedByPtyId && agentActivityCache.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey && - agentActivityCache.acknowledgedAgentsByPaneKey === state.acknowledgedAgentsByPaneKey && agentActivityCache.runtimeAgentOrchestrationByPaneKey === runtimeAgentOrchestrationByPaneKey ) { return agentActivityCache.summaries @@ -148,10 +144,7 @@ function getWorktreeAgentActivitySummaries( if (entry.state === 'done') { addParentPaneId(summary, orchestration, worktreeId, tabIdToWorktreeId) } - applyAgentPaneActivityFlags( - summary, - acknowledgedAgentEntry(entry, state.acknowledgedAgentsByPaneKey[paneKey]) - ) + applyAgentPaneActivityFlags(summary, entry) } for (const unsupported of Object.values(state.migrationUnsupportedByPtyId ?? {})) { @@ -165,11 +158,7 @@ function getWorktreeAgentActivitySummaries( for (const retained of Object.values(state.retainedAgentsByPaneKey ?? {})) { const summary = summaryForWorktree(retained.worktreeId) // Why: a failed agent is retained so its failure stays visible, not so it reads done. - const entry = acknowledgedAgentEntry( - retained.entry, - state.acknowledgedAgentsByPaneKey[retained.entry.paneKey] - ) - if (agentVerdictDisplayMark(entry) === 'failed') { + if (agentVerdictDisplayMark(retained.entry) === 'failed') { summary.hasRetainedFailed = true } else { summary.hasRetainedDone = true @@ -202,7 +191,6 @@ function getWorktreeAgentActivitySummaries( agentStatusEpoch: state.agentStatusEpoch, migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId, retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, - acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey, runtimeAgentOrchestrationByPaneKey, summaries } diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts index d1590fd9b08b..9f070c355bdd 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts @@ -2,7 +2,7 @@ import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' -import type { AcknowledgedAgentRow as DashboardAgentRowData } from '@/lib/agent-entry-acknowledgement' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { CompactAgentRow, getCompactAgentSecondary } from './worktree-card-compact-agent-row' import { buildSummaryAgentGroups, @@ -10,7 +10,6 @@ import { summarizeAgents } from './worktree-card-agent-summary' import { buildSubagentChildRows } from './worktree-subagent-child-rows' -import { acknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' function monitoringAgent(): DashboardAgentRowData { return { @@ -35,8 +34,7 @@ function monitoringAgent(): DashboardAgentRowData { updatedAt: 1, stateStartedAt: 1, stateHistory: [], - paneKey: 'tab-1:leaf-1', - acknowledgedAt: undefined + paneKey: 'tab-1:leaf-1' } } } @@ -227,50 +225,6 @@ describe('worktree card agent summary', () => { }) expect(getAgentDotState(parent)).toBe('failed') - expect(getAgentDotState(acknowledgedAgentRow(child, undefined))).toBe('working') - }) - - // The row's own acknowledgement, the one that un-bolds it: once visited, the cut-short row reads - // like a finished one, here and in the compact summary; a failure stays failed. - it('reads a cut-short row failed only until the user visits it', () => { - const done = monitoringAgent() - done.state = 'done' - done.entry = { - ...done.entry, - state: 'done', - workingMode: undefined, - lastAssistantMessage: 'Halfway through the loop' - } - const withVerdict = ( - outcome: 'interruption' | 'failure', - acknowledgedAt: number | undefined - ): DashboardAgentRowData => ({ - ...done, - paneKey: `tab-1:${outcome}`, - entry: { - ...done.entry, - mainAgent: { state: 'done', outcome, stateStartedAt: 1 }, - acknowledgedAt - } - }) - - expect(getAgentDotState(withVerdict('interruption', 0))).toBe('failed') - expect(getAgentDotState(withVerdict('interruption', 1))).toBe('done') - expect(getAgentDotState(withVerdict('failure', 1))).toBe('failed') - expect(getCompactAgentSecondary(withVerdict('interruption', 1), 0)).toBe( - 'Halfway through the loop' - ) - expect( - summarizeAgents([withVerdict('interruption', 1), withVerdict('failure', 1)], 'Agents') - ).toBe('Agents: 1 failed, 1 done') - const markup = (acknowledgedAt: number | undefined) => - renderCompactAgentRow({ - agent: withVerdict('interruption', acknowledgedAt), - now: 0, - onActivate: vi.fn() - }) - expect(markup(undefined)).toContain('Failed') - expect(markup(1)).not.toContain('Failed') - expect(markup(1)).toContain('Halfway through the loop') + expect(getAgentDotState(child)).toBe('working') }) }) diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts index 8dbc87c4b4df..072ff7f2ba0d 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts @@ -1,11 +1,11 @@ import type { AgentDotState } from '@/components/AgentStateDot' -import type { AcknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { formatAgentTypeLabel } from '@/lib/agent-status' import { agentRowDisplayDotState } from '@/lib/agent-row-dot-state' export type SummaryAgentGroup = { state: AgentDotState - agents: AcknowledgedAgentRow[] + agents: DashboardAgentRowData[] } const SUMMARY_STATE_ORDER: AgentDotState[] = [ @@ -24,7 +24,7 @@ const SUMMARY_STATE_ORDER: AgentDotState[] = [ 'idle' ] -export function getAgentDotState(agent: AcknowledgedAgentRow): AgentDotState { +export function getAgentDotState(agent: DashboardAgentRowData): AgentDotState { return agentRowDisplayDotState(agent) } @@ -55,8 +55,8 @@ export function formatSummaryStateLabel(state: AgentDotState): string { } } -export function buildSummaryAgentGroups(agents: AcknowledgedAgentRow[]): SummaryAgentGroup[] { - const groups = new Map() +export function buildSummaryAgentGroups(agents: DashboardAgentRowData[]): SummaryAgentGroup[] { + const groups = new Map() for (const agent of agents) { const dotState = getAgentDotState(agent) const group = groups.get(dotState) @@ -72,7 +72,7 @@ export function buildSummaryAgentGroups(agents: AcknowledgedAgentRow[]): Summary }) } -export function summarizeAgents(agents: AcknowledgedAgentRow[], subjectLabel: string): string { +export function summarizeAgents(agents: DashboardAgentRowData[], subjectLabel: string): string { const counts = new Map() for (const agent of agents) { const dotState = getAgentDotState(agent) @@ -95,7 +95,7 @@ export function summarizeAgents(agents: AcknowledgedAgentRow[], subjectLabel: st return `${subjectLabel}: ${parts.join(', ')}` } -export function summarizeAgentIdentities(agents: AcknowledgedAgentRow[]): string { +export function summarizeAgentIdentities(agents: DashboardAgentRowData[]): string { return agents .map((agent) => { const agentLabel = formatAgentTypeLabel(agent.agentType) @@ -106,10 +106,10 @@ export function summarizeAgentIdentities(agents: AcknowledgedAgentRow[]): string } export function selectSummaryGroupIconAgents( - agents: AcknowledgedAgentRow[], + agents: DashboardAgentRowData[], maxCount: number -): AcknowledgedAgentRow[] { - const groups = new Map() +): DashboardAgentRowData[] { + const groups = new Map() agents.forEach((agent, index) => { const key = agent.agentType ?? 'unknown' const group = groups.get(key) @@ -122,7 +122,7 @@ export function selectSummaryGroupIconAgents( const sortedGroups = [...groups.values()].sort( (a, b) => b.agents.length - a.agents.length || a.firstIndex - b.firstIndex ) - const selected: AcknowledgedAgentRow[] = [] + const selected: DashboardAgentRowData[] = [] for (const group of sortedGroups) { if (selected.length >= maxCount) { break diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx index e747f6c8a235..c9510cfef4fa 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.stable-message.test.tsx @@ -2,7 +2,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AcknowledgedAgentRow as DashboardAgentRowData } from '@/lib/agent-entry-acknowledgement' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { TooltipProvider } from '@/components/ui/tooltip' import { CompactAgentRow } from './worktree-card-compact-agent-row' diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index de99970028a5..4f3a34a2b05a 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef } from 'react' import { DashboardAgentChildDisclosure } from '@/components/dashboard/DashboardAgentChildDisclosure' import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' import { AgentChildRowContent } from '@/components/AgentChildRowContent' -import type { AcknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { cn } from '@/lib/utils' @@ -17,7 +17,7 @@ import { formatShortTimeAgo } from '@/lib/short-time-ago' import { agentVerdictStatusLine } from '@/lib/agent-verdict-status-line' function getCompactAgentPrimary( - agent: AcknowledgedAgentRow, + agent: DashboardAgentRowData, conversationName: string | null ): string { const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry) @@ -25,7 +25,7 @@ function getCompactAgentPrimary( } export function getCompactAgentSecondary( - agent: AcknowledgedAgentRow, + agent: DashboardAgentRowData, now: number, lastAssistantMessageOverride?: string ): string { @@ -58,7 +58,7 @@ export function getCompactAgentSecondary( return formatAgentTypeLabel(agent.agentType) } -function getCompactAgentTime(agent: AcknowledgedAgentRow, now: number): string | null { +function getCompactAgentTime(agent: DashboardAgentRowData, now: number): string | null { const doneAt = lastEnteredDoneAt(agent) if (doneAt !== null) { return formatShortTimeAgo(doneAt, now) @@ -68,7 +68,7 @@ function getCompactAgentTime(agent: AcknowledgedAgentRow, now: number): string | } type CompactAgentRowProps = { - agent: AcknowledgedAgentRow + agent: DashboardAgentRowData now: number onActivate: (tabId: string, paneKey: string) => void // Why: send-popover target mode temporarily turns compact sidebar rows into diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx index c7cf8cb026db..e77304a064d7 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useRef } from 'react' import { ChevronDown } from 'lucide-react' import { AgentStateDot } from '@/components/AgentStateDot' -import type { AcknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent } from '@/lib/agent-status' import { cn } from '@/lib/utils' @@ -24,7 +24,7 @@ function stopActivationKeyPropagation(e: React.KeyboardEvent): void { } type CompactAgentSummaryButtonProps = { - agents: AcknowledgedAgentRow[] + agents: DashboardAgentRowData[] subjectLabel: string expanded: boolean onToggle: () => void diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 5467ffb89714..1707f3efee34 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -105,7 +105,6 @@ export default function SortableTab({ tab, agentStatusByPaneKey: s.agentStatusByPaneKey, agentStatusEpoch: s.agentStatusEpoch, - acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey, runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, ptyIdsByTabId: s.ptyIdsByTabId, terminalLayout: s.terminalLayoutsByTabId?.[tab.id] diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index d0eafad90aa0..7a18342bb4ba 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -68,7 +68,6 @@ describe('resolveTerminalTabActivityStatus', () => { }) expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: { id: TAB_ID, title: 'Codex - action required' }, agentStatusByPaneKey: { [done.paneKey]: done }, ptyIdsByTabId: LIVE_PTY @@ -80,7 +79,6 @@ describe('resolveTerminalTabActivityStatus', () => { const working = entry(FIRST_LEAF_ID, 'working') expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [working.paneKey]: working }, ptyIdsByTabId: LIVE_PTY @@ -97,7 +95,6 @@ describe('resolveTerminalTabActivityStatus', () => { }) expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: { id: TAB_ID, title: '✋ Gemini CLI' }, agentStatusByPaneKey: { [stale.paneKey]: stale }, ptyIdsByTabId: LIVE_PTY, @@ -112,7 +109,6 @@ describe('resolveTerminalTabActivityStatus', () => { const working = entry(SECOND_LEAF_ID, 'working') expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [monitoring.paneKey]: monitoring }, ptyIdsByTabId: LIVE_PTY @@ -120,7 +116,6 @@ describe('resolveTerminalTabActivityStatus', () => { ).toBe('monitoring') expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [monitoring.paneKey]: monitoring, @@ -136,7 +131,6 @@ describe('resolveTerminalTabActivityStatus', () => { const waiting = entry(SECOND_LEAF_ID, 'waiting') expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [working.paneKey]: working, @@ -151,7 +145,6 @@ describe('resolveTerminalTabActivityStatus', () => { const done = entry(FIRST_LEAF_ID, 'done') expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [done.paneKey]: done }, ptyIdsByTabId: LIVE_PTY @@ -172,7 +165,6 @@ describe('resolveTerminalTabActivityStatus', () => { }) expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [ended.paneKey]: ended }, ptyIdsByTabId: LIVE_PTY @@ -180,38 +172,10 @@ describe('resolveTerminalTabActivityStatus', () => { ).toBe(status) }) - // The chat's own tab agrees with the sidebar beside it: a cut-short turn is news only until seen. - it.each([ - ['interruption', 'failed', 'done'], - ['failure', 'failed', 'failed'], - ['cancellation', 'interrupted', 'interrupted'], - ['superseded', 'interrupted', 'interrupted'], - ['unconfirmed', 'unconfirmed', 'unconfirmed'] - ] as const)( - 'reads a %s done as %s before the user sees it and %s after', - (outcome, unseen, seen) => { - const ended = entry(FIRST_LEAF_ID, 'done', { - mainAgent: { state: 'done', outcome, stateStartedAt: NOW } - }) - // One status map throughout: an acknowledgement alone must re-read the tab. - const agentStatusByPaneKey = { [ended.paneKey]: ended } - const statusWith = (acknowledgedAt: number) => - resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: { [ended.paneKey]: acknowledgedAt }, - tab: TAB, - agentStatusByPaneKey, - ptyIdsByTabId: LIVE_PTY - }) - expect(statusWith(NOW - 1)).toBe(unseen) - expect(statusWith(NOW)).toBe(seen) - } - ) - it("reads an old host's user-stop flag as interrupted", () => { const stopped = entry(FIRST_LEAF_ID, 'done', { interrupted: true }) expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [stopped.paneKey]: stopped }, ptyIdsByTabId: LIVE_PTY @@ -226,7 +190,6 @@ describe('resolveTerminalTabActivityStatus', () => { const finished = entry(SECOND_LEAF_ID, 'done') expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [failed.paneKey]: failed, [finished.paneKey]: finished }, ptyIdsByTabId: LIVE_PTY @@ -240,7 +203,6 @@ describe('resolveTerminalTabActivityStatus', () => { mainAgent: { state: 'done', outcome, stateStartedAt: NOW } }) return resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [held.paneKey]: held }, ptyIdsByTabId: LIVE_PTY @@ -261,7 +223,6 @@ describe('resolveTerminalTabActivityStatus', () => { const finished = entry(SECOND_LEAF_ID, 'done') expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [interrupted.paneKey]: interrupted, @@ -277,7 +238,6 @@ describe('resolveTerminalTabActivityStatus', () => { vi.setSystemTime(31 * 60 * 1000) expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: { id: TAB_ID, title: 'Codex working' }, agentStatusByPaneKey: { [stale.paneKey]: stale }, ptyIdsByTabId: LIVE_PTY @@ -289,7 +249,6 @@ describe('resolveTerminalTabActivityStatus', () => { const restored = entry(FIRST_LEAF_ID, 'working', { restoredUnconfirmed: true }) expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: { id: TAB_ID, title: 'Codex working' }, agentStatusByPaneKey: { [restored.paneKey]: restored }, ptyIdsByTabId: LIVE_PTY @@ -301,7 +260,6 @@ describe('resolveTerminalTabActivityStatus', () => { const restored = entry(FIRST_LEAF_ID, 'working', { restoredUnconfirmed: true }) expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [restored.paneKey]: restored }, runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex working', 2: 'Claude working' } }, @@ -328,7 +286,6 @@ describe('resolveTerminalTabActivityStatus', () => { const agentStatusByPaneKey = { [working.paneKey]: working } expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey, agentStatusEpoch: 0, @@ -340,7 +297,6 @@ describe('resolveTerminalTabActivityStatus', () => { // Same map reference, bumped epoch — the entry is now stale. expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey, agentStatusEpoch: 1, @@ -352,7 +308,6 @@ describe('resolveTerminalTabActivityStatus', () => { it('does not treat a preserved title on a sleeping tab as activity', () => { expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: { id: TAB_ID, title: 'Codex working' }, runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex working' } }, ptyIdsByTabId: { [TAB_ID]: [] } @@ -364,7 +319,6 @@ describe('resolveTerminalTabActivityStatus', () => { const blocked = entry(FIRST_LEAF_ID, 'blocked') expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [blocked.paneKey]: blocked }, ptyIdsByTabId: LIVE_PTY @@ -376,7 +330,6 @@ describe('resolveTerminalTabActivityStatus', () => { const working = entry(FIRST_LEAF_ID, 'working', { paneKey: `${TAB_ID}:3` }) expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [working.paneKey]: working }, ptyIdsByTabId: LIVE_PTY @@ -387,7 +340,6 @@ describe('resolveTerminalTabActivityStatus', () => { it('reports a live shell with no agent as active (no activity glyph)', () => { expect( resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: { id: TAB_ID, title: 'zsh' }, ptyIdsByTabId: LIVE_PTY }) diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts index 809485e4b8d5..9c92d90c884d 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -14,7 +14,6 @@ import { type AgentPaneActivityFlags } from '@/lib/agent-pane-activity-flags' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' -import { acknowledgedAgentEntry } from '@/lib/agent-entry-acknowledgement' // Why: a terminal tab is a container of panes, exactly like a worktree card is // a container of tabs. Reuse the WorktreeCard status vocabulary and resolver so @@ -32,7 +31,6 @@ type TerminalTabActivityFlags = AgentPaneActivityFlags & { type FlagsCache = { agentStatusByPaneKey: Record | undefined agentStatusEpoch: number | undefined - acknowledgedAgentsByPaneKey: Record flagsByTabId: Map } @@ -44,8 +42,7 @@ let flagsCache: FlagsCache | null = null function getTerminalTabActivityFlags( agentStatusByPaneKey: Record | undefined, - agentStatusEpoch: number | undefined, - acknowledgedAgentsByPaneKey: Record + agentStatusEpoch: number | undefined ): Map { // Why: freshness is time-based, so the store bumps agentStatusEpoch without // replacing the map at the 30m stale boundary (createFreshnessScheduler). @@ -55,8 +52,7 @@ function getTerminalTabActivityFlags( if ( flagsCache && flagsCache.agentStatusByPaneKey === agentStatusByPaneKey && - flagsCache.agentStatusEpoch === agentStatusEpoch && - flagsCache.acknowledgedAgentsByPaneKey === acknowledgedAgentsByPaneKey + flagsCache.agentStatusEpoch === agentStatusEpoch ) { return flagsCache.flagsByTabId } @@ -85,13 +81,10 @@ function getTerminalTabActivityFlags( const flags = getOrCreateTerminalTabActivityFlags(flagsByTabId, identity.tabId) flags.paneIds.add(identity.paneId) - applyAgentPaneActivityFlags( - flags, - acknowledgedAgentEntry(entry, acknowledgedAgentsByPaneKey[entry.paneKey || paneKey]) - ) + applyAgentPaneActivityFlags(flags, entry) } - flagsCache = { agentStatusByPaneKey, agentStatusEpoch, acknowledgedAgentsByPaneKey, flagsByTabId } + flagsCache = { agentStatusByPaneKey, agentStatusEpoch, flagsByTabId } return flagsByTabId } @@ -140,8 +133,6 @@ type TerminalTabActivityInput = { // Why: the store bumps this at the 30m stale boundary without replacing the // pane-status map; it is the flag cache's invalidation key (see above). agentStatusEpoch?: number - /** The user's acknowledgements: a turn cut short reads failed only until seen, as on the card. */ - acknowledgedAgentsByPaneKey: Record runtimePaneTitlesByTabId?: Record> ptyIdsByTabId?: Record terminalLayout?: TerminalLayoutSnapshot @@ -157,16 +148,11 @@ export function resolveTerminalTabActivityStatus({ tab, agentStatusByPaneKey, agentStatusEpoch, - acknowledgedAgentsByPaneKey, runtimePaneTitlesByTabId, ptyIdsByTabId, terminalLayout }: TerminalTabActivityInput): TerminalTabActivityStatus { - const flags = getTerminalTabActivityFlags( - agentStatusByPaneKey, - agentStatusEpoch, - acknowledgedAgentsByPaneKey - ).get(tab.id) + const flags = getTerminalTabActivityFlags(agentStatusByPaneKey, agentStatusEpoch).get(tab.id) return resolveWorktreeStatus({ tabs: [tab], browserTabs: [], diff --git a/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts index 2bb8cc0021c6..caa14841166d 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts @@ -16,7 +16,6 @@ describe('#9040 terminal tab dot attributes spinner titles to the launched agent it('reports working for a Claude spinner title on a live tab', () => { const status = resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: { id: 'tab-1', title: '⠋ implementing the feature', @@ -31,7 +30,6 @@ describe('#9040 terminal tab dot attributes spinner titles to the launched agent // Control: the named-provider path this must stay at parity with. it('reports working for a named-provider title', () => { const status = resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: { id: 'tab-1', title: 'claude [working]' } as TerminalTab, ptyIdsByTabId: { 'tab-1': ['pty-0'] } }) @@ -41,7 +39,6 @@ describe('#9040 terminal tab dot attributes spinner titles to the launched agent it('stays out of working for a spinner title with no launch identity', () => { const status = resolveTerminalTabActivityStatus({ - acknowledgedAgentsByPaneKey: {}, tab: { id: 'tab-1', title: '⠐ Review branch for regressions' } as TerminalTab, ptyIdsByTabId: { 'tab-1': ['pty-0'] } }) diff --git a/src/renderer/src/components/use-worktree-jump-palette-recent-tabs.ts b/src/renderer/src/components/use-worktree-jump-palette-recent-tabs.ts index b861cc1aba7d..129114ab4d11 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-recent-tabs.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-recent-tabs.ts @@ -1,8 +1,10 @@ import { useLayoutEffect, useMemo, useRef, useState } from 'react' -import { buildExplicitEntriesByTabId } from '@/components/sidebar/smart-attention' +import { + buildExplicitEntriesByTabId, + type TabPaneInputSources +} from '@/components/sidebar/smart-attention' import { orderRecentWorkspaceTabs, - type RecentWorkspaceTabPaneSources, type RecentWorkspaceTabRow } from '@/lib/recent-workspace-tab-rows' import type { TerminalTab } from '../../../shared/terminal-tab-types' @@ -43,7 +45,6 @@ const EMPTY_RECENT_TAB_SNAPSHOT: RecentTabOrderSnapshot = { export function useWorktreeJumpPaletteRecentTabs({ tabsByWorktree, agentStatusByPaneKey, - acknowledgedAgentsByPaneKey, migrationUnsupportedByPtyId, ptyIdsByTabId, runtimePaneTitlesByTabId, @@ -98,7 +99,7 @@ export function useWorktreeJumpPaletteRecentTabs({ } return byWorktree }, [tabsByWorktree]) - const recentTabPaneSources = useMemo( + const recentTabPaneSources = useMemo( () => ({ entriesByTabId: buildExplicitEntriesByTabId( agentStatusByPaneKey, @@ -106,11 +107,9 @@ export function useWorktreeJumpPaletteRecentTabs({ ), ptyIdsByTabId, runtimePaneTitlesByTabId, - terminalLayoutsByTabId, - acknowledgedAgentsByPaneKey + terminalLayoutsByTabId }), [ - acknowledgedAgentsByPaneKey, agentStatusByPaneKey, migrationUnsupportedByPtyId, ptyIdsByTabId, diff --git a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts index 27e73a754e46..21b0bc67f42c 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts @@ -72,8 +72,7 @@ export function useWorktreeJumpPaletteStoreState({ agentStatusByPaneKey, runtimePaneTitlesByTabId, unreadTerminalTabs, - unreadAgentCompletionPanes, - acknowledgedAgentsByPaneKey + unreadAgentCompletionPanes } = paletteIndexStatus const openFiles = useAppStore((state) => state.openFiles) const activeGroupIdByWorktree = useAppStore((state) => state.activeGroupIdByWorktree) @@ -160,7 +159,6 @@ export function useWorktreeJumpPaletteStoreState({ runtimePaneTitlesByTabId, unreadTerminalTabs, unreadAgentCompletionPanes, - acknowledgedAgentsByPaneKey, openFiles, activeGroupIdByWorktree, groupsByWorktree, diff --git a/src/renderer/src/components/worktree-jump-palette-recent-inclusion.test.ts b/src/renderer/src/components/worktree-jump-palette-recent-inclusion.test.ts index f95ffc32afdf..654324782323 100644 --- a/src/renderer/src/components/worktree-jump-palette-recent-inclusion.test.ts +++ b/src/renderer/src/components/worktree-jump-palette-recent-inclusion.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { WorkspaceTabPaletteSearchResult } from '@/lib/workspace-tab-palette-search' -import type { RecentWorkspaceTabPaneSources } from '@/lib/recent-workspace-tab-rows' +import type { TabPaneInputSources } from '@/components/sidebar/smart-attention' import type { AgentJournalTurnOutcome } from '../../../shared/agent-turn-outcome' import type { AgentStatusEntry } from '../../../shared/agent-status-types' import { shouldIncludeOpenTabInRecentSection } from './worktree-jump-palette-recent-inclusion' @@ -41,11 +41,10 @@ function currentTabResult(): WorkspaceTabPaletteSearchResult { } function includesCurrentTabWith(entry: AgentStatusEntry): boolean { - const paneSources: RecentWorkspaceTabPaneSources = { + const paneSources: TabPaneInputSources = { entriesByTabId: new Map([[TAB_ID, [entry]]]), ptyIdsByTabId: {}, - runtimePaneTitlesByTabId: {}, - acknowledgedAgentsByPaneKey: {} + runtimePaneTitlesByTabId: {} } return shouldIncludeOpenTabInRecentSection({ item: { id: 'item-1', type: 'workspace-tab', result: currentTabResult() }, diff --git a/src/renderer/src/components/worktree-jump-palette-recent-inclusion.ts b/src/renderer/src/components/worktree-jump-palette-recent-inclusion.ts index f654ae5777f6..dfaed9635e5c 100644 --- a/src/renderer/src/components/worktree-jump-palette-recent-inclusion.ts +++ b/src/renderer/src/components/worktree-jump-palette-recent-inclusion.ts @@ -3,10 +3,8 @@ import { resolveTerminalTabAttentionBadge, terminalTabHasUnreadActivity } from '@/components/tab-bar/terminal-tab-activity-status' -import { - resolveRecentWorkspaceTabStatus, - type RecentWorkspaceTabPaneSources -} from '@/lib/recent-workspace-tab-rows' +import { resolveRecentWorkspaceTabStatus } from '@/lib/recent-workspace-tab-rows' +import type { TabPaneInputSources } from '@/components/sidebar/smart-attention' import type { OpenTabPaletteItem, OpenTabRecentRow } from './worktree-jump-palette-model' import type { Worktree } from '../../../shared/worktree/types' @@ -26,7 +24,7 @@ export function shouldIncludeOpenTabInRecentSection({ item: OpenTabPaletteItem worktree: Worktree row: OpenTabRecentRow['row'] - paneSources: RecentWorkspaceTabPaneSources + paneSources: TabPaneInputSources unreadTerminalTabs: Record unreadAgentCompletionPanes: Record now: number diff --git a/src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts b/src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts index 9b2293c51c30..67685c941648 100644 --- a/src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts +++ b/src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts @@ -14,8 +14,7 @@ const BASE: PaletteStatusInputsState = { terminalLayoutsByTabId: {}, tabsByWorktree: {}, unreadTerminalTabs: {}, - unreadAgentCompletionPanes: {}, - acknowledgedAgentsByPaneKey: {} + unreadAgentCompletionPanes: {} } describe('selectPaletteStatusInputs', () => { diff --git a/src/renderer/src/components/worktree-jump-palette-status-inputs.ts b/src/renderer/src/components/worktree-jump-palette-status-inputs.ts index d4977f1bda32..24e915081c16 100644 --- a/src/renderer/src/components/worktree-jump-palette-status-inputs.ts +++ b/src/renderer/src/components/worktree-jump-palette-status-inputs.ts @@ -9,7 +9,6 @@ export type PaletteStatusInputsState = Pick< | 'tabsByWorktree' | 'unreadTerminalTabs' | 'unreadAgentCompletionPanes' - | 'acknowledgedAgentsByPaneKey' > export type PaletteStatusInputs = Pick< @@ -24,15 +23,13 @@ export type PaletteIndexStatusSnapshot = Pick< | 'runtimePaneTitlesByTabId' | 'unreadTerminalTabs' | 'unreadAgentCompletionPanes' - | 'acknowledgedAgentsByPaneKey' > const EMPTY_PALETTE_INDEX_STATUS: PaletteIndexStatusSnapshot = Object.freeze({ agentStatusByPaneKey: {}, runtimePaneTitlesByTabId: {}, unreadTerminalTabs: {}, - unreadAgentCompletionPanes: {}, - acknowledgedAgentsByPaneKey: {} + unreadAgentCompletionPanes: {} }) /** @@ -52,8 +49,7 @@ export function selectPaletteIndexStatusSnapshot( agentStatusByPaneKey: s.agentStatusByPaneKey, runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, unreadTerminalTabs: s.unreadTerminalTabs, - unreadAgentCompletionPanes: s.unreadAgentCompletionPanes, - acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey + unreadAgentCompletionPanes: s.unreadAgentCompletionPanes } } diff --git a/src/renderer/src/lib/activity-thread-display.test.ts b/src/renderer/src/lib/activity-thread-display.test.ts index 9ad4e26bb941..0f491dca0d54 100644 --- a/src/renderer/src/lib/activity-thread-display.test.ts +++ b/src/renderer/src/lib/activity-thread-display.test.ts @@ -164,8 +164,6 @@ describe('getActivityThreadStatusPreview', () => { it('shows tool activity while working and assistant replies otherwise', () => { expect( getActivityThreadStatusPreview({ - stateStartedAt: 1, - acknowledgedAt: undefined, state: 'working', toolName: 'Bash', toolInput: 'pnpm test', @@ -176,8 +174,6 @@ describe('getActivityThreadStatusPreview', () => { expect( getActivityThreadStatusPreview( { - stateStartedAt: 1, - acknowledgedAt: undefined, state: 'done', prompt: 'yes', lastAssistantMessage: 'Implemented the skill creator port.' @@ -190,8 +186,6 @@ describe('getActivityThreadStatusPreview', () => { it('rejects hook previews that echo the live user prompt', () => { expect( getActivityThreadStatusPreview({ - stateStartedAt: 1, - acknowledgedAt: undefined, state: 'working', prompt: 'yes', lastAssistantMessage: 'yes' @@ -202,8 +196,6 @@ describe('getActivityThreadStatusPreview', () => { it('surfaces interrupted sessions explicitly', () => { expect( getActivityThreadStatusPreview({ - stateStartedAt: 1, - acknowledgedAt: undefined, state: 'done', interrupted: true, prompt: 'Ship it' @@ -214,8 +206,6 @@ describe('getActivityThreadStatusPreview', () => { it('surfaces a failed turn as failed, not as its last reply', () => { expect( getActivityThreadStatusPreview({ - stateStartedAt: 1, - acknowledgedAt: undefined, state: 'done', lastAssistantMessage: 'Done!', mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 1 }, @@ -229,8 +219,6 @@ describe('getActivityThreadStatusPreview', () => { // only thing that says what the user is being asked to approve (STA-3160). expect( getActivityThreadStatusPreview({ - stateStartedAt: 1, - acknowledgedAt: undefined, state: 'waiting', toolName: 'bash', toolInput: 'rm -rf build/', @@ -245,8 +233,6 @@ describe('getActivityThreadStatusPreview', () => { for (const state of ['done', 'blocked'] as const) { expect( getActivityThreadStatusPreview({ - stateStartedAt: 1, - acknowledgedAt: undefined, state, toolName: 'bash', toolInput: 'rm -rf build/', @@ -260,8 +246,6 @@ describe('getActivityThreadStatusPreview', () => { // Why: not every wait is an approval — a question-style wait must not borrow tool text. expect( getActivityThreadStatusPreview({ - stateStartedAt: 1, - acknowledgedAt: undefined, state: 'waiting', prompt: 'Pick a branch' }) @@ -271,8 +255,6 @@ describe('getActivityThreadStatusPreview', () => { it('surfaces the completed-turn reply on a finished row', () => { expect( getActivityThreadStatusPreview({ - stateStartedAt: 1, - acknowledgedAt: undefined, state: 'done', prompt: 'Audit the repo', lastCompletedAssistantMessage: 'Filed 8 issues from the audit.' @@ -283,8 +265,6 @@ describe('getActivityThreadStatusPreview', () => { it('does not show a prior completed reply while the agent is working', () => { expect( getActivityThreadStatusPreview({ - stateStartedAt: 1, - acknowledgedAt: undefined, state: 'working', prompt: 'Next turn', lastCompletedAssistantMessage: 'Filed 8 issues from the audit.' @@ -295,8 +275,6 @@ describe('getActivityThreadStatusPreview', () => { it('skips orchestration worker_done wrap-up so the card shows the reply', () => { expect( getActivityThreadStatusPreview({ - stateStartedAt: 1, - acknowledgedAt: undefined, state: 'done', prompt: 'On the m4air environment, use the terminal', lastAssistantMessage: @@ -308,8 +286,6 @@ describe('getActivityThreadStatusPreview', () => { it('unwraps worker_done wrap-up that uses ascii dashes or markdown verdict labels', () => { expect( getActivityThreadStatusPreview({ - stateStartedAt: 1, - acknowledgedAt: undefined, state: 'done', prompt: 'On the m4air environment, use the terminal', lastAssistantMessage: @@ -321,8 +297,6 @@ describe('getActivityThreadStatusPreview', () => { it('drops a worker_done report that has no assistant reply', () => { expect( getActivityThreadStatusPreview({ - stateStartedAt: 1, - acknowledgedAt: undefined, state: 'done', prompt: 'Fix checkout', lastAssistantMessage: 'Done — worker_done sent with outcome succeeded.' @@ -333,8 +307,6 @@ describe('getActivityThreadStatusPreview', () => { it('falls through to the completed-turn reply when the live preview is only worker_done', () => { expect( getActivityThreadStatusPreview({ - stateStartedAt: 1, - acknowledgedAt: undefined, state: 'done', prompt: 'Fix checkout', lastAssistantMessage: 'Done — worker_done sent with outcome succeeded.', @@ -349,8 +321,6 @@ describe('resolveActivityThreadStatusPreview', () => { expect( resolveActivityThreadStatusPreview( { - stateStartedAt: 1, - acknowledgedAt: undefined, state: 'working', prompt: 'yes', lastAssistantMessage: 'yes' @@ -365,8 +335,6 @@ describe('resolveActivityThreadStatusPreview', () => { expect( resolveActivityThreadStatusPreview( { - stateStartedAt: 1, - acknowledgedAt: undefined, state: 'done', prompt: 'hi', lastAssistantMessage: '' diff --git a/src/renderer/src/lib/activity-thread-display.ts b/src/renderer/src/lib/activity-thread-display.ts index 17ac93c0df19..8177372e102a 100644 --- a/src/renderer/src/lib/activity-thread-display.ts +++ b/src/renderer/src/lib/activity-thread-display.ts @@ -12,7 +12,6 @@ import { } from './agent-row-primary-text' import { formatAgentToolPreview } from './agent-row-tool-preview' import { agentVerdictStatusLine } from './agent-verdict-status-line' -import type { AgentTurnAcknowledgement } from '../../../shared/agent-turn-acknowledgement' // Why: follow-up replies ("yes", "ok proceed") are valid hook prompts but are // terrible scan labels for a cross-worktree agent list — treat them as non-titles. @@ -198,8 +197,7 @@ export function getActivityThreadStatusPreview( | 'interrupted' | 'mainAgent' | 'prompt' - > & - AgentTurnAcknowledgement, + >, agentState?: AgentStatusState | null ): string { const verdictLine = agentVerdictStatusLine(entry) @@ -235,8 +233,7 @@ export function resolveActivityThreadStatusPreview( | 'interrupted' | 'mainAgent' | 'prompt' - > & - AgentTurnAcknowledgement, + >, agentState: AgentStatusState | null | undefined, previousPreview?: string ): string { diff --git a/src/renderer/src/lib/agent-entry-acknowledgement.test.ts b/src/renderer/src/lib/agent-entry-acknowledgement.test.ts deleted file mode 100644 index 20d9e43d9ef5..000000000000 --- a/src/renderer/src/lib/agent-entry-acknowledgement.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' -import type { AgentStatusEntry } from '../../../shared/agent-status-types' -import { acknowledgedAgentEntry, acknowledgedAgentRow } from './agent-entry-acknowledgement' - -const entry: AgentStatusEntry = { - paneKey: 'tab-1:leaf-1', - state: 'done', - prompt: 'Run the tests', - updatedAt: 2_000, - stateStartedAt: 2_000, - stateHistory: [] -} - -const row: DashboardAgentRow = { - paneKey: 'tab-1:leaf-1', - entry, - tab: { - id: 'tab-1', - ptyId: null, - worktreeId: 'wt-1', - title: 'Claude', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - }, - agentType: 'claude', - state: 'done', - startedAt: 1_000 -} - -describe('acknowledgedAgentEntry', () => { - it('carries the acknowledgement beside the entry', () => { - expect(acknowledgedAgentEntry(entry, 1_500)).toEqual({ ...entry, acknowledgedAt: 1_500 }) - expect(acknowledgedAgentEntry(entry, undefined)).toEqual({ - ...entry, - acknowledgedAt: undefined - }) - }) - - // Memoized rows and selectors compare by identity, so a rebuild must not hand them a new object. - it('keeps one object until the entry or its acknowledgement changes', () => { - const first = acknowledgedAgentEntry(entry, 1_500) - expect(acknowledgedAgentEntry(entry, 1_500)).toBe(first) - expect(acknowledgedAgentEntry(entry, 2_000)).not.toBe(first) - expect(acknowledgedAgentEntry({ ...entry }, 2_000)).not.toBe(first) - }) -}) - -describe('acknowledgedAgentRow', () => { - it('joins the row entry, keeping one object per row and acknowledgement', () => { - const first = acknowledgedAgentRow(row, 2_000) - expect(first.entry.acknowledgedAt).toBe(2_000) - expect(first.paneKey).toBe(row.paneKey) - expect(acknowledgedAgentRow(row, 2_000)).toBe(first) - expect(acknowledgedAgentRow(row, undefined).entry.acknowledgedAt).toBeUndefined() - }) -}) diff --git a/src/renderer/src/lib/agent-entry-acknowledgement.ts b/src/renderer/src/lib/agent-entry-acknowledgement.ts deleted file mode 100644 index 8d85b42ef654..000000000000 --- a/src/renderer/src/lib/agent-entry-acknowledgement.ts +++ /dev/null @@ -1,43 +0,0 @@ -// An agent's status joined with the user's acknowledgement of it, read from the store's one record -// (`acknowledgedAgentsByPaneKey`) where a surface builds its rows, so every display of the verdict -// reads the same seen-ness and none can leave it out. - -import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' -import type { AgentStatusEntry } from '../../../shared/agent-status-types' -import type { AgentTurnAcknowledgement } from '../../../shared/agent-turn-acknowledgement' - -export type AcknowledgedAgentStatusEntry = AgentStatusEntry & AgentTurnAcknowledgement - -/** A row whose entry carries its acknowledgement. */ -export type AcknowledgedAgentRow = DashboardAgentRow & { entry: AcknowledgedAgentStatusEntry } - -// Why: a joined value keeps its identity until its entry or acknowledgement changes, so memoized -// rows and selectors do not re-render on every rebuild. -const joinedEntries = new WeakMap() -const joinedRows = new WeakMap() - -export function acknowledgedAgentEntry( - entry: AgentStatusEntry, - acknowledgedAt: number | undefined -): AcknowledgedAgentStatusEntry { - const cached = joinedEntries.get(entry) - if (cached && cached.acknowledgedAt === acknowledgedAt) { - return cached - } - const joined = { ...entry, acknowledgedAt } - joinedEntries.set(entry, joined) - return joined -} - -export function acknowledgedAgentRow( - row: DashboardAgentRow, - acknowledgedAt: number | undefined -): AcknowledgedAgentRow { - const cached = joinedRows.get(row) - if (cached && cached.entry.acknowledgedAt === acknowledgedAt) { - return cached - } - const joined = { ...row, entry: acknowledgedAgentEntry(row.entry, acknowledgedAt) } - joinedRows.set(row, joined) - return joined -} diff --git a/src/renderer/src/lib/agent-pane-activity-flags.ts b/src/renderer/src/lib/agent-pane-activity-flags.ts index 92aa72a6f076..ad70621bb4d9 100644 --- a/src/renderer/src/lib/agent-pane-activity-flags.ts +++ b/src/renderer/src/lib/agent-pane-activity-flags.ts @@ -1,6 +1,5 @@ import type { AgentStatusEntry } from '../../../shared/agent-status-types' import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' -import type { AgentTurnAcknowledgement } from '../../../shared/agent-turn-acknowledgement' /** What fresh agent panes contribute to a container's status (worktree card, terminal tab). */ export type AgentPaneActivityFlags = { @@ -16,8 +15,7 @@ export type AgentPaneActivityFlags = { /** Fold one fresh pane's entry into its container's flags; `resolveWorktreeStatus` ranks them. */ export function applyAgentPaneActivityFlags( flags: AgentPaneActivityFlags, - entry: Pick & - AgentTurnAcknowledgement + entry: Pick ): void { const mark = agentVerdictDisplayMark(entry) if (entry.state === 'blocked' || entry.state === 'waiting') { diff --git a/src/renderer/src/lib/agent-row-dot-state.ts b/src/renderer/src/lib/agent-row-dot-state.ts index 064d975a3fd3..bf2b7b1e5320 100644 --- a/src/renderer/src/lib/agent-row-dot-state.ts +++ b/src/renderer/src/lib/agent-row-dot-state.ts @@ -2,7 +2,6 @@ import type { AgentDotState } from '@/components/AgentStateDot' import type { AgentChildRowModel } from '../../../shared/agent-child-row-model' import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' import type { AgentStatusEntry, AgentWorkingMode } from '../../../shared/agent-status-types' -import type { AgentTurnAcknowledgement } from '../../../shared/agent-turn-acknowledgement' import type { AgentRowState } from './agent-row-decay-state' /** @@ -32,8 +31,7 @@ export function agentRowDotState( */ export function agentRowDisplayDotState(agent: { state: AgentRowState - entry: Pick & - AgentTurnAcknowledgement + entry: Pick childRow?: Pick }): AgentDotState { if (agent.childRow) { diff --git a/src/renderer/src/lib/agent-verdict-status-line.ts b/src/renderer/src/lib/agent-verdict-status-line.ts index 21e5e3a5d144..2c7aecefd4a5 100644 --- a/src/renderer/src/lib/agent-verdict-status-line.ts +++ b/src/renderer/src/lib/agent-verdict-status-line.ts @@ -1,13 +1,13 @@ import { agentMainAgentVerdict, agentVerdictDisplayMark, - type AgentVerdictDisplaySource + type AgentMainAgentVerdictSource } from '../../../shared/agent-main-agent-verdict' /** The line an agent row shows in place of its preview once its verdict marks it. A user's Stop * says so; a turn a newer request replaced reads plainly interrupted, since no one is named; a - * turn cut short with nobody asking reads failed, as a failure does, until the user has seen it. */ -export function agentVerdictStatusLine(entry: AgentVerdictDisplaySource): string | null { + * turn cut off by a crash reads failed, as a failure does. */ +export function agentVerdictStatusLine(entry: AgentMainAgentVerdictSource): string | null { switch (agentVerdictDisplayMark(entry)) { case 'failed': return 'Failed' diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts index b40bebf10e9f..f9c091901047 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest' import { orderRecentWorkspaceTabs, resolveRecentWorkspaceTabStatus, - type RecentWorkspaceTabPaneSources, type RecentWorkspaceTabRow } from './recent-workspace-tab-rows' +import type { TabPaneInputSources } from '@/components/sidebar/smart-attention' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry, @@ -44,8 +44,8 @@ function row(id: string, overrides: Partial = {}): Recent function sources( entries: AgentStatusEntry[], - overrides: Partial = {} -): RecentWorkspaceTabPaneSources { + overrides: Partial = {} +): TabPaneInputSources { const entriesByTabId = new Map() for (const item of entries) { const tabId = item.paneKey.split(':')[0] @@ -56,7 +56,6 @@ function sources( ptyIdsByTabId: {}, runtimePaneTitlesByTabId: {}, terminalLayoutsByTabId: {}, - acknowledgedAgentsByPaneKey: {}, ...overrides } } @@ -167,39 +166,6 @@ describe('resolveRecentWorkspaceTabStatus', () => { expect(resolveRecentWorkspaceTabStatus(row('cut'), sources([cut]), NOW)).toBe('failed') }) - it('reads a cut-short turn the user has seen as done, as the sidebar does; a failure stays failed', () => { - const at = NOW - 1_000 - const cut = entry('cut', 'done', at, { - mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: at } - }) - const failed = entry('failed', 'done', at, { - mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: at } - }) - const seen = { [cut.paneKey]: at, [failed.paneKey]: at } - - expect( - resolveRecentWorkspaceTabStatus( - row('cut'), - sources([cut], { acknowledgedAgentsByPaneKey: seen }), - NOW - ) - ).toBe('done') - expect( - resolveRecentWorkspaceTabStatus( - row('cut'), - sources([cut], { acknowledgedAgentsByPaneKey: { [cut.paneKey]: at - 1 } }), - NOW - ) - ).toBe('failed') - expect( - resolveRecentWorkspaceTabStatus( - row('failed'), - sources([failed], { acknowledgedAgentsByPaneKey: seen }), - NOW - ) - ).toBe('failed') - }) - it("reads a user's Stop as interrupted though attention demotes it, whether recorded or an old host's flag", () => { const recorded = entry('stopped', 'done', NOW - 1_000, { mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: NOW - 1_000 } diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.ts b/src/renderer/src/lib/recent-workspace-tab-rows.ts index 2b7e68ebdf9f..124ab24535c8 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.ts @@ -8,7 +8,6 @@ import { } from '@/components/sidebar/smart-attention' import { tabHasLivePty } from './tab-has-live-pty' import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' -import { acknowledgedAgentEntry } from './agent-entry-acknowledgement' import { isExplicitAgentStatusFresh } from './pane-agent-evidence' import type { WorktreeStatus } from './worktree-status' import type { TerminalTab } from '../../../shared/terminal-tab-types' @@ -36,12 +35,6 @@ export type RecentWorkspaceTabRow = { lastFocusedAt?: number | null } -/** The panes a recent row reads, beside the user's acknowledgements, so its dot agrees with the - * sidebar's once the user has seen a turn cut short. */ -export type RecentWorkspaceTabPaneSources = TabPaneInputSources & { - acknowledgedAgentsByPaneKey: Readonly> -} - export type RecentWorkspaceTabOrderInputs = { rows: readonly RecentWorkspaceTabRow[] } @@ -73,7 +66,7 @@ export function resolveRecentWorkspaceTabAttention( /** Live status dot for a hero row — re-read on agent churn, unlike the frozen ordering. */ export function resolveRecentWorkspaceTabStatus( row: RecentWorkspaceTabRow, - paneSources: RecentWorkspaceTabPaneSources, + paneSources: TabPaneInputSources, now: number ): WorktreeStatus { if (!row.terminalTab) { @@ -89,14 +82,7 @@ export function resolveRecentWorkspaceTabStatus( panes.flatMap((pane) => pane.kind === 'hook' && isExplicitAgentStatusFresh(pane.entry, now, AGENT_STATUS_STALE_AFTER_MS) - ? [ - agentVerdictDisplayMark( - acknowledgedAgentEntry( - pane.entry, - paneSources.acknowledgedAgentsByPaneKey[pane.entry.paneKey] - ) - ) - ] + ? [agentVerdictDisplayMark(pane.entry)] : [] ) ) diff --git a/src/renderer/src/runtime/paired-agent-row-turn-outcome-arms.test.ts b/src/renderer/src/runtime/paired-agent-row-turn-outcome-arms.test.ts index 6e3f7bceedae..71a1400409e0 100644 --- a/src/renderer/src/runtime/paired-agent-row-turn-outcome-arms.test.ts +++ b/src/renderer/src/runtime/paired-agent-row-turn-outcome-arms.test.ts @@ -125,7 +125,7 @@ describe('a paired client mirroring a host verdict arm', () => { const [row, ...rest] = mirroredRows('from-a-newer-host') expect(rest).toEqual([]) expect(row?.state).toBe('done') - expect(row && agentVerdictDisplayMark({ ...row.entry, acknowledgedAt: undefined })).toBeNull() + expect(row && agentVerdictDisplayMark(row.entry)).toBeNull() }) it.each([ @@ -133,6 +133,6 @@ describe('a paired client mirroring a host verdict arm', () => { ['unconfirmed', 'unconfirmed'] ] as const)('reads a mirrored %s as its own mark', (outcome, mark) => { const [row] = mirroredRows(outcome) - expect(row && agentVerdictDisplayMark({ ...row.entry, acknowledgedAt: undefined })).toBe(mark) + expect(row && agentVerdictDisplayMark(row.entry)).toBe(mark) }) }) diff --git a/src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts b/src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts index 1ac4fb568687..97fdd8666fbe 100644 --- a/src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts +++ b/src/renderer/src/store/slices/agent-status-observation-neutrality.test.ts @@ -7,7 +7,6 @@ import { buildWorktreeAgentRows } from '@/components/sidebar/worktree-agent-rows import { getAgentDotState } from '@/components/sidebar/worktree-card-agent-summary' import { resolveAttention } from '@/components/sidebar/smart-attention' import { createTestStore, makeTab } from './store-test-helpers' -import { acknowledgedAgentRow } from '@/lib/agent-entry-acknowledgement' // The safety argument for STA-4293 step 1 in one file: an observation-stamped row and an // unstamped row must be indistinguishable to every consumer that reads status today. @@ -119,9 +118,7 @@ describe('agent status observation is behavior-neutral', () => { expect(stampedRows.map((row) => ({ ...row, entry: withoutObservation(row.entry) }))).toEqual( unstampedRows.map((row) => ({ ...row, entry: withoutObservation(row.entry) })) ) - const dotState = (row: (typeof stampedRows)[number]) => - getAgentDotState(acknowledgedAgentRow(row, undefined)) - expect(stampedRows.map(dotState)).toEqual(unstampedRows.map(dotState)) + expect(stampedRows.map(getAgentDotState)).toEqual(unstampedRows.map(getAgentDotState)) expect(resolveAttention([{ kind: 'hook', entry: stamped, hasLivePty: false }], at)).toEqual( resolveAttention([{ kind: 'hook', entry: unstamped, hasLivePty: false }], at) @@ -155,7 +152,7 @@ describe('agent status observation is behavior-neutral', () => { }) expect(rows).toHaveLength(1) expect(rows[0].entry.observation).toBeUndefined() - expect(getAgentDotState(acknowledgedAgentRow(rows[0], undefined))).toBe('blocked') + expect(getAgentDotState(rows[0])).toBe('blocked') }) it('does not carry a previous observation onto an unstamped write', () => { diff --git a/src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts b/src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts index 17cb2a4da209..669ebceb53ef 100644 --- a/src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts +++ b/src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts @@ -67,25 +67,6 @@ describe('acknowledgeAgents with a clock-skewed execution host', () => { expect(ackAt < futureStartedAt).toBe(false) }) - // Child work can hold the row open past the main agent's cut; the failed mark reads the cut's clock. - it("stamps at least the main agent's own clock when it is newer than the row's", () => { - const store = createUIStore() - const cutAt = NOW + SKEW_MS - const heldOpen: Partial = { - agentStatusByPaneKey: { - [PANE_KEY]: makeAgentEntry({ - state: 'working', - mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: cutAt } - }) - } - } - store.setState(heldOpen) - - store.getState().acknowledgeAgents([PANE_KEY]) - - expect(store.getState().acknowledgedAgentsByPaneKey[PANE_KEY]).toBe(cutAt) - }) - it('stops rewriting the ack map once a future-stamped turn is acknowledged', () => { const store = createUIStore() const futureStartedAt = NOW + SKEW_MS diff --git a/src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts b/src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts index 6c983d268a83..e7b600a2ec84 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-agent-notification-acknowledgement.ts @@ -1,7 +1,6 @@ import type { AppState } from '../../types' import { buildAgentNotificationId } from '../../../../../shared/agent-notification-id' import { parsePaneKey } from '../../../../../shared/stable-pane-id' -import { isAgentTurnAcknowledged } from '../../../../../shared/agent-turn-acknowledgement' export function resolvePaneKeyWorktreeIdFromTabs(state: AppState, paneKey: string): string | null { const parsed = parsePaneKey(paneKey) @@ -29,10 +28,7 @@ export function collectAcknowledgedAgentNotificationId({ stateStartedAt: number | null | undefined previousAckAt: number }): void { - if ( - typeof stateStartedAt !== 'number' || - isAgentTurnAcknowledged({ stateStartedAt, acknowledgedAt: previousAckAt }) - ) { + if (typeof stateStartedAt !== 'number' || previousAckAt >= stateStartedAt) { return } const id = buildAgentNotificationId({ worktreeId, paneKey, stateStartedAt }) @@ -49,13 +45,8 @@ export function usableTimestamp(value: unknown): number { export function latestAgentTurnTimestamp(entry: { stateStartedAt?: number stateHistory?: { startedAt?: number }[] - mainAgent?: { stateStartedAt?: number } }): number { - // Why the main agent's clock too: a cut it ended can postdate the row's, and the failed mark reads it. - let latest = Math.max( - usableTimestamp(entry.stateStartedAt), - usableTimestamp(entry.mainAgent?.stateStartedAt) - ) + let latest = usableTimestamp(entry.stateStartedAt) // Why history too: Activity renders one event per stateHistory entry, each with its own unread check. for (const history of entry.stateHistory ?? []) { latest = Math.max(latest, usableTimestamp(history.startedAt)) diff --git a/src/shared/agent-main-agent-verdict.test.ts b/src/shared/agent-main-agent-verdict.test.ts index f5cd523f07b4..d2b1bd185f59 100644 --- a/src/shared/agent-main-agent-verdict.test.ts +++ b/src/shared/agent-main-agent-verdict.test.ts @@ -10,10 +10,6 @@ import { import type { AgentStatusState } from './agent-status-types' import { AGENT_TURN_OUTCOMES, type AgentTurnOutcome } from './agent-turn-outcome' -/** The mark before the user has seen the turn, which every case below but the last reads. */ -const unseenMark = (row: AgentMainAgentVerdictSource) => - agentVerdictDisplayMark({ ...row, stateStartedAt: 1_000, acknowledgedAt: undefined }) - const STATES: AgentStatusState[] = ['working', 'blocked', 'waiting', 'done'] const OUTCOMES: (AgentTurnOutcome | undefined)[] = [undefined, ...AGENT_TURN_OUTCOMES] @@ -54,7 +50,7 @@ describe('agentMainAgentVerdict', () => { expect(agentTurnEndedOnPurpose(row), label).toBe( verdict === 'cancellation' || verdict === 'superseded' ) - expect(unseenMark(row), label).toBe( + expect(agentVerdictDisplayMark(row), label).toBe( verdict === 'failure' || verdict === 'interruption' ? 'failed' : row.state !== 'done' @@ -73,29 +69,14 @@ describe('agentMainAgentVerdict', () => { (outcome) => { const row = { state: 'working' as const, mainAgent: { state: 'done' as const, outcome } } expect(agentMainAgentVerdict(row)).toBe(outcome) - expect(unseenMark(row)).toBe('failed') + expect(agentVerdictDisplayMark(row)).toBe('failed') } ) - // A subagent holds the row working since 1_000; the main agent was cut at 3_000. An - // acknowledgement between the two saw the work, not the cut. - it('reads a cut seen only before it happened as failed while subagents hold the row open', () => { - const held = (outcome: 'interruption' | 'failure', acknowledgedAt: number) => - agentVerdictDisplayMark({ - state: 'working', - stateStartedAt: 1_000, - mainAgent: { state: 'done', outcome, stateStartedAt: 3_000 }, - acknowledgedAt - }) - expect(held('interruption', 2_000)).toBe('failed') - expect(held('interruption', 3_000)).toBeNull() - expect(held('failure', 3_000)).toBe('failed') - }) - it('keeps a success or a stop with live subagent work reading working', () => { for (const outcome of ['success', 'cancellation'] as const) { const row = { state: 'working' as const, mainAgent: { state: 'done' as const, outcome } } - expect(unseenMark(row)).toBeNull() + expect(agentVerdictDisplayMark(row)).toBeNull() } }) @@ -127,10 +108,10 @@ describe('agentMainAgentVerdict', () => { ['unconfirmed', 'unconfirmed'] ] as const) { const row = { state: 'done' as const, mainAgent: { state: 'done' as const, outcome } } - expect(unseenMark(row), outcome).toBe(mark) + expect(agentVerdictDisplayMark(row), outcome).toBe(mark) } // An old host's legacy flag is a user's Stop too. - expect(unseenMark({ state: 'done', interrupted: true })).toBe('interrupted') + expect(agentVerdictDisplayMark({ state: 'done', interrupted: true })).toBe('interrupted') }) it('reads a crash-cut turn as failed and an unproven end as unconfirmed, neither a stop', () => { @@ -139,33 +120,13 @@ describe('agentMainAgentVerdict', () => { ['unconfirmed', 'unconfirmed'] ] as const) { const row = { state: 'done' as const, mainAgent: { state: 'done' as const, outcome } } - expect(unseenMark(row)).toBe(mark) + expect(agentVerdictDisplayMark(row)).toBe(mark) expect(agentTurnEndedUncleanly(row)).toBe(true) // Nobody asked for it, so attention ranks it as news, like a completion or a failure. expect(agentTurnEndedOnPurpose(row)).toBe(false) } }) - // Attention only while unseen: once acknowledged, a turn cut short reads like a finished one, and - // the chat's notice row is what still says it stopped. Every other verdict keeps its mark. - it('drops the failed mark from a cut-short turn the user has seen, and from nothing else', () => { - for (const [outcome, unseen, seen] of [ - ['interruption', 'failed', null], - ['failure', 'failed', 'failed'], - ['cancellation', 'interrupted', 'interrupted'], - ['superseded', 'interrupted', 'interrupted'], - ['unconfirmed', 'unconfirmed', 'unconfirmed'], - ['success', null, null] - ] as const) { - const row = { state: 'done' as const, mainAgent: { state: 'done' as const, outcome } } - const markAfter = (acknowledgedAt: number | undefined) => - agentVerdictDisplayMark({ ...row, stateStartedAt: 1_000, acknowledgedAt }) - expect(markAfter(undefined), outcome).toBe(unseen) - expect(markAfter(999), outcome).toBe(unseen) - expect(markAfter(1_000), outcome).toBe(seen) - } - }) - it('reads an arm from a newer host as no verdict, so the row reads done as it did', () => { const row = { state: 'done' as const, @@ -176,7 +137,7 @@ describe('agentMainAgentVerdict', () => { } } expect(agentMainAgentVerdict(row)).toBeNull() - expect(unseenMark(row)).toBeNull() + expect(agentVerdictDisplayMark(row)).toBeNull() expect(agentTurnEndedUncleanly(row)).toBe(false) }) diff --git a/src/shared/agent-main-agent-verdict.ts b/src/shared/agent-main-agent-verdict.ts index 20186bf9e0d8..929b1d2748cf 100644 --- a/src/shared/agent-main-agent-verdict.ts +++ b/src/shared/agent-main-agent-verdict.ts @@ -1,16 +1,11 @@ import { isAgentTurnOutcome, type AgentTurnOutcome } from './agent-turn-outcome' import type { AgentStatusState } from './agent-status-types' import type { AgentMainAgentStatus } from './main-agent-status' -import { - agentAttentionStartedAt, - isAgentTurnAcknowledged, - type AgentTurnAcknowledgement -} from './agent-turn-acknowledgement' export type AgentMainAgentVerdictSource = { state: AgentStatusState interrupted?: boolean - mainAgent?: { state: AgentStatusState; outcome?: AgentTurnOutcome; stateStartedAt?: number } + mainAgent?: { state: AgentStatusState; outcome?: AgentTurnOutcome } } /** The fields that carry the verdict. History entries, sleep records and `worktree ps` rows copy @@ -51,30 +46,20 @@ export function agentMainAgentVerdict(row: AgentMainAgentVerdictSource): AgentTu return row.state === 'done' && row.interrupted === true ? 'cancellation' : null } -/** A verdict as a display reads it: with whether the user has seen it. */ -export type AgentVerdictDisplaySource = AgentMainAgentVerdictSource & AgentTurnAcknowledgement - /** - * What the verdict marks on the agent's own display. A failure reads failed and outranks every - * combined state: it is news the user must see even while subagents still run. A turn cut short - * when the agent stopped without anyone asking reads failed the same way, but only until the user - * has seen it; then it reads like a finished turn, and the chat's notice row still says it stopped. - * A user's Stop, or a turn a newer request replaced, reads interrupted, and an unproven end - * unconfirmed, only on a row that is itself done, so live child work still reads working. + * What the verdict marks on the agent's own display. A fault, whether the turn failed or something + * other than the user cut it short, reads failed and outranks every combined state: it is news the + * user must see even while subagents still run. A user's Stop, or a turn a newer request replaced, + * reads interrupted, and an unproven end unconfirmed, only on a row that is itself done, so live + * child work still reads working. */ export function agentVerdictDisplayMark( - row: AgentVerdictDisplaySource + row: AgentMainAgentVerdictSource ): 'failed' | 'interrupted' | 'unconfirmed' | null { switch (agentMainAgentVerdict(row)) { case 'failure': - return 'failed' case 'interruption': - return isAgentTurnAcknowledged({ - stateStartedAt: agentAttentionStartedAt(row), - acknowledgedAt: row.acknowledgedAt - }) - ? null - : 'failed' + return 'failed' case 'cancellation': case 'superseded': return row.state === 'done' ? 'interrupted' : null diff --git a/src/shared/agent-turn-acknowledgement.ts b/src/shared/agent-turn-acknowledgement.ts deleted file mode 100644 index 04afc70e9839..000000000000 --- a/src/shared/agent-turn-acknowledgement.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Whether the user has seen an agent's current state, by Orca's existing acknowledgement: the rule -// that un-bolds a sidebar row and that auto-acknowledgement, dashboard buckets, notification -// acknowledgement and the Activity unread count read beside an entry's state. A turn cut short with -// nobody asking reads failed only until this holds for its attention clock. Per-event Activity unread -// compares event times and keeps its own comparison. - -/** An agent's current state beside the newest time the user acknowledged it, joined where the - * entry is read so no surface can leave the acknowledgement out. */ -export type AgentTurnAcknowledgement = { - /** When the agent entered its current state. */ - stateStartedAt: number - /** When the user last acknowledged the agent; undefined when never. */ - acknowledgedAt: number | undefined -} - -/** Compared with `stateStartedAt`, not the newest update, so a same-state ping is not new news. */ -export function isAgentTurnAcknowledged({ - stateStartedAt, - acknowledgedAt -}: AgentTurnAcknowledgement): boolean { - return (acknowledgedAt ?? 0) >= stateStartedAt -} - -/** - * When the agent's newest news began: the row's combined state, or the main agent's own state when - * that came later. Child work can hold the row open past the main agent's end, so a cut the user - * has not seen must not count as seen by an acknowledgement of the work before it. - */ -export function agentAttentionStartedAt(entry: { - stateStartedAt: number - mainAgent?: { stateStartedAt?: number } -}): number { - return Math.max(entry.stateStartedAt, entry.mainAgent?.stateStartedAt ?? 0) -} From 82147078fbc76f6e5b97486704be73c6000eef2a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:24:34 -0700 Subject: [PATCH 16/17] test(native-chat): pin that a restart note after a continuation's turn leaves the cut's notice --- src/shared/native-chat-cut-turn-notice.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/shared/native-chat-cut-turn-notice.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts index c69e0770c0d1..2f1fb39cddcd 100644 --- a/src/shared/native-chat-cut-turn-notice.test.ts +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -268,6 +268,20 @@ describe('withNativeChatCutTurnNotices', () => { expect(notices(noteOnly)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) }) + // An unconfirmed continuation whose turn opened before its warning: the warning is about that + // turn, so the cut keeps its own notice beside it. + it('keeps the notice when a restart note follows a turn the continuation opened', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('u2'), + turn('t2', 'u2', { state: 'running' }), + restartNote(AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, 'warning') + ] + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + }) + // A send after the cut, whose new agent died before its turn opened: that exit is about the send. it('lets no exit row about the conversation explain a cut a message was sent after', () => { const items = [ From 62c1a35f7679a4cfc71e0420186c77c333a17f52 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:32:07 -0700 Subject: [PATCH 17/17] test(native-chat): keep a cut turn's notice beside a later message drawn as not sent --- .../native-chat-cut-turn-notice.test.ts | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/shared/native-chat-cut-turn-notice.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts index 945fb9f810d3..09fc37ba5773 100644 --- a/src/shared/native-chat-cut-turn-notice.test.ts +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest' import { agentSessionFailureFact } from './agent-session-failure' import { agentSessionFailureWords } from './agent-session-failure-words' -import { agentJournalItemKey } from './agent-session-journal-item-key' +import { agentJournalItemKey, agentJournalSubmissionKey } from './agent-session-journal-item-key' import type { AgentJournalItemBody, + AgentJournalSubmission, AgentJournalRenderItem, AgentJournalTurnOutcome, AgentJournalTurnScope @@ -350,6 +351,41 @@ describe('withNativeChatCutTurnNotices', () => { expect(turnKeys[notice]).toBe('u1') }) + // A message Orca accepted and then failed to deliver after the cut is drawn in place as not sent, + // in no turn; the cut keeps its notice in its own turn, and the exit row about that send does + // not stand in for it. + it('keeps the notice in its turn beside a later message drawn as not sent', () => { + const sent = agentJournalSubmissionKey('client-u2') + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user(sent), + exitRow('provider-exit:s:7:gen', THREAD) + ] + const submissions: AgentJournalSubmission[] = [ + { + clientMessageId: 'client-u2', + fence: 2, + payloadFingerprint: 'fp-u2', + dispatchState: 'rejected', + providerItemId: null, + reason: NOTICE, + submittedAt: 50, + resolvedAt: 60 + } + ] + const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + const messages = projectStructuredAgentSessionMessages(derived, [], submissions, { + rejectedInPlace: true + }) + const { turnKeys } = nativeChatTurnMembership(messages, { items: derived, submissions }) + + const notice = messages.findIndex((message) => message.id.includes('cut-turn-notice')) + expect(turnKeys[notice]).toBe('u1') + expect(messages.find((message) => message.id === sent)).toMatchObject({ unsent: true }) + }) + it('reads a journal from a host that states no scope, without making it look like one that does', () => { const items = [ user('u1', null),