Spaces:
Build error
Build error
| import { useMemo, useState } from "react"; | |
| import type { TranscriptEntry } from "../../adapters"; | |
| import { MarkdownBody } from "../MarkdownBody"; | |
| import { cn, formatTokens } from "../../lib/utils"; | |
| import { | |
| Check, | |
| ChevronDown, | |
| ChevronRight, | |
| CircleAlert, | |
| TerminalSquare, | |
| User, | |
| Wrench, | |
| } from "lucide-react"; | |
| export type TranscriptMode = "nice" | "raw"; | |
| export type TranscriptDensity = "comfortable" | "compact"; | |
| interface RunTranscriptViewProps { | |
| entries: TranscriptEntry[]; | |
| mode?: TranscriptMode; | |
| density?: TranscriptDensity; | |
| limit?: number; | |
| streaming?: boolean; | |
| collapseStdout?: boolean; | |
| emptyMessage?: string; | |
| className?: string; | |
| thinkingClassName?: string; | |
| } | |
| type TranscriptBlock = | |
| | { | |
| type: "message"; | |
| role: "assistant" | "user"; | |
| ts: string; | |
| text: string; | |
| streaming: boolean; | |
| } | |
| | { | |
| type: "thinking"; | |
| ts: string; | |
| text: string; | |
| streaming: boolean; | |
| } | |
| | { | |
| type: "tool"; | |
| ts: string; | |
| endTs?: string; | |
| name: string; | |
| toolUseId?: string; | |
| input: unknown; | |
| result?: string; | |
| isError?: boolean; | |
| status: "running" | "completed" | "error"; | |
| } | |
| | { | |
| type: "activity"; | |
| ts: string; | |
| activityId?: string; | |
| name: string; | |
| status: "running" | "completed"; | |
| } | |
| | { | |
| type: "command_group"; | |
| ts: string; | |
| endTs?: string; | |
| items: Array<{ | |
| ts: string; | |
| endTs?: string; | |
| input: unknown; | |
| result?: string; | |
| isError?: boolean; | |
| status: "running" | "completed" | "error"; | |
| }>; | |
| } | |
| | { | |
| type: "stdout"; | |
| ts: string; | |
| text: string; | |
| } | |
| | { | |
| type: "event"; | |
| ts: string; | |
| label: string; | |
| tone: "info" | "warn" | "error" | "neutral"; | |
| text: string; | |
| detail?: string; | |
| }; | |
| function asRecord(value: unknown): Record<string, unknown> | null { | |
| if (typeof value !== "object" || value === null || Array.isArray(value)) return null; | |
| return value as Record<string, unknown>; | |
| } | |
| function compactWhitespace(value: string): string { | |
| return value.replace(/\s+/g, " ").trim(); | |
| } | |
| function truncate(value: string, max: number): string { | |
| return value.length > max ? `${value.slice(0, Math.max(0, max - 1))}…` : value; | |
| } | |
| function humanizeLabel(value: string): string { | |
| return value | |
| .replace(/[_-]+/g, " ") | |
| .trim() | |
| .replace(/\b\w/g, (char) => char.toUpperCase()); | |
| } | |
| function stripWrappedShell(command: string): string { | |
| const trimmed = compactWhitespace(command); | |
| const shellWrapped = trimmed.match(/^(?:(?:\/bin\/)?(?:zsh|bash|sh)|cmd(?:\.exe)?(?:\s+\/d)?(?:\s+\/s)?(?:\s+\/c)?)\s+(?:-lc|\/c)\s+(.+)$/i); | |
| const inner = shellWrapped?.[1] ?? trimmed; | |
| const quoted = inner.match(/^(['"])([\s\S]*)\1$/); | |
| return compactWhitespace(quoted?.[2] ?? inner); | |
| } | |
| function formatUnknown(value: unknown): string { | |
| if (typeof value === "string") return value; | |
| if (value === null || value === undefined) return ""; | |
| try { | |
| return JSON.stringify(value, null, 2); | |
| } catch { | |
| return String(value); | |
| } | |
| } | |
| function formatToolPayload(value: unknown): string { | |
| if (typeof value === "string") { | |
| try { | |
| return JSON.stringify(JSON.parse(value), null, 2); | |
| } catch { | |
| return value; | |
| } | |
| } | |
| return formatUnknown(value); | |
| } | |
| function extractToolUseId(input: unknown): string | undefined { | |
| const record = asRecord(input); | |
| if (!record) return undefined; | |
| const candidates = [ | |
| record.toolUseId, | |
| record.tool_use_id, | |
| record.callId, | |
| record.call_id, | |
| record.id, | |
| ]; | |
| for (const candidate of candidates) { | |
| if (typeof candidate === "string" && candidate.trim()) { | |
| return candidate; | |
| } | |
| } | |
| return undefined; | |
| } | |
| function summarizeRecord(record: Record<string, unknown>, keys: string[]): string | null { | |
| for (const key of keys) { | |
| const value = record[key]; | |
| if (typeof value === "string" && value.trim()) { | |
| return truncate(compactWhitespace(value), 120); | |
| } | |
| } | |
| return null; | |
| } | |
| function summarizeToolInput(name: string, input: unknown, density: TranscriptDensity): string { | |
| const compactMax = density === "compact" ? 72 : 120; | |
| if (typeof input === "string") { | |
| const normalized = isCommandTool(name, input) ? stripWrappedShell(input) : compactWhitespace(input); | |
| return truncate(normalized, compactMax); | |
| } | |
| const record = asRecord(input); | |
| if (!record) { | |
| const serialized = compactWhitespace(formatUnknown(input)); | |
| return serialized ? truncate(serialized, compactMax) : `Inspect ${name} input`; | |
| } | |
| const command = typeof record.command === "string" | |
| ? record.command | |
| : typeof record.cmd === "string" | |
| ? record.cmd | |
| : null; | |
| if (command && isCommandTool(name, record)) { | |
| return truncate(stripWrappedShell(command), compactMax); | |
| } | |
| const direct = | |
| summarizeRecord(record, ["command", "cmd", "path", "filePath", "file_path", "query", "url", "prompt", "message"]) | |
| ?? summarizeRecord(record, ["pattern", "name", "title", "target", "tool"]) | |
| ?? null; | |
| if (direct) return truncate(direct, compactMax); | |
| if (Array.isArray(record.paths) && record.paths.length > 0) { | |
| const first = record.paths.find((value): value is string => typeof value === "string" && value.trim().length > 0); | |
| if (first) { | |
| return truncate(`${record.paths.length} paths, starting with ${first}`, compactMax); | |
| } | |
| } | |
| const keys = Object.keys(record); | |
| if (keys.length === 0) return `No ${name} input`; | |
| if (keys.length === 1) return truncate(`${keys[0]} payload`, compactMax); | |
| return truncate(`${keys.length} fields: ${keys.slice(0, 3).join(", ")}`, compactMax); | |
| } | |
| function parseStructuredToolResult(result: string | undefined) { | |
| if (!result) return null; | |
| const lines = result.split(/\r?\n/); | |
| const metadata = new Map<string, string>(); | |
| let bodyStartIndex = lines.findIndex((line) => line.trim() === ""); | |
| if (bodyStartIndex === -1) bodyStartIndex = lines.length; | |
| for (let index = 0; index < bodyStartIndex; index += 1) { | |
| const match = lines[index]?.match(/^([a-z_]+):\s*(.+)$/i); | |
| if (match) { | |
| metadata.set(match[1].toLowerCase(), compactWhitespace(match[2])); | |
| } | |
| } | |
| const body = lines.slice(Math.min(bodyStartIndex + 1, lines.length)) | |
| .map((line) => compactWhitespace(line)) | |
| .filter(Boolean) | |
| .join("\n"); | |
| return { | |
| command: metadata.get("command") ?? null, | |
| status: metadata.get("status") ?? null, | |
| exitCode: metadata.get("exit_code") ?? null, | |
| body, | |
| }; | |
| } | |
| function isCommandTool(name: string, input: unknown): boolean { | |
| if (name === "command_execution" || name === "shell" || name === "shellToolCall" || name === "bash") { | |
| return true; | |
| } | |
| if (typeof input === "string") { | |
| return /\b(?:bash|zsh|sh|cmd|powershell)\b/i.test(input); | |
| } | |
| const record = asRecord(input); | |
| return Boolean(record && (typeof record.command === "string" || typeof record.cmd === "string")); | |
| } | |
| function displayToolName(name: string, input: unknown): string { | |
| if (isCommandTool(name, input)) return "Executing command"; | |
| return humanizeLabel(name); | |
| } | |
| function summarizeToolResult(result: string | undefined, isError: boolean | undefined, density: TranscriptDensity): string { | |
| if (!result) return isError ? "Tool failed" : "Waiting for result"; | |
| const structured = parseStructuredToolResult(result); | |
| if (structured) { | |
| if (structured.body) { | |
| return truncate(structured.body.split("\n")[0] ?? structured.body, density === "compact" ? 84 : 140); | |
| } | |
| if (structured.status === "completed") return "Completed"; | |
| if (structured.status === "failed" || structured.status === "error") { | |
| return structured.exitCode ? `Failed with exit code ${structured.exitCode}` : "Failed"; | |
| } | |
| } | |
| const lines = result | |
| .split(/\r?\n/) | |
| .map((line) => compactWhitespace(line)) | |
| .filter(Boolean); | |
| const firstLine = lines[0] ?? result; | |
| return truncate(firstLine, density === "compact" ? 84 : 140); | |
| } | |
| function parseSystemActivity(text: string): { activityId?: string; name: string; status: "running" | "completed" } | null { | |
| const match = text.match(/^item (started|completed):\s*([a-z0-9_-]+)(?:\s+\(id=([^)]+)\))?$/i); | |
| if (!match) return null; | |
| return { | |
| status: match[1].toLowerCase() === "started" ? "running" : "completed", | |
| name: humanizeLabel(match[2] ?? "Activity"), | |
| activityId: match[3] || undefined, | |
| }; | |
| } | |
| function shouldHideNiceModeStderr(text: string): boolean { | |
| const normalized = compactWhitespace(text).toLowerCase(); | |
| return normalized.startsWith("[paperclip] skipping saved session resume"); | |
| } | |
| function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] { | |
| const grouped: TranscriptBlock[] = []; | |
| let pending: Array<Extract<TranscriptBlock, { type: "command_group" }>["items"][number]> = []; | |
| let groupTs: string | null = null; | |
| let groupEndTs: string | undefined; | |
| const flush = () => { | |
| if (pending.length === 0 || !groupTs) return; | |
| grouped.push({ | |
| type: "command_group", | |
| ts: groupTs, | |
| endTs: groupEndTs, | |
| items: pending, | |
| }); | |
| pending = []; | |
| groupTs = null; | |
| groupEndTs = undefined; | |
| }; | |
| for (const block of blocks) { | |
| if (block.type === "tool" && isCommandTool(block.name, block.input)) { | |
| if (!groupTs) { | |
| groupTs = block.ts; | |
| } | |
| groupEndTs = block.endTs ?? block.ts; | |
| pending.push({ | |
| ts: block.ts, | |
| endTs: block.endTs, | |
| input: block.input, | |
| result: block.result, | |
| isError: block.isError, | |
| status: block.status, | |
| }); | |
| continue; | |
| } | |
| flush(); | |
| grouped.push(block); | |
| } | |
| flush(); | |
| return grouped; | |
| } | |
| export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] { | |
| const blocks: TranscriptBlock[] = []; | |
| const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>(); | |
| const pendingActivityBlocks = new Map<string, Extract<TranscriptBlock, { type: "activity" }>>(); | |
| for (const entry of entries) { | |
| const previous = blocks[blocks.length - 1]; | |
| if (entry.kind === "assistant" || entry.kind === "user") { | |
| const isStreaming = streaming && entry.kind === "assistant" && entry.delta === true; | |
| if (previous?.type === "message" && previous.role === entry.kind) { | |
| previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`; | |
| previous.ts = entry.ts; | |
| previous.streaming = previous.streaming || isStreaming; | |
| } else { | |
| blocks.push({ | |
| type: "message", | |
| role: entry.kind, | |
| ts: entry.ts, | |
| text: entry.text, | |
| streaming: isStreaming, | |
| }); | |
| } | |
| continue; | |
| } | |
| if (entry.kind === "thinking") { | |
| const isStreaming = streaming && entry.delta === true; | |
| if (previous?.type === "thinking") { | |
| previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`; | |
| previous.ts = entry.ts; | |
| previous.streaming = previous.streaming || isStreaming; | |
| } else { | |
| blocks.push({ | |
| type: "thinking", | |
| ts: entry.ts, | |
| text: entry.text, | |
| streaming: isStreaming, | |
| }); | |
| } | |
| continue; | |
| } | |
| if (entry.kind === "tool_call") { | |
| const toolBlock: Extract<TranscriptBlock, { type: "tool" }> = { | |
| type: "tool", | |
| ts: entry.ts, | |
| name: displayToolName(entry.name, entry.input), | |
| toolUseId: entry.toolUseId ?? extractToolUseId(entry.input), | |
| input: entry.input, | |
| status: "running", | |
| }; | |
| blocks.push(toolBlock); | |
| if (toolBlock.toolUseId) { | |
| pendingToolBlocks.set(toolBlock.toolUseId, toolBlock); | |
| } | |
| continue; | |
| } | |
| if (entry.kind === "tool_result") { | |
| const matched = | |
| pendingToolBlocks.get(entry.toolUseId) | |
| ?? [...blocks].reverse().find((block): block is Extract<TranscriptBlock, { type: "tool" }> => block.type === "tool" && block.status === "running"); | |
| if (matched) { | |
| matched.result = entry.content; | |
| matched.isError = entry.isError; | |
| matched.status = entry.isError ? "error" : "completed"; | |
| matched.endTs = entry.ts; | |
| pendingToolBlocks.delete(entry.toolUseId); | |
| } else { | |
| blocks.push({ | |
| type: "tool", | |
| ts: entry.ts, | |
| endTs: entry.ts, | |
| name: "tool", | |
| toolUseId: entry.toolUseId, | |
| input: null, | |
| result: entry.content, | |
| isError: entry.isError, | |
| status: entry.isError ? "error" : "completed", | |
| }); | |
| } | |
| continue; | |
| } | |
| if (entry.kind === "init") { | |
| blocks.push({ | |
| type: "event", | |
| ts: entry.ts, | |
| label: "init", | |
| tone: "info", | |
| text: `model ${entry.model}${entry.sessionId ? ` • session ${entry.sessionId}` : ""}`, | |
| }); | |
| continue; | |
| } | |
| if (entry.kind === "result") { | |
| blocks.push({ | |
| type: "event", | |
| ts: entry.ts, | |
| label: "result", | |
| tone: entry.isError ? "error" : "info", | |
| text: entry.text.trim() || entry.errors[0] || (entry.isError ? "Run failed" : "Completed"), | |
| }); | |
| continue; | |
| } | |
| if (entry.kind === "stderr") { | |
| if (shouldHideNiceModeStderr(entry.text)) { | |
| continue; | |
| } | |
| blocks.push({ | |
| type: "event", | |
| ts: entry.ts, | |
| label: "stderr", | |
| tone: "error", | |
| text: entry.text, | |
| }); | |
| continue; | |
| } | |
| if (entry.kind === "system") { | |
| if (compactWhitespace(entry.text).toLowerCase() === "turn started") { | |
| continue; | |
| } | |
| const activity = parseSystemActivity(entry.text); | |
| if (activity) { | |
| const existing = activity.activityId ? pendingActivityBlocks.get(activity.activityId) : undefined; | |
| if (existing) { | |
| existing.status = activity.status; | |
| existing.ts = entry.ts; | |
| if (activity.status === "completed" && activity.activityId) { | |
| pendingActivityBlocks.delete(activity.activityId); | |
| } | |
| } else { | |
| const block: Extract<TranscriptBlock, { type: "activity" }> = { | |
| type: "activity", | |
| ts: entry.ts, | |
| activityId: activity.activityId, | |
| name: activity.name, | |
| status: activity.status, | |
| }; | |
| blocks.push(block); | |
| if (activity.status === "running" && activity.activityId) { | |
| pendingActivityBlocks.set(activity.activityId, block); | |
| } | |
| } | |
| continue; | |
| } | |
| blocks.push({ | |
| type: "event", | |
| ts: entry.ts, | |
| label: "system", | |
| tone: "warn", | |
| text: entry.text, | |
| }); | |
| continue; | |
| } | |
| const activeCommandBlock = [...blocks].reverse().find( | |
| (block): block is Extract<TranscriptBlock, { type: "tool" }> => | |
| block.type === "tool" && block.status === "running" && isCommandTool(block.name, block.input), | |
| ); | |
| if (activeCommandBlock) { | |
| activeCommandBlock.result = activeCommandBlock.result | |
| ? `${activeCommandBlock.result}${activeCommandBlock.result.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`}` | |
| : entry.text; | |
| continue; | |
| } | |
| if (previous?.type === "stdout") { | |
| previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`; | |
| previous.ts = entry.ts; | |
| } else { | |
| blocks.push({ | |
| type: "stdout", | |
| ts: entry.ts, | |
| text: entry.text, | |
| }); | |
| } | |
| } | |
| return groupCommandBlocks(blocks); | |
| } | |
| function TranscriptMessageBlock({ | |
| block, | |
| density, | |
| }: { | |
| block: Extract<TranscriptBlock, { type: "message" }>; | |
| density: TranscriptDensity; | |
| }) { | |
| const isAssistant = block.role === "assistant"; | |
| const compact = density === "compact"; | |
| return ( | |
| <div> | |
| {!isAssistant && ( | |
| <div className="mb-1.5 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground"> | |
| <User className={compact ? "h-3.5 w-3.5" : "h-4 w-4"} /> | |
| <span>User</span> | |
| </div> | |
| )} | |
| <MarkdownBody | |
| className={cn( | |
| "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0", | |
| compact ? "text-xs leading-5 text-foreground/85" : "text-sm", | |
| )} | |
| > | |
| {block.text} | |
| </MarkdownBody> | |
| {block.streaming && ( | |
| <div className="mt-2 inline-flex items-center gap-1 text-[10px] font-medium italic text-muted-foreground"> | |
| <span className="relative flex h-1.5 w-1.5"> | |
| <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-70" /> | |
| <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" /> | |
| </span> | |
| Streaming | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function TranscriptThinkingBlock({ | |
| block, | |
| density, | |
| className, | |
| }: { | |
| block: Extract<TranscriptBlock, { type: "thinking" }>; | |
| density: TranscriptDensity; | |
| className?: string; | |
| }) { | |
| return ( | |
| <MarkdownBody | |
| className={cn( | |
| "italic text-foreground/70 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", | |
| density === "compact" ? "text-[11px] leading-5" : "text-sm leading-6", | |
| className, | |
| )} | |
| > | |
| {block.text} | |
| </MarkdownBody> | |
| ); | |
| } | |
| function TranscriptToolCard({ | |
| block, | |
| density, | |
| }: { | |
| block: Extract<TranscriptBlock, { type: "tool" }>; | |
| density: TranscriptDensity; | |
| }) { | |
| const [open, setOpen] = useState(block.status === "error"); | |
| const compact = density === "compact"; | |
| const parsedResult = parseStructuredToolResult(block.result); | |
| const statusLabel = | |
| block.status === "running" | |
| ? "Running" | |
| : block.status === "error" | |
| ? "Errored" | |
| : "Completed"; | |
| const statusTone = | |
| block.status === "running" | |
| ? "text-cyan-700 dark:text-cyan-300" | |
| : block.status === "error" | |
| ? "text-red-700 dark:text-red-300" | |
| : "text-emerald-700 dark:text-emerald-300"; | |
| const detailsClass = cn( | |
| "space-y-3", | |
| block.status === "error" && "rounded-xl border border-red-500/20 bg-red-500/[0.06] p-3", | |
| ); | |
| const iconClass = cn( | |
| "mt-0.5 h-3.5 w-3.5 shrink-0", | |
| block.status === "error" | |
| ? "text-red-600 dark:text-red-300" | |
| : block.status === "completed" | |
| ? "text-emerald-600 dark:text-emerald-300" | |
| : "text-cyan-600 dark:text-cyan-300", | |
| ); | |
| const summary = block.status === "running" | |
| ? summarizeToolInput(block.name, block.input, density) | |
| : block.status === "completed" && parsedResult?.body | |
| ? truncate(parsedResult.body.split("\n")[0] ?? parsedResult.body, compact ? 84 : 140) | |
| : summarizeToolResult(block.result, block.isError, density); | |
| return ( | |
| <div className={cn(block.status === "error" && "rounded-xl border border-red-500/20 bg-red-500/[0.04] p-3")}> | |
| <div className="flex items-start gap-2"> | |
| {block.status === "error" ? ( | |
| <CircleAlert className={iconClass} /> | |
| ) : block.status === "completed" ? ( | |
| <Check className={iconClass} /> | |
| ) : ( | |
| <Wrench className={iconClass} /> | |
| )} | |
| <div className="min-w-0 flex-1"> | |
| <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| <span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground"> | |
| {block.name} | |
| </span> | |
| <span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em]", statusTone)}> | |
| {statusLabel} | |
| </span> | |
| </div> | |
| <div className={cn("mt-1 break-words text-foreground/80", compact ? "text-xs" : "text-sm")}> | |
| {summary} | |
| </div> | |
| </div> | |
| <button | |
| type="button" | |
| className="mt-0.5 inline-flex h-5 w-5 items-center justify-center text-muted-foreground transition-colors hover:text-foreground" | |
| onClick={() => setOpen((value) => !value)} | |
| aria-label={open ? "Collapse tool details" : "Expand tool details"} | |
| > | |
| {open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />} | |
| </button> | |
| </div> | |
| {open && ( | |
| <div className="mt-3"> | |
| <div className={detailsClass}> | |
| <div className={cn("grid gap-3", compact ? "grid-cols-1" : "lg:grid-cols-2")}> | |
| <div> | |
| <div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground"> | |
| Input | |
| </div> | |
| <pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-foreground/80"> | |
| {formatToolPayload(block.input) || "<empty>"} | |
| </pre> | |
| </div> | |
| <div> | |
| <div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground"> | |
| Result | |
| </div> | |
| <pre className={cn( | |
| "overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px]", | |
| block.status === "error" ? "text-red-700 dark:text-red-300" : "text-foreground/80", | |
| )}> | |
| {block.result ? formatToolPayload(block.result) : "Waiting for result..."} | |
| </pre> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function hasSelectedText() { | |
| if (typeof window === "undefined") return false; | |
| return (window.getSelection()?.toString().length ?? 0) > 0; | |
| } | |
| function TranscriptCommandGroup({ | |
| block, | |
| density, | |
| }: { | |
| block: Extract<TranscriptBlock, { type: "command_group" }>; | |
| density: TranscriptDensity; | |
| }) { | |
| const [open, setOpen] = useState(false); | |
| const compact = density === "compact"; | |
| const runningItem = [...block.items].reverse().find((item) => item.status === "running"); | |
| const latestItem = block.items[block.items.length - 1] ?? null; | |
| const hasError = block.items.some((item) => item.status === "error"); | |
| const isRunning = Boolean(runningItem); | |
| const showExpandedErrorState = open && hasError; | |
| const title = isRunning | |
| ? "Executing command" | |
| : block.items.length === 1 | |
| ? "Executed command" | |
| : `Executed ${block.items.length} commands`; | |
| const subtitle = runningItem | |
| ? summarizeToolInput("command_execution", runningItem.input, density) | |
| : null; | |
| const statusTone = isRunning | |
| ? "text-cyan-700 dark:text-cyan-300" | |
| : "text-foreground/70"; | |
| return ( | |
| <div className={cn(showExpandedErrorState && "rounded-xl border border-red-500/20 bg-red-500/[0.04] p-3")}> | |
| <div | |
| role="button" | |
| tabIndex={0} | |
| className={cn("flex cursor-pointer gap-2", subtitle ? "items-start" : "items-center")} | |
| onClick={() => { | |
| if (hasSelectedText()) return; | |
| setOpen((value) => !value); | |
| }} | |
| onKeyDown={(event) => { | |
| if (event.key === "Enter" || event.key === " ") { | |
| event.preventDefault(); | |
| setOpen((value) => !value); | |
| } | |
| }} | |
| > | |
| <div className={cn("flex shrink-0 items-center", subtitle && "mt-0.5")}> | |
| {block.items.slice(0, Math.min(block.items.length, 3)).map((_, index) => ( | |
| <span | |
| key={index} | |
| className={cn( | |
| "inline-flex h-6 w-6 items-center justify-center rounded-full border shadow-sm", | |
| index > 0 && "-ml-1.5", | |
| isRunning | |
| ? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300" | |
| : "border-border/70 bg-background text-foreground/55", | |
| isRunning && "animate-pulse", | |
| )} | |
| > | |
| <TerminalSquare className="h-3.5 w-3.5" /> | |
| </span> | |
| ))} | |
| </div> | |
| <div className="min-w-0 flex-1"> | |
| <div className="text-[11px] font-semibold uppercase leading-none tracking-[0.1em] text-muted-foreground/70"> | |
| {title} | |
| </div> | |
| {subtitle && ( | |
| <div className={cn("mt-1 break-words font-mono text-foreground/85", compact ? "text-xs" : "text-sm")}> | |
| {subtitle} | |
| </div> | |
| )} | |
| {!subtitle && latestItem?.status === "error" && open && ( | |
| <div className={cn("mt-1", compact ? "text-xs" : "text-sm", statusTone)}> | |
| Command failed | |
| </div> | |
| )} | |
| </div> | |
| <button | |
| type="button" | |
| className={cn( | |
| "inline-flex h-5 w-5 items-center justify-center text-muted-foreground transition-colors hover:text-foreground", | |
| subtitle && "mt-0.5", | |
| )} | |
| onClick={(event) => { | |
| event.stopPropagation(); | |
| setOpen((value) => !value); | |
| }} | |
| aria-label={open ? "Collapse command details" : "Expand command details"} | |
| > | |
| {open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />} | |
| </button> | |
| </div> | |
| {open && ( | |
| <div className={cn("mt-3 space-y-3", hasError && "rounded-xl border border-red-500/20 bg-red-500/[0.06] p-3")}> | |
| {block.items.map((item, index) => ( | |
| <div key={`${item.ts}-${index}`} className="space-y-2"> | |
| <div className="flex items-center gap-2"> | |
| <span className={cn( | |
| "inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border", | |
| item.status === "error" | |
| ? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300" | |
| : item.status === "running" | |
| ? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300" | |
| : "border-border/70 bg-background text-foreground/55", | |
| )}> | |
| <TerminalSquare className="h-3 w-3" /> | |
| </span> | |
| <span className={cn("font-mono break-all", compact ? "text-[11px]" : "text-xs")}> | |
| {summarizeToolInput("command_execution", item.input, density)} | |
| </span> | |
| </div> | |
| {item.result && ( | |
| <pre className={cn( | |
| "overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px]", | |
| item.status === "error" ? "text-red-700 dark:text-red-300" : "text-foreground/80", | |
| )}> | |
| {formatToolPayload(item.result)} | |
| </pre> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function TranscriptActivityRow({ | |
| block, | |
| density, | |
| }: { | |
| block: Extract<TranscriptBlock, { type: "activity" }>; | |
| density: TranscriptDensity; | |
| }) { | |
| return ( | |
| <div className="flex items-start gap-2"> | |
| {block.status === "completed" ? ( | |
| <Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-300" /> | |
| ) : ( | |
| <span className="relative mt-1 flex h-2.5 w-2.5 shrink-0"> | |
| <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-cyan-400 opacity-70" /> | |
| <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-cyan-500" /> | |
| </span> | |
| )} | |
| <div className={cn( | |
| "break-words text-foreground/80", | |
| density === "compact" ? "text-xs leading-5" : "text-sm leading-6", | |
| )}> | |
| {block.name} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function TranscriptEventRow({ | |
| block, | |
| density, | |
| }: { | |
| block: Extract<TranscriptBlock, { type: "event" }>; | |
| density: TranscriptDensity; | |
| }) { | |
| const compact = density === "compact"; | |
| const toneClasses = | |
| block.tone === "error" | |
| ? "rounded-xl border border-red-500/20 bg-red-500/[0.06] p-3 text-red-700 dark:text-red-300" | |
| : block.tone === "warn" | |
| ? "text-amber-700 dark:text-amber-300" | |
| : block.tone === "info" | |
| ? "text-sky-700 dark:text-sky-300" | |
| : "text-foreground/75"; | |
| return ( | |
| <div className={toneClasses}> | |
| <div className="flex items-start gap-2"> | |
| {block.tone === "error" ? ( | |
| <CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" /> | |
| ) : block.tone === "warn" ? ( | |
| <TerminalSquare className="mt-0.5 h-3.5 w-3.5 shrink-0" /> | |
| ) : ( | |
| <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-current/50" /> | |
| )} | |
| <div className="min-w-0 flex-1"> | |
| {block.label === "result" && block.tone !== "error" ? ( | |
| <div className={cn("whitespace-pre-wrap break-words text-sky-700 dark:text-sky-300", compact ? "text-[11px]" : "text-xs")}> | |
| {block.text} | |
| </div> | |
| ) : ( | |
| <div className={cn("whitespace-pre-wrap break-words", compact ? "text-[11px]" : "text-xs")}> | |
| <span className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/70"> | |
| {block.label} | |
| </span> | |
| {block.text ? <span className="ml-2">{block.text}</span> : null} | |
| </div> | |
| )} | |
| {block.detail && ( | |
| <pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-foreground/75"> | |
| {block.detail} | |
| </pre> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function TranscriptStdoutRow({ | |
| block, | |
| density, | |
| collapseByDefault, | |
| }: { | |
| block: Extract<TranscriptBlock, { type: "stdout" }>; | |
| density: TranscriptDensity; | |
| collapseByDefault: boolean; | |
| }) { | |
| const [open, setOpen] = useState(!collapseByDefault); | |
| return ( | |
| <div> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground"> | |
| stdout | |
| </span> | |
| <button | |
| type="button" | |
| className="inline-flex h-5 w-5 items-center justify-center text-muted-foreground transition-colors hover:text-foreground" | |
| onClick={() => setOpen((value) => !value)} | |
| aria-label={open ? "Collapse stdout" : "Expand stdout"} | |
| > | |
| {open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />} | |
| </button> | |
| </div> | |
| {open && ( | |
| <pre className={cn( | |
| "mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono text-foreground/80", | |
| density === "compact" ? "text-[11px]" : "text-xs", | |
| )}> | |
| {block.text} | |
| </pre> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function RawTranscriptView({ | |
| entries, | |
| density, | |
| }: { | |
| entries: TranscriptEntry[]; | |
| density: TranscriptDensity; | |
| }) { | |
| const compact = density === "compact"; | |
| return ( | |
| <div className={cn("font-mono", compact ? "space-y-1 text-[11px]" : "space-y-1.5 text-xs")}> | |
| {entries.map((entry, idx) => ( | |
| <div | |
| key={`${entry.kind}-${entry.ts}-${idx}`} | |
| className={cn( | |
| "grid gap-x-3", | |
| "grid-cols-[auto_1fr]", | |
| )} | |
| > | |
| <span className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground"> | |
| {entry.kind} | |
| </span> | |
| <pre className="min-w-0 whitespace-pre-wrap break-words text-foreground/80"> | |
| {entry.kind === "tool_call" | |
| ? `${entry.name}\n${formatToolPayload(entry.input)}` | |
| : entry.kind === "tool_result" | |
| ? formatToolPayload(entry.content) | |
| : entry.kind === "result" | |
| ? `${entry.text}\n${formatTokens(entry.inputTokens)} / ${formatTokens(entry.outputTokens)} / $${entry.costUsd.toFixed(6)}` | |
| : entry.kind === "init" | |
| ? `model=${entry.model}${entry.sessionId ? ` session=${entry.sessionId}` : ""}` | |
| : entry.text} | |
| </pre> | |
| </div> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| export function RunTranscriptView({ | |
| entries, | |
| mode = "nice", | |
| density = "comfortable", | |
| limit, | |
| streaming = false, | |
| collapseStdout = false, | |
| emptyMessage = "No transcript yet.", | |
| className, | |
| thinkingClassName, | |
| }: RunTranscriptViewProps) { | |
| const blocks = useMemo(() => normalizeTranscript(entries, streaming), [entries, streaming]); | |
| const visibleBlocks = limit ? blocks.slice(-limit) : blocks; | |
| const visibleEntries = limit ? entries.slice(-limit) : entries; | |
| if (entries.length === 0) { | |
| return ( | |
| <div className={cn("rounded-2xl border border-dashed border-border/70 bg-background/40 p-4 text-sm text-muted-foreground", className)}> | |
| {emptyMessage} | |
| </div> | |
| ); | |
| } | |
| if (mode === "raw") { | |
| return ( | |
| <div className={className}> | |
| <RawTranscriptView entries={visibleEntries} density={density} /> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className={cn("space-y-3", className)}> | |
| {visibleBlocks.map((block, index) => ( | |
| <div | |
| key={`${block.type}-${block.ts}-${index}`} | |
| className={cn(index === visibleBlocks.length - 1 && streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300")} | |
| > | |
| {block.type === "message" && <TranscriptMessageBlock block={block} density={density} />} | |
| {block.type === "thinking" && ( | |
| <TranscriptThinkingBlock block={block} density={density} className={thinkingClassName} /> | |
| )} | |
| {block.type === "tool" && <TranscriptToolCard block={block} density={density} />} | |
| {block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />} | |
| {block.type === "stdout" && ( | |
| <TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} /> | |
| )} | |
| {block.type === "activity" && <TranscriptActivityRow block={block} density={density} />} | |
| {block.type === "event" && <TranscriptEventRow block={block} density={density} />} | |
| </div> | |
| ))} | |
| </div> | |
| ); | |
| } | |