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.
This commit is contained in:
Brennan Benson
2026-08-05 21:08:35 -07:00
parent b49a554fd7
commit fbddf9bd63
2 changed files with 76 additions and 2 deletions
@@ -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>): 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)
})
})
@@ -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
}
}
}