// Renders a mixed timeline of agent parts (reasoning + tool calls). Standalone — // lifted from the chat transcript (client/components/TurnView.tsx) so we can // iterate on the design in isolation (see /playground/tool-calls). Takes // `Part[]` directly and `cwd` as a prop, so it renders with no workspace // provider mounted. // // Layout: each row is a two-column flex — a left timeline rail (one node per row // linked by a 1px rule) and a collapsible header/body. The rail primitives live // in ./TimelineRow; the subagent card in ./SubagentCard; the body animates open // (./Collapse, height spring) and renders via ./ToolOutput (syntax-highlighted // code/json when detected, plain text otherwise). Formatting + detection live in // ./format and ./detect. import { type ReactNode, useState } from 'react' import { IconLoader2, IconPackage } from '@tabler/icons-react' import { cn } from '@/client/lib/cn' import { PlainMarkdownText } from '@/client/features/chat/MarkdownContent' import { formatMcpServerName, getMcpIcon } from '@/client/features/connectors/mcp-icons' import type { Part, ToolCall } from '@/lib/types' import { Collapse } from './Collapse' import { ReadImagePreview, readImageRelPath } from './ReadImagePreview' import { SubagentCard } from './SubagentCard' import { HEADER, IconMarker, McpLogo, RowChevron, type RowPosition, TimelineRow } from './TimelineRow' import { ToolOutput } from './ToolOutput' import { formatDuration, formatInputBrief, parseCodexMcp, formatMcpTool, getToolDisplayName, parseMcporterCall, parseNativeMcp } from './format' type ToolCallGroupProps = { // A mixed timeline of parts; reasoning + tool-call render, others are skipped. parts: Part[] // Working directory used to shorten absolute paths in tool briefs. Pass the // workspace cwd to match the live chat; null leaves paths absolute. cwd?: string | null // Whether the agent is still working. Drives the live last row: a trailing // reasoning shows "Thinking" (vs "Thought"); a trailing running tool spins. processing?: boolean } export function ToolCallGroup({ parts, cwd = null, processing = false }: ToolCallGroupProps) { const rows = parts.filter(p => p.type === 'reasoning' || p.type === 'tool-call') return (
{rows.map((part, i) => { const isLast = i === rows.length - 1 const pos = { isFirst: i === 0, isLast } if (part.type === 'reasoning') // A reasoning block reads as live "Thinking" only while it's the last // row of an active stream; anything after it makes it a done "Thought". return ( ) return })}
) } type ToolRowProps = RowPosition & { call: ToolCall leading?: ReactNode marker?: ReactNode name: string brief: string // Extra expanded content rendered above the output (e.g. the image a Read // tool call opened). preview?: ReactNode } function ToolRow({ isFirst, isLast, call, leading, marker, name, brief, preview }: ToolRowProps) { const [open, setOpen] = useState(false) const isError = call.state === 'error' const isRunning = call.state === 'running' || call.state === 'pending' const output = isError ? (call.errorText ?? '') : typeof call.output === 'string' ? call.output : '' const hasBody = !!(output || isError || preview) const title = brief ? `${name}: ${brief}` : name return (
{hasBody && (
{preview} {(output || isError) && }
)}
) } // Reasoning row — collapsible thought text as italic prose. Labelled "Thinking" // while live (spinner node), "Thought" once done (dot node). No leading glyph. // While live (`inProgress` — the last row of an active stream) it stays expanded // so the streaming thought is visible; it collapses on its own the moment // anything follows it (a text/tool row makes it no longer the last row, so // `inProgress` goes false and it reverts to the user's collapsed default). // // Some backends report that the model reasoned without handing over the text // (Anthropic `redacted_thinking`; the OpenClaw codex app-server, which sends a // Reasoning item with timing only). Those rows have nothing to expand, so they // render as a bare label — "Thought for 1.2s" — with no chevron. type ReasoningRowProps = RowPosition & { text: string inProgress?: boolean durationMs?: number } function ReasoningRow({ isFirst, isLast, text, inProgress = false, durationMs }: ReasoningRowProps) { const [open, setOpen] = useState(false) if (!text) { const doneLabel = durationMs === undefined ? 'Thought' : `Thought for ${formatDuration(durationMs)}` return (
{inProgress ? 'Thinking' : doneLabel}
) } const label = inProgress ? 'Thinking' : 'Thought' const expanded = inProgress || open return (
) } // Dispatch a tool call to the right row chrome: a subagent card for an Agent // call, a "Loading Skill" row for a Skill call, the server-branded card for an // MCP shape (`mcporter call …` Bash or a native `mcp__server__tool` name), else a // plain tool row. type ToolCallCardProps = RowPosition & { call: ToolCall; cwd: string | null } function ToolCallCard({ call, cwd, isFirst, isLast }: ToolCallCardProps) { if (call.caller === 'subagent') { return ( } /> ) } if (call.name === 'Skill' && call.skill) { // On success, swap the timeline dot for a package icon node (same box as the // MCP logo). Running keeps the spinner node; an error keeps the red dot. const succeeded = call.state === 'success' return ( : undefined} name="Loading skill" brief={call.skill.skillName} /> ) } const mcp = parseMcporterCall(call) ?? parseNativeMcp(call) ?? parseCodexMcp(call) if (mcp) { const fn = formatMcpTool(mcp.server, mcp.tool) return ( } name={formatMcpServerName(mcp.server)} brief={mcp.rest ? `${fn} ${mcp.rest}` : fn} /> ) } // A Read of a workspace image gets the actual picture in its expanded body // (skipped while errored — the file likely wasn't readable). const imageRelPath = call.state === 'error' ? null : readImageRelPath(call, cwd) return ( } name={getToolDisplayName(call)} brief={formatInputBrief(call, cwd)} preview={imageRelPath ? : undefined} /> ) } function CallerBadge({ call }: { call: ToolCall }) { if (call.caller !== 'mcp' && call.caller !== 'server-tool') return null const label = call.caller === 'mcp' ? `mcp${call.mcpServer ? `:${call.mcpServer.slice(0, 8)}` : ''}` : 'server' return ( {label} ) }