SPB Git

Repository Public

detail-parts.tsx 9.3 KB 190 lines tsx

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

Theme