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) } }