feat(desktop): render delegate_task as its live subagent list

A fan-out showed up in the transcript as one grey row and a JSON blob, so the several agents it started were invisible until they finished. Give the call its own card: one line per child with the goal, the model running it, and a live timer, over a single ticking line of that child's relayed activity.
This commit is contained in:
Brooklyn Nicholson 2026-07-28 02:59:49 -05:00
parent 9a5f102d0e
commit 2818c9e514
6 changed files with 489 additions and 24 deletions

View file

@ -8,6 +8,7 @@ import { type ComponentProps, type FC, type ReactNode, useEffect, useRef, useSta
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
import { DelegateTool } from '@/components/assistant-ui/tool/delegate'
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool/fallback'
import { formatElapsed, useElapsedSeconds, useMeasuredDuration } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
@ -36,12 +37,26 @@ const ImageGenerateTool: FC<ToolCallMessagePartProps> = props => {
)
}
const DelegateToolPart: FC<ToolCallMessagePartProps> = props => {
// A call that failed outright dispatched nothing — there are no children to
// list, only an error. The generic row extracts and expands it properly.
if (props.isError) {
return <ToolFallback {...props} />
}
return <DelegateTool args={props.args} result={props.result} toolCallId={props.toolCallId} />
}
const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
// todo parts are hoisted to a dedicated panel above the message content.
if (props.toolName === 'todo') {
return null
}
if (props.toolName === 'delegate_task') {
return <DelegateToolPart {...props} />
}
if (props.toolName === 'image_generate') {
return <ImageGenerateTool {...props} />
}

View file

@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest'
import type { SubagentProgress } from '@/store/subagents'
import { delegateGoals, delegateRowsFromCall, mergeDelegateRows } from './delegate-model'
const subagent = (overrides: Partial<SubagentProgress>): SubagentProgress => ({
filesRead: [],
filesWritten: [],
goal: 'Research Cursor',
id: 'sub-1',
parentId: null,
startedAt: 0,
status: 'running',
stream: [],
taskCount: 1,
taskIndex: 0,
updatedAt: 0,
...overrides
})
describe('delegateGoals', () => {
it('reads a batch in task order and a single goal alike', () => {
expect(delegateGoals({ tasks: [{ goal: 'A' }, { goal: 'B' }] })).toEqual(['A', 'B'])
expect(delegateGoals({ goal: 'Solo' })).toEqual(['Solo'])
expect(delegateGoals('{"goal":"Serialized"}')).toEqual(['Serialized'])
})
})
describe('delegateRowsFromCall', () => {
it('reads as running before a result and parked once dispatched', () => {
const args = { tasks: [{ goal: 'A' }, { goal: 'B' }] }
expect(delegateRowsFromCall(args, undefined).map(r => r.status)).toEqual(['running', 'running'])
expect(delegateRowsFromCall(args, { status: 'dispatched', goals: ['A', 'B'] }).map(r => r.status)).toEqual([
'dispatched',
'dispatched'
])
})
it('takes status, model and duration from each settled result', () => {
const rows = delegateRowsFromCall(
{ tasks: [{ goal: 'A' }, { goal: 'B' }] },
{
results: [
{ status: 'completed', summary: 'found it', model: 'anthropic/claude-opus-5', duration_seconds: 12 },
{ status: 'failed', summary: 'nope' }
]
}
)
expect(rows.map(r => r.status)).toEqual(['completed', 'failed'])
expect(rows[0]).toMatchObject({ activity: ['found it'], durationSeconds: 12, model: 'anthropic/claude-opus-5' })
})
it('still lists a background dispatch whose goals only survive in the result', () => {
expect(delegateRowsFromCall({}, { status: 'dispatched', goals: ['A', 'B'] }).map(r => r.goal)).toEqual(['A', 'B'])
})
})
describe('mergeDelegateRows', () => {
it('joins fallback rows by the tool call id they were keyed with', () => {
const rows = delegateRowsFromCall({ tasks: [{ goal: 'A' }, { goal: 'B' }] }, undefined, 'call-7')
const merged = mergeDelegateRows(
rows,
[
subagent({ id: 'delegate-tool:call-7:1', goal: 'B', status: 'completed' }),
subagent({ id: 'delegate-tool:call-7:0', goal: 'A', model: 'gpt-5' })
],
'call-7'
)
expect(merged.map(r => r.status)).toEqual(['running', 'completed'])
expect(merged[0]!.model).toBe('gpt-5')
})
it('joins native events by goal text and prefers their live state', () => {
const rows = delegateRowsFromCall({ tasks: [{ goal: 'Research Cursor' }] }, undefined, 'call-1')
const merged = mergeDelegateRows(
rows,
[
subagent({
goal: 'Research Cursor',
model: 'anthropic/claude-opus-5',
sessionId: 'child-1',
stream: [
{ at: 1, kind: 'tool', text: 'Read File("a.ts")' },
{ at: 2, kind: 'progress', text: 'comparing' }
]
})
],
'call-1'
)
expect(merged[0]).toMatchObject({
activity: ['Read File("a.ts")', 'comparing'],
model: 'anthropic/claude-opus-5',
sessionId: 'child-1',
status: 'running'
})
})
it('never lets a second delegation claim another call\u2019s workers', () => {
const rows = delegateRowsFromCall({ tasks: [{ goal: 'C' }] }, undefined, 'call-2')
// Two unrelated children in the session, neither matching this call's goal.
const merged = mergeDelegateRows(
rows,
[subagent({ id: 'other-a', goal: 'A' }), subagent({ id: 'other-b', goal: 'B' })],
'call-2'
)
expect(merged[0]!.goal).toBe('C')
expect(merged[0]!.model).toBeUndefined()
})
it('falls back to task order only when both sides agree on the shape', () => {
const rows = delegateRowsFromCall({ tasks: [{ goal: 'A' }, { goal: 'B' }] }, undefined, 'call-3')
const merged = mergeDelegateRows(
rows,
[
subagent({ id: 'x', goal: 'renamed A', taskIndex: 0, model: 'm0' }),
subagent({ id: 'y', goal: 'renamed B', taskIndex: 1, model: 'm1' })
],
'call-3'
)
expect(merged.map(r => r.model)).toEqual(['m0', 'm1'])
})
})

View file

@ -0,0 +1,150 @@
import { normalize } from '@/lib/text'
import type { SubagentProgress, SubagentStatus } from '@/store/subagents'
import { firstStringField, numberValue, parseMaybeObject } from './fallback-model'
/**
* A delegation runs somewhere the transcript can't see: the tool call carries
* the goals it dispatched, the subagent store carries what those children are
* actually doing, and the tool result carries how they finished. One row is
* all three of those views of the same child.
*/
export interface DelegateRow {
/** Latest relayed activity, oldest → newest. The card tickers the tail. */
activity: string[]
durationSeconds?: number
goal: string
id: string
model?: string
/** The child's own session id, when it reported one — opens its window. */
sessionId?: string
status: DelegateRowStatus
}
/**
* `dispatched` is the state the other two sources can't describe: a background
* delegation whose children outlived the turn, seen from a transcript that has
* been reloaded since. It is running, but nothing here is watching it, so it
* must not spin.
*/
export type DelegateRowStatus = SubagentStatus | 'dispatched'
const field = (record: Record<string, unknown>, key: string): string => firstStringField(record, [key])
/** The goals a `delegate_task` call dispatched, in task order. */
export function delegateGoals(args: unknown): string[] {
const record = parseMaybeObject(args)
const tasks = Array.isArray(record.tasks) ? record.tasks : []
if (tasks.length > 0) {
return tasks.map((task, index) => field(parseMaybeObject(task), 'goal') || `Task ${index + 1}`)
}
const goal = field(record, 'goal')
return goal ? [goal] : []
}
function resultRows(result: unknown): Record<string, unknown>[] {
const record = parseMaybeObject(result)
const results = Array.isArray(record.results) ? record.results : []
return results.map(parseMaybeObject)
}
function dispatchedGoals(result: unknown): string[] {
const record = parseMaybeObject(result)
if (field(record, 'status') !== 'dispatched') {
return []
}
return Array.isArray(record.goals) ? record.goals.filter((goal): goal is string => typeof goal === 'string') : []
}
/**
* The rows a call describes on its own before any live subagent state is
* layered on. This is what a rehydrated transcript has to work with: the goals
* it dispatched, and whatever the result said about how they went.
*
* A call with no result yet is still being placed, so its rows read as
* running; the moment a background dispatch answers, they drop to parked.
*/
export function delegateRowsFromCall(args: unknown, result: unknown, toolCallId = ''): DelegateRow[] {
const goals = delegateGoals(args)
const finished = resultRows(result)
const dispatched = dispatchedGoals(result)
const titles = goals.length > 0 ? goals : dispatched.length > 0 ? dispatched : finished.map(() => 'Delegated task')
const idle: DelegateRowStatus = result === undefined ? 'running' : 'dispatched'
return titles.map((goal, index) => {
const entry = finished[index]
const summary = entry ? field(entry, 'summary') : ''
return {
activity: summary ? [summary] : [],
durationSeconds: entry ? (numberValue(entry.duration_seconds) ?? undefined) : undefined,
goal,
id: `${toolCallId}:${index}`,
model: entry ? field(entry, 'model') || undefined : undefined,
status: entry ? (field(entry, 'status') === 'failed' ? 'failed' : 'completed') : idle
}
})
}
function fromSubagent(live: SubagentProgress, fallbackId: string, fallbackGoal: string): DelegateRow {
return {
activity: live.stream.map(entry => entry.text).filter(Boolean),
durationSeconds: live.durationSeconds,
goal: live.goal || fallbackGoal,
id: live.id || fallbackId,
model: live.model,
sessionId: live.sessionId,
status: live.status
}
}
/**
* Layer the session's live subagents over the rows a call describes.
*
* Three joins, narrowest first. The delegate fallback (used when the gateway
* relays no native `subagent.*` events) keys its rows off the tool call id, so
* those match exactly. Native events carry no tool linkage, but they do carry
* the goal string verbatim from the same arguments this call was built from.
* Failing both, task order is how the delegate tool numbers its children but
* only trust it when the two sides agree on how many there are, or a second
* delegation in the same turn will claim the first one's workers.
*
* Live state wins wherever it exists: a settled result tells you a child
* finished, but only the store knows what it is doing right now.
*/
export function mergeDelegateRows(
rows: readonly DelegateRow[],
live: readonly SubagentProgress[],
toolCallId = ''
): DelegateRow[] {
if (live.length === 0) {
return [...rows]
}
const unclaimed = [...live]
const claim = (predicate: (candidate: SubagentProgress) => boolean): SubagentProgress | undefined => {
const index = unclaimed.findIndex(predicate)
return index >= 0 ? unclaimed.splice(index, 1)[0] : undefined
}
const prefix = toolCallId ? `delegate-tool:${toolCallId}:` : ''
const byId = rows.map((_row, index) => (prefix ? claim(c => c.id === `${prefix}${index}`) : undefined))
const byGoal = rows.map((row, index) => byId[index] ?? claim(c => normalize(c.goal) === normalize(row.goal)))
const sameShape = rows.length === live.length
return rows.map((row, index) => {
const matched = byGoal[index] ?? (sameShape ? claim(c => c.taskIndex === index) : undefined)
return matched ? fromSubagent(matched, row.id, row.goal) : row
})
}
export const isDelegateRowLive = (status: DelegateRowStatus): boolean => status === 'running' || status === 'queued'

View file

@ -0,0 +1,158 @@
'use client'
import { useStore } from '@nanostores/react'
import { type FC, type ReactNode, useMemo } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { SCAFFOLD_LABEL_CLASS, SCAFFOLD_META_CLASS } from '@/components/chat/scaffold-row'
import { FadeText } from '@/components/ui/fade-text'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { displayModelName } from '@/lib/model-status-label'
import { useSessionSlice } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $subagentsBySession } from '@/store/subagents'
import { openSessionInNewWindow } from '@/store/windows'
import {
type DelegateRow,
delegateRowsFromCall,
type DelegateRowStatus,
isDelegateRowLive,
mergeDelegateRows
} from './delegate-model'
import { formatDurationSeconds, type ToolPart } from './fallback-model'
import { ToolRunTicker } from './run-ticker'
// Activity lines kept mounted behind the visible one. Enough for the reel to
// read as motion, few enough that a chatty child doesn't hold a hundred rows
// in the DOM per subagent.
const TICKER_DEPTH = 6
function statusGlyph(status: DelegateRowStatus, label: string): ReactNode {
if (isDelegateRowLive(status)) {
return (
<GlyphSpinner ariaLabel={label} className="size-3.5 text-[0.95rem] text-(--ui-text-tertiary)" spinner="breathe" />
)
}
if (status === 'failed' || status === 'interrupted') {
return <AlertCircle aria-label={label} className="size-3.5 text-destructive" />
}
if (status === 'dispatched') {
// Parked, not watched: the children outlived the turn that spawned them
// and nothing in this transcript is streaming their progress. A spinner
// here would claim a liveness we can't back up.
return <span aria-hidden className="size-1.5 rounded-full bg-(--ui-text-tertiary)" />
}
return <CheckCircle2 aria-label={label} className="size-3.5 text-emerald-600/85 dark:text-emerald-400/85" />
}
/**
* One delegated child: who it is on the first line, what it is doing on the
* second.
*
* The title carries the goal and the model running it the two things that
* identify a child you didn't dispatch yourself with the elapsed time
* trailing while it works. Underneath, a single ticking line of its relayed
* activity, so a fan-out of five children costs ten lines of transcript
* whatever they get up to.
*/
function DelegateRowView({ row }: { row: DelegateRow }) {
const { t } = useI18n()
const copy = t.assistant.tool
const { sessionId } = row
const live = isDelegateRowLive(row.status)
const elapsed = useElapsedSeconds(live, `delegate:${row.id}`)
const activity = row.activity.slice(-TICKER_DEPTH)
const statusLabel = live
? copy.statusRunning
: row.status === 'failed' || row.status === 'interrupted'
? copy.statusError
: copy.statusDone
const meta = [
row.model ? displayModelName(row.model) : '',
!live && row.durationSeconds ? formatDurationSeconds(row.durationSeconds) : ''
].filter(Boolean)
// Only a child that reported its own session id has somewhere to go.
const open = sessionId ? () => void openSessionInNewWindow(sessionId, { watch: true }) : undefined
return (
<div className="grid min-w-0 max-w-full gap-0.5" data-conversation-scaffold="">
<div className="flex min-w-0 max-w-full items-center gap-1.5">
<span className="grid size-3.5 shrink-0 place-items-center">{statusGlyph(row.status, statusLabel)}</span>
<button
className={cn(
SCAFFOLD_LABEL_CLASS,
'min-w-0 truncate text-left transition-colors',
open ? 'hover:text-foreground focus-visible:text-foreground focus-visible:outline-none' : 'cursor-default'
)}
disabled={!open}
onClick={open}
type="button"
>
{row.goal}
</button>
{meta.length > 0 && <span className={SCAFFOLD_META_CLASS}>{meta.join(' · ')}</span>}
{live && <ActivityTimerText className={cn(SCAFFOLD_META_CLASS, 'ml-auto')} seconds={elapsed} />}
</div>
{activity.length > 0 && (
<div className="min-w-0 max-w-full pl-5">
<ToolRunTicker>
{activity.map((text, index) => (
<FadeText
className={cn(SCAFFOLD_LABEL_CLASS, 'text-(--conversation-scaffold-meta)', live && 'shimmer')}
key={`${row.id}:${index}`}
>
{text}
</FadeText>
))}
</ToolRunTicker>
</div>
)}
</div>
)
}
/**
* A `delegate_task` call, as the fan-out it is.
*
* The generic tool row can only say "Delegated 2 tasks" and hand over a blob
* of JSON the work itself happens in child sessions the transcript never
* sees. This lists those children instead, joining what the call dispatched to
* what the subagent store knows about them, so a delegation reads like the
* several agents it actually is.
*
* A card, never folded into a run summary: the point of the block is the live
* list, and a ticker cycling one line across five children would show four of
* them nothing.
*/
export const DelegateTool: FC<Pick<ToolPart, 'args' | 'result' | 'toolCallId'>> = ({ args, result, toolCallId }) => {
const sessionId = useStore(useSessionView().$runtimeId)
const live = useSessionSlice($subagentsBySession, sessionId)
const rows = useMemo(
() => mergeDelegateRows(delegateRowsFromCall(args, result, toolCallId), live, toolCallId),
[args, live, result, toolCallId]
)
if (rows.length === 0) {
return null
}
return (
<div className="grid min-w-0 max-w-full gap-(--tool-row-gap)" data-slot="tool-block">
{rows.map(row => (
<DelegateRowView key={row.id} row={row} />
))}
</div>
)
}

View file

@ -5,10 +5,8 @@ import { useStore } from '@nanostores/react'
import {
Children,
createContext,
type CSSProperties,
type FC,
Fragment,
isValidElement,
type PropsWithChildren,
type ReactNode,
useContext,
@ -68,6 +66,7 @@ import {
type ToolTitleAction
} from './fallback-model'
import { isToolCallPart, summarizeToolRun } from './run-summary'
import { ToolRunTicker } from './run-ticker'
// `true` when a ToolEntry is rendered inside an embedding wrapper that owns
// the per-row chrome (timer / preview). The flat ToolGroupSlot sets this
@ -708,12 +707,13 @@ function TerminalTranscript({ command, exitCode }: TerminalTranscriptProps) {
// - File edits are the deliverable, not scaffolding. The diff is what the
// user reviews, so it stays visible at its place in the turn, live and
// settled, the way a PR shows its changes.
// - `clarify` and `image_generate` bypass ToolEntry to render their own
// markup: a question the user has to answer, an image they asked for.
// - `clarify`, `image_generate` and `delegate_task` bypass ToolEntry to
// render their own markup: a question the user has to answer, an image
// they asked for, the several agents a fan-out is running.
//
// Everything else is ephemeral activity — reads, searches, commands — which is
// what a run summarizes and what the live ticker cycles through.
const CARD_TOOLS = new Set(['clarify', 'image_generate'])
const CARD_TOOLS = new Set(['clarify', 'delegate_task', 'image_generate'])
export function isCardTool(toolName: string): boolean {
return CARD_TOOLS.has(toolName) || isFileEditTool(toolName)
@ -761,26 +761,7 @@ export function splitRunItems(toolNames: readonly string[]): RunItem[] {
* touches thirty files reads as one line ticking over in place instead of a
* list growing down the page. When the run settles the ticker goes away and
* the summary above it is all that's left.
*
* Rows are clipped to a uniform line box so the reel's offset stays exact
* whatever a row happens to contain.
*/
function ToolRunTicker({ children }: { children: ReactNode }) {
const rows = Children.toArray(children)
return (
<div className="tool-ticker" data-tool-ticker="">
<div className="tool-ticker__reel" style={{ '--tool-ticker-index': rows.length - 1 } as CSSProperties}>
{rows.map((row, index) => (
<div className="tool-ticker__row" key={isValidElement(row) ? (row.key ?? index) : index}>
{row}
</div>
))}
</div>
</div>
)
}
// The one grey line that stands in for a run of tool calls — "Explored 3
// files, ran 5 commands". Live, it narrates in the present tense above the
// ticker and offers no toggle, since there is nothing settled to unfold yet.

View file

@ -0,0 +1,28 @@
import { Children, type CSSProperties, isValidElement, type ReactNode } from 'react'
/**
* A one-line window over a growing list of rows.
*
* Each new row slides the one before it up and out, so activity that would
* otherwise grow down the page reads as a single line ticking over in place.
* Rows are clipped to a uniform line box so the reel's offset stays exact
* whatever a row happens to contain.
*
* Shared by the tool run (its calls) and a delegation card (each subagent's
* relayed stream), which are the same thing seen from two sides.
*/
export function ToolRunTicker({ children }: { children: ReactNode }) {
const rows = Children.toArray(children)
return (
<div className="tool-ticker" data-tool-ticker="">
<div className="tool-ticker__reel" style={{ '--tool-ticker-index': rows.length - 1 } as CSSProperties}>
{rows.map((row, index) => (
<div className="tool-ticker__row" key={isValidElement(row) ? (row.key ?? index) : index}>
{row}
</div>
))}
</div>
</div>
)
}