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:
@@ -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