Repository Public
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