// 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
})}
)
}
// 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 (