diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index f58d211293e..c9e063c344a 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -1,15 +1,32 @@ import { useStore } from '@nanostores/react' -import { useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' -import { Activity, AlertCircle, Layers3, Loader2, type LucideIcon, RefreshCw, Sparkles } from '@/lib/icons' +import { useElapsedSeconds } from '@/components/chat/activity-timer' +import { ActivityTimerText } from '@/components/chat/activity-timer-text' +import { BrailleSpinner } from '@/components/ui/braille-spinner' +import { FadeText } from '@/components/ui/fade-text' +import { + Activity, + AlertCircle, + Layers3, + Loader2, + type LucideIcon, + RefreshCw, + Sparkles +} from '@/lib/icons' import { cn } from '@/lib/utils' import { $desktopActionTasks, buildRailTasks, type RailTask, type RailTaskStatus } from '@/store/activity' import { $previewServerRestart } from '@/store/preview' import { $activeSessionId, $sessions, $workingSessionIds } from '@/store/session' -import { $subagentsBySession, buildSubagentTree, type SubagentNode, type SubagentStatus } from '@/store/subagents' +import { + $subagentsBySession, + buildSubagentTree, + type SubagentNode, + type SubagentStatus, + type SubagentStreamEntry +} from '@/store/subagents' import { useRouteEnumParam } from '../hooks/use-route-enum-param' -import { OverlayCard } from '../overlays/overlay-chrome' import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout' import { OverlayView } from '../overlays/overlay-view' @@ -30,18 +47,40 @@ const SECTIONS: readonly SectionDef[] = [ const SECTION_IDS = SECTIONS.map(s => s.id) as readonly AgentsSection[] -const STATUS_TONE: Record = { +const RAIL_TONE: Record = { error: 'text-destructive', running: 'text-foreground', success: 'text-emerald-500' } -const STATUS_ICON: Record = { +const RAIL_ICON: Record = { error: AlertCircle, running: Loader2, success: Sparkles } +const STATUS_GLYPH: Record = { + completed: '✓', + failed: '✗', + interrupted: '■', + queued: '○', + running: '●' +} + +const STREAM_GLYPH: Record = { + progress: '·', + summary: '✓', + thinking: '💭', + tool: '●' +} + +const STREAM_TONE: Record = { + progress: 'text-muted-foreground/75', + summary: 'text-foreground/85', + thinking: 'text-muted-foreground/80', + tool: 'text-foreground/85' +} + interface AgentsViewProps { initialSection?: AgentsSection onClose: () => void @@ -63,7 +102,12 @@ export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps ) const active = SECTIONS.find(s => s.id === section) ?? SECTIONS[0]! - const activeSubagents = activeSessionId ? (subagentsBySession[activeSessionId] ?? []) : [] + + const activeSubagents = useMemo( + () => (activeSessionId ? (subagentsBySession[activeSessionId] ?? []) : []), + [activeSessionId, subagentsBySession] + ) + const tree = useMemo(() => buildSubagentTree(activeSubagents), [activeSubagents]) return ( @@ -100,106 +144,302 @@ export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps ) } -const STATUS_CLASS: Record = { - completed: 'text-emerald-500', - failed: 'text-destructive', - interrupted: 'text-amber-500', - queued: 'text-muted-foreground', - running: 'text-primary' +const fmtDuration = (seconds?: number) => { + if (!seconds || seconds <= 0) return '' + if (seconds < 60) return `${seconds.toFixed(1)}s` + + const m = Math.floor(seconds / 60) + const s = Math.round(seconds % 60) + + return `${m}m ${s}s` +} + +const fmtTokens = (value?: number) => { + if (!value) return '' + + return value >= 1000 ? `${(value / 1000).toFixed(1)}k tok` : `${value} tok` +} + +const fmtAge = (updatedAt: number, nowMs: number) => { + const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000)) + if (s < 2) return 'now' + if (s < 60) return `${s}s ago` + + const m = Math.floor(s / 60) + if (m < 60) return `${m}m ago` + + return `${Math.floor(m / 60)}h ago` +} + +const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] => + nodes.flatMap(node => [node, ...flatten(node.children)]) + +interface RootGroup { + id: string + label: string + nodes: SubagentNode[] + taskCount: number +} + +function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] { + const groups: RootGroup[] = [] + let n = 0 + + for (const node of roots) { + const prev = groups.at(-1) + const prevTail = prev?.nodes.at(-1) + const closeInTime = prevTail ? Math.abs(node.startedAt - prevTail.startedAt) <= 5_000 : false + const sameShape = prev && node.taskCount > 1 && prev.taskCount === node.taskCount + const uniqueStep = prev ? !prev.nodes.some(item => item.taskIndex === node.taskIndex) : false + + if (prev && sameShape && closeInTime && uniqueStep) { + prev.nodes.push(node) + continue + } + + if (node.taskCount > 1) { + n += 1 + groups.push({ id: `delegation-${n}`, label: `Delegation ${n}`, nodes: [node], taskCount: node.taskCount }) + continue + } + + groups.push({ id: node.id, label: '', nodes: [node], taskCount: node.taskCount }) + } + + return groups } function SubagentTree({ tree }: { tree: SubagentNode[] }) { + const flat = useMemo(() => flatten(tree), [tree]) + const groups = useMemo(() => groupDelegations(tree), [tree]) + const [nowMs, setNowMs] = useState(() => Date.now()) + + const active = flat.filter(n => n.status === 'running' || n.status === 'queued').length + const failed = flat.filter(n => n.status === 'failed' || n.status === 'interrupted').length + const tools = flat.reduce((sum, n) => sum + (n.toolCount ?? 0), 0) + const files = flat.reduce((sum, n) => sum + n.filesRead.length + n.filesWritten.length, 0) + const tokens = flat.reduce((sum, n) => sum + (n.inputTokens ?? 0) + (n.outputTokens ?? 0), 0) + const cost = flat.reduce((sum, n) => sum + (n.costUsd ?? 0), 0) + + useEffect(() => { + if (active <= 0 || typeof window === 'undefined') return + + const id = window.setInterval(() => setNowMs(Date.now()), 500) + + return () => window.clearInterval(id) + }, [active]) + if (tree.length === 0) { return ( - - -
-

No live subagents

-

- When a turn delegates work, child agents appear here as a live spawn tree. -

-
-
+
+ +

No live subagents

+

+ When a turn delegates work, child agents stream their progress here. +

+
) } + const summary = [ + `${flat.length} ${flat.length === 1 ? 'agent' : 'agents'}`, + active > 0 ? `${active} active` : '', + failed > 0 ? `${failed} failed` : '', + tools > 0 ? `${tools} tools` : '', + files > 0 ? `${files} files` : '', + tokens > 0 ? fmtTokens(tokens) : '', + cost > 0 ? `$${cost.toFixed(2)}` : '' + ].filter(Boolean) + return ( -
- {tree.map(node => ( - - ))} +
+

{summary.join(' · ')}

+
+
+ {groups.map(group => ( + + ))} +
+
) } -function SubagentRow({ node, depth = 0 }: { node: SubagentNode; depth?: number }) { - const running = node.status === 'running' || node.status === 'queued' +function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) { + if (group.nodes.length === 1 && group.taskCount <= 1) { + return + } + + const activeWorkers = group.nodes.filter(n => n.status === 'running' || n.status === 'queued').length return ( - -
- {running ? ( - - ) : ( - - )} -
-
-
{node.goal}
- {node.status} -
-
- {node.model && {node.model}} - {typeof node.durationSeconds === 'number' && {node.durationSeconds.toFixed(1)}s} - {typeof node.costUsd === 'number' && ${node.costUsd.toFixed(4)}} - {typeof node.apiCalls === 'number' && {node.apiCalls} calls} -
- {(node.toolName || node.toolPreview || node.summary) && ( -
- {node.summary || [node.toolName, node.toolPreview].filter(Boolean).join(' · ')} -
- )} -
+
+

+ {group.label} · {group.nodes.length} workers + {activeWorkers > 0 ? · {activeWorkers} active : null} +

+
+ {group.nodes.map(node => ( + + ))}
- {node.children.length > 0 && ( -
- {node.children.map(child => ( - +
+ ) +} + +function StreamLine({ active, entry }: { active: boolean; entry: SubagentStreamEntry }) { + const isMono = entry.kind === 'tool' + const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind] + + return ( +
+ {STREAM_GLYPH[entry.kind]} + + {entry.text} + {active ? ( + + ) : null} + +
+ ) +} + +function indicatorTone(status: SubagentStatus, running: boolean): string { + if (running) return 'bg-primary/15 text-primary ring-2 ring-primary/30' + if (status === 'failed' || status === 'interrupted') return 'bg-destructive/15 text-destructive' + if (status === 'completed') return 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400' + + return 'bg-muted text-muted-foreground/85' +} + +function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: number; nowMs: number }) { + const running = node.status === 'running' || node.status === 'queued' + const elapsed = useElapsedSeconds(running, `subagent:${node.id}`) + const durationSeconds = + typeof node.durationSeconds === 'number' ? Math.max(0, Math.round(node.durationSeconds)) : elapsed + const [open, setOpen] = useState(() => running || depth < 2) + + useEffect(() => { + if (running) setOpen(true) + }, [running]) + + const visibleRows = open ? node.stream.slice(-10) : node.stream.slice(-2) + const fileLines = [...node.filesWritten.map(p => `+ ${p}`), ...node.filesRead.map(p => `· ${p}`)] + const step = node.taskCount > 1 ? `${node.taskIndex + 1}` : '' + + const subtitle = [ + node.model, + fmtDuration(durationSeconds), + node.toolCount ? `${node.toolCount} tools` : '', + fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0)), + `updated ${fmtAge(node.updatedAt, nowMs)}` + ].filter(Boolean) + + return ( +
0 && 'pl-4')}> + + + {visibleRows.length > 0 ? ( +
+ {visibleRows.map((entry, i) => ( + ))}
- )} - + ) : null} + + {open && fileLines.length > 0 ? ( +
+

Files

+ {fileLines.slice(0, 8).map(line => ( +

+ {line} +

+ ))} + {fileLines.length > 8 ? ( +

+ +{fileLines.length - 8} more files +

+ ) : null} +
+ ) : null} + + {node.children.length > 0 ? ( +
+ {node.children.map(child => ( + + ))} +
+ ) : null} +
) } function ActivityList({ tasks }: { tasks: readonly RailTask[] }) { if (tasks.length === 0) { return ( - +

No background activity. Long-running tools, preview restarts, and parallel sessions surface here. - +

) } return ( -
+
{tasks.map(task => { - const Icon = STATUS_ICON[task.status] + const Icon = RAIL_ICON[task.status] return ( - +
-
{task.label}
- {task.detail &&
{task.detail}
} +
{task.label}
+ {task.detail ?
{task.detail}
: null}
- +
) })}
@@ -208,19 +448,9 @@ function ActivityList({ tasks }: { tasks: readonly RailTask[] }) { function SectionStub({ label }: { label: string }) { return ( - - -
-

{label} — coming soon

-

- Subagent stores aren't wired into the desktop yet. Once gateway events for{' '} - - subagent.spawn / progress / complete - {' '} - land here, this view shows the live spawn tree, replay history, and pause/kill controls — modelled on the - TUI's /agents overlay. -

-
-
+
+ +

{label} — coming soon

+
) } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index f30e122b693..8a84ba31b38 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -31,7 +31,7 @@ import { setCurrentUsage, setTurnStartedAt } from '@/store/session' -import { clearSessionSubagents, upsertSubagent } from '@/store/subagents' +import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents' import { recordToolDiff } from '@/store/tool-diffs' import type { RpcEvent } from '@/types/hermes' @@ -60,6 +60,7 @@ interface QueuedStreamDeltas { } const STREAM_DELTA_FLUSH_MS = 16 + const SUBAGENT_EVENT_TYPES = new Set([ 'subagent.spawn_requested', 'subagent.start', @@ -75,11 +76,83 @@ function toTodoPayload(payload: GatewayEventPayload | undefined): GatewayEventPa if (!payload) { return undefined } + const isTodo = payload.name === 'todo' || (!payload.name && Object.hasOwn(payload, 'todos')) return isTodo ? { ...payload, name: 'todo', tool_id: payload.tool_id || 'todo-live' } : undefined } +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} +} + +function parseMaybeRecord(value: unknown): Record { + if (typeof value === 'string') { + try { + return asRecord(JSON.parse(value)) + } catch { + return {} + } + } + + return asRecord(value) +} + +const firstString = (...candidates: unknown[]): string => { + for (const v of candidates) { + if (typeof v === 'string' && v) return v + } + + return '' +} + +function delegateTaskPayloads( + payload: GatewayEventPayload | undefined, + phase: 'running' | 'complete', + sourceEventType?: string +): Record[] { + if (payload?.name !== 'delegate_task') return [] + + const args = parseMaybeRecord(payload.args ?? payload.input) + const result = parseMaybeRecord(payload.result) + const rawTasks = Array.isArray(args.tasks) ? args.tasks : [] + const tasks = rawTasks.length ? rawTasks.map(parseMaybeRecord) : [args] + const status = phase === 'complete' ? (payload.error ? 'failed' : 'completed') : 'running' + const toolId = payload.tool_id || payload.tool_call_id || payload.id || 'delegate_task' + const progressText = firstString(payload.preview, payload.message, payload.context) + const eventType = + phase === 'complete' + ? 'subagent.complete' + : sourceEventType === 'tool.start' + ? 'subagent.start' + : 'subagent.progress' + + return tasks.map((task, index) => { + const goal = firstString(task.goal, args.goal, payload.context) || 'Delegated task' + const summary = firstString(result.summary, payload.summary, payload.message) + + return { + depth: 0, + duration_seconds: payload.duration_s, + goal, + status, + subagent_id: `delegate-tool:${toolId}:${index}`, + summary: summary || undefined, + task_count: tasks.length, + task_index: index, + text: eventType === 'subagent.progress' ? progressText || goal : undefined, + tool_name: eventType === 'subagent.start' ? 'delegate_task' : undefined, + tool_preview: eventType === 'subagent.start' ? progressText : undefined, + toolsets: Array.isArray(task.toolsets) ? task.toolsets : Array.isArray(args.toolsets) ? args.toolsets : [], + event_type: eventType, + output_tail: + phase === 'complete' && summary + ? [{ is_error: Boolean(payload.error), preview: summary, tool: 'delegate_task' }] + : undefined + } + }) +} + export function useMessageStream({ activeSessionIdRef, hydrateFromStoredSession, @@ -154,6 +227,7 @@ export function useMessageStream({ const queuedDeltasRef = useRef>(new Map()) const flushHandleRef = useRef(null) + const nativeSubagentSessionsRef = useRef>(new Set()) const flushQueuedDeltas = useCallback( (sessionId?: string) => { @@ -290,7 +364,18 @@ export function useMessageStream({ ) const upsertToolCall = useCallback( - (sessionId: string, payload: GatewayEventPayload | undefined, phase: 'running' | 'complete') => { + ( + sessionId: string, + payload: GatewayEventPayload | undefined, + phase: 'running' | 'complete', + sourceEventType?: string + ) => { + if (!nativeSubagentSessionsRef.current.has(sessionId)) { + for (const subagentPayload of delegateTaskPayloads(payload, phase, sourceEventType)) { + upsertSubagent(sessionId, subagentPayload, true, phase === 'complete' ? 'delegate.complete' : 'delegate.running') + } + } + mutateStream( sessionId, parts => upsertToolPart(parts, payload, phase), @@ -516,6 +601,7 @@ export function useMessageStream({ flushQueuedDeltas(sessionId) clearSessionSubagents(sessionId) + nativeSubagentSessionsRef.current.delete(sessionId) if (isActiveEvent) { triggerHaptic('streamStart') @@ -574,12 +660,13 @@ export function useMessageStream({ if (!sessionId) { return } + flushQueuedDeltas(sessionId) - upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running') + upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running', event.type) } else if (event.type === 'tool.complete') { if (sessionId) { flushQueuedDeltas(sessionId) - upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete') + upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type) } if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) { @@ -587,7 +674,17 @@ export function useMessageStream({ } } else if (SUBAGENT_EVENT_TYPES.has(event.type)) { if (sessionId && payload) { - upsertSubagent(sessionId, payload as Record, event.type === 'subagent.spawn_requested') + if (!nativeSubagentSessionsRef.current.has(sessionId)) { + pruneDelegateFallbackSubagents(sessionId) + } + + nativeSubagentSessionsRef.current.add(sessionId) + upsertSubagent( + sessionId, + payload as Record, + event.type === 'subagent.spawn_requested' || event.type === 'subagent.start', + event.type + ) } } else if (event.type === 'clarify.request') { if (!isActiveEvent) { diff --git a/apps/desktop/src/store/subagents.test.ts b/apps/desktop/src/store/subagents.test.ts index 57e3390147c..4d4d079aebe 100644 --- a/apps/desktop/src/store/subagents.test.ts +++ b/apps/desktop/src/store/subagents.test.ts @@ -1,54 +1,96 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' -import { $subagentsBySession, activeSubagentCount, buildSubagentTree, clearSessionSubagents, upsertSubagent } from './subagents' +import { + $subagentsBySession, + activeSubagentCount, + buildSubagentTree, + clearSessionSubagents, + pruneDelegateFallbackSubagents, + upsertSubagent +} from './subagents' + +const listFor = (sid: string) => $subagentsBySession.get()[sid] ?? [] describe('subagent store', () => { - beforeEach(() => { - $subagentsBySession.set({}) - }) + beforeEach(() => $subagentsBySession.set({})) it('upserts subagent progress and keeps terminal status stable', () => { - upsertSubagent('s1', { - goal: 'scan files', - status: 'running', - subagent_id: 'a1', - task_index: 0 - }) - upsertSubagent('s1', { - goal: 'scan files', - status: 'completed', - subagent_id: 'a1', - summary: 'done', - task_index: 0 - }) - upsertSubagent('s1', { - goal: 'scan files', - status: 'running', - subagent_id: 'a1', - task_index: 0, - text: 'late' - }) + upsertSubagent('s1', { goal: 'scan files', status: 'running', subagent_id: 'a1', task_index: 0 }) + upsertSubagent('s1', { goal: 'scan files', status: 'completed', subagent_id: 'a1', summary: 'done', task_index: 0 }) + upsertSubagent('s1', { goal: 'scan files', status: 'running', subagent_id: 'a1', task_index: 0, text: 'late' }) - const item = $subagentsBySession.get().s1?.[0] + const item = listFor('s1')[0] expect(item?.status).toBe('completed') expect(item?.summary).toBe('done') }) it('builds parent/child trees', () => { upsertSubagent('s1', { goal: 'parent', status: 'running', subagent_id: 'p', task_index: 0 }) - upsertSubagent('s1', { - goal: 'child', - parent_id: 'p', - status: 'queued', - subagent_id: 'c', - task_index: 1 - }) - - const tree = buildSubagentTree($subagentsBySession.get().s1 ?? []) + upsertSubagent('s1', { goal: 'child', parent_id: 'p', status: 'queued', subagent_id: 'c', task_index: 1 }) + const tree = buildSubagentTree(listFor('s1')) expect(tree).toHaveLength(1) expect(tree[0]?.children[0]?.goal).toBe('child') - expect(activeSubagentCount($subagentsBySession.get().s1 ?? [])).toBe(2) + expect(activeSubagentCount(listFor('s1'))).toBe(2) + }) + + it('keeps root nodes in spawn order, not task index order', () => { + const nowSpy = vi.spyOn(Date, 'now') + nowSpy.mockReturnValueOnce(1_000) + upsertSubagent('s1', { goal: 'first spawn', status: 'running', subagent_id: 'a', task_index: 2 }) + nowSpy.mockReturnValueOnce(2_000) + upsertSubagent('s1', { goal: 'second spawn', status: 'running', subagent_id: 'b', task_index: 0 }) + nowSpy.mockRestore() + + expect(buildSubagentTree(listFor('s1')).map(n => n.id)).toEqual(['a', 'b']) + }) + + it('captures live thinking/progress/tool stream lines', () => { + upsertSubagent( + 's1', + { goal: 'scan files', status: 'queued', subagent_id: 'a1', task_index: 0 }, + true, + 'subagent.spawn_requested' + ) + upsertSubagent( + 's1', + { status: 'running', subagent_id: 'a1', task_index: 0, tool_name: 'search_files', tool_preview: 'pattern=hermes' }, + false, + 'subagent.tool' + ) + upsertSubagent( + 's1', + { status: 'running', subagent_id: 'a1', task_index: 0, text: 'plan the search order' }, + false, + 'subagent.thinking' + ) + upsertSubagent( + 's1', + { status: 'running', subagent_id: 'a1', task_index: 0, text: 'found candidate matches' }, + false, + 'subagent.progress' + ) + upsertSubagent( + 's1', + { status: 'completed', subagent_id: 'a1', summary: 'search complete', task_index: 0 }, + false, + 'subagent.complete' + ) + + const item = listFor('s1')[0] + expect(item?.stream.map(e => e.kind)).toEqual(['tool', 'thinking', 'progress', 'summary']) + expect(item?.stream.find(e => e.kind === 'tool')?.text).toContain('Search Files') + expect(item?.stream.find(e => e.kind === 'thinking')?.text).toBe('plan the search order') + expect(item?.stream.find(e => e.kind === 'summary')?.text).toBe('search complete') + }) + + it('prunes delegate fallback rows once native events arrive', () => { + upsertSubagent('s1', { goal: 'fallback', status: 'running', subagent_id: 'delegate-tool:abc:0', task_index: 0 }) + upsertSubagent('s1', { goal: 'native', status: 'running', subagent_id: 'sa-0-xyz', task_index: 0 }) + + pruneDelegateFallbackSubagents('s1') + + expect(listFor('s1').map(item => item.id)).toEqual(['sa-0-xyz']) }) it('clears one session without touching another', () => { diff --git a/apps/desktop/src/store/subagents.ts b/apps/desktop/src/store/subagents.ts index a9b6a6d6788..db01e2db35d 100644 --- a/apps/desktop/src/store/subagents.ts +++ b/apps/desktop/src/store/subagents.ts @@ -1,31 +1,36 @@ import { atom } from 'nanostores' export type SubagentStatus = 'completed' | 'failed' | 'interrupted' | 'queued' | 'running' +export type SubagentStreamKind = 'progress' | 'summary' | 'thinking' | 'tool' + +export interface SubagentStreamEntry { + at: number + isError?: boolean + kind: SubagentStreamKind + text: string +} export interface SubagentProgress { id: string - apiCalls?: number - costUsd?: number - depth: number - durationSeconds?: number - filesRead: string[] - filesWritten: string[] - goal: string - inputTokens?: number - model?: string - outputTail: { isError?: boolean; preview?: string; tool?: string }[] - outputTokens?: number parentId: null | string - reasoningTokens?: number - sessionId: string + goal: string + model?: string status: SubagentStatus - summary?: string taskCount: number taskIndex: number - toolName?: string - toolPreview?: string - toolsets: string[] + startedAt: number updatedAt: number + durationSeconds?: number + costUsd?: number + inputTokens?: number + outputTokens?: number + toolCount?: number + filesRead: string[] + filesWritten: string[] + stream: SubagentStreamEntry[] + summary?: string + /** Active tool while running — cleared on terminal status. */ + currentTool?: string } export interface SubagentNode extends SubagentProgress { @@ -34,122 +39,172 @@ export interface SubagentNode extends SubagentProgress { export type SubagentPayload = Record +const TERMINAL: ReadonlySet = new Set(['completed', 'failed', 'interrupted']) +const MAX_STREAM = 24 +const PREVIEW_MAX = 220 +const TOOL_PREVIEW_MAX = 96 + export const $subagentsBySession = atom>({}) -const TERMINAL = new Set(['completed', 'failed', 'interrupted']) +const isStr = (v: unknown): v is string => typeof v === 'string' +const str = (v: unknown) => (isStr(v) ? v : '') +const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined) +const strList = (v: unknown) => (Array.isArray(v) ? v.filter(isStr) : []) -const asString = (value: unknown) => (typeof value === 'string' ? value : '') -const asNumber = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined) -const asStatus = (value: unknown): SubagentStatus => - value === 'completed' || value === 'failed' || value === 'interrupted' || value === 'queued' ? value : 'running' +const asStatus = (v: unknown): SubagentStatus => + v === 'completed' || v === 'failed' || v === 'interrupted' || v === 'queued' ? v : 'running' -const asStringList = (value: unknown) => (Array.isArray(value) ? value.map(asString).filter(Boolean) : []) +const compact = (text: string, max = PREVIEW_MAX) => { + const line = text.replace(/\s+/g, ' ').trim() + if (!line) return '' + return line.length > max ? `${line.slice(0, max - 1)}…` : line +} -const asOutputTail = (value: unknown): SubagentProgress['outputTail'] => - Array.isArray(value) - ? value - .map(item => (item && typeof item === 'object' ? (item as Record) : null)) - .filter((item): item is Record => Boolean(item)) +const toolLabel = (name: string) => + name.split('_').filter(Boolean).map(p => p[0]!.toUpperCase() + p.slice(1)).join(' ') || name + +const formatTool = (name: string, preview = '') => { + const snippet = compact(preview, TOOL_PREVIEW_MAX) + return snippet ? `${toolLabel(name)}("${snippet}")` : toolLabel(name) +} + +interface TailEntry { + isError?: boolean + preview?: string + tool?: string +} + +const asTail = (v: unknown): TailEntry[] => + Array.isArray(v) + ? v + .filter((item): item is Record => !!item && typeof item === 'object') .map(item => ({ isError: item.is_error === true, - preview: asString(item.preview) || undefined, - tool: asString(item.tool) || undefined + preview: str(item.preview) || undefined, + tool: str(item.tool) || undefined })) : [] -function idFor(payload: SubagentPayload) { - return ( - asString(payload.subagent_id) || - `${asString(payload.parent_id) || 'root'}:${asNumber(payload.task_index) ?? 0}:${asString(payload.goal)}` - ) +const idOf = (p: SubagentPayload) => + str(p.subagent_id) || `${str(p.parent_id) || 'root'}:${num(p.task_index) ?? 0}:${str(p.goal)}` + +const appendStream = (stream: SubagentStreamEntry[], entry: SubagentStreamEntry) => { + const last = stream.at(-1) + if (last?.kind === entry.kind && last.text === entry.text && last.isError === entry.isError) return stream + + return [...stream, entry].slice(-MAX_STREAM) } -function toProgress(sessionId: string, payload: SubagentPayload, previous?: SubagentProgress): SubagentProgress { +function streamFromPayload( + payload: SubagentPayload, + status: SubagentStatus, + eventType: string, + at: number +): SubagentStreamEntry[] { + const out: SubagentStreamEntry[] = [] + const tool = str(payload.tool_name) + const preview = str(payload.tool_preview) || str(payload.text) + const text = compact(str(payload.text) || preview) + + for (const tail of asTail(payload.output_tail)) { + const line = tail.tool ? formatTool(tail.tool, tail.preview ?? '') : compact(tail.preview ?? '') + if (line) out.push({ at, isError: tail.isError, kind: tail.tool ? 'tool' : 'progress', text: line }) + } + + if (tool) out.push({ at, isError: !!payload.error, kind: 'tool', text: formatTool(tool, preview) }) + + if (eventType === 'subagent.progress' && text) + out.push({ at, isError: !!payload.error, kind: 'progress', text }) + + if (eventType === 'subagent.thinking' && text) out.push({ at, kind: 'thinking', text }) + + const summary = compact(str(payload.summary) || str(payload.text)) + if (TERMINAL.has(status) && summary) + out.push({ at, isError: status === 'failed', kind: 'summary', text: summary }) + + return out +} + +function toProgress(payload: SubagentPayload, prev: SubagentProgress | undefined, eventType = ''): SubagentProgress { + const at = Date.now() + const status = asStatus(payload.status) + const tool = str(payload.tool_name) + const stream = streamFromPayload(payload, status, eventType, at).reduce(appendStream, prev?.stream ?? []) + const filesRead = strList(payload.files_read) + const filesWritten = strList(payload.files_written) + return { - apiCalls: asNumber(payload.api_calls) ?? previous?.apiCalls, - costUsd: asNumber(payload.cost_usd) ?? previous?.costUsd, - depth: asNumber(payload.depth) ?? previous?.depth ?? 0, - durationSeconds: asNumber(payload.duration_seconds) ?? previous?.durationSeconds, - filesRead: asStringList(payload.files_read).length ? asStringList(payload.files_read) : (previous?.filesRead ?? []), - filesWritten: asStringList(payload.files_written).length - ? asStringList(payload.files_written) - : (previous?.filesWritten ?? []), - goal: asString(payload.goal) || previous?.goal || 'Subagent', - id: previous?.id || idFor(payload), - inputTokens: asNumber(payload.input_tokens) ?? previous?.inputTokens, - model: asString(payload.model) || previous?.model, - outputTail: asOutputTail(payload.output_tail).length ? asOutputTail(payload.output_tail) : (previous?.outputTail ?? []), - outputTokens: asNumber(payload.output_tokens) ?? previous?.outputTokens, - parentId: asString(payload.parent_id) || previous?.parentId || null, - reasoningTokens: asNumber(payload.reasoning_tokens) ?? previous?.reasoningTokens, - sessionId, - status: asStatus(payload.status), - summary: asString(payload.summary) || previous?.summary, - taskCount: asNumber(payload.task_count) ?? previous?.taskCount ?? 1, - taskIndex: asNumber(payload.task_index) ?? previous?.taskIndex ?? 0, - toolName: asString(payload.tool_name) || previous?.toolName, - toolPreview: asString(payload.tool_preview) || asString(payload.text) || previous?.toolPreview, - toolsets: asStringList(payload.toolsets).length ? asStringList(payload.toolsets) : (previous?.toolsets ?? []), - updatedAt: Date.now() + id: prev?.id ?? idOf(payload), + parentId: str(payload.parent_id) || prev?.parentId || null, + goal: str(payload.goal) || prev?.goal || 'Subagent', + model: str(payload.model) || prev?.model, + status, + taskCount: num(payload.task_count) ?? prev?.taskCount ?? 1, + taskIndex: num(payload.task_index) ?? prev?.taskIndex ?? 0, + startedAt: prev?.startedAt ?? at, + updatedAt: at, + durationSeconds: num(payload.duration_seconds) ?? prev?.durationSeconds, + costUsd: num(payload.cost_usd) ?? prev?.costUsd, + inputTokens: num(payload.input_tokens) ?? prev?.inputTokens, + outputTokens: num(payload.output_tokens) ?? prev?.outputTokens, + toolCount: num(payload.tool_count) ?? prev?.toolCount, + filesRead: filesRead.length ? filesRead : (prev?.filesRead ?? []), + filesWritten: filesWritten.length ? filesWritten : (prev?.filesWritten ?? []), + stream, + summary: str(payload.summary) || prev?.summary, + currentTool: TERMINAL.has(status) ? undefined : tool || prev?.currentTool } } -export function clearSessionSubagents(sessionId: string) { - const current = $subagentsBySession.get() +export function clearSessionSubagents(sid: string) { + const map = $subagentsBySession.get() + if (!(sid in map)) return - if (!(sessionId in current)) { - return - } - - const next = { ...current } - delete next[sessionId] - $subagentsBySession.set(next) + const { [sid]: _drop, ...rest } = map + $subagentsBySession.set(rest) } -export function upsertSubagent(sessionId: string, payload: SubagentPayload, createIfMissing = true) { - const current = $subagentsBySession.get() - const list = current[sessionId] ?? [] - const id = idFor(payload) - const index = list.findIndex(item => item.id === id) +export function pruneDelegateFallbackSubagents(sid: string) { + const map = $subagentsBySession.get() + const list = map[sid] + if (!list?.length) return - if (index < 0 && !createIfMissing) { - return - } + const next = list.filter(item => !item.id.startsWith('delegate-tool:')) + if (next.length === list.length) return - const previous = index >= 0 ? list[index] : undefined + $subagentsBySession.set({ ...map, [sid]: next }) +} - if (previous && TERMINAL.has(previous.status)) { - return - } +export function upsertSubagent(sid: string, payload: SubagentPayload, createIfMissing = true, eventType?: string) { + const map = $subagentsBySession.get() + const list = map[sid] ?? [] + const id = idOf(payload) + const idx = list.findIndex(item => item.id === id) + if (idx < 0 && !createIfMissing) return - const nextItem = toProgress(sessionId, payload, previous) - const nextList = index >= 0 ? list.map(item => (item.id === id ? nextItem : item)) : [...list, nextItem] + const prev = idx >= 0 ? list[idx] : undefined + if (prev && TERMINAL.has(prev.status)) return - $subagentsBySession.set({ ...current, [sessionId]: nextList }) + const next = toProgress(payload, prev, eventType) + const nextList = idx >= 0 ? list.map(item => (item.id === id ? next : item)) : [...list, next] + + $subagentsBySession.set({ ...map, [sid]: nextList }) } export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] { const nodes = new Map() - - for (const item of items) { - nodes.set(item.id, { ...item, children: [] }) - } + for (const item of items) nodes.set(item.id, { ...item, children: [] }) const roots: SubagentNode[] = [] - for (const node of nodes.values()) { const parent = node.parentId ? nodes.get(node.parentId) : null - - if (parent) { - parent.children.push(node) - } else { - roots.push(node) - } + if (parent) parent.children.push(node) + else roots.push(node) } - const sort = (a: SubagentNode, b: SubagentNode) => a.taskIndex - b.taskIndex || a.goal.localeCompare(b.goal) + const sort = (a: SubagentNode, b: SubagentNode) => + a.startedAt - b.startedAt || a.taskIndex - b.taskIndex || a.goal.localeCompare(b.goal) const walk = (node: SubagentNode) => node.children.sort(sort).forEach(walk) - roots.sort(sort).forEach(walk) return roots