From c3918e947fca6fffbfdf49f07df6ec56e7cc0d4f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:25:22 -0700 Subject: [PATCH] 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. --- .../activity/activity-thread-presentation.ts | 4 +++- .../activity/useActivityUnreadCount.test.ts | 21 +++++++++++++++++++ .../activity/useActivityUnreadCount.ts | 10 ++++++++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/activity/activity-thread-presentation.ts b/src/renderer/src/components/activity/activity-thread-presentation.ts index 55c5b4145af..93d69c7688a 100644 --- a/src/renderer/src/components/activity/activity-thread-presentation.ts +++ b/src/renderer/src/components/activity/activity-thread-presentation.ts @@ -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 } diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.test.ts b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts index 9c1330a2cd5..62c423ce1bd 100644 --- a/src/renderer/src/components/activity/useActivityUnreadCount.test.ts +++ b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.ts b/src/renderer/src/components/activity/useActivityUnreadCount.ts index fb1628db559..3a3074d24e9 100644 --- a/src/renderer/src/components/activity/useActivityUnreadCount.ts +++ b/src/renderer/src/components/activity/useActivityUnreadCount.ts @@ -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() // 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) } }