From 21f0ccb184e7aacaff1d6114606161d7492273d2 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 29 Mar 2026 00:04:05 +0100 Subject: [PATCH] feat: show sub-agent activity cards in chat stream Display compact status cards in the chat transcript showing each sub-agent's name, current activity (Thinking, Using grep, etc.), and elapsed time. Cards appear while sub-agents are running and clear when the session goes idle. - Extend SessionEventRecord with description, error, toolCallId, and model fields for subagent events - Forward additional subagent fields in handleTurnScopedEvent - Add subagentTracker reducer to derive active subagent state from session events, including agent-activity correlation - Create SubagentActivityCard component with spinner, status icon, agent name, activity label, and live elapsed timer - Wire activeSubagents state in App.tsx and pass to ChatPane - Add 16 unit tests for the subagent tracker Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main/AryxAppService.ts | 4 + src/renderer/App.tsx | 14 + src/renderer/components/ChatPane.tsx | 9 + .../components/chat/SubagentActivityCard.tsx | 89 +++++++ src/renderer/lib/subagentTracker.ts | 162 ++++++++++++ src/shared/domain/event.ts | 4 + tests/renderer/subagentTracker.test.ts | 246 ++++++++++++++++++ 7 files changed, 528 insertions(+) create mode 100644 src/renderer/components/chat/SubagentActivityCard.tsx create mode 100644 src/renderer/lib/subagentTracker.ts create mode 100644 tests/renderer/subagentTracker.test.ts diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index ac53fa5..bc6db5c 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -1827,6 +1827,10 @@ export class AryxAppService extends EventEmitter { subagentEventKind: event.eventKind, customAgentName: event.customAgentName, customAgentDisplayName: event.customAgentDisplayName, + customAgentDescription: event.customAgentDescription, + subagentError: event.error, + subagentToolCallId: event.toolCallId, + subagentModel: event.model, }); return; case 'skill-invoked': diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5a8aec0..64e14fb 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -21,6 +21,7 @@ import { type SessionUsageMap, type TurnEventLogMap, } from '@renderer/lib/sessionActivity'; +import { applySubagentEvent, pruneSubagentMap, type ActiveSubagentMap } from '@renderer/lib/subagentTracker'; import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace'; import { WelcomePane } from '@renderer/components/WelcomePane'; import { getElectronApi } from '@renderer/lib/electronApi'; @@ -101,6 +102,7 @@ export default function App() { const [sessionActivities, setSessionActivities] = useState({}); const [sessionUsage, setSessionUsage] = useState({}); const [turnEventLogs, setTurnEventLogs] = useState({}); + const [activeSubagents, setActiveSubagents] = useState({}); const [showSettings, setShowSettings] = useState(false); const [projectSettingsId, setProjectSettingsId] = useState(); @@ -144,6 +146,12 @@ export default function App() { ws.sessions.map((session) => session.id), ), ); + setActiveSubagents((current) => + pruneSubagentMap( + current, + ws.sessions.map((session) => session.id), + ), + ); }); const offSessionEvent = api.onSessionEvent((event) => { @@ -151,6 +159,7 @@ export default function App() { setSessionActivities((current) => applySessionEventActivity(current, event)); setSessionUsage((current) => applySessionUsageEvent(current, event)); setTurnEventLogs((current) => applyTurnEventLog(current, event)); + setActiveSubagents((current) => applySubagentEvent(current, event)); }); return () => { @@ -205,6 +214,10 @@ export default function App() { () => (selectedSession ? sessionUsage[selectedSession.id] : undefined), [selectedSession, sessionUsage], ); + const subagentsForSession = useMemo( + () => (selectedSession ? activeSubagents[selectedSession.id] : undefined), + [selectedSession, activeSubagents], + ); const turnEventsForSession = useMemo( () => (selectedSession ? turnEventLogs[selectedSession.id] : undefined), [selectedSession, turnEventLogs], @@ -388,6 +401,7 @@ export default function App() { runtimeTools={sidecarCapabilities?.runtimeTools} session={selectedSession} sessionUsage={usageForSession} + activeSubagents={subagentsForSession} terminalOpen={terminalOpen} terminalRunning={terminalRunning} toolingSettings={chatToolingSettings ?? workspace.settings.tooling} diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index af4b80b..d9e1f86 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -10,12 +10,14 @@ import { UserInputBanner } from '@renderer/components/chat/UserInputBanner'; import { InlineApprovalPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills'; import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill'; import { ThinkingDots } from '@renderer/components/chat/ThinkingDots'; +import { SubagentActivityList } from '@renderer/components/chat/SubagentActivityCard'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; import type { ApprovalDecision } from '@shared/domain/approval'; import type { InteractionMode, MessageMode } from '@shared/contracts/sidecar'; import type { ChatMessageAttachment } from '@shared/domain/attachment'; import { getAttachmentDisplayName, isImageAttachment } from '@shared/domain/attachment'; import type { SessionUsageState } from '@renderer/lib/sessionActivity'; +import type { ActiveSubagent } from '@renderer/lib/subagentTracker'; import { findModel, getSupportedReasoningEfforts, @@ -44,6 +46,7 @@ interface ChatPaneProps { mcpProbingServerIds?: string[]; runtimeTools?: ReadonlyArray; sessionUsage?: SessionUsageState; + activeSubagents?: ReadonlyArray; terminalOpen?: boolean; terminalRunning?: boolean; onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise; @@ -72,6 +75,7 @@ export function ChatPane({ mcpProbingServerIds, runtimeTools, sessionUsage, + activeSubagents, terminalOpen, terminalRunning, onSend, @@ -409,6 +413,11 @@ export function ChatPane({ ); })} + {activeSubagents && activeSubagents.length > 0 && ( +
+ +
+ )} )} diff --git a/src/renderer/components/chat/SubagentActivityCard.tsx b/src/renderer/components/chat/SubagentActivityCard.tsx new file mode 100644 index 0000000..24674b3 --- /dev/null +++ b/src/renderer/components/chat/SubagentActivityCard.tsx @@ -0,0 +1,89 @@ +import { useEffect, useState } from 'react'; +import { Bot, CheckCircle2, Loader2, XCircle } from 'lucide-react'; + +import type { ActiveSubagent } from '@renderer/lib/subagentTracker'; + +function formatElapsed(startedAt: string): string { + const seconds = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return `${minutes}m ${remainder}s`; +} + +function StatusIcon({ status }: { status: ActiveSubagent['status'] }) { + switch (status) { + case 'running': + return ; + case 'completed': + return ; + case 'failed': + return ; + } +} + +function ElapsedTimer({ startedAt }: { startedAt: string }) { + const [elapsed, setElapsed] = useState(() => formatElapsed(startedAt)); + + useEffect(() => { + const id = setInterval(() => setElapsed(formatElapsed(startedAt)), 1000); + return () => clearInterval(id); + }, [startedAt]); + + return ( + {elapsed} + ); +} + +interface SubagentActivityCardProps { + subagent: ActiveSubagent; +} + +function SubagentActivityCard({ subagent }: SubagentActivityCardProps) { + const borderClass = + subagent.status === 'running' + ? 'border-sky-500/20' + : subagent.status === 'failed' + ? 'border-red-500/20' + : 'border-emerald-500/20'; + + return ( +
+ + + {subagent.name} + + {subagent.activityLabel} + {subagent.status === 'running' && } + {subagent.error && ( + + {subagent.error} + + )} +
+ ); +} + +interface SubagentActivityListProps { + subagents: ReadonlyArray; +} + +export function SubagentActivityList({ subagents }: SubagentActivityListProps) { + if (subagents.length === 0) return null; + + // Only show running subagents in the chat stream + const visible = subagents.filter((s) => s.status === 'running'); + if (visible.length === 0) return null; + + return ( +
+ {visible.map((subagent) => ( + + ))} +
+ ); +} diff --git a/src/renderer/lib/subagentTracker.ts b/src/renderer/lib/subagentTracker.ts new file mode 100644 index 0000000..3dee78e --- /dev/null +++ b/src/renderer/lib/subagentTracker.ts @@ -0,0 +1,162 @@ +import type { SessionEventRecord, SubagentEventKind } from '@shared/domain/event'; + +export interface ActiveSubagent { + toolCallId: string; + name: string; + description?: string; + model?: string; + activityLabel: string; + startedAt: string; + status: 'running' | 'completed' | 'failed'; + error?: string; +} + +export type ActiveSubagentMap = Record | undefined>; + +function subagentKey(event: SessionEventRecord): string { + return event.subagentToolCallId ?? event.customAgentName ?? 'unknown'; +} + +function subagentDisplayName(event: SessionEventRecord): string { + return event.customAgentDisplayName ?? event.customAgentName ?? 'Sub-agent'; +} + +function activityLabelForKind(kind: SubagentEventKind | undefined): string { + switch (kind) { + case 'started': + return 'Starting…'; + case 'selected': + return 'Selected'; + case 'completed': + return 'Completed'; + case 'failed': + return 'Failed'; + case 'deselected': + return 'Deselected'; + default: + return 'Working…'; + } +} + +export function applySubagentEvent( + current: ActiveSubagentMap, + event: SessionEventRecord, +): ActiveSubagentMap { + if (event.kind !== 'subagent') { + // Clear all subagents when session goes idle + if (event.kind === 'status' && event.status === 'idle') { + if (!current[event.sessionId]?.length) return current; + const next = { ...current }; + delete next[event.sessionId]; + return next; + } + + // Update running subagent activity from agent-activity events + if (event.kind === 'agent-activity' && event.agentName) { + const existing = current[event.sessionId]; + if (!existing?.length) return current; + + const agentName = event.agentName.trim(); + const hasMatch = existing.some( + (s) => s.status === 'running' && s.name === agentName, + ); + if (!hasMatch) return current; + + let label: string; + switch (event.activityType) { + case 'tool-calling': + label = `Using ${event.toolName?.trim() || 'a tool'}…`; + break; + case 'thinking': + label = 'Thinking…'; + break; + case 'handoff': + label = 'Handling handoff…'; + break; + default: + return current; + } + + return { + ...current, + [event.sessionId]: existing.map((s) => + s.status === 'running' && s.name === agentName + ? { ...s, activityLabel: label } + : s, + ), + }; + } + + return current; + } + + const key = subagentKey(event); + const existing = current[event.sessionId] ?? []; + + switch (event.subagentEventKind) { + case 'started': { + const entry: ActiveSubagent = { + toolCallId: key, + name: subagentDisplayName(event), + description: event.customAgentDescription, + model: event.subagentModel, + activityLabel: activityLabelForKind('started'), + startedAt: event.occurredAt, + status: 'running', + }; + return { + ...current, + [event.sessionId]: [...existing.filter((s) => s.toolCallId !== key), entry], + }; + } + + case 'completed': { + return { + ...current, + [event.sessionId]: existing.map((s) => + s.toolCallId === key + ? { ...s, status: 'completed' as const, activityLabel: activityLabelForKind('completed') } + : s, + ), + }; + } + + case 'failed': { + return { + ...current, + [event.sessionId]: existing.map((s) => + s.toolCallId === key + ? { + ...s, + status: 'failed' as const, + activityLabel: activityLabelForKind('failed'), + error: event.subagentError, + } + : s, + ), + }; + } + + default: + return current; + } +} + +export function pruneSubagentMap( + current: ActiveSubagentMap, + sessionIds: Iterable, +): ActiveSubagentMap { + const allowed = new Set(sessionIds); + const next: ActiveSubagentMap = {}; + let changed = false; + + for (const [sessionId, subagents] of Object.entries(current)) { + if (!allowed.has(sessionId)) { + changed = true; + continue; + } + next[sessionId] = subagents; + } + + return changed || Object.keys(next).length !== Object.keys(current).length ? next : current; +} diff --git a/src/shared/domain/event.ts b/src/shared/domain/event.ts index e9e792c..01a4e33 100644 --- a/src/shared/domain/event.ts +++ b/src/shared/domain/event.ts @@ -40,6 +40,10 @@ export interface SessionEventRecord { subagentEventKind?: SubagentEventKind; customAgentName?: string; customAgentDisplayName?: string; + customAgentDescription?: string; + subagentError?: string; + subagentToolCallId?: string; + subagentModel?: string; // Skill invoked fields skillName?: string; diff --git a/tests/renderer/subagentTracker.test.ts b/tests/renderer/subagentTracker.test.ts new file mode 100644 index 0000000..d70b6be --- /dev/null +++ b/tests/renderer/subagentTracker.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, test } from 'bun:test'; + +import { + applySubagentEvent, + pruneSubagentMap, + type ActiveSubagentMap, +} from '@renderer/lib/subagentTracker'; +import type { SessionEventRecord } from '@shared/domain/event'; + +function subagentStarted(overrides: Partial = {}): SessionEventRecord { + return { + sessionId: 'session-1', + kind: 'subagent', + occurredAt: '2026-03-28T10:00:00.000Z', + subagentEventKind: 'started', + customAgentName: 'explore', + customAgentDisplayName: 'Explore Agent', + customAgentDescription: 'Explores the codebase', + subagentToolCallId: 'tc-1', + subagentModel: 'claude-haiku-4.5', + ...overrides, + }; +} + +function subagentCompleted(overrides: Partial = {}): SessionEventRecord { + return { + sessionId: 'session-1', + kind: 'subagent', + occurredAt: '2026-03-28T10:00:05.000Z', + subagentEventKind: 'completed', + customAgentName: 'explore', + customAgentDisplayName: 'Explore Agent', + subagentToolCallId: 'tc-1', + ...overrides, + }; +} + +function subagentFailed(overrides: Partial = {}): SessionEventRecord { + return { + sessionId: 'session-1', + kind: 'subagent', + occurredAt: '2026-03-28T10:00:05.000Z', + subagentEventKind: 'failed', + customAgentName: 'explore', + customAgentDisplayName: 'Explore Agent', + subagentToolCallId: 'tc-1', + subagentError: 'Something went wrong', + ...overrides, + }; +} + +function agentActivity(overrides: Partial = {}): SessionEventRecord { + return { + sessionId: 'session-1', + kind: 'agent-activity', + occurredAt: '2026-03-28T10:00:01.000Z', + activityType: 'thinking', + agentName: 'Explore Agent', + ...overrides, + }; +} + +describe('subagent tracker', () => { + describe('applySubagentEvent', () => { + test('adds a running subagent on started event', () => { + const result = applySubagentEvent({}, subagentStarted()); + + const subagents = result['session-1']; + expect(subagents).toHaveLength(1); + expect(subagents![0]).toEqual(expect.objectContaining({ + toolCallId: 'tc-1', + name: 'Explore Agent', + description: 'Explores the codebase', + model: 'claude-haiku-4.5', + status: 'running', + })); + }); + + test('marks subagent completed', () => { + let state: ActiveSubagentMap = {}; + state = applySubagentEvent(state, subagentStarted()); + state = applySubagentEvent(state, subagentCompleted()); + + const subagents = state['session-1']; + expect(subagents).toHaveLength(1); + expect(subagents![0]!.status).toBe('completed'); + expect(subagents![0]!.activityLabel).toBe('Completed'); + }); + + test('marks subagent failed with error', () => { + let state: ActiveSubagentMap = {}; + state = applySubagentEvent(state, subagentStarted()); + state = applySubagentEvent(state, subagentFailed()); + + const subagents = state['session-1']; + expect(subagents).toHaveLength(1); + expect(subagents![0]!.status).toBe('failed'); + expect(subagents![0]!.error).toBe('Something went wrong'); + }); + + test('tracks multiple subagents concurrently', () => { + let state: ActiveSubagentMap = {}; + state = applySubagentEvent(state, subagentStarted({ subagentToolCallId: 'tc-1', customAgentDisplayName: 'Agent A' })); + state = applySubagentEvent(state, subagentStarted({ subagentToolCallId: 'tc-2', customAgentDisplayName: 'Agent B' })); + + const subagents = state['session-1']; + expect(subagents).toHaveLength(2); + expect(subagents![0]!.name).toBe('Agent A'); + expect(subagents![1]!.name).toBe('Agent B'); + }); + + test('completes one subagent while another keeps running', () => { + let state: ActiveSubagentMap = {}; + state = applySubagentEvent(state, subagentStarted({ subagentToolCallId: 'tc-1', customAgentDisplayName: 'Agent A' })); + state = applySubagentEvent(state, subagentStarted({ subagentToolCallId: 'tc-2', customAgentDisplayName: 'Agent B' })); + state = applySubagentEvent(state, subagentCompleted({ subagentToolCallId: 'tc-1' })); + + const subagents = state['session-1']; + expect(subagents).toHaveLength(2); + expect(subagents![0]!.status).toBe('completed'); + expect(subagents![1]!.status).toBe('running'); + }); + + test('clears subagents when session goes idle', () => { + let state: ActiveSubagentMap = {}; + state = applySubagentEvent(state, subagentStarted()); + state = applySubagentEvent(state, { + sessionId: 'session-1', + kind: 'status', + occurredAt: '2026-03-28T10:00:10.000Z', + status: 'idle', + }); + + expect(state['session-1']).toBeUndefined(); + }); + + test('returns same reference when idle event has no subagents', () => { + const state: ActiveSubagentMap = {}; + const result = applySubagentEvent(state, { + sessionId: 'session-1', + kind: 'status', + occurredAt: '2026-03-28T10:00:10.000Z', + status: 'idle', + }); + + expect(result).toBe(state); + }); + + test('ignores unrelated event kinds', () => { + const state: ActiveSubagentMap = {}; + const result = applySubagentEvent(state, { + sessionId: 'session-1', + kind: 'message-delta', + occurredAt: '2026-03-28T10:00:00.000Z', + contentDelta: 'hello', + }); + + expect(result).toBe(state); + }); + + test('updates activity label from agent-activity event', () => { + let state: ActiveSubagentMap = {}; + state = applySubagentEvent(state, subagentStarted()); + state = applySubagentEvent(state, agentActivity({ + activityType: 'tool-calling', + toolName: 'grep', + })); + + expect(state['session-1']![0]!.activityLabel).toBe('Using grep…'); + }); + + test('updates activity label to thinking', () => { + let state: ActiveSubagentMap = {}; + state = applySubagentEvent(state, subagentStarted()); + state = applySubagentEvent(state, agentActivity({ + activityType: 'thinking', + })); + + expect(state['session-1']![0]!.activityLabel).toBe('Thinking…'); + }); + + test('ignores agent-activity for non-matching agent name', () => { + let state: ActiveSubagentMap = {}; + state = applySubagentEvent(state, subagentStarted()); + const before = state; + state = applySubagentEvent(state, agentActivity({ + agentName: 'Completely Different Agent', + activityType: 'thinking', + })); + + expect(state).toBe(before); + }); + + test('does not update completed subagent from agent-activity', () => { + let state: ActiveSubagentMap = {}; + state = applySubagentEvent(state, subagentStarted()); + state = applySubagentEvent(state, subagentCompleted()); + const before = state; + state = applySubagentEvent(state, agentActivity({ + activityType: 'thinking', + })); + + expect(state).toBe(before); + }); + + test('uses customAgentName when displayName is absent', () => { + const result = applySubagentEvent({}, subagentStarted({ + customAgentDisplayName: undefined, + customAgentName: 'task-agent', + })); + + expect(result['session-1']![0]!.name).toBe('task-agent'); + }); + + test('uses toolCallId as key, falls back to customAgentName', () => { + const result = applySubagentEvent({}, subagentStarted({ + subagentToolCallId: undefined, + customAgentName: 'fallback-key', + })); + + expect(result['session-1']![0]!.toolCallId).toBe('fallback-key'); + }); + }); + + describe('pruneSubagentMap', () => { + test('removes entries for deleted sessions', () => { + const state: ActiveSubagentMap = { + 'session-1': [{ toolCallId: 'tc-1', name: 'A', activityLabel: 'Working…', startedAt: '', status: 'running' }], + 'session-2': [{ toolCallId: 'tc-2', name: 'B', activityLabel: 'Working…', startedAt: '', status: 'running' }], + }; + + const result = pruneSubagentMap(state, ['session-1']); + expect(result['session-1']).toBeDefined(); + expect(result['session-2']).toBeUndefined(); + }); + + test('returns same reference when nothing is pruned', () => { + const state: ActiveSubagentMap = { + 'session-1': [], + }; + + const result = pruneSubagentMap(state, ['session-1']); + expect(result).toBe(state); + }); + }); +});