Repository Public
1'use client';2import { useMemo, useState } from 'react';3import { Check, Circle, Download, SendHorizontal } from 'lucide-react';4import type { DoneCriterion, ModelInfo, RunDto } from '@aos/shared';5import { api, artifactUrl, errorMessage } from '@/lib/api';6import { useArtifacts } from '@/lib/queries';7import { RUN_STATUS, isActive } from '@/lib/status';8import { fmtBytes, fmtTokens, fmtUsd } from '@/lib/format';9import { timeline } from '@/stores/timeline';10import { toast } from '@/stores/toasts';11import { useBreakpoint } from '@/hooks/use-viewport';12import { Dot } from '@/components/ui/badge';13import { Button } from '@/components/ui/button';14import { Textarea } from '@/components/ui/input';15import { cn } from '@/lib/cn';1617/** Done-criteria checklist with the evidence the agent recorded when it flipped each one. */18export function Criteria({ criteria, active }: { criteria: DoneCriterion[]; active: boolean }) {19 if (criteria.length === 0) {20 return <p className="px-4 py-3 text-xs leading-relaxed text-muted" data-testid="criteria-empty">{active ? 'No criteria yet — the agent proposes them first and asks you to confirm before it writes anything.' : 'This mission ended without done criteria.'}</p>;21 }22 const done = criteria.filter((c) => c.passes).length;23 return (24 <div data-testid="criteria">25 <p className="tnum px-4 pt-2.5 text-[11px] text-muted">{done} of {criteria.length} passing</p>26 <ul className="flex flex-col px-2 pb-2 pt-1">27 {criteria.map((c) => (28 <li key={c.id} className="flex items-start gap-2.5 rounded-sm px-2 py-1.5" data-testid="criterion" data-passes={c.passes}>29 {c.passes ? (30 <span className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full bg-success-vivid text-white" role="img" aria-label="Passing"><Check className="size-3" strokeWidth={3} /></span>31 ) : (32 <Circle className="mt-0.5 size-4 shrink-0 text-faint" role="img" aria-label="Not passing yet" />33 )}34 <span className="min-w-0 flex-1">35 <span className={cn('block text-[13px] leading-snug', c.passes ? 'text-text' : 'text-text-2')}>{c.text}</span>36 {c.evidence && <span className="mt-0.5 block whitespace-pre-wrap break-words text-xs leading-relaxed text-muted" data-testid="criterion-evidence"><span className="font-medium text-text-2">Evidence: </span>{c.evidence}</span>}37 </span>38 </li>39 ))}40 </ul>41 </div>42 );43}4445interface Node {46 run: RunDto;47 children: Node[];48}49function toTree(runs: RunDto[]): Node[] {50 const nodes = new Map<string, Node>(runs.map((r) => [r.id, { run: r, children: [] }]));51 const roots: Node[] = [];52 for (const n of nodes.values()) {53 const parent = n.run.parentRunId ? nodes.get(n.run.parentRunId) : undefined;54 if (parent) parent.children.push(n);55 else roots.push(n);56 }57 const sort = (list: Node[]) => {58 list.sort((a, b) => a.run.createdAt.localeCompare(b.run.createdAt));59 list.forEach((n) => sort(n.children));60 };61 sort(roots);62 return roots;63}64function TreeNode({ node, depth, selected, onSelect, models }: { node: Node; depth: number; selected: string | null; onSelect: (id: string) => void; models: ModelInfo[] }) {65 const r = node.run;66 const st = RUN_STATUS[r.status];67 const on = selected === r.id;68 return (69 <li role="treeitem" aria-selected={on} aria-expanded={node.children.length ? true : undefined}>70 <button71 onClick={() => onSelect(r.id)}72 data-testid="run-node"73 data-run-id={r.id}74 style={{ paddingLeft: 10 + depth * 18 }}75 className={cn('flex min-h-12 w-full items-center gap-2.5 rounded-[10px] py-1.5 pr-2.5 text-left transition-colors', on ? 'bg-accent/[0.08]' : 'hover:bg-hover')}76 >77 <Dot tone={st.tone} pulse={st.pulse} label={st.label} />78 <span className="min-w-0 flex-1">79 <span className="flex items-center gap-1.5">80 <span className="truncate text-[13px] font-semibold capitalize text-text">{r.role || 'agent'}</span>81 <span className="shrink-0 text-[11px] text-muted">{st.label}</span>82 {r.pendingWaits > 0 && <span className="tnum flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-info-ink px-1 text-[10px] font-bold text-info-fg" aria-label={`${r.pendingWaits} waiting for you`}>{r.pendingWaits}</span>}83 </span>84 <span className="tnum block truncate text-[11px] text-muted">85 {models.find((m) => m.id === r.model)?.label ?? r.model}{r.effort ? ` · ${r.effort}` : ''} · {r.usage.turns} turns · <span title="tokens processed: prompt (including cache reads and writes) + output, summed over the turns">{fmtTokens(r.usage.inputTokens + r.usage.cacheReadTokens + r.usage.cacheWriteTokens + r.usage.outputTokens)} tok</span>86 </span>87 </span>88 <span className="tnum shrink-0 text-xs font-medium text-text-2">{fmtUsd(r.usage.usd)}</span>89 </button>90 {node.children.length > 0 && (91 <ul role="group">92 {node.children.map((c) => <TreeNode key={c.run.id} node={c} depth={depth + 1} selected={selected} onSelect={onSelect} models={models} />)}93 </ul>94 )}95 </li>96 );97}98/** Run tree: one node per run, children indented under the run that spawned them. */99export function RunTree({ runs, selected, onSelect, models }: { runs: RunDto[]; selected: string | null; onSelect: (id: string) => void; models: ModelInfo[] }) {100 const tree = useMemo(() => toTree(runs), [runs]);101 if (runs.length === 0) return <p className="px-4 py-3 text-xs text-muted">No run yet.</p>;102 return (103 <ul role="tree" aria-label="Runs" className="flex flex-col p-1.5" data-testid="run-tree">104 {tree.map((n) => <TreeNode key={n.run.id} node={n} depth={0} selected={selected} onSelect={onSelect} models={models} />)}105 </ul>106 );107}108109export function Artifacts({ runId }: { runId: string }) {110 const q = useArtifacts(runId);111 const list = q.data ?? [];112 if (q.isPending) return <p className="px-4 py-3 text-xs text-muted">Loading…</p>;113 if (q.isError) return <p className="px-4 py-3 text-xs text-muted">Artifacts could not be loaded.</p>;114 if (list.length === 0) return <p className="px-4 py-3 text-xs text-muted">No artifact yet. Long tool outputs are stored here in full.</p>;115 return (116 <ul className="flex flex-col p-1.5" data-testid="artifacts">117 {list.map((a) => (118 <li key={`${a.sha256}-${a.name}`}>119 <a href={artifactUrl(a.sha256, a.name)} download={a.name} className="flex min-h-9 items-center gap-2 rounded-sm px-2.5 py-1.5 text-[13px] hover:bg-hover">120 <Download className="size-3.5 shrink-0 text-muted" aria-hidden />121 <span className="min-w-0 flex-1 truncate font-mono text-[12px] text-text">{a.name}</span>122 <span className="tnum shrink-0 text-[11px] text-muted">{fmtBytes(a.size)}</span>123 </a>124 </li>125 ))}126 </ul>127 );128}129130/** Steering message to a run. The agent reads it between tool batches, not instantly. */131export function Composer({ run }: { run: RunDto }) {132 const [text, setText] = useState('');133 const [busy, setBusy] = useState(false);134 const bp = useBreakpoint();135 const live = isActive(run.status);136 const send = async () => {137 const t = text.trim();138 if (!t || busy) return;139 setBusy(true);140 const key = timeline.addPending(run.id, t);141 setText('');142 try {143 await api.post(`/runs/${run.id}/message`, { text: t });144 } catch (e) {145 timeline.removePending(run.id, key);146 setText(t);147 toast.error('Message not sent', errorMessage(e));148 } finally {149 setBusy(false);150 }151 };152 if (!live) {153 return <p className="border-t border-line px-4 py-3 text-center text-xs text-muted" data-testid="composer-ended">This run has ended ({RUN_STATUS[run.status].label.toLowerCase()}); it no longer reads messages.</p>;154 }155 return (156 <form157 className="border-t border-line bg-bg px-3 pb-[max(10px,env(safe-area-inset-bottom))] pt-2.5 md:px-6"158 data-testid="composer"159 onSubmit={(e) => {160 e.preventDefault();161 void send();162 }}163 >164 <p id="composer-hint" className="mx-auto mb-1.5 w-full max-w-3xl px-1 text-[11.5px] text-muted md:sr-only">It will read this after the current tool calls.</p>165 <div className="mx-auto flex w-full max-w-3xl items-end gap-2">166 <Textarea167 value={text}168 onChange={(e) => setText(e.target.value)}169 onKeyDown={(e) => {170 if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && bp !== 'mobile') {171 e.preventDefault();172 void send();173 }174 }}175 rows={1}176 maxLength={8000}177 placeholder={bp === 'mobile' ? 'Message the agent' : 'Message the agent — it will read this after the current tool calls'}178 aria-describedby="composer-hint"179 aria-label="Message the agent"180 className="max-h-40 min-h-10 flex-1 resize-none rounded-[14px] py-2.5 [field-sizing:content] pointer-coarse:min-h-12 pointer-coarse:py-3"181 data-testid="composer-input"182 />183 <Button type="submit" variant="brand" aria-label="Send message" className="size-10 rounded-full p-0 pointer-coarse:size-12" loading={busy} disabled={!text.trim()} data-testid="composer-send">184 {!busy && <SendHorizontal className="size-4" aria-hidden />}185 </Button>186 </div>187 </form>188 );189}190