From a268dfff0a055dad7d73d45ede53a5687159a65c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 24 Jun 2026 18:16:14 -0500 Subject: [PATCH] fix(desktop): make Agents indicator match the Spawn-tree panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status-bar "Agents" item conflated three unrelated signals — running subagents (aggregated across all sessions), in-flight session turns, and failed background *system* actions (gateway restarts, toolset installs, computer-use grants via $desktopActionTasks/preview restart) — yet clicking it opens AgentsView, which renders only subagents. A failed gateway restart therefore showed "Agents (1 Failed)" over an empty "No live subagents" tree. AgentsView also filtered to the active session, so a subagent running in a background session showed "Agents N running" with nothing in the tree (the desync reported in #49808). Unify the scope both surfaces speak: - AgentsView aggregates subagents across every session (salvages #49819). - The indicator's running/failed counts come from subagents only (aggregated), never background system actions — those keep their own surfaces in settings / command center. So "Agents (N …)" now always points at a populated Spawn tree. Supersedes #49819. Fixes #49808. --- apps/desktop/src/app/agents/index.tsx | 13 ++--- .../app/shell/hooks/use-statusbar-items.tsx | 48 +++++++------------ apps/desktop/src/store/subagents.test.ts | 23 +++++++++ apps/desktop/src/store/subagents.ts | 7 +++ 4 files changed, 51 insertions(+), 40 deletions(-) diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index 6a1fbf9eeea..20958c00939 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/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 ( { - 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 ? ( - ) : bgRunning > 0 || subagentsRunning > 0 ? ( + ) : subagentsRunning > 0 ? ( ) : ( @@ -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 ] diff --git a/apps/desktop/src/store/subagents.test.ts b/apps/desktop/src/store/subagents.test.ts index 6dee494e2ef..c0b87ba58ea 100644 --- a/apps/desktop/src/store/subagents.test.ts +++ b/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 }) diff --git a/apps/desktop/src/store/subagents.ts b/apps/desktop/src/store/subagents.ts index 2b406e3f539..c4695db3bd2 100644 --- a/apps/desktop/src/store/subagents.ts +++ b/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) => Object.values(bySession).flat()