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>
This commit is contained in:
David Kaya
2026-03-29 00:04:05 +01:00
co-authored by Copilot
parent 7921b6648f
commit 21f0ccb184
7 changed files with 528 additions and 0 deletions
+4
View File
@@ -1827,6 +1827,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
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':
+14
View File
@@ -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<SessionActivityMap>({});
const [sessionUsage, setSessionUsage] = useState<SessionUsageMap>({});
const [turnEventLogs, setTurnEventLogs] = useState<TurnEventLogMap>({});
const [activeSubagents, setActiveSubagents] = useState<ActiveSubagentMap>({});
const [showSettings, setShowSettings] = useState(false);
const [projectSettingsId, setProjectSettingsId] = useState<string>();
@@ -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}
+9
View File
@@ -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<RuntimeToolDefinition>;
sessionUsage?: SessionUsageState;
activeSubagents?: ReadonlyArray<ActiveSubagent>;
terminalOpen?: boolean;
terminalRunning?: boolean;
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>;
@@ -72,6 +75,7 @@ export function ChatPane({
mcpProbingServerIds,
runtimeTools,
sessionUsage,
activeSubagents,
terminalOpen,
terminalRunning,
onSend,
@@ -409,6 +413,11 @@ export function ChatPane({
);
})}
</div>
{activeSubagents && activeSubagents.length > 0 && (
<div className="px-6 py-1">
<SubagentActivityList subagents={activeSubagents} />
</div>
)}
</div>
)}
</div>
@@ -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 <Loader2 className="size-3.5 animate-spin text-sky-400" aria-label="Running" />;
case 'completed':
return <CheckCircle2 className="size-3.5 text-emerald-400" aria-label="Completed" />;
case 'failed':
return <XCircle className="size-3.5 text-red-400" aria-label="Failed" />;
}
}
function ElapsedTimer({ startedAt }: { startedAt: string }) {
const [elapsed, setElapsed] = useState(() => formatElapsed(startedAt));
useEffect(() => {
const id = setInterval(() => setElapsed(formatElapsed(startedAt)), 1000);
return () => clearInterval(id);
}, [startedAt]);
return (
<span className="ml-auto shrink-0 text-[10px] tabular-nums text-zinc-600">{elapsed}</span>
);
}
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 (
<div
className={`flex items-center gap-2 rounded-lg border bg-zinc-900/60 px-3 py-1.5 ${borderClass}`}
role="status"
aria-label={`Sub-agent ${subagent.name}: ${subagent.activityLabel}`}
>
<StatusIcon status={subagent.status} />
<Bot className="size-3 text-zinc-500" />
<span className="text-[11px] font-medium text-zinc-300">{subagent.name}</span>
<span className="text-[10px] text-zinc-600"></span>
<span className="text-[10px] text-zinc-500">{subagent.activityLabel}</span>
{subagent.status === 'running' && <ElapsedTimer startedAt={subagent.startedAt} />}
{subagent.error && (
<span className="truncate text-[10px] text-red-400" title={subagent.error}>
{subagent.error}
</span>
)}
</div>
);
}
interface SubagentActivityListProps {
subagents: ReadonlyArray<ActiveSubagent>;
}
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 (
<div className="flex flex-col gap-1 py-1" aria-label="Active sub-agents">
{visible.map((subagent) => (
<SubagentActivityCard key={subagent.toolCallId} subagent={subagent} />
))}
</div>
);
}
+162
View File
@@ -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<string, ReadonlyArray<ActiveSubagent> | 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<string>,
): 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;
}
+4
View File
@@ -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;
+246
View File
@@ -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> = {}): 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> = {}): 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> = {}): 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> = {}): 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);
});
});
});