Fix desynced agent count between UI components
Cluster 49808 · apps/desktop. Data as of
Nothing open on GitHub. Fix merged in #52183 on 25 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #52183. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #52183. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items address a mismatch between the status bar agent indicator and the Spawn tree panel in the desktop app, where the two components reported different agent states. The work focuses on unifying their scope or data source so they stay in sync.
Open issues0
Open PRs0
Fixes on record
-
#52183 fix(desktop): make Agents indicator match the Spawn-tree panel
files:
apps/desktop/src/app/agents/index.tsx,apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx,apps/desktop/src/store/subagents.test.ts,apps/desktop/src/store/subagents.tsShow the diff
-
apps/desktop/src/app/agents/index.tsx@@ -9,9 +9,9 @@ import { type Translations, useI18n } from '@/i18n' import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' -import { $activeSessionId } from '@/store/session' import { $subagentsBySession, + allSubagents, buildSubagentTree, type SubagentNode, type SubagentStatus, @@ -77,15 +77,12 @@ interface AgentsViewProps { export function AgentsView({ onClose }: AgentsViewProps) { const { t } = useI18n() - const activeSessionId = useStore($activeSessionId) const subagentsBySession = useStore($subagentsBySession) - const activeSubagents = useMemo( - () => (activeSessionId ? (subagentsBySession[activeSessionId] ?? []) : []), - [activeSessionId, subagentsBySession] - ) - - const tree = useMemo(() => buildSubagentTree(activeSubagents), [activeSubagents]) + // Aggregate every session, matching the status-bar indicator — a subagent + // running in a background session must still be visible here, or the two + // desync ("Agents N running" vs an empty tree). + const tree = useMemo(() => buildSubagentTree(allSubagents(subagentsBySession)), [subagentsBySession]) return ( <OverlayView -
apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx@@ -22,8 +22,6 @@ import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' import { cn } from '@/lib/utils' import { setGlobalYolo, setSessionYolo } from '@/lib/yolo-session' -import { $desktopActionTasks } from '@/store/activity' -import { $previewServerRestartStatus } from '@/store/preview' import { $activeSessionId, $busy, @@ -31,11 +29,10 @@ import { $currentUsage, $sessionStartedAt, $turnStartedAt, - $workingSessionIds, $yoloActive, setYoloActive } from '@/store/session' -import { $subagentsBySession, activeSubagentCount } from '@/store/subagents' +import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents' import { $gatewayRestarting } from '@/store/system-actions' import { $backendUpdateApply, @@ -90,12 +87,9 @@ export function useStatusbarItems({ const yoloActive = useStore($yoloActive) const busy = useStore($busy) const currentUsage = useStore($currentUsage) - const desktopActionTasks = useStore($desktopActionTasks) const gatewayRestarting = useStore($gatewayRestarting) - const previewServerRestartStatus = useStore($previewServerRestartStatus) const sessionStartedAt = useStore($sessionStartedAt) const turnStartedAt = useStore($turnStartedAt) - const workingSessionIds = useStore($workingSessionIds) const subagentsBySession = useStore($subagentsBySession) const updateStatus = useStore($updateStatus) const updateApply = useStore($updateApply) @@ -159,24 +153,17 @@ export function useStatusbarItems({ [gatewayLogLines, gatewayState, inferenceStatus, openCommandCenterSection, statusSnapshot] ) - const { bgFailed, bgRunning, subagentsRunning } = useMemo(() => { - const actions = Object.values(desktopActionTasks) - const running = actions.filter(t => t.status.running).length - const failed = actions.filter(t => !t.status.running && (t.status.exit_code ?? 0) !== 0).length - const previewRunning = previewServerRestartStatus === 'running' ? 1 : 0 - const previewFailed = previewServerRestartStatus === 'error' ? 1 : 0 - - const subagentsRunning = Object.values(subagentsBySession).reduce( - (sum, items) => sum + activeSubagentCount(items), - 0 - ) + // The indicator must speak the same scope as the Spawn-tree panel it opens: + // every session's subagents, never background system actions (gateway + // restarts, toolset installs) which surface in their own panels. + const { subagentsFailed, subagentsRunning } = useMemo(() => { + const lists = Object.values(subagentsBySession) return { - bgFailed: failed + previewFailed, - bgRunning: workingSessionIds.length + running + previewRunning, - subagentsRunning + subagentsFailed: lists.reduce((sum, items) => sum + failedSubagentCount(items), 0), + subagentsRunning: lists.reduce((sum, items) => sum + activeSubagentCount(items), 0) } - }, [desktopActionTasks, previewServerRestartStatus, subagentsBySession, workingSessionIds]) + }, [subagentsBySession]) const gatewayOpen = gatewayState === 'open' const gatewayConnecting = gatewayState === 'connecting' @@ -321,20 +308,18 @@ export function useStatusbarItems({ { className: cn( agentsOpen && 'bg-accent/55 text-foreground', - bgFailed > 0 && 'text-destructive hover:text-destructive' + subagentsFailed > 0 && 'text-destructive hover:text-destructive' ), detail: subagentsRunning > 0 ? copy.subagents(subagentsRunning) - : bgFailed > 0 - ? copy.failed(bgFailed) - : bgRunning > 0 - ? copy.running(bgRunning) - : undefined, + : subagentsFailed > 0 + ? copy.failed(subagentsFailed) + : undefined, icon: - bgFailed > 0 ? ( + subagentsFailed > 0 ? ( <AlertCircle className="size-3" /> - ) : bgRunning > 0 || subagentsRunning > 0 ? ( + ) : subagentsRunning > 0 ? ( <Loader2 className="size-3 animate-spin" /> ) : ( <Sparkles className="size-3" /> @@ -356,8 +341,6 @@ export function useStatusbarItems({ ], [ agentsOpen, - bgFailed, - bgRunning, commandCenterOpen, copy, gatewayMenuContent, @@ -367,6 +350,7 @@ export function useStatusbarItems({ inferenceReady, inferenceStatus?.reason, openAgents, + subagentsFailed, subagentsRunning, toggleCommandCenter ] -
apps/desktop/src/store/subagents.test.ts@@ -3,8 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { $subagentsBySession, activeSubagentCount, + allSubagents, buildSubagentTree, clearSessionSubagents, + failedSubagentCount, pruneDelegateFallbackSubagents, upsertSubagent } from './subagents' @@ -99,6 +101,27 @@ describe('subagent store', () => { expect(listFor('s1').map(item => item.id)).toEqual(['sa-0-xyz']) }) + // Contract: the status-bar "Agents" indicator and the Spawn-tree panel read + // the same scope — every session's subagents — so a count can never point at + // an empty tree (the desync behind "Agents (N)" vs "No live subagents"). + it('counts running/failed across every session, matching the aggregated tree', () => { + upsertSubagent('s1', { goal: 'a', status: 'running', subagent_id: 'a', task_index: 0 }) + upsertSubagent('s1', { goal: 'b', status: 'failed', subagent_id: 'b', task_index: 1 }) + upsertSubagent('s2', { goal: 'c', status: 'running', subagent_id: 'c', task_index: 0 }) + upsertSubagent('s2', { goal: 'd', status: 'failed', subagent_id: 'd', task_index: 1 }) + + const flat = allSubagents($subagentsBySession.get()) + const indicatorRunning = Object.values($subagentsBySession.get()).reduce((n, l) => n + activeSubagentCount(l), 0) + const indicatorFailed = Object.values($subagentsBySession.get()).reduce((n, l) => n + failedSubagentCount(l), 0) + const tree = buildSubagentTree(flat) + + // The active-session-only filter would have reported 1/1 here, not 2/2. + expect(indicatorRunning).toBe(2) + expect(indicatorFailed).toBe(2) + expect(tree).toHaveLength(4) + expect(indicatorRunning + indicatorFailed).toBe(tree.length) + }) + it('clears one session without touching another', () => { upsertSubagent('s1', { goal: 'one', status: 'running', subagent_id: 'a1', task_index: 0 }) upsertSubagent('s2', { goal: 'two', status: 'running', subagent_id: 'a2', task_index: 0 }) -
apps/desktop/src/store/subagents.ts@@ -261,3 +261,10 @@ export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentN export const activeSubagentCount = (items: readonly SubagentProgress[]) => items.filter(item => item.status === 'queued' || item.status === 'running').length + +export const failedSubagentCount = (items: readonly SubagentProgress[]) => + items.filter(item => item.status === 'failed' || item.status === 'interrupted').length + +/** Flatten every session's subagents — the scope the Spawn-tree panel and the + * status-bar indicator must agree on. */ +export const allSubagents = (bySession: Record<string, SubagentProgress[]>) => Object.values(bySession).flat()
-
1 PR closed without merging
From the close comments: superseded by another PR 1
-
#49819 fix(desktop): unify Spawn tree scope with status bar indicator · superseded by another PR →
#52183
Closed issues · 1
- Closed#49808Desktop: Spawn tree shows empty state while status bar reports 'Agents 2 running' — desynced subagent state
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- apps/desktop
- Merged PRs
- #52183
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 25 Jun 2026