From 32db3358d81fd94c243553a4df989d8e5f8a9b3d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:27:52 -0700 Subject: [PATCH 1/4] fix(sidebar): clear a cut-short turn's red mark once the user has seen it When the agent stopped mid-reply without anyone asking (a crash, a quit, an eviction), every agent-status surface marked the turn Failed, and nothing cleared it after the user had read the chat. The verdict's display now takes Orca's existing acknowledgement as a required part of what it reads, judged by one shared rule against the turn's attention clock (the later of the row's and the main agent's, since a subagent can hold the row open past the main agent's cut). Each surface joins it once where it builds its rows: the sidebar's agent rows (and through them the compact summary and the notes send menu), the workspace card summary, the tab bar, Cmd-J recent rows and Activity threads. Auto-acknowledgement of the chat on screen reads the same clock, and its stamp covers it. Failures, Stops, replaced turns and unproven ends keep their marks; 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 | 16 ++- ...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 +- .../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 +- .../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 +++++ 79 files changed, 1046 insertions(+), 187 deletions(-) create mode 100644 src/renderer/src/attention/agent-attention-acknowledgement.test.ts create mode 100644 src/renderer/src/components/sidebar/useWorktreeAgentRows.acknowledgement.test.tsx 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 e8dfe9f7f5e6..a2e081937acd 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([]) @@ -184,7 +184,7 @@ 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. + // must never show the done tick of a finished turn. The tab's mark lasts until the user sees it. 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 Failed after N, marked failed until seen, for a turn cut off by %s', async (_label, cut) => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) @@ -216,7 +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, acknowledgedAt: undefined })).toBe('failed') + // Once the user has seen it, the chat itself is what still says it failed. + expect( + row && agentVerdictDisplayMark({ ...row, acknowledgedAt: row.stateStartedAt }) + ).toBeNull() const [settled] = [ ...selectStructuredAgentSettledTurns(session.journal.snapshot().items).values() ] @@ -250,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 4ae9f59a015a..a119b78deaa9 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/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 c4e1d7f5f073..8cebf19dd673 100644 --- a/src/renderer/src/attention/agent-attention-acknowledgement.ts +++ b/src/renderer/src/attention/agent-attention-acknowledgement.ts @@ -3,12 +3,19 @@ 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 } @@ -33,8 +40,8 @@ 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. + * 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, @@ -44,13 +51,22 @@ 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: agentAttentionStartedAt(liveTurn), acknowledgedAt }) + ) { targets.push(subjectKey) } const retainedTurn = records.retainedTurns[subjectKey] - if (retainedTurn && acknowledgedAt < retainedTurn.entry.stateStartedAt) { + if ( + retainedTurn && + !isAgentTurnAcknowledged({ + stateStartedAt: agentAttentionStartedAt(retainedTurn.entry), + acknowledgedAt + }) + ) { 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 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 ce1f571cdbcc..95dea2b21ffc 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 5ac748027972..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,6 +333,30 @@ 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 2cacdfb5474b..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 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 391c45127cda..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 => { 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/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: {} } }) 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: {} } } 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/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/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 90b3274c4094..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 @@ -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: {} @@ -232,6 +237,110 @@ 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) @@ -246,6 +355,7 @@ describe('selectWorktreeAgentActivitySummary', () => { mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: 1_000 } }) }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -271,6 +381,7 @@ describe('selectWorktreeAgentActivitySummary', () => { mainAgent: { state: 'done', outcome, stateStartedAt: 1_000 } }) }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -317,6 +428,7 @@ describe('selectWorktreeAgentActivitySummary', () => { tabsByWorktree: { [worktreeId]: [liveTab, retainedTab] }, agentStatusEpoch: epoch++, agentStatusByPaneKey, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey @@ -376,6 +488,7 @@ describe('selectWorktreeAgentActivitySummary', () => { restoredUnconfirmed: true }) }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -402,6 +515,7 @@ describe('selectWorktreeAgentActivitySummary', () => { const changedPaneKey = makePaneKey('tab-11', LEAF_ID) const baseInputs = { tabsByWorktree, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -445,6 +559,7 @@ describe('selectWorktreeAgentActivitySummary', () => { const secondPaneKey = makePaneKey('tab-2', LEAF_ID) const replacementPaneKey = makePaneKey('tab-3', LEAF_ID) const sharedInputs = { + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -515,6 +630,7 @@ describe('selectWorktreeAgentActivitySummary', () => { worktreeId: 'repo::/wt-1' }) }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -542,6 +658,7 @@ describe('selectWorktreeAgentActivitySummary', () => { parentPaneKey }) }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -567,6 +684,7 @@ describe('selectWorktreeAgentActivitySummary', () => { worktreeId: 'repo::/wt-1' }) }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: { [childPaneKey]: { @@ -594,6 +712,7 @@ describe('selectWorktreeAgentActivitySummary', () => { tabsByWorktree: { 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] }, agentStatusEpoch: 0, agentStatusByPaneKey: { [paneKey]: entry }, + acknowledgedAgentsByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} @@ -620,6 +739,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 795d297344c4..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,6 +57,7 @@ export type AgentActivityInput = Pick< | 'agentStatusByPaneKey' | 'migrationUnsupportedByPtyId' | 'retainedAgentsByPaneKey' + | 'acknowledgedAgentsByPaneKey' > & { tabsByWorktree: AgentActivityTabsByWorktree runtimeAgentOrchestrationByPaneKey?: AppState['runtimeAgentOrchestrationByPaneKey'] @@ -66,6 +68,7 @@ type AgentActivityCache = { agentStatusEpoch: number migrationUnsupportedByPtyId: AppState['migrationUnsupportedByPtyId'] retainedAgentsByPaneKey: AppState['retainedAgentsByPaneKey'] + acknowledgedAgentsByPaneKey: AppState['acknowledgedAgentsByPaneKey'] runtimeAgentOrchestrationByPaneKey: AppState['runtimeAgentOrchestrationByPaneKey'] | undefined summaries: Map } @@ -89,6 +92,7 @@ function getWorktreeAgentActivitySummaries( agentActivityCache.agentStatusEpoch === state.agentStatusEpoch && agentActivityCache.migrationUnsupportedByPtyId === state.migrationUnsupportedByPtyId && agentActivityCache.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey && + agentActivityCache.acknowledgedAgentsByPaneKey === state.acknowledgedAgentsByPaneKey && agentActivityCache.runtimeAgentOrchestrationByPaneKey === runtimeAgentOrchestrationByPaneKey ) { return agentActivityCache.summaries @@ -144,7 +148,10 @@ function getWorktreeAgentActivitySummaries( if (entry.state === 'done') { addParentPaneId(summary, orchestration, worktreeId, tabIdToWorktreeId) } - applyAgentPaneActivityFlags(summary, entry) + applyAgentPaneActivityFlags( + summary, + acknowledgedAgentEntry(entry, state.acknowledgedAgentsByPaneKey[paneKey]) + ) } for (const unsupported of Object.values(state.migrationUnsupportedByPtyId ?? {})) { @@ -158,7 +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. - if (agentVerdictDisplayMark(retained.entry) === 'failed') { + const entry = acknowledgedAgentEntry( + retained.entry, + state.acknowledgedAgentsByPaneKey[retained.entry.paneKey] + ) + if (agentVerdictDisplayMark(entry) === 'failed') { summary.hasRetainedFailed = true } else { summary.hasRetainedDone = true @@ -191,6 +202,7 @@ 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 9f070c355bdd..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 } } } @@ -225,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 072ff7f2ba0d..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,11 +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' export type SummaryAgentGroup = { state: AgentDotState - agents: DashboardAgentRowData[] + agents: AcknowledgedAgentRow[] } const SUMMARY_STATE_ORDER: AgentDotState[] = [ @@ -24,7 +24,7 @@ const SUMMARY_STATE_ORDER: AgentDotState[] = [ 'idle' ] -export function getAgentDotState(agent: DashboardAgentRowData): AgentDotState { +export function getAgentDotState(agent: AcknowledgedAgentRow): AgentDotState { return agentRowDisplayDotState(agent) } @@ -55,8 +55,8 @@ export function formatSummaryStateLabel(state: AgentDotState): string { } } -export function buildSummaryAgentGroups(agents: DashboardAgentRowData[]): SummaryAgentGroup[] { - const groups = new Map() +export function buildSummaryAgentGroups(agents: AcknowledgedAgentRow[]): 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: DashboardAgentRowData[]): Summar }) } -export function summarizeAgents(agents: DashboardAgentRowData[], subjectLabel: string): string { +export function summarizeAgents(agents: AcknowledgedAgentRow[], subjectLabel: string): string { const counts = new Map() for (const agent of agents) { const dotState = getAgentDotState(agent) @@ -95,7 +95,7 @@ export function summarizeAgents(agents: DashboardAgentRowData[], subjectLabel: s return `${subjectLabel}: ${parts.join(', ')}` } -export function summarizeAgentIdentities(agents: DashboardAgentRowData[]): string { +export function summarizeAgentIdentities(agents: AcknowledgedAgentRow[]): string { return agents .map((agent) => { const agentLabel = formatAgentTypeLabel(agent.agentType) @@ -106,10 +106,10 @@ export function summarizeAgentIdentities(agents: DashboardAgentRowData[]): strin } 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) @@ -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: 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 4f3a34a2b05a..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' @@ -17,7 +17,7 @@ import { formatShortTimeAgo } from '@/lib/short-time-ago' import { agentVerdictStatusLine } from '@/lib/agent-verdict-status-line' function getCompactAgentPrimary( - agent: DashboardAgentRowData, + agent: AcknowledgedAgentRow, conversationName: string | null ): string { const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry) @@ -25,7 +25,7 @@ function getCompactAgentPrimary( } export function getCompactAgentSecondary( - agent: DashboardAgentRowData, + agent: AcknowledgedAgentRow, now: number, lastAssistantMessageOverride?: string ): string { @@ -58,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) @@ -68,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 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..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' @@ -24,7 +24,7 @@ function stopActivationKeyPropagation(e: React.KeyboardEvent): void { } type CompactAgentSummaryButtonProps = { - agents: DashboardAgentRowData[] + agents: AcknowledgedAgentRow[] 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 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..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 @@ -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,38 @@ 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 @@ -190,6 +226,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 +240,7 @@ describe('resolveTerminalTabActivityStatus', () => { mainAgent: { state: 'done', outcome, stateStartedAt: NOW } }) return resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [held.paneKey]: held }, ptyIdsByTabId: LIVE_PTY @@ -223,6 +261,7 @@ describe('resolveTerminalTabActivityStatus', () => { const finished = entry(SECOND_LEAF_ID, 'done') expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [interrupted.paneKey]: interrupted, @@ -238,6 +277,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 +289,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 +301,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 +328,7 @@ describe('resolveTerminalTabActivityStatus', () => { const agentStatusByPaneKey = { [working.paneKey]: working } expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey, agentStatusEpoch: 0, @@ -297,6 +340,7 @@ describe('resolveTerminalTabActivityStatus', () => { // Same map reference, bumped epoch — the entry is now stale. expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey, agentStatusEpoch: 1, @@ -308,6 +352,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 +364,7 @@ describe('resolveTerminalTabActivityStatus', () => { const blocked = entry(FIRST_LEAF_ID, 'blocked') expect( resolveTerminalTabActivityStatus({ + acknowledgedAgentsByPaneKey: {}, tab: TAB, agentStatusByPaneKey: { [blocked.paneKey]: blocked }, ptyIdsByTabId: LIVE_PTY @@ -330,6 +376,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 +387,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 ad70621bb4d9..92aa72a6f076 100644 --- a/src/renderer/src/lib/agent-pane-activity-flags.ts +++ b/src/renderer/src/lib/agent-pane-activity-flags.ts @@ -1,5 +1,6 @@ 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 = { @@ -15,7 +16,8 @@ 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 & + AgentTurnAcknowledgement ): 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 bf2b7b1e5320..064d975a3fd3 100644 --- a/src/renderer/src/lib/agent-row-dot-state.ts +++ b/src/renderer/src/lib/agent-row-dot-state.ts @@ -2,6 +2,7 @@ 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' /** @@ -31,7 +32,8 @@ export function agentRowDotState( */ export function agentRowDisplayDotState(agent: { state: AgentRowState - entry: Pick + entry: Pick & + AgentTurnAcknowledgement 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 2c7aecefd4a5..21e5e3a5d144 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 AgentMainAgentVerdictSource + 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. */ -export function agentVerdictStatusLine(entry: AgentMainAgentVerdictSource): string | null { + * 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' 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 97fdd8666fbe..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,7 +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(getAgentDotState)).toEqual(unstampedRows.map(getAgentDotState)) + 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) @@ -152,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/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 e7b600a2ec84..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 @@ -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 }) @@ -45,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 d2b1bd185f59..59783465cc08 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,29 @@ 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') } ) + // 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(agentVerdictDisplayMark(row)).toBeNull() + expect(unseenMark(row)).toBeNull() } }) @@ -108,10 +127,10 @@ 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 a crash-cut turn as failed and an unproven end as unconfirmed, neither a stop', () => { @@ -120,13 +139,33 @@ describe('agentMainAgentVerdict', () => { ['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 acknowledged, a turn cut short reads like a finished one, and + // the chat itself 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, @@ -137,7 +176,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 929b1d2748cf..b3d1e8c22895 100644 --- a/src/shared/agent-main-agent-verdict.ts +++ b/src/shared/agent-main-agent-verdict.ts @@ -1,11 +1,16 @@ 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 } + mainAgent?: { state: AgentStatusState; outcome?: AgentTurnOutcome; stateStartedAt?: number } } /** The fields that carry the verdict. History entries, sleep records and `worktree ps` rows copy @@ -46,20 +51,30 @@ 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 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. + * 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 itself 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 + row: AgentVerdictDisplaySource ): 'failed' | 'interrupted' | 'unconfirmed' | null { switch (agentMainAgentVerdict(row)) { case 'failure': - case 'interruption': return 'failed' + case 'interruption': + 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 new file mode 100644 index 000000000000..04afc70e9839 --- /dev/null +++ b/src/shared/agent-turn-acknowledgement.ts @@ -0,0 +1,34 @@ +// 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 14d1d43e14d96c2d1f5ae0406b0752dc5e71f620 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:27:53 -0700 Subject: [PATCH 2/4] refactor(attention): use the one acknowledgement rule where it was copied The Activity unread count and dashboard row buckets each spelled the same "acknowledged at or after the current state began" comparison; they now call the shared rule, with the row's clock as before. --- .../src/components/activity/useActivityUnreadCount.ts | 3 ++- .../src/components/dashboard/dashboard-row-bucket.ts | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) 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 }) ) From 798846c9a04e02f6972d531fe68505674abef78a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:32:50 -0700 Subject: [PATCH 3/4] test: leave the recovered-turn test's name and comment to the transcript change Only the mark's lines change here, so the label change can touch the rest of this test without the two changes meeting. --- .../structured-agent-session-recovered-turn-clock.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a2e081937acd..4ce683642bb8 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 @@ -184,7 +184,7 @@ 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 lasts until the user sees it. + // must never show the done tick of a finished turn. 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 until seen, for a turn cut off by %s', + 'reads Failed after N, marked failed, for a turn cut off by %s', async (_label, cut) => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) From 9067b967bedf93ca4bcc84ce5c32fc993d095b4d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:32:56 -0700 Subject: [PATCH 4/4] test: word the recovered-turn seen comment for either transcript --- .../structured-agent-session-recovered-turn-clock.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4ce683642bb8..1b04ff3d19a5 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 @@ -217,7 +217,7 @@ describe('a turn recovery settled after its host went away', () => { const [row] = session.server.getStatusSnapshot() expect(row && agentVerdictDisplayMark({ ...row, acknowledgedAt: undefined })).toBe('failed') - // Once the user has seen it, the chat itself is what still says it failed. + // Once the user has seen it, the chat itself is what still says the turn was cut. expect( row && agentVerdictDisplayMark({ ...row, acknowledgedAt: row.stateStartedAt }) ).toBeNull()