Deduplicate activity unread counts across source overlaps

Live pane status is the primary source; retained and migration entries
serve as fallback caches that may briefly overlap it during lifecycle
transitions. Count each pane only once by tracking seen keys, prioritizing
the live status as the canonical source.

Also fix monitoring state display: it's a distinct agent state, not a
tool-running row state, so exclude it from tool preview checks.
This commit is contained in:
Jinjing
2026-09-02 10:25:22 -07:00
parent b16bc4d8c3
commit c3918e947f
3 changed files with 33 additions and 2 deletions
@@ -143,13 +143,15 @@ export function activityThreadRowCopy(thread: AgentPaneThread): ActivityThreadRo
responsePreview: thread.responsePreview
})
const liveState = thread.currentAgentState ?? thread.latestEvent?.state ?? null
const toolPreviewState = liveState === 'monitoring' ? null : liveState
const state = threadAgentState(thread)
const needsAttention = state === 'waiting' || state === 'blocked' || state === 'permission'
if (renderedPreview && !previewDuplicatesIdentity(renderedPreview, taskTitle, workspaceLabel)) {
return {
taskTitle,
statusLine: renderedPreview,
statusKind: showsAgentToolPreview(liveState) ? 'tool' : 'message',
// Monitoring is a distinct live state, not a tool-running row state.
statusKind: showsAgentToolPreview(toolPreviewState) ? 'tool' : 'message',
needsAttention,
workspaceLabel
}
@@ -88,3 +88,24 @@ describe('countActivityUnread with Clear completed cutoffs', () => {
expect(countActivityUnread(source)).toBe(1)
})
})
describe('countActivityUnread source overlap', () => {
it('counts an overlapping live and retained pane only once', () => {
const entry = makeEntry({})
const source = {
acknowledgedAgentsByPaneKey: { [PANE]: 0 },
agentStatusByPaneKey: { [PANE]: entry },
retainedAgentsByPaneKey: {
[PANE]: {
entry,
worktreeId: 'wt-1',
tab: {} as never,
agentType: 'claude',
startedAt: 1_000
}
},
migrationUnsupportedByPtyId: {}
}
expect(countActivityUnread(source)).toBe(1)
})
})
@@ -24,6 +24,7 @@ function isUnreadAgentState(state: AgentStatusState): boolean {
/** Counts unread done/blocked/waiting events for the Activity page titlebar badge. */
export function countActivityUnread(source: ActivityUnreadCountSource): number {
let count = 0
const seenPaneKeys = new Set<string>()
// Why no worktree.isUnread here: Activity lists only agent threads, so a worktree
// unread would light a badge with no row to read and no way to clear it.
@@ -50,14 +51,21 @@ export function countActivityUnread(source: ActivityUnreadCountSource): number {
}
for (const [paneKey, entry] of Object.entries(source.agentStatusByPaneKey)) {
seenPaneKeys.add(paneKey)
countEntry(entry, source.acknowledgedAgentsByPaneKey[paneKey] ?? 0)
}
for (const [paneKey, retained] of Object.entries(source.retainedAgentsByPaneKey)) {
// Live status is the primary source; retained is a handoff cache and may briefly overlap it.
if (seenPaneKeys.has(paneKey)) {
continue
}
seenPaneKeys.add(paneKey)
countEntry(retained.entry, source.acknowledgedAgentsByPaneKey[paneKey] ?? 0)
}
for (const unsupported of Object.values(source.migrationUnsupportedByPtyId)) {
const entry = migrationUnsupportedToAgentStatusEntry(unsupported)
if (entry) {
if (entry && !seenPaneKeys.has(entry.paneKey)) {
seenPaneKeys.add(entry.paneKey)
countEntry(entry, source.acknowledgedAgentsByPaneKey[entry.paneKey] ?? 0)
}
}