From fbddf9bd6358b071e8a9a4951d2d0cb571301d72 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:08:35 -0700 Subject: [PATCH] fix(agent-hooks): keep a displaced completion unread in the sidebar badge (STA-3386) Review round 3: sidebar-badge mode counts only the live entry, so a session boundary landing on an unacknowledged completion silently dropped the sidebar badge while the agent-events count kept it. Count the displaced completion from history for boundary rows, and pin the behavior with countActivityUnread tests. --- .../activity/useActivityUnreadCount.test.ts | 66 +++++++++++++++++++ .../activity/useActivityUnreadCount.ts | 12 +++- 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/activity/useActivityUnreadCount.test.ts diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.test.ts b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts new file mode 100644 index 00000000000..5c28a132762 --- /dev/null +++ b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { countActivityUnread } from './useActivityUnreadCount' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +function makeEntry(overrides: Partial): AgentStatusEntry { + return { + state: 'done', + prompt: '', + updatedAt: 2_000, + stateStartedAt: 2_000, + paneKey: PANE, + agentType: 'claude', + stateHistory: [], + ...overrides + } +} + +function makeSource(entry: AgentStatusEntry, ackAt = 0) { + return { + acknowledgedAgentsByPaneKey: { [PANE]: ackAt }, + agentStatusByPaneKey: { [PANE]: entry }, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: {}, + worktreesByRepo: {} + } +} + +describe('countActivityUnread session-boundary rows (STA-3386)', () => { + it('does not count a session-boundary done as unread in either mode', () => { + const source = makeSource(makeEntry({ sessionBoundary: true })) + expect(countActivityUnread(source, 'sidebar-badge')).toBe(0) + expect(countActivityUnread(source, 'agent-events')).toBe(0) + }) + + it('keeps counting a real completion displaced into history by a session boundary', () => { + // Why: agent finished (unacknowledged), then the user resumed the session — the + // boundary row replaces the live done but the finish must stay unread in both badges. + const source = makeSource( + makeEntry({ + sessionBoundary: true, + stateHistory: [{ state: 'done', prompt: 'fix bug', startedAt: 1_000 }] + }) + ) + expect(countActivityUnread(source, 'sidebar-badge')).toBe(1) + expect(countActivityUnread(source, 'agent-events')).toBe(1) + }) + + it('stops counting the displaced completion once acknowledged', () => { + const source = makeSource( + makeEntry({ + sessionBoundary: true, + stateHistory: [{ state: 'done', prompt: 'fix bug', startedAt: 1_000 }] + }), + 1_500 + ) + expect(countActivityUnread(source, 'sidebar-badge')).toBe(0) + expect(countActivityUnread(source, 'agent-events')).toBe(0) + }) + + it('still counts an ordinary unacknowledged done in sidebar-badge mode', () => { + const source = makeSource(makeEntry({})) + expect(countActivityUnread(source, 'sidebar-badge')).toBe(1) + }) +}) diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.ts b/src/renderer/src/components/activity/useActivityUnreadCount.ts index 70b6db29490..91b51c4455e 100644 --- a/src/renderer/src/components/activity/useActivityUnreadCount.ts +++ b/src/renderer/src/components/activity/useActivityUnreadCount.ts @@ -60,14 +60,22 @@ export function countActivityUnread( } } } - // Why: a session-boundary done is an idle connect (STA-3386), not an event to read; - // history never contains one (the status slice skips the push), so only the live entry needs the gate. + // Why: a session-boundary done is an idle connect (STA-3386), not an event to read. + // History never contains a boundary, but it DOES keep the real completion a boundary + // displaced (the slice pushes it on done→done), so sidebar-badge mode — which skips the + // history loop above — must still count that displaced completion or the badge silently + // drops an unacknowledged finish the moment its session is resumed. if ( isUnreadAgentState(entry.state) && entry.sessionBoundary !== true && ackAt < entry.stateStartedAt ) { count += 1 + } else if (mode === 'sidebar-badge' && entry.state === 'done' && entry.sessionBoundary) { + const displaced = entry.stateHistory.at(-1) + if (displaced && isUnreadAgentState(displaced.state) && ackAt < displaced.startedAt) { + count += 1 + } } }