SPB Git

Repository Public

timeline.tsx 19.9 KB 367 lines tsx

1'use client';2import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';3import { useVirtualizer } from '@tanstack/react-virtual';4import { ArrowDown, Ban, Brain, ChevronRight, CircleHelp, Flag, GitBranch, ShieldCheck, TriangleAlert, User } from 'lucide-react';5import type { ModelInfo, RunDto, WaitDto } from '@aos/shared';6import { useModels, useWaits } from '@/lib/queries';7import { buildItems, durationMs, obj, setupLine, toolView, waitPayloadOf, type Item, type RefusedCall, type Step } from '@/lib/timeline';8import { isActive } from '@/lib/status';9import { timeline, useTimeline } from '@/stores/timeline';10import { fmtMs, fmtTime, fmtTokens, fmtUsd } from '@/lib/format';11import { RUN_STATUS } from '@/lib/status';12import { EmptyState, ErrorState, Skeleton } from '@/components/ui/misc';13import { Badge } from '@/components/ui/badge';14import { IllusMission } from '@/components/ui/illustrations';15import { WaitCard, WaitOutcome, waitView } from '@/components/waits/wait-card';16import { cn } from '@/lib/cn';17import { Markdown } from './markdown';18import { ToolRow } from './tool-row';1920type Row = Item | { key: string; type: 'result'; run: RunDto };21const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v : null);2223function TurnHeader({ step, label, models, ended }: { step: Step | null; label: string; models: ModelInfo[]; ended: boolean }) {24  const u = obj(step?.usage);25  const model = str(obj(step?.result).model) ?? str(obj(step?.request).model);26  const usd = typeof u.usd === 'number' ? u.usd : null;27  // Prompt size = uncached input + cache reads + cache writes (the provider bills them apart; the reader wants the total).28  const n = (v: unknown): number => (typeof v === 'number' ? v : 0);29  const inTok = typeof u.inputTokens === 'number' ? u.inputTokens + n(u.cacheReadTokens) + n(u.cacheWriteTokens) : null;30  const cached = n(u.cacheReadTokens);31  const outTok = typeof u.outputTokens === 'number' ? u.outputTokens : null;32  return (33    <div className="flex items-center gap-3 pt-3" data-testid="turn-header">34      <span className="shrink-0 text-[11px] font-bold uppercase tracking-[0.08em] text-text-2">{label}</span>35      <span className="h-px min-w-4 flex-1 bg-line-2" />36      {step && (37        <span className="tnum flex min-w-0 shrink items-center gap-2 truncate text-[11px] text-muted">38          {model && <span className="hidden truncate sm:inline">{models.find((m) => m.id === model)?.label ?? model}</span>}39          {inTok != null && outTok != null && <span title={`prompt tokens (${fmtTokens(cached)} read from cache) / output tokens`}>{fmtTokens(inTok)} in · {fmtTokens(outTok)} out</span>}40          {usd != null && <span className="font-medium text-text-2">{fmtUsd(usd)}</span>}41          <span>{step.status === 'started' ? (ended ? 'interrupted' : 'in progress') : fmtMs(durationMs(step))}</span>42        </span>43      )}44    </div>45  );46}4748function Reasoning({ summary, streaming, open, onToggle, k }: { summary: string; streaming?: boolean; open: boolean; k: string; onToggle: (key: string, open: boolean) => void }) {49  const first = summary.trim().split('\n')[0] ?? '';50  return (51    <div data-testid="reasoning" data-streaming={streaming || undefined} className="rounded-[12px] border border-dashed border-line-3">52      <button onClick={() => onToggle(k, !open)} aria-expanded={open} className="flex min-h-9 w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-hover pointer-coarse:min-h-11">53        <Brain className={cn('size-3.5 shrink-0 text-muted', streaming && 'anim-pulse text-accent')} aria-hidden />54        <span className="shrink-0 font-semibold text-text-2">Reasoning</span>55        {!open && <span className="min-w-0 flex-1 truncate text-muted">{first}</span>}56        {open && <span className="flex-1 text-muted">operational summary</span>}57        <ChevronRight className={cn('size-3.5 shrink-0 text-faint transition-transform', open && 'rotate-90')} aria-hidden />58      </button>59      {open && <p className="whitespace-pre-wrap break-words border-t border-dashed border-line-3 px-3 py-2.5 text-[13px] italic leading-relaxed text-text-2 [overflow-wrap:anywhere]">{summary}</p>}60    </div>61  );62}6364function WaitRow({ wait, step }: { wait: WaitDto | null; step: Step | null }) {65  const pending = wait?.status === 'pending';66  if (wait && pending) {67    return (68      <div className="rounded-[14px] border border-info/35 bg-info/[0.06] p-3.5 md:p-4" data-testid="timeline-wait" data-state="pending">69        <WaitCard wait={wait} layout="inline" showMission={false} />70      </div>71    );72  }73  // settled: from the wait itself, or from the journal step when the wait is not known to this session74  const payload = wait?.payload ?? (step ? waitPayloadOf(step) : {});75  const kind: WaitDto['kind'] = wait?.kind ?? (step?.name === 'question' ? 'question' : 'approval');76  const resolution = wait?.resolution ?? (step?.result != null ? obj(step.result) : null);77  const status: WaitDto['status'] = wait?.status ?? (step?.status === 'started' ? 'pending' : obj(step?.result).expired === true ? 'expired' : 'resolved');78  const v = waitView({ kind, payload } as WaitDto);79  const Icon = kind === 'question' ? CircleHelp : ShieldCheck;80  const said = str(obj(resolution).answer) ?? str(obj(resolution).note);81  return (82    <div className="flex items-start gap-2.5 rounded-[12px] border border-line-2 bg-surface px-3 py-2.5" data-testid="timeline-wait" data-state={status}>83      <Icon className="mt-0.5 size-4 shrink-0 text-muted" aria-hidden />84      <div className="min-w-0 flex-1">85        <div className="flex flex-wrap items-center gap-x-2 gap-y-1">86          <span className="min-w-0 text-[13px] font-medium text-text">{kind === 'question' ? (v.question ?? 'Question') : v.title}</span>87          {status === 'pending' ? <Badge tone="info">Waiting for you</Badge> : <WaitOutcome wait={{ kind, status, resolution }} />}88        </div>89        {said && <p className="mt-1 whitespace-pre-wrap break-words text-[13px] text-text-2"><span className="text-muted">{kind === 'question' ? 'You answered: ' : 'Your note: '}</span>{said}</p>}90      </div>91    </div>92  );93}9495/** A tool call the model issued that the engine did not execute (invalid input, or denied by policy). */96function RefusedRow({ call, open, onToggle, k }: { call: RefusedCall; open: boolean; k: string; onToggle: (key: string, open: boolean) => void }) {97  return (98    <div data-testid="refused-call" data-tool={call.tool} className="overflow-hidden rounded-[12px] border border-dashed border-line-3 bg-surface">99      <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">100        <span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-surface-2 text-muted"><Ban className="size-3.5" aria-hidden /></span>101        <span className="shrink-0 font-mono text-[12.5px] font-medium text-text-2 line-through decoration-faint">{call.tool}</span>102        <span className="min-w-0 flex-1 truncate font-mono text-[12px] text-muted">{call.keyArg ?? ''}</span>103        <span className="shrink-0 text-[11px] font-medium text-warning">not executed</span>104        <ChevronRight className={cn('size-3.5 shrink-0 text-faint transition-transform', open && 'rotate-90')} aria-hidden />105      </button>106      {open && (107        <div className="flex flex-col gap-2 border-t border-line px-3 py-3 text-[13px] text-text-2">108          <p data-testid="refused-reason">109            {call.reason110              ? <>Denied by policy: {call.reason}.</>111              : <>The agent asked for this call but it was not run: its input was invalid, or policy denied it. The reason was returned to the agent, which continued from there.</>}112          </p>113          <pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-sm bg-surface-2 px-3 py-2 font-mono text-[12px] leading-relaxed text-text [overflow-wrap:anywhere]">{JSON.stringify(call.input ?? {}, null, 2)}</pre>114        </div>115      )}116    </div>117  );118}119120function ResultCard({ run }: { run: RunDto }) {121  const r = run.result;122  if (!r) return null;123  const st = RUN_STATUS[r.status];124  return (125    <section className="card mt-2 p-4" data-testid="run-result" aria-label="Run result">126      <header className="mb-2 flex items-center gap-2">127        <Flag className="size-4 text-muted" aria-hidden />128        <h3 className="flex-1 text-[13px] font-semibold">Result</h3>129        <Badge tone={st.tone}>{st.label}</Badge>130      </header>131      {r.summary ? <Markdown text={r.summary} className="text-[13.5px]" /> : <p className="text-[13px] text-muted">No summary was returned.</p>}132      {r.evidence.length > 0 && (133        <div className="mt-3">134          <p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted">Evidence</p>135          <ul className="flex flex-col gap-1.5">136            {r.evidence.map((e, i) => (137              <li key={i} className="flex items-start gap-2 text-[13px] text-text-2">138                <Badge tone="neutral" className="mt-px">{e.kind}</Badge>139                <span className="min-w-0 whitespace-pre-wrap break-words">{e.detail}</span>140              </li>141            ))}142          </ul>143        </div>144      )}145      {r.openQuestions.length > 0 && (146        <div className="mt-3">147          <p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted">Open questions</p>148          <ul className="list-disc pl-5 text-[13px] text-text-2">{r.openQuestions.map((q, i) => <li key={i}>{q}</li>)}</ul>149        </div>150      )}151    </section>152  );153}154155const RowView = memo(function RowView({ row, open, onToggle, models, ended }: { row: Row; open: boolean; onToggle: (key: string, open: boolean) => void; models: ModelInfo[]; ended: boolean }) {156  switch (row.type) {157    case 'turn':158      return <TurnHeader step={row.step} label={row.label} models={models} ended={ended} />;159    case 'text':160      return (161        <div data-testid="assistant-text" data-streaming={row.streaming || undefined} className="px-0.5">162          <Markdown text={row.text} />163          {row.streaming && <span className="anim-pulse ml-0.5 inline-block h-4 w-[7px] translate-y-0.5 rounded-[2px] bg-accent" aria-label="streaming" />}164        </div>165      );166    case 'reasoning':167      return <Reasoning summary={row.summary} streaming={row.streaming} open={open} onToggle={onToggle} k={row.key} />;168    case 'thinking':169      return (170        <div className="flex items-center gap-2 px-0.5 text-[13px] text-muted" data-testid="thinking" role="status">171          <span className="flex gap-1" aria-hidden>172            {[0, 1, 2].map((i) => <span key={i} className="anim-pulse size-1.5 rounded-full bg-accent" style={{ animationDelay: `${i * 0.2}s` }} />)}173          </span>174          Thinking175        </div>176      );177    case 'tool':178      return <ToolRow step={row.step} open={open} onToggle={onToggle} k={row.key} ended={ended} />;179    case 'refused':180      return <RefusedRow call={row.call} open={open} onToggle={onToggle} k={row.key} />;181    case 'wait':182      return <WaitRow wait={row.wait} step={row.step} />;183    case 'owner':184      return (185        <div className="flex justify-end" data-testid="owner-message" data-pending={row.message.pending || undefined}>186          <div className="max-w-[85%] rounded-[16px] rounded-br-[6px] bg-accent/10 px-3.5 py-2.5">187            <p className="mb-0.5 flex items-center gap-1.5 text-[11px] font-semibold text-accent"><User className="size-3" aria-hidden /> You{row.message.ts ? <span className="tnum font-normal text-muted">{fmtTime(row.message.ts)}</span> : null}</p>188            <p className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-text [overflow-wrap:anywhere]">{row.message.text}</p>189            {row.message.pending && <p className="mt-1 text-[11px] text-muted">Queued — the agent reads it after the current tool calls.</p>}190          </div>191        </div>192      );193    case 'decision': {194      const d = row.decision;195      return (196        <p className="flex items-center gap-2 px-1 text-xs text-muted" data-testid="policy-decision" data-decision={d.decision}>197          <ShieldCheck className={cn('size-3.5 shrink-0', d.decision === 'deny' ? 'text-error' : d.decision === 'ask' ? 'text-info-ink' : 'text-faint')} aria-hidden />198          <span>199            Policy: <span className={cn('font-semibold', d.decision === 'deny' ? 'text-error' : d.decision === 'ask' ? 'text-info-ink' : 'text-text-2')}>{d.decision || 'decision'}</span>200            {d.tool && <> <code className="font-mono text-[11.5px] text-text-2">{d.tool}</code></>}201            {d.reason && <> — {d.reason}</>}202          </span>203        </p>204      );205    }206    case 'error':207      return (208        <div role="alert" className="flex items-start gap-2.5 rounded-[12px] border border-error-vivid/30 bg-error-vivid/[0.07] px-3 py-2.5" data-testid="timeline-error">209          <TriangleAlert className="mt-0.5 size-4 shrink-0 text-error" aria-hidden />210          <div className="min-w-0">211            <p className="font-mono text-[12px] font-medium text-text">{row.step.kind} · {row.step.name}</p>212            <p className="mt-0.5 whitespace-pre-wrap break-words text-[13px] text-text-2">{row.text}</p>213          </div>214        </div>215      );216    case 'system': {217      const line = setupLine(row.step);218      if (line) {219        return (220          <p className="flex items-center gap-2 px-1 text-xs text-muted" data-testid="setup-line">221            <GitBranch className="size-3.5 shrink-0 text-faint" aria-hidden /> <span className="min-w-0 break-words [overflow-wrap:anywhere]">{line}</span>222          </p>223        );224      }225      return (226        <p className="tnum flex items-center gap-2 px-1 font-mono text-[11.5px] text-muted" data-testid="system-step">227          <span className="text-faint">#{row.step.seq}</span> {row.step.kind} · {row.step.name}228          <span className="text-faint">{row.step.status === 'started' ? (ended ? 'interrupted' : 'in progress') : fmtMs(durationMs(row.step))}</span>229        </p>230      );231    }232    case 'result':233      return <ResultCard run={row.run} />;234  }235});236237/** Small diffs and failures are worth seeing without a click; everything else starts collapsed. */238function defaultOpen(row: Row): boolean {239  if (row.type === 'reasoning') return !!row.streaming;240  if (row.type !== 'tool') return false;241  const v = toolView(row.step);242  if (v.ok === false) return true;243  if ((v.tool === 'edit_file' || v.tool === 'write_file') && v.diff) return v.diff.reduce((n, f) => n + f.added + f.removed, 0) <= 30;244  return false;245}246247/**248 * The live timeline of one run: journal steps grouped by turn, streamed text, waits inline,249 * virtualised, pinned to the latest item unless the reader scrolled up.250 */251export function Timeline({ run, showSystem = false }: { run: RunDto; showSystem?: boolean }) {252  const runId = run.id;253  const tl = useTimeline((s) => s.runs[runId]);254  const resolved = useTimeline((s) => s.resolved);255  const pendingWaits = useWaits().data;256  const models = useModels().data ?? [];257  useEffect(() => timeline.ensure(runId), [runId]);258259  const rows = useMemo<Row[]>(() => {260    if (!tl) return [];261    const waits = [...(pendingWaits ?? []).filter((w) => w.runId === runId), ...Object.values(resolved).filter((w) => w.runId === runId && !(pendingWaits ?? []).some((p) => p.id === w.id))];262    const items: Row[] = buildItems({ steps: tl.steps, waits, decisions: tl.decisions, pendingMessages: tl.pending, stream: tl.stream, showSystem, runEnded: !isActive(run.status) });263    if (run.result) items.push({ key: 'result', type: 'result', run });264    return items;265  }, [tl, pendingWaits, resolved, runId, run, showSystem]);266267  const [toggled, setToggled] = useState<Record<string, boolean>>({});268  const onToggle = useCallback((key: string, open: boolean) => setToggled((t) => ({ ...t, [key]: open })), []);269270  const scroller = useRef<HTMLDivElement>(null);271  const virt = useVirtualizer({272    count: rows.length,273    getScrollElement: () => scroller.current,274    estimateSize: (i) => (rows[i]?.type === 'turn' ? 36 : rows[i]?.type === 'text' ? 96 : 52),275    getItemKey: (i) => rows[i]?.key ?? i,276    overscan: 10,277    gap: 10,278    paddingStart: 8,279    paddingEnd: 24,280  });281  const pinned = useRef(true);282  const [away, setAway] = useState(false);283  const [unseen, setUnseen] = useState(false);284  const toEnd = useCallback(() => {285    const el = scroller.current;286    if (el) el.scrollTop = el.scrollHeight;287  }, []);288  const onScroll = () => {289    const el = scroller.current;290    if (!el) return;291    const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 80;292    pinned.current = atEnd;293    setAway(!atEnd);294    if (atEnd) setUnseen(false);295  };296  // Sending a steering message brings the reader back to the tail, where it appears.297  const sent = tl?.pending.filter((m) => m.key.startsWith('local-')).map((m) => m.key).join() ?? '';298  const sentBefore = useRef('');299  useEffect(() => {300    if (sent && sent !== sentBefore.current) {301      pinned.current = true;302      setAway(false);303      toEnd();304    }305    sentBefore.current = sent;306  }, [sent, toEnd]);307  const total = virt.getTotalSize();308  const rev = tl?.rev ?? 0;309  // Follow the tail while pinned: after new rows and after measured heights settle (total changes).310  const seenRev = useRef(rev);311  useLayoutEffect(() => {312    if (pinned.current) toEnd();313    else if (rev !== seenRev.current) setUnseen(true);314    seenRev.current = rev;315  }, [rev, total, rows.length, toEnd]);316317  if (!tl || (tl.status === 'loading' && tl.steps.length === 0)) {318    return (319      <div className="mx-auto flex w-full max-w-3xl flex-col gap-3 p-4" data-testid="timeline-loading">320        <Skeleton className="h-4 w-24" /><Skeleton className="h-16" /><Skeleton className="h-10" /><Skeleton className="h-10" />321      </div>322    );323  }324  if (tl.status === 'error' && tl.steps.length === 0) return <ErrorState error={new Error(tl.error ?? 'Could not load the timeline')} onRetry={() => void timeline.reload(runId)} />;325326  return (327    <div className="relative flex min-h-0 flex-1 flex-col" data-testid="timeline" data-steps={tl.steps.length} data-last-seq={tl.steps[tl.steps.length - 1]?.seq ?? ''} data-rows={rows.length}>328      <div ref={scroller} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 md:px-6" data-testid="timeline-scroll">329        {rows.length === 0 ? (330          <EmptyState331            illustration={<IllusMission />}332            title={run.status === 'queued' ? 'Waiting for a worker' : 'Nothing journaled yet'}333            description={run.status === 'queued' ? 'The run is queued. Its first step appears here as soon as a worker claims it.' : 'Steps appear here as the agent works.'}334          />335        ) : (336          <div className="relative mx-auto w-full max-w-3xl" style={{ height: total }}>337            {virt.getVirtualItems().map((vi) => {338              const row = rows[vi.index];339              if (!row) return null;340              return (341                <div key={vi.key} data-index={vi.index} ref={virt.measureElement} className="absolute inset-x-0 top-0" style={{ transform: `translateY(${vi.start}px)` }} data-row={row.type} data-key={row.key}>342                  <RowView row={row} open={toggled[row.key] ?? defaultOpen(row)} onToggle={onToggle} models={models} ended={!isActive(run.status)} />343                </div>344              );345            })}346          </div>347        )}348      </div>349      {away && (350        <button351          onClick={() => {352            pinned.current = true;353            setUnseen(false);354            toEnd();355            requestAnimationFrame(toEnd);356          }}357          data-testid="jump-latest"358          className="glass pressable absolute bottom-3 left-1/2 z-10 flex h-9 -translate-x-1/2 items-center gap-1.5 rounded-full border border-card-line px-3.5 text-xs font-semibold text-text shadow-float pointer-coarse:h-11"359        >360          <ArrowDown className="size-3.5" aria-hidden /> {unseen ? 'New activity' : 'Jump to latest'}361          {unseen && <span className="size-1.5 rounded-full bg-accent" aria-hidden />}362        </button>363      )}364    </div>365  );366}367
Files main
Menu

Theme