SPB Git

Repository Public

tool-row.tsx 9.1 KB 148 lines tsx

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
Files main
Menu

Theme