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:
@@ -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'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'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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user