diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index 63f2c9fb2a17..77b79caa8ae2 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -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 = props => { ) } +const DelegateToolPart: FC = 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 + } + + return +} + const ChainToolFallback: FC = 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 + } + if (props.toolName === 'image_generate') { return } diff --git a/apps/desktop/src/components/assistant-ui/tool/delegate-model.test.ts b/apps/desktop/src/components/assistant-ui/tool/delegate-model.test.ts new file mode 100644 index 000000000000..975ad90627bc --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/delegate-model.test.ts @@ -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 => ({ + 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']) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/tool/delegate-model.ts b/apps/desktop/src/components/assistant-ui/tool/delegate-model.ts new file mode 100644 index 000000000000..7f1c4b48d07e --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/delegate-model.ts @@ -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, 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[] { + 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' diff --git a/apps/desktop/src/components/assistant-ui/tool/delegate.tsx b/apps/desktop/src/components/assistant-ui/tool/delegate.tsx new file mode 100644 index 000000000000..8ec3d92b243c --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/delegate.tsx @@ -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 ( + + ) + } + + if (status === 'failed' || status === 'interrupted') { + return + } + + 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 + } + + return +} + +/** + * 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 ( +
+
+ {statusGlyph(row.status, statusLabel)} + + {meta.length > 0 && {meta.join(' · ')}} + {live && } +
+ {activity.length > 0 && ( +
+ + {activity.map((text, index) => ( + + {text} + + ))} + +
+ )} +
+ ) +} + +/** + * 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> = ({ 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 ( +
+ {rows.map(row => ( + + ))} +
+ ) +} diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 966e977b177f..a28a4dbf4d70 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -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 ( -
-
- {rows.map((row, index) => ( -
- {row} -
- ))} -
-
- ) -} - // 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. diff --git a/apps/desktop/src/components/assistant-ui/tool/run-ticker.tsx b/apps/desktop/src/components/assistant-ui/tool/run-ticker.tsx new file mode 100644 index 000000000000..07ad0f75d313 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/run-ticker.tsx @@ -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 ( +
+
+ {rows.map((row, index) => ( +
+ {row} +
+ ))} +
+
+ ) +} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 2b260de97b71..72b219d17ba2 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1338,6 +1338,14 @@ text-* variant utilities. */ .btn-arc { max-width: 100%; } +/* A delegation is a list of short rows — goal, model, timer — not prose, so it + reads better narrow than stretched across the full reading column. Scoped + here rather than as a utility on the element: the rule above sets width on + every tool block and would win over it. */ +[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-delegate-card] { + max-width: 75%; +} + [data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] code { max-width: none; font-family: inherit;