diff --git a/apps/desktop/src/main/todo-agent/supervisor.ts b/apps/desktop/src/main/todo-agent/supervisor.ts index e3111c9c9e4..55fd2ab0d79 100644 --- a/apps/desktop/src/main/todo-agent/supervisor.ts +++ b/apps/desktop/src/main/todo-agent/supervisor.ts @@ -751,6 +751,8 @@ class TodoSupervisor { kind: parsed.event.kind, label: parsed.event.label, text: parsed.event.text, + toolUseId: parsed.event.toolUseId, + parentToolUseId: parsed.event.parentToolUseId, }, ]); } @@ -1008,6 +1010,18 @@ interface ClassifiedEvent { kind: TodoStreamEventKind; label: string; text: string; + /** + * For `tool_use` events this is the tool_use block id. + * For `tool_result` events this is the `tool_use_id` the result + * targets. Undefined for non-tool events. + */ + toolUseId?: string; + /** + * Set when the NDJSON record has a top-level `parent_tool_use_id`, + * i.e. the message was emitted from inside a subagent (Agent/Task + * tool) context. + */ + parentToolUseId?: string; } interface ClassifiedLine { @@ -1037,6 +1051,13 @@ function classifyStreamJson(payload: unknown): ClassifiedLine { const type = typeof rec.type === "string" ? (rec.type as string) : ""; const sessionId = typeof rec.session_id === "string" ? (rec.session_id as string) : null; + // Claude Code sets `parent_tool_use_id` on the top-level NDJSON + // record whenever the message was emitted inside a subagent + // context (i.e. the main session invoked the Task/Agent tool). + const parentToolUseId = + typeof rec.parent_tool_use_id === "string" + ? (rec.parent_tool_use_id as string) + : undefined; if (type === "system" && rec.subtype === "init") { return { @@ -1056,7 +1077,12 @@ function classifyStreamJson(payload: unknown): ClassifiedLine { return { ...empty, sessionId, - event: { kind: "assistant_text", label: "Claude", text }, + event: { + kind: "assistant_text", + label: "Claude", + text, + parentToolUseId, + }, }; } const tool = extractToolUseSummary(rec.message); @@ -1064,22 +1090,30 @@ function classifyStreamJson(payload: unknown): ClassifiedLine { return { ...empty, sessionId, - event: { kind: "tool_use", label: tool.label, text: tool.text }, + event: { + kind: "tool_use", + label: tool.label, + text: tool.text, + toolUseId: tool.id, + parentToolUseId, + }, }; } return empty; } if (type === "user") { - const text = extractToolResultText(rec.message); - if (text) { + const result = extractToolResultDetails(rec.message); + if (result) { return { ...empty, sessionId, event: { kind: "tool_result", label: "tool result", - text: truncate(text, 400), + text: truncate(result.text, 400), + toolUseId: result.toolUseId, + parentToolUseId, }, }; } @@ -1144,7 +1178,7 @@ function extractAssistantText(message: unknown): string | null { function extractToolUseSummary( message: unknown, -): { label: string; text: string } | null { +): { label: string; text: string; id: string | undefined } | null { if (typeof message !== "object" || message === null) return null; const content = (message as { content?: unknown }).content; if (!Array.isArray(content)) return null; @@ -1153,22 +1187,29 @@ function extractToolUseSummary( const rec = part as Record; if (rec.type !== "tool_use") continue; const name = typeof rec.name === "string" ? (rec.name as string) : "tool"; + const id = typeof rec.id === "string" ? (rec.id as string) : undefined; const input = rec.input; const inputSummary = summarizeToolInput(name, input); - return { label: name, text: inputSummary }; + return { label: name, text: inputSummary, id }; } return null; } -function extractToolResultText(message: unknown): string | null { +function extractToolResultDetails( + message: unknown, +): { text: string; toolUseId: string | undefined } | null { if (typeof message !== "object" || message === null) return null; const content = (message as { content?: unknown }).content; if (!Array.isArray(content)) return null; const parts: string[] = []; + let toolUseId: string | undefined; for (const part of content) { if (typeof part !== "object" || part === null) continue; const rec = part as Record; if (rec.type === "tool_result") { + if (!toolUseId && typeof rec.tool_use_id === "string") { + toolUseId = rec.tool_use_id as string; + } const inner = rec.content; if (typeof inner === "string") { parts.push(inner); @@ -1184,7 +1225,8 @@ function extractToolResultText(message: unknown): string | null { } } const joined = parts.join("\n").trim(); - return joined.length > 0 ? joined : null; + if (joined.length === 0) return null; + return { text: joined, toolUseId }; } function summarizeToolInput(name: string, input: unknown): string { diff --git a/apps/desktop/src/main/todo-agent/types.ts b/apps/desktop/src/main/todo-agent/types.ts index b095f2377bf..ea3139ce85d 100644 --- a/apps/desktop/src/main/todo-agent/types.ts +++ b/apps/desktop/src/main/todo-agent/types.ts @@ -172,6 +172,23 @@ export interface TodoStreamEvent { text: string; /** Optional raw payload for the "raw" / debug kind. */ raw?: unknown; + /** + * The Anthropic tool-use block id this event corresponds to. + * - For `tool_use` events: the id of the tool_use content block. + * - For `tool_result` events: the `tool_use_id` the result answers. + * Lets the UI pair tool_use ↔ tool_result by id instead of position, + * which is robust to concurrent / out-of-order SDK emissions. + */ + toolUseId?: string; + /** + * Set on messages emitted from inside a subagent's context (i.e. when + * the main session invoked the `Task`/`Agent` tool). Its value is the + * tool_use id of the parent Agent tool call. The UI uses this to nest + * sub-tool activity under the parent Agent card, matching the VSCode + * Claude Code extension's presentation. + * See: https://docs.claude.com/en/docs/agent-sdk/ (Subagents) + */ + parentToolUseId?: string; } export interface TodoStreamUpdate { diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx b/apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx index b807cb53a15..b7ad05ae9c8 100644 --- a/apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx +++ b/apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx @@ -14,12 +14,30 @@ import { ScrollArea } from "@superset/ui/scroll-area"; import { toast } from "@superset/ui/sonner"; import { Textarea } from "@superset/ui/textarea"; import { cn } from "@superset/ui/utils"; +import { + Bot, + CheckSquare, + Cog, + FileEdit, + FilePen, + FilePlus, + FileText, + FolderSearch, + Globe, + ListTree, + type LucideIcon, + Search, + Sparkles, + SquareTerminal, + Wrench, +} from "lucide-react"; import type { TodoSessionListEntry, TodoStreamEvent, } from "main/todo-agent/types"; import { type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, useCallback, useEffect, useMemo, @@ -1502,10 +1520,18 @@ function formatDuration(startMs: number | null, endMs: number | null): string { } /** - * Pair consecutive tool_use → tool_result events into a single card - * (matching VSCode Claude Code extension's IN / OUT grid layout). - * Non-tool events stay as singles. Unpaired tool_use (still streaming) - * appears as a card with empty OUT row. + * Tree node for the live stream UI. + * + * `tool` nodes pair a `tool_use` with its `tool_result` (matched by + * `toolUseId`, NOT by positional proximity) and may contain `children` + * — sub-agent activity that Claude Code emits with `parent_tool_use_id` + * pointing at the parent Agent/Task tool call. This matches the VSCode + * Claude Code extension's presentation: a Task tool folds all of its + * subagent's tool calls underneath itself. + * + * `message` nodes are anything non-tool (assistant text, result, error, + * system_init, raw). They can also appear as `children` of a tool when + * they were emitted inside a subagent context. */ type StreamItem = | { type: "message"; id: string; event: TodoStreamEvent } @@ -1514,33 +1540,94 @@ type StreamItem = id: string; toolUse: TodoStreamEvent; toolResult: TodoStreamEvent | null; + children: StreamItem[]; }; -function pairStreamEvents(events: TodoStreamEvent[]): StreamItem[] { - const items: StreamItem[] = []; - for (let i = 0; i < events.length; i++) { - const ev = events[i]; +/** + * Build the render tree from the flat event buffer. + * + * Step 1: Pair tool_use ↔ tool_result by `toolUseId` (not position). + * Unpaired `tool_result` events fall back to a standalone + * message row so we never silently drop data. Legacy events + * in `stream.jsonl` from before this field existed are paired + * positionally (the original heuristic) to keep replay of + * historical sessions intact. + * Step 2: Nest items under their `parentToolUseId` when it points at a + * known tool node. Items whose parent is unknown stay at the + * top level — that preserves visibility during a mid-session + * restart where we replayed the jsonl without the Agent frame + * that spawned them. + */ +function buildStreamTree(events: TodoStreamEvent[]): StreamItem[] { + const toolNodeById = new Map>(); + const resultByUseId = new Map(); + const allItems: StreamItem[] = []; + + // Index tool_results with ids up front so we can attach them to + // their tool_use even if the events were appended out of order. + for (const ev of events) { + if (ev.kind === "tool_result" && ev.toolUseId) { + resultByUseId.set(ev.toolUseId, ev); + } + } + + // Most-recent tool_use node that lacks a toolUseId and is still + // awaiting its result. Used only for legacy positional pairing. + let pendingLegacyTool: Extract | null = null; + + for (const ev of events) { if (!ev) continue; if (ev.kind === "tool_use") { - const next = events[i + 1]; - if (next?.kind === "tool_result") { - items.push({ - type: "tool", - id: ev.id, - toolUse: ev, - toolResult: next, - }); - i++; - } else { - items.push({ type: "tool", id: ev.id, toolUse: ev, toolResult: null }); + const matchedResult = ev.toolUseId + ? (resultByUseId.get(ev.toolUseId) ?? null) + : null; + const node: Extract = { + type: "tool", + id: ev.id, + toolUse: ev, + toolResult: matchedResult, + children: [], + }; + if (ev.toolUseId) toolNodeById.set(ev.toolUseId, node); + allItems.push(node); + pendingLegacyTool = !ev.toolUseId && !matchedResult ? node : null; + continue; + } + if (ev.kind === "tool_result") { + // Modern path: already attached via resultByUseId. + if (ev.toolUseId && toolNodeById.has(ev.toolUseId)) continue; + // Legacy fallback: attach to the most recent dangling + // tool_use without a toolUseId (same positional heuristic + // the old impl used). Keeps replay of pre-upgrade sessions + // from losing pairs. + if (!ev.toolUseId && pendingLegacyTool) { + pendingLegacyTool.toolResult = ev; + pendingLegacyTool = null; + continue; + } + allItems.push({ type: "message", id: ev.id, event: ev }); + continue; + } + allItems.push({ type: "message", id: ev.id, event: ev }); + } + + // Nest items under their parent Agent/Task tool node. + const roots: StreamItem[] = []; + for (const item of allItems) { + const parentId = + item.type === "tool" + ? item.toolUse.parentToolUseId + : item.event.parentToolUseId; + if (parentId) { + const parent = toolNodeById.get(parentId); + if (parent) { + parent.children.push(item); + continue; } - } else if (ev.kind === "tool_result") { - items.push({ type: "message", id: ev.id, event: ev }); - } else { - items.push({ type: "message", id: ev.id, event: ev }); } + roots.push(item); } - return items; + return roots; } function StreamView({ events }: { events: TodoStreamEvent[] }) { @@ -1563,13 +1650,13 @@ function StreamView({ events }: { events: TodoStreamEvent[] }) { el.scrollTop = el.scrollHeight; }, [events.length]); - const items = useMemo(() => pairStreamEvents(events), [events]); + const items = useMemo(() => buildStreamTree(events), [events]); return (
{events.length === 0 ? (
@@ -1578,83 +1665,144 @@ function StreamView({ events }: { events: TodoStreamEvent[] }) {
) : (
- {items.map((item) => - item.type === "tool" ? ( - - ) : ( - - ), - )} + {items.map((item) => ( + + ))}
)}
); } +function StreamNode({ item }: { item: StreamItem }) { + if (item.type === "tool") { + return ; + } + return ; +} + /** - * VSCode Claude Code extension faithful reproduction: uses `
` so - * the tool call folds by default, showing only a 2-line summary (bold tool - * name + monospace secondary info). Expanded body shows an IN/OUT grid. - * This matches the extension's `.Ze/._e/.or/.D/.rr/.ir/.lo/.tr` CSS - * classes we reverse-engineered from webview/index.css. + * Styling intent: + * - Tool name gets a distinct subtle color tied to the tool kind (Bash, + * Read, Edit, Task/Agent, …) so scanning the stream is fast. + * - When the tool is still running (no tool_result yet) the name shimmers + * with a pure-CSS `ShinyText` so the user sees it as "live". + * - Expanding the card reveals IN / OUT panes plus — for Agent/Task calls + * — the nested subagent activity tree. Matches the VSCode extension. */ function ToolCallCard({ item, }: { item: Extract; }) { - const { toolUse, toolResult } = item; + const { toolUse, toolResult, children } = item; const toolName = toolUse.label; const secondary = extractSecondaryInfo(toolName, toolUse.text); const hasResult = toolResult != null; + const isRunning = !hasResult; + const palette = getToolPalette(toolName); + const Icon = palette.icon; + const hasChildren = children.length > 0; + // Controlled `open` so React does not clobber the user's toggles on + // re-render (streaming events cause frequent re-renders). Initial + // value auto-expands Agent/Task cards that already have children so + // the user can see the subagent's nested activity without clicking. + const [open, setOpen] = useState(hasChildren); + // Auto-open the card the first time a child arrives in this tool + // (i.e. the subagent just started doing something). The user can + // still close it back down; we only nudge on the 0 → 1 transition. + const prevHadChildren = useRef(hasChildren); + useEffect(() => { + if (!prevHadChildren.current && hasChildren) setOpen(true); + prevHadChildren.current = hasChildren; + }, [hasChildren]); return ( -
- - +
setOpen(e.currentTarget.open)} + > + + - {toolName} + + + + {isRunning ? ( + + {toolName} + + ) : ( + + {toolName} + + )} {secondary && ( - + {secondary} )} - {!hasResult && ( - - … + {hasChildren && ( + + {children.length} )} + {isRunning && ( + + )} -
+
-
+
IN
-
+
 								{toolUse.text}
 							
-
+
OUT
-
+
{toolResult ? (
 									{toolResult.text}
 								
) : ( - - 実行中… - + 実行中… )}
+ {hasChildren && ( +
+
+
+ {children.map((child) => ( + + ))} +
+
+ )}
); @@ -1669,32 +1817,196 @@ function extractSecondaryInfo(_toolName: string, text: string): string | null { return text.slice(0, 80); } +/** + * Lightweight, dependency-free shimmering text. A pure-CSS animated + * linear-gradient clipped to the text serves as the "currently running" + * affordance for tool names and the OUT-pending label. The actual + * animation lives in `globals.css` under `.animate-shine` — this + * component is just a small wrapper so callers don't have to remember + * the class name. + */ +function ShinyText({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} + +interface ToolPalette { + icon: LucideIcon; + iconBg: string; + iconColor: string; + name: string; + accent: string; +} + +/** + * Map Claude Code tool names to a small accent palette. The defaults are + * intentionally low-saturation so a flood of tool calls in the stream + * doesn't turn into a rainbow. Unknown tools fall through to the + * generic wrench icon. Keep keys here aligned with the actual tool + * names Claude Code emits in the NDJSON stream. + */ +function getToolPalette(toolName: string): ToolPalette { + const fallback: ToolPalette = { + icon: Wrench, + iconBg: "bg-muted", + iconColor: "text-muted-foreground", + name: "text-foreground", + accent: "hover:bg-accent/20", + }; + const palettes: Record = { + Agent: { + icon: Bot, + iconBg: "bg-violet-500/15", + iconColor: "text-violet-400", + name: "text-violet-300", + accent: "hover:bg-violet-500/10", + }, + Task: { + icon: Bot, + iconBg: "bg-violet-500/15", + iconColor: "text-violet-400", + name: "text-violet-300", + accent: "hover:bg-violet-500/10", + }, + Bash: { + icon: SquareTerminal, + iconBg: "bg-emerald-500/15", + iconColor: "text-emerald-400", + name: "text-emerald-300", + accent: "hover:bg-emerald-500/10", + }, + Read: { + icon: FileText, + iconBg: "bg-sky-500/15", + iconColor: "text-sky-400", + name: "text-sky-300", + accent: "hover:bg-sky-500/10", + }, + Edit: { + icon: FileEdit, + iconBg: "bg-amber-500/15", + iconColor: "text-amber-400", + name: "text-amber-300", + accent: "hover:bg-amber-500/10", + }, + MultiEdit: { + icon: FilePen, + iconBg: "bg-amber-500/15", + iconColor: "text-amber-400", + name: "text-amber-300", + accent: "hover:bg-amber-500/10", + }, + Write: { + icon: FilePlus, + iconBg: "bg-orange-500/15", + iconColor: "text-orange-400", + name: "text-orange-300", + accent: "hover:bg-orange-500/10", + }, + Grep: { + icon: Search, + iconBg: "bg-indigo-500/15", + iconColor: "text-indigo-400", + name: "text-indigo-300", + accent: "hover:bg-indigo-500/10", + }, + Glob: { + icon: FolderSearch, + iconBg: "bg-indigo-500/15", + iconColor: "text-indigo-400", + name: "text-indigo-300", + accent: "hover:bg-indigo-500/10", + }, + WebFetch: { + icon: Globe, + iconBg: "bg-cyan-500/15", + iconColor: "text-cyan-400", + name: "text-cyan-300", + accent: "hover:bg-cyan-500/10", + }, + WebSearch: { + icon: Globe, + iconBg: "bg-cyan-500/15", + iconColor: "text-cyan-400", + name: "text-cyan-300", + accent: "hover:bg-cyan-500/10", + }, + TodoWrite: { + icon: CheckSquare, + iconBg: "bg-pink-500/15", + iconColor: "text-pink-400", + name: "text-pink-300", + accent: "hover:bg-pink-500/10", + }, + NotebookEdit: { + icon: FilePen, + iconBg: "bg-amber-500/15", + iconColor: "text-amber-400", + name: "text-amber-300", + accent: "hover:bg-amber-500/10", + }, + SlashCommand: { + icon: Sparkles, + iconBg: "bg-fuchsia-500/15", + iconColor: "text-fuchsia-400", + name: "text-fuchsia-300", + accent: "hover:bg-fuchsia-500/10", + }, + ExitPlanMode: { + icon: ListTree, + iconBg: "bg-teal-500/15", + iconColor: "text-teal-400", + name: "text-teal-300", + accent: "hover:bg-teal-500/10", + }, + ToolSearch: { + icon: Cog, + iconBg: "bg-slate-500/15", + iconColor: "text-slate-400", + name: "text-slate-300", + accent: "hover:bg-slate-500/10", + }, + }; + return palettes[toolName] ?? fallback; +} + function MessageRow({ event }: { event: TodoStreamEvent }) { if (event.kind === "assistant_text") { return ( -
+
); } if (event.kind === "result") { return ( -
+
); } if (event.kind === "error") { return ( -
+
{event.text}
); } if (event.kind === "system_init") { return ( -
- {event.label} +
+ + {event.label} + {event.text}
); diff --git a/apps/desktop/src/renderer/globals.css b/apps/desktop/src/renderer/globals.css index e6785d1973d..f64f0d4f739 100644 --- a/apps/desktop/src/renderer/globals.css +++ b/apps/desktop/src/renderer/globals.css @@ -522,4 +522,39 @@ .animate-clone-indeterminate { animation: clone-indeterminate 1.4s ease-in-out infinite; } + + /* + * Pure-CSS shimmer used by . A two-stop gradient built from + * `currentColor` and a theme-aware `--shine-peak` slides horizontally + * across the text. `-webkit-text-fill-color: transparent` keeps the + * `color` declaration intact so `currentColor` in the gradient still + * reflects whatever text color the surrounding class set. + */ + @keyframes shine { + 0% { + background-position: 150% center; + } + 100% { + background-position: -50% center; + } + } + .animate-shine { + --shine-peak: rgba(255, 255, 255, 0.92); + background-image: linear-gradient( + 110deg, + currentColor 0%, + currentColor 35%, + var(--shine-peak) 50%, + currentColor 65%, + currentColor 100% + ); + background-size: 200% auto; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + animation: shine 2.4s linear infinite; + } + :root.light .animate-shine { + --shine-peak: rgba(0, 0, 0, 0.55); + } }