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 0857e3565d69..dfcd9bee89c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -176,7 +176,7 @@ describe('a turn recovery settled after its host went away', () => { // Nobody stopped it: the flag older readers take as a user's stop stays down. expect(row?.interrupted ?? false).toBe(false) // The sidebar and tab read the published row, with no user action in between. - expect(row && agentVerdictDisplayMark(row)).toBe(mark) + expect(row && agentVerdictDisplayMark({ ...row, acknowledgedAt: undefined })).toBe(mark) expect(row && agentTurnEndedOnPurpose(row)).toBe(false) // The dot and the OS notification come only from a completion event, and none is sent. expect(session.completionEvents).toEqual([]) @@ -216,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 the turn was cut. + 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 50056eedd031..dd36abc9c8e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -111,7 +111,12 @@ function settled(turnId?: string) { turn, label: timing ? formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...timing }) : null, mark: verdict - ? agentVerdictDisplayMark({ state: 'done', mainAgent: { state: 'done', outcome: verdict } }) + ? agentVerdictDisplayMark({ + state: 'done', + mainAgent: { state: 'done', outcome: verdict }, + stateStartedAt: 1, + acknowledgedAt: undefined + }) : null, errorRows: items.flatMap((item) => item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] diff --git a/src/renderer/src/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 d64f60c11a15..36b42e7aeab6 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/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/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/dashboard/dashboard-row-bucket.ts b/src/renderer/src/components/dashboard/dashboard-row-bucket.ts index 0b4377ac9aa0..3ada3074a35a 100644 --- a/src/renderer/src/components/dashboard/dashboard-row-bucket.ts +++ b/src/renderer/src/components/dashboard/dashboard-row-bucket.ts @@ -6,6 +6,7 @@ import { } from '../../../../shared/dashboard-snapshot' import { dashboardBucketForDotState } from './dashboard-card-bucket' import type { AgentRowState } from '@/lib/agent-row-decay-state' +import { isAgentTurnAcknowledged } from '../../../../shared/agent-turn-acknowledgement' /** * Project a row state onto the published card vocabulary. @@ -39,7 +40,11 @@ export function dashboardRowBucketProjection( ? row.entry.workingMode : undefined const unseen = - !isTitleDerived && (acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt + !isTitleDerived && + !isAgentTurnAcknowledged({ + stateStartedAt: row.entry.stateStartedAt, + acknowledgedAt: acknowledgedAgentsByPaneKey?.[row.paneKey] + }) const bucket = dashboardBucketForDotState( dashboardCardDisplayState({ dotState, workingMode, unseen }) ) diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index 935d98dd9861..2fe81cbdf790 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({ @@ -251,7 +251,7 @@ function AgentTargetMenuItem({ onSend }: { target: NotesSendAgentTarget - agent: DashboardAgentRowData | null + agent: AcknowledgedAgentRow | null now: number disabled: boolean onSend: (target: NotesSendAgentTarget) => void @@ -298,7 +298,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() @@ -322,7 +322,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) +}