Repository Public
1'use client';2import { memo, useState } from 'react';3import {4 Ban, Check, ChevronRight, CircleHelp, Download, FileText, FilePen, FilePlus, Flag, FolderOpen, GitBranch, GitCommitHorizontal, ListChecks, Paperclip, Search, SquareTerminal, TriangleAlert, Upload, Wrench, X,5} from 'lucide-react';6import { artifactUrl } from '@/lib/api';7import { durationMs, toolView, type Step, type ToolView } from '@/lib/timeline';8import { fmtBytes, fmtMs } from '@/lib/format';9import { cn } from '@/lib/cn';10import { DiffView } from './diff-view';1112const ICONS: Record<string, typeof Wrench> = {13 read_file: FileText, list_dir: FolderOpen, search: Search, edit_file: FilePen, write_file: FilePlus, shell: SquareTerminal,14 git_status: GitBranch, git_diff: GitBranch, git_commit: GitCommitHorizontal, git_push: Upload, ask_owner: CircleHelp,15 update_criteria: ListChecks, propose_criteria: ListChecks, finish: Flag, read_artifact: Paperclip,16};17const CAP = 'max-h-[26rem]';1819/** Monospace, wrapped, capped height with "show more". Rendered as text only. */20export function OutputBlock({ text, terminal, label, testId }: { text: string; terminal?: boolean; label: string; testId?: string }) {21 const [open, setOpen] = useState(false);22 const long = text.length > 2400 || text.split('\n').length > 24;23 return (24 <div className="min-w-0">25 <pre26 aria-label={label}27 data-testid={testId}28 className={cn(29 'overflow-x-auto whitespace-pre-wrap break-words font-mono text-[12px] leading-[1.6] [overflow-wrap:anywhere]',30 terminal ? 'text-term-text' : 'rounded-sm bg-surface-2 px-3 py-2 text-text',31 long && !open && `${CAP} overflow-y-hidden`,32 )}33 >34 {text || ' '}35 </pre>36 {long && (37 <button onClick={() => setOpen(!open)} data-testid="show-more" className={cn('mt-1 rounded-sm px-1.5 py-0.5 text-xs font-medium pointer-coarse:min-h-9', terminal ? 'text-term-dim hover:text-term-text' : 'text-accent hover:bg-hover')}>38 {open ? 'Show less' : `Show more (${text.split('\n').length} lines)`}39 </button>40 )}41 </div>42 );43}4445function ShellBlock({ v, interrupted }: { v: ToolView; interrupted: boolean }) {46 const input = v.input as { command?: unknown } | null;47 const command = typeof input?.command === 'string' ? input.command : (v.keyArg ?? '');48 return (49 <div className="overflow-hidden rounded-sm bg-term-bg" data-testid="shell-block">50 <div className="flex items-start gap-2 border-b border-white/10 px-3 py-2 font-mono text-[12px] text-term-text">51 <span className="select-none text-term-dim" aria-hidden>$</span>52 <span className="min-w-0 flex-1 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">{command}</span>53 </div>54 <div className="px-3 py-2">55 {interrupted ? <p className="font-mono text-[12px] text-term-dim">interrupted before a result was recorded</p> : v.ok === null ? <p className="anim-pulse font-mono text-[12px] text-term-dim">running…</p> : <OutputBlock text={v.output} terminal label="Command output" testId="shell-output" />}56 </div>57 {v.ok !== null && (58 <div className="flex items-center gap-2 border-t border-white/10 px-3 py-1.5 font-mono text-[11px] text-term-dim">59 <span data-testid="exit-code" data-ok={v.exitCode === 0 || (v.exitCode == null && v.ok)} className={cn('rounded px-1.5 py-px font-semibold', v.exitCode === 0 || (v.exitCode == null && v.ok) ? 'bg-emerald-400/15 text-emerald-300' : 'bg-red-400/15 text-red-300')}>60 {v.exitCode == null ? (v.ok ? 'ok' : 'failed') : `exit ${v.exitCode}`}61 </span>62 {v.statusNote && <span data-testid="shell-status-note">{v.statusNote}</span>}63 {v.truncated && <span>output truncated by the server</span>}64 </div>65 )}66 </div>67 );68}6970/** Compact tool-call row, expandable to input and output; diffs and shell output get their own views. */71/** `ended`: the run reached a terminal status — a call still without a result was interrupted (cancel / crash), it is not running. */72export const ToolRow = memo(function ToolRow({ step, open, k, onToggle, ended = false }: { step: Step; open: boolean; k: string; onToggle: (key: string, open: boolean) => void; ended?: boolean }) {73 const v = toolView(step);74 // needs_reconcile: the worker died after starting a non-idempotent call; a question asks the owner whether it ran75 const unknown = step.status === 'needs_reconcile';76 const interrupted = v.ok === null && ended && !unknown;77 const Icon = ICONS[v.tool] ?? Wrench;78 const ms = durationMs(step);79 const added = v.diff?.reduce((n, f) => n + f.added, 0) ?? 0;80 const removed = v.diff?.reduce((n, f) => n + f.removed, 0) ?? 0;81 const inputText = typeof v.input === 'string' ? v.input : JSON.stringify(v.input ?? {}, null, 2);82 return (83 <div data-testid="tool-row" data-tool={v.tool} data-seq={step.seq} data-state={unknown ? 'needs_reconcile' : interrupted ? 'interrupted' : v.ok === null ? 'running' : v.ok ? 'ok' : 'failed'} className={cn('overflow-hidden rounded-[12px] border bg-surface transition-colors', v.ok === false ? 'border-error-vivid/30' : 'border-line-2', open && 'shadow-card')}>84 <button onClick={() => onToggle(k, !open)} aria-expanded={open} className="flex min-h-10 w-full items-center gap-2.5 px-3 py-1.5 text-left hover:bg-hover pointer-coarse:min-h-12">85 <span className={cn('flex size-6 shrink-0 items-center justify-center rounded-full', v.ok === null ? 'bg-accent/12 text-accent' : v.ok ? 'bg-surface-2 text-text-2' : 'bg-error-vivid/12 text-error')}>86 <Icon className={cn('size-3.5', v.ok === null && !interrupted && !unknown && 'anim-pulse')} aria-hidden />87 </span>88 <span className="shrink-0 font-mono text-[12.5px] font-medium text-text">{v.tool}</span>89 <span className="min-w-0 flex-1 truncate font-mono text-[12px] text-muted">{v.keyArg ?? ''}</span>90 {v.diff && (added > 0 || removed > 0) && (91 <span className="tnum shrink-0 font-mono text-[11px]"><span className="text-success">+{added}</span> <span className="text-error">−{removed}</span></span>92 )}93 {v.tool === 'shell' && v.exitCode != null && v.exitCode !== 0 && <span className="tnum shrink-0 font-mono text-[11px] text-error">exit {v.exitCode}</span>}94 <span className={cn('tnum shrink-0 text-[11px] text-muted', !interrupted && !unknown && 'hidden sm:inline')}>{unknown ? 'outcome unknown — answer the question' : interrupted ? 'interrupted' : v.ok === null ? 'running' : fmtMs(ms)}</span>95 {unknown ? (96 <TriangleAlert className="size-3.5 shrink-0 text-warning" aria-label="Outcome unknown" />97 ) : interrupted ? (98 <Ban className="size-3.5 shrink-0 text-muted" aria-label="Interrupted" />99 ) : v.ok === null ? (100 <span className="size-3 shrink-0 animate-spin rounded-full border-[1.5px] border-accent border-t-transparent" role="status" aria-label="Running" />101 ) : v.ok ? (102 <Check className="size-3.5 shrink-0 text-success" aria-label="Succeeded" />103 ) : (104 <X className="size-3.5 shrink-0 text-error" aria-label="Failed" />105 )}106 <ChevronRight className={cn('size-3.5 shrink-0 text-faint transition-transform', open && 'rotate-90')} aria-hidden />107 </button>108 {open && (109 <div className="flex flex-col gap-3 border-t border-line px-3 py-3" data-testid="tool-detail">110 {v.tool === 'shell' ? (111 <ShellBlock v={v} interrupted={interrupted || unknown} />112 ) : (113 <>114 {v.diff ? (115 <DiffView files={v.diff} />116 ) : (117 <div>118 <p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted">Input</p>119 <OutputBlock text={inputText} label="Tool input" testId="tool-input" />120 </div>121 )}122 {v.ok !== null && (!v.diff || !v.ok) && (123 <div>124 <p className={cn('mb-1 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide', v.ok ? 'text-muted' : 'text-error')}>125 {!v.ok && <TriangleAlert className="size-3" aria-hidden />} {v.ok ? 'Output' : 'Error'}126 </p>127 <OutputBlock text={v.output || '(no output)'} label="Tool output" testId="tool-output" />128 </div>129 )}130 </>131 )}132 {(v.artifact || (v.truncated && v.tool !== 'shell')) && (133 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">134 {v.truncated && v.tool !== 'shell' && <span>Shown truncated by the server.</span>}135 {v.artifact && (136 <a href={artifactUrl(v.artifact.sha256, v.artifact.name)} download={v.artifact.name} data-testid="artifact-link" className="inline-flex items-center gap-1.5 rounded-sm font-medium text-accent hover:underline pointer-coarse:min-h-9">137 <Download className="size-3.5" aria-hidden /> Full output: {v.artifact.name}138 <span className="tnum font-normal text-muted">({fmtBytes(v.artifact.size)})</span>139 </a>140 )}141 </div>142 )}143 </div>144 )}145 </div>146 );147});148