refactor(desktop): subagent overlay reads like a live transcript, not a dashboard

Strip the card chrome and rewire /agents to feel like peeking into the
child agent's stream:

- subagents store: single `stream` of typed entries (thinking/tool/progress/
  summary) replaces the parallel notes/thinking/tools arrays. Drop unused
  fields (toolsets, depth, apiCalls, reasoningTokens, sessionId).
- agents view: no OverlayCards, no boxed stream, no per-row borders. Goal +
  status pill + indented stream lines, full row width.
- Group root spawns into "Delegation N" sections when batch shape + spawn
  time match — hides task-index interleaving and makes hierarchy obvious.
- Sort tree by spawn time, then task_index. Step indicator is one colored
  pill (primary while running, emerald when done) inside the row, not a
  trailing pill that wrapped under the chevron.
- Tree picks up `subagent.start` (not only `spawn_requested`) and prunes
  delegate-tool fallback rows once native subagent events land for the
  session — fixes duplicate "Delegated task" rows alongside the real ones.
This commit is contained in:
Brooklyn Nicholson 2026-05-13 17:33:12 -05:00
parent 17e86dddc7
commit 98d39fc2c4
4 changed files with 644 additions and 220 deletions

View file

@ -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<RailTaskStatus, string> = {
const RAIL_TONE: Record<RailTaskStatus, string> = {
error: 'text-destructive',
running: 'text-foreground',
success: 'text-emerald-500'
}
const STATUS_ICON: Record<RailTaskStatus, LucideIcon> = {
const RAIL_ICON: Record<RailTaskStatus, LucideIcon> = {
error: AlertCircle,
running: Loader2,
success: Sparkles
}
const STATUS_GLYPH: Record<SubagentStatus, string> = {
completed: '✓',
failed: '✗',
interrupted: '■',
queued: '○',
running: '●'
}
const STREAM_GLYPH: Record<SubagentStreamEntry['kind'], string> = {
progress: '·',
summary: '✓',
thinking: '💭',
tool: '●'
}
const STREAM_TONE: Record<SubagentStreamEntry['kind'], string> = {
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<SubagentStatus, string> = {
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 (
<OverlayCard className="grid place-items-center gap-3 px-6 py-12 text-center">
<Sparkles className="size-6 text-muted-foreground/70" />
<div className="grid gap-1">
<p className="text-sm font-medium text-foreground">No live subagents</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground">
When a turn delegates work, child agents appear here as a live spawn tree.
</p>
</div>
</OverlayCard>
<div className="grid place-items-center gap-3 py-12 text-center">
<Sparkles className="size-6 text-muted-foreground/60" />
<p className="text-sm font-medium text-foreground/90">No live subagents</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">
When a turn delegates work, child agents stream their progress here.
</p>
</div>
)
}
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 (
<div className="grid gap-2 overflow-y-auto pr-1">
{tree.map(node => (
<SubagentRow key={node.id} node={node} />
))}
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 overflow-hidden">
<p className="shrink-0 text-[0.7rem] text-muted-foreground/70">{summary.join(' · ')}</p>
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pr-1">
<div className="flex min-w-0 flex-col gap-6">
{groups.map(group => (
<DelegationGroup group={group} key={group.id} nowMs={nowMs} />
))}
</div>
</div>
</div>
)
}
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 <SubagentRow node={group.nodes[0]!} nowMs={nowMs} />
}
const activeWorkers = group.nodes.filter(n => n.status === 'running' || n.status === 'queued').length
return (
<OverlayCard className="px-3 py-2" style={{ marginLeft: depth ? `${Math.min(depth, 4) * 1.25}rem` : undefined }}>
<div className="flex items-start gap-2">
{running ? (
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin text-primary" />
) : (
<Sparkles className={cn('mt-0.5 size-3.5 shrink-0', STATUS_CLASS[node.status])} />
)}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium text-foreground">{node.goal}</div>
<span className={cn('shrink-0 text-[0.65rem]', STATUS_CLASS[node.status])}>{node.status}</span>
</div>
<div className="mt-0.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.68rem] text-muted-foreground">
{node.model && <span>{node.model}</span>}
{typeof node.durationSeconds === 'number' && <span>{node.durationSeconds.toFixed(1)}s</span>}
{typeof node.costUsd === 'number' && <span>${node.costUsd.toFixed(4)}</span>}
{typeof node.apiCalls === 'number' && <span>{node.apiCalls} calls</span>}
</div>
{(node.toolName || node.toolPreview || node.summary) && (
<div className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{node.summary || [node.toolName, node.toolPreview].filter(Boolean).join(' · ')}
</div>
)}
</div>
<section className="grid min-w-0 gap-3">
<p className="text-[0.66rem] font-medium uppercase tracking-wider text-muted-foreground/70">
{group.label} <span className="text-muted-foreground/50">·</span> {group.nodes.length} workers
{activeWorkers > 0 ? <span className="text-primary/85"> · {activeWorkers} active</span> : null}
</p>
<div className="grid min-w-0 gap-4">
{group.nodes.map(node => (
<SubagentRow key={node.id} node={node} nowMs={nowMs} />
))}
</div>
{node.children.length > 0 && (
<div className="mt-2 grid gap-2">
{node.children.map(child => (
<SubagentRow depth={depth + 1} key={child.id} node={child} />
</section>
)
}
function StreamLine({ active, entry }: { active: boolean; entry: SubagentStreamEntry }) {
const isMono = entry.kind === 'tool'
const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind]
return (
<div className="flex min-w-0 items-baseline gap-2 text-[0.72rem] leading-relaxed">
<span className={cn('shrink-0 text-[0.65rem]', tone)}>{STREAM_GLYPH[entry.kind]}</span>
<span className={cn('min-w-0 flex-1 wrap-anywhere', tone, isMono && 'font-mono text-[0.69rem]')}>
{entry.text}
{active ? (
<span className="ml-1 inline-block h-2.5 w-0.5 animate-pulse rounded-sm bg-primary/75 align-middle" />
) : null}
</span>
</div>
)
}
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 (
<div className={cn('grid min-w-0 max-w-full gap-2', depth > 0 && 'pl-4')}>
<button
aria-expanded={open}
className="group flex w-full min-w-0 items-start gap-2.5 text-left"
onClick={() => setOpen(v => !v)}
type="button"
>
<span
className={cn(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[0.65rem] font-medium tabular-nums',
indicatorTone(node.status, running)
)}
>
{step ? (
step
) : running ? (
<BrailleSpinner ariaLabel="Running" className="text-[0.82rem]" spinner="breathe" />
) : (
<span className="text-[0.78rem] leading-none">{STATUS_GLYPH[node.status]}</span>
)}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<FadeText
className={cn(
'wrap-anywhere text-[0.84rem] font-medium leading-snug text-foreground/95 transition-colors group-hover:text-foreground',
running && 'text-foreground/85'
)}
>
{node.goal}
</FadeText>
{subtitle.length > 0 ? (
<FadeText className="text-[0.66rem] leading-snug text-muted-foreground/65">{subtitle.join(' · ')}</FadeText>
) : null}
</span>
{running ? <ActivityTimerText className="mt-1 shrink-0 text-[0.6rem]" seconds={durationSeconds} /> : null}
</button>
{visibleRows.length > 0 ? (
<div className="grid min-w-0 gap-1 pl-7">
{visibleRows.map((entry, i) => (
<StreamLine
active={running && i === visibleRows.length - 1}
entry={entry}
key={`${entry.kind}:${entry.at}:${i}`}
/>
))}
</div>
)}
</OverlayCard>
) : null}
{open && fileLines.length > 0 ? (
<div className="grid min-w-0 gap-0.5 pl-7">
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">Files</p>
{fileLines.slice(0, 8).map(line => (
<p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}>
{line}
</p>
))}
{fileLines.length > 8 ? (
<p className="font-mono text-[0.67rem] leading-relaxed text-muted-foreground/65">
+{fileLines.length - 8} more files
</p>
) : null}
</div>
) : null}
{node.children.length > 0 ? (
<div className="grid min-w-0 gap-3 pl-7">
{node.children.map(child => (
<SubagentRow depth={depth + 1} key={child.id} node={child} nowMs={nowMs} />
))}
</div>
) : null}
</div>
)
}
function ActivityList({ tasks }: { tasks: readonly RailTask[] }) {
if (tasks.length === 0) {
return (
<OverlayCard className="px-3 py-4 text-sm text-muted-foreground">
<p className="py-4 text-sm text-muted-foreground/75">
No background activity. Long-running tools, preview restarts, and parallel sessions surface here.
</OverlayCard>
</p>
)
}
return (
<div className="grid min-h-0 gap-1.5 overflow-y-auto pr-1">
<div className="grid min-h-0 gap-2 overflow-y-auto pr-1">
{tasks.map(task => {
const Icon = STATUS_ICON[task.status]
const Icon = RAIL_ICON[task.status]
return (
<OverlayCard className="flex items-start gap-2.5 px-3 py-2" key={task.id}>
<div className="flex items-start gap-2.5" key={task.id}>
<Icon
className={cn(
'mt-0.5 size-3.5 shrink-0',
STATUS_TONE[task.status],
RAIL_TONE[task.status],
task.status === 'running' && 'animate-spin'
)}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{task.label}</div>
{task.detail && <div className="truncate text-xs text-muted-foreground">{task.detail}</div>}
<div className="truncate text-sm font-medium text-foreground/90">{task.label}</div>
{task.detail ? <div className="truncate text-xs text-muted-foreground/75">{task.detail}</div> : null}
</div>
</OverlayCard>
</div>
)
})}
</div>
@ -208,19 +448,9 @@ function ActivityList({ tasks }: { tasks: readonly RailTask[] }) {
function SectionStub({ label }: { label: string }) {
return (
<OverlayCard className="grid place-items-center gap-3 px-6 py-12 text-center">
<Sparkles className="size-6 text-muted-foreground/70" />
<div className="grid gap-1">
<p className="text-sm font-medium text-foreground">{label} coming soon</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground">
Subagent stores aren&apos;t wired into the desktop yet. Once gateway events for{' '}
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">
subagent.spawn / progress / complete
</code>{' '}
land here, this view shows the live spawn tree, replay history, and pause/kill controls modelled on the
TUI&apos;s <code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">/agents</code> overlay.
</p>
</div>
</OverlayCard>
<div className="grid place-items-center gap-3 py-12 text-center">
<Sparkles className="size-6 text-muted-foreground/60" />
<p className="text-sm font-medium text-foreground/90">{label} coming soon</p>
</div>
)
}

View file

@ -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<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
}
function parseMaybeRecord(value: unknown): Record<string, unknown> {
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<string, unknown>[] {
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<Map<string, QueuedStreamDeltas>>(new Map())
const flushHandleRef = useRef<number | null>(null)
const nativeSubagentSessionsRef = useRef<Set<string>>(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<string, unknown>, event.type === 'subagent.spawn_requested')
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
pruneDelegateFallbackSubagents(sessionId)
}
nativeSubagentSessionsRef.current.add(sessionId)
upsertSubagent(
sessionId,
payload as Record<string, unknown>,
event.type === 'subagent.spawn_requested' || event.type === 'subagent.start',
event.type
)
}
} else if (event.type === 'clarify.request') {
if (!isActiveEvent) {

View file

@ -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', () => {

View file

@ -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<string, unknown>
const TERMINAL: ReadonlySet<SubagentStatus> = new Set(['completed', 'failed', 'interrupted'])
const MAX_STREAM = 24
const PREVIEW_MAX = 220
const TOOL_PREVIEW_MAX = 96
export const $subagentsBySession = atom<Record<string, SubagentProgress[]>>({})
const TERMINAL = new Set<SubagentStatus>(['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<string, unknown>) : null))
.filter((item): item is Record<string, unknown> => 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<string, unknown> => !!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<string, SubagentNode>()
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