mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
9a5f102d0e
commit
2818c9e514
6 changed files with 489 additions and 24 deletions
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
150
apps/desktop/src/components/assistant-ui/tool/delegate-model.ts
Normal file
150
apps/desktop/src/components/assistant-ui/tool/delegate-model.ts
Normal 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'
|
||||
158
apps/desktop/src/components/assistant-ui/tool/delegate.tsx
Normal file
158
apps/desktop/src/components/assistant-ui/tool/delegate.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
28
apps/desktop/src/components/assistant-ui/tool/run-ticker.tsx
Normal file
28
apps/desktop/src/components/assistant-ui/tool/run-ticker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue