Agent statusTrace view
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callTrace view
One agent run as a waterfall of spans, with latency and tokens. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add trace-viewReact
npx designme add trace-view --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Trace view lays one agent run out as an observability waterfall: every span, whether an LLM call, a tool call, a retrieval or a sub-agent, sits on a shared time axis, nested under the span that started it with a tree's guides, with its tokens and latency. Each kind has its own colour and a count in the key, and while the run streams a playhead marks where it is. A tool call that runs over its time budget turns amber and is tagged slow, and any span opens to show where it sat in the run, its input, output, latency and tokens.
- When people debug or review what an agent did in one run, and where the time and tokens went.
- When a run streams in live and people want to watch each step start and finish.
- When a slow or costly step should stand out without reading every number.
- When people need the exact input and output of one step, like the request a tool made and what came back.
spansis the run as a tree: each span has aname, akindofagent,llm,toolorretrieval, astartand adurationin seconds, itstokens, andchildren. An agent span without its owntokensadds up every span under it.- Each kind has its own mark and colour, and its name in a tooltip on the mark. A key under the title counts the LLM calls, tool calls and retrievals that have started. Guides join each span to the one that started it.
- Each row shows its bar on the time axis, its tokens and its latency; the header shows the run's total time. An agent span is drawn as a bracket over the time its spans take.
timestreams a run in: spans that start later stay hidden, a span without adurationgrows up to it with a light passing along its bar and a bright leading edge, and a playhead markstimedown every track. Leave it out once the run is over and the playhead fades.- A tool call that takes longer than
slowAfterseconds (2 by default) turns amber and hatched and gets a slow tag, with the budget in its tooltip. - Pressing a span opens its details under the waterfall: a bar showing where it sat in the run, its kind, start, latency, tokens, input and output, while the other bars step back. Pressing it again, the close button or Escape shuts them.
- On a narrow card the names keep their room: the tokens column, the slow tag and the guides give way, the amber bar and time still mark a slow span, and every span's tokens stay in its details.
- Each span is a button in a labelled group, named with its name, kind, latency and tokens, and whether it is still running or slow.
- Each span says whether its details are open, and the details are a labelled region.
- The key is a labelled list, so the counts by kind are read out too. The guides, the playhead and the bar in the details are decoration and hidden from screen readers.
- The arrow keys, Home and End move between spans, and Escape closes the details and puts focus back on the span.
- The line under the title is announced politely when it changes.
- With reduced motion nothing slides, fades or shimmers; the bars, the playhead and the numbers still update.
Usage
Import it and drive it from your own state.
Import
import { TraceView } from '@/components/designme/trace-view/TraceView';Example
The demo above as a React component, 52 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useEffect, useRef, useState } from 'react';
import { TraceView, type TraceSpan } from './TraceView';
import { useStory } from '../lib/useScript';
/* ---------- Trace view: the run streams in, the page fetch runs long and turns amber, and the person opens it ---------- */
type Flat = Omit<TraceSpan, 'children'> & { duration: number; parent?: string };
const RUN: Flat[] = [
{ id: 'run', kind: 'agent', name: 'Pricing research', start: 0, duration: 4.82, input: "Compare Northwind's plans with ours", output: '3 prices saved to Pricing research' },
{ id: 'plan', parent: 'run', kind: 'llm', name: 'Plan the steps', start: .04, duration: .62, tokens: 1240, input: 'The task and 2 earlier messages', output: 'Search notes, fetch the page, summarise' },
{ id: 'notes', parent: 'run', kind: 'retrieval', name: 'Search saved notes', start: .7, duration: .38, tokens: 910, input: '"northwind pricing", top 4', output: '4 notes, best match 0.82' },
{ id: 'fetch', parent: 'run', kind: 'tool', name: 'fetch_page', start: 1.12, duration: 2.46, tokens: 3120, input: 'GET northwind.app/pricing', output: '200 OK, 48 KB, 3 plans' },
{ id: 'sum', parent: 'run', kind: 'agent', name: 'Summariser', start: 3.62, duration: 1.02, input: 'The page and 4 notes', output: 'A 120-word summary' },
{ id: 'write', parent: 'sum', kind: 'llm', name: 'Write the summary', start: 3.66, duration: .94, tokens: 2380, input: 'Page text and notes, 2,050 tokens', output: '120 words, 3 prices' },
{ id: 'ans', parent: 'run', kind: 'llm', name: 'Final answer', start: 4.66, duration: .16, tokens: 560, input: 'The summary', output: '3 prices saved to Pricing research' }
];
const END = 4.82, PACE = 1.5;
// the spans as they stand `t` seconds into the run: a running one has no duration yet, and its tokens count up
function at(t: number, parent?: string): TraceSpan[] {
return RUN.filter(x => x.parent === parent && x.start <= t).map(({ parent: _, duration, tokens, ...x }) => {
const done = t >= x.start + duration, kids = at(t, x.id);
return { ...x, duration: done ? duration : undefined, tokens: tokens === undefined ? undefined : Math.round(tokens * Math.min(1, (t - x.start) / duration)), children: kids.length ? kids : undefined };
});
}
export default function TraceViewDemo() {
const ref = useRef<HTMLDivElement>(null);
const [t, setT] = useState(0);
const [open, setOpen] = useState<string | null>(null);
// the run streams in at a pace a person can follow: one second of the run every 1.5 seconds
useEffect(() => {
let raf = 0, t0 = 0;
const go = (now: number) => { t0 ||= now; const v = Math.min(END, (now - t0) / 1000 / PACE); setT(v); if (v < END) raf = requestAnimationFrame(go); };
const start = setTimeout(() => { raf = requestAnimationFrame(go); }, 600);
return () => { clearTimeout(start); cancelAnimationFrame(raf); };
}, []);
// the preview's story: open the slow fetch once the run is done, read it, then close it
useStory(ref, async ({ wait, press }) => {
await wait(9000); press('[data-id="fetch"]');
await wait(4500); press('.dm-trv-x');
});
return (
<div ref={ref} style={{ width: '100%' }}>
<TraceView title="Pricing research" spans={at(t)} time={t < END ? t : undefined} axis={5} selected={open} onSelectedChange={setOpen} />
</div>
);
}
Props
Everything TraceView takes.
| Prop | Type | What it does |
|---|---|---|
titlerequired | string | The run's name, like "Pricing research" |
spansrequired | TraceSpan[] | The run's top spans, usually one root with everything nested under it |
time | number | Seconds into the run while it streams in: spans that start later stay hidden, and running ones reach this far. Leave it out once the run is over |
axis | number | Seconds the time axis covers. Defaults to the run's length, rounded up to a whole second |
slowAfter | number | A tool call that takes longer than this many seconds turns amber and is tagged slow. Defaults to 2 |
status | string | The line under the title. Defaults to "Running · 4 spans", then "Done · 7 spans, 1 slow" |
selected | string | null | The id of the open span, whose details show under the waterfall. null closes them |
defaultSelected | string | null | |
onSelectedChange | (id: string | null) => void | |
className | string |
Code
The full source, as the CLI installs it.
TraceView.tsxReact · 177 lines
'use client';
import { useRef, type CSSProperties, type KeyboardEvent } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { enter, Fold, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './trace-view.css';
export type SpanKind = 'agent' | 'llm' | 'tool' | 'retrieval';
export interface TraceSpan {
id: string;
/** Like "Plan the steps" or the tool's name, "fetch_page" */
name: string;
kind: SpanKind;
/** When it started, in seconds from the start of the run */
start: number;
/** How long it took, in seconds. Leave it out while it runs: its bar grows to `time` */
duration?: number;
/** Tokens it used. An agent span without its own count adds up every span under it */
tokens?: number;
/** What went in and what came out, shown when the span is opened */
input?: string;
output?: string;
children?: TraceSpan[];
}
export interface TraceViewProps {
/** The run's name, like "Pricing research" */
title: string;
/** The run's top spans, usually one root with everything nested under it */
spans: TraceSpan[];
/** Seconds into the run while it streams in: spans that start later stay hidden, and running ones reach this far.
Leave it out once the run is over */
time?: number;
/** Seconds the time axis covers. Defaults to the run's length, rounded up to a whole second */
axis?: number;
/** A tool call that takes longer than this many seconds turns amber and is tagged slow. Defaults to 2 */
slowAfter?: number;
/** The line under the title. Defaults to "Running · 4 spans", then "Done · 7 spans, 1 slow" */
status?: string;
/** The id of the open span, whose details show under the waterfall. null closes them */
selected?: string | null;
defaultSelected?: string | null;
onSelectedChange?: (id: string | null) => void;
className?: string;
}
const KIND: Record<SpanKind, [string, IconName, string]> = {
agent: ['Agent', 'cpu', 'var(--dm-muted)'], llm: ['LLM call', 'spark', 'var(--dm-c2)'], tool: ['Tool call', 'term', 'var(--dm-c6)'], retrieval: ['Retrieval', 'search', 'var(--dm-c3)']
};
const sec = (v: number) => v.toFixed(2) + 's';
const short = (v: number) => v >= 1000 ? (v / 1000).toFixed(1) + 'k' : String(Math.round(v));
const tokensOf = (s: TraceSpan): number => s.tokens ?? (s.children ?? []).reduce((a, k) => a + tokensOf(k), 0);
type Row = { s: TraceSpan; lv: number; up?: Row };
/** One agent run as an observability waterfall: every span (LLM call, tool call, retrieval, sub-agent) on a shared time
axis, nested under the span that started it with a tree's guides, with its tokens and latency. Each kind has its colour
and a count in the key; while the run streams a playhead marks now and running bars glow at their leading edge. A slow
tool call turns amber and hatched, and any span opens to show where it sat in the run, its input, output, latency and tokens. */
export function TraceView({ title, spans, time, axis, slowAfter = 2, status, selected, defaultSelected = null, onSelectedChange, className }: TraceViewProps) {
const [open, setOpen] = useControllable(selected, defaultSelected, onSelectedChange);
const rows = useRef<HTMLDivElement>(null);
const all: Row[] = [];
(function walk(list: TraceSpan[], lv: number, up?: Row) { list.forEach(s => { const row = { s, lv, up }; all.push(row); if (s.children) walk(s.children, lv + 1, row); }); })(spans, 0);
const end = Math.max(0, ...all.map(({ s }) => s.start + (s.duration ?? Math.max(0, (time ?? s.start) - s.start))));
const span = axis ?? Math.max(1, Math.ceil(end));
const now = time ?? Infinity;
const shown = all.filter(({ s }) => s.start <= now);
// the tree's guides: a line on down past a parent's mark, an elbow into each child, and the lines of ancestors whose own
// siblings are still to come
const later = (x: Row) => shown.some(y => y !== x && y.up === x.up && all.indexOf(y) > all.indexOf(x));
const guides = (x: Row) => {
const g: { x: number; cls?: string }[] = [];
for (let a = x.up; a && a.up; a = a.up) if (later(a)) g.push({ x: a.lv - 1 });
if (x.up) g.push({ x: x.lv - 1, cls: cx('dm-el', !later(x) && 'dm-end') });
if (shown.some(y => y.up === x)) g.push({ x: x.lv, cls: 'dm-dn' });
return g;
};
const KEYS: SpanKind[] = ['llm', 'tool', 'retrieval'];
const took = (s: TraceSpan) => s.duration !== undefined ? Math.min(s.duration, now - s.start) : Math.max(0, (time ?? s.start) - s.start);
const running = (s: TraceSpan) => s.duration === undefined || now < s.start + s.duration;
const slow = (s: TraceSpan) => s.kind === 'tool' && took(s) > slowAfter;
const nSlow = shown.filter(({ s }) => slow(s)).length;
const live = shown.some(({ s }) => running(s));
const line = status ?? (live ? `Running · ${shown.length} span${shown.length === 1 ? '' : 's'}` : `Done · ${shown.length} spans${nSlow ? `, ${nSlow} slow` : ''}`);
// a label every other tick, and a faint line on every tick
const tick = span <= 6 ? 1 : span <= 15 ? 2 : span <= 30 ? 5 : 10;
const labels = Array.from({ length: Math.floor(span / tick / 2) + 1 }, (_, i) => i * tick * 2).filter(v => v < span);
const grid: CSSProperties = { backgroundSize: `${(tick / span) * 100}% 100%` };
const sel = open ? all.find(({ s }) => s.id === open)?.s : undefined;
// the details keep the last span while they fold shut
const last = useRef<TraceSpan | undefined>(undefined); if (sel) last.current = sel;
const cur = sel ?? last.current;
const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
const btns = [...(rows.current?.querySelectorAll<HTMLElement>('.dm-trv-r') ?? [])], i = btns.indexOf(document.activeElement as HTMLElement);
if (i < 0) return;
const go = e.key === 'ArrowDown' ? btns[i + 1] : e.key === 'ArrowUp' ? btns[i - 1] : e.key === 'Home' ? btns[0] : e.key === 'End' ? btns[btns.length - 1] : null;
if (!go && !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
e.preventDefault(); go?.focus({ preventScroll: true });
};
// closing hands focus back to the span, if it was inside the details
const close = (back: boolean) => {
const id = open; setOpen(null);
if (back && id) rows.current?.querySelector<HTMLElement>(`[data-id="${CSS.escape(id)}"]`)?.focus({ preventScroll: true });
};
return (
<div data-dm="" className={cx('dm-trv dm-panel', live && time !== undefined && time > 0 && 'dm-live', className)} style={{ '--dm-now': Math.min(end, now) / span } as CSSProperties}
onKeyDown={e => { if (e.key === 'Escape' && open) close(true); }}>
<div className="dm-trv-h">
<div className="dm-trv-ht"><b>{title}</b><span className="dm-muted dm-trv-st" aria-live="polite"><Swap>{line}</Swap></span></div>
<span className="dm-trv-tot dm-num">{sec(Math.min(end, now))}</span>
</div>
<div className="dm-trv-lg" role="list" aria-label="Spans by kind">
{KEYS.map(k => { const n = shown.filter(({ s }) => s.kind === k).length;
return <span key={k} role="listitem" className={cx(!n && 'dm-z')} style={{ '--dm-k': KIND[k][2] } as CSSProperties}><i />{KIND[k][0]} <b key={n} className={cx('dm-num', n > 0 && 'dm-enter')}>{n}</b></span>; })}
</div>
<div className="dm-trv-ax" aria-hidden="true">
<span>Span</span>
<span className="dm-trv-sc">{labels.map(v => <span key={v} style={{ left: `${(v / span) * 100}%` }}>{v}s</span>)}</span>
<span className="dm-trv-tk">Tokens</span><span className="dm-trv-ms">Time</span>
</div>
<div ref={rows} className={cx('dm-trv-rows', sel && 'dm-sel')} role="group" aria-label="Spans" onKeyDown={onKey}>
{shown.map(row => {
const { s, lv } = row;
const [kind, icon, col] = KIND[s.kind], w = took(s), tk = tokensOf(s), run = running(s), sl = slow(s);
const why = `Over the ${slowAfter}s budget for a tool call`;
return (
<button key={s.id} type="button" data-id={s.id} data-k={s.kind} className={cx('dm-trv-r dm-enter', !lv && 'dm-root', run && 'dm-run', sl && 'dm-slow')}
style={{ '--dm-k': col, '--dm-lv': lv } as CSSProperties} aria-expanded={open === s.id}
aria-label={`${s.name}, ${kind}, ${run ? 'running, ' : ''}${sec(w)}, ${Math.round(tk).toLocaleString('en-US')} tokens${sl ? ', slow' : ''}`}
onClick={() => setOpen(open === s.id ? null : s.id)}>
<span className="dm-trv-n">
<span className="dm-trv-g" aria-hidden="true">{guides(row).map((g, i) => <i key={i} className={g.cls} style={{ '--dm-x': g.x } as CSSProperties} />)}</span>
<Tip label={kind}><span className="dm-trv-k"><Icon name={icon} size="xs" /></span></Tip>
<span className="dm-trv-nm">{s.name}</span>
{sl && <Tip label={why}><span className={cx('dm-tag dm-warn', enter().className)}>slow</span></Tip>}
</span>
<span className="dm-trv-b" style={grid}><i style={{ left: `${(s.start / span) * 100}%`, width: `${(w / span) * 100}%` }} /></span>
<span className="dm-trv-tk dm-num">{short(tk)}</span>
{sl ? <Tip label={why}><span className="dm-trv-ms dm-num">{sec(w)}</span></Tip> : <span className="dm-trv-ms dm-num">{sec(w)}</span>}
</button>
);
})}
</div>
<Fold open={!!sel} className="dm-trv-dt">
{cur && (
<div key={cur.id} className="dm-trv-dc dm-enter" role="region" aria-label="Span details">
<div className="dm-trv-dh">
<span className="dm-trv-k" style={{ '--dm-k': KIND[cur.kind][2] } as CSSProperties}><Icon name={KIND[cur.kind][1]} size="xs" /></span>
<b>{cur.name}</b>{slow(cur) && <span className="dm-tag dm-warn">slow</span>}
<Tip label="Close"><button type="button" className="dm-ib dm-trv-x" aria-label="Close details" onClick={e => close(e.currentTarget.contains(document.activeElement))}><Icon name="x" /></button></Tip>
</div>
<div className="dm-trv-pos" aria-hidden="true" style={{ '--dm-k': KIND[cur.kind][2] } as CSSProperties}>
<i style={{ left: `${(cur.start / span) * 100}%`, width: `${(took(cur) / span) * 100}%` }} />
</div>
<div className="dm-trv-kv">
<div><span>Kind</span><b>{KIND[cur.kind][0]}</b></div>
<div><span>Starts at</span><b className="dm-num">{sec(cur.start)}</b></div>
<div><span>Latency</span><b className="dm-num">{sec(took(cur))}</b></div>
<div><span>Tokens</span><b className="dm-num">{Math.round(tokensOf(cur)).toLocaleString('en-US')}</b></div>
</div>
<div className="dm-trv-io">
<span>Input</span><code>{cur.input ?? '–'}</code>
<span>Output</span><code>{running(cur) ? 'Running…' : cur.output ?? '–'}</code>
</div>
</div>
)}
</Fold>
</div>
);
}
trace-view.cssReact · 89 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Trace view ---------- */
/* one agent run as a waterfall: every span on a shared time axis, nested under the span that started it, with its tokens
and latency. Each kind has its colour (the chart palette's identity slots): its mark, its bar and its count in the key.
While the run streams, a playhead marks now and the running bars glow at their leading edge. A span over its time budget
turns amber and hatched, and any span opens to show where it sat in the run, its input and its output */
.dm-trv{width:100%;max-width:720px;margin:0 auto;padding:6px;container-type:inline-size;--dm-cols:minmax(0,11fr) minmax(0,8fr) 40px 44px}
.dm-trv-h{display:flex;align-items:center;justify-content:space-between;gap:var(--dm-s-3);padding:var(--dm-s-2) 10px 6px}
.dm-trv-ht{display:flex;flex-direction:column;min-width:0}
.dm-trv-ht b{font-weight:500;font-size:14px}
.dm-trv-st{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-trv-tot{font-size:14px;font-weight:500;flex:none}
/* the key: each kind with its colour and how many of its spans have started */
.dm-trv-lg{display:flex;flex-wrap:wrap;gap:2px var(--dm-s-3);padding:0 10px var(--dm-s-2);font-size:12px;color:var(--dm-muted)}
.dm-trv-lg span{display:inline-flex;align-items:center;gap:5px;white-space:nowrap;transition:color .3s}
.dm-trv-lg i{width:8px;height:8px;border-radius:2px;background:var(--dm-k);flex:none}
.dm-trv-lg b{font-weight:500;color:var(--dm-fg2)}
.dm-trv-lg span.dm-z{color:var(--dm-dis)}.dm-trv-lg span.dm-z b{color:inherit}.dm-trv-lg span.dm-z i{opacity:.45}
.dm-trv-ax,.dm-trv-r{display:grid;grid-template-columns:var(--dm-cols);align-items:center;gap:var(--dm-s-2);padding:0 10px}
.dm-trv-ax{height:30px;border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:12px;color:var(--dm-muted)}
.dm-trv-tk,.dm-trv-ms{text-align:right;white-space:nowrap}
.dm-trv-sc{position:relative;height:18px}
.dm-trv-sc span{position:absolute;top:0;transform:translateX(-50%);font-size:12px}
.dm-trv-sc span:first-child{transform:none}
.dm-trv-rows{display:flex;flex-direction:column;padding-bottom:var(--dm-s-1)}
.dm-trv-r{width:100%;min-height:32px;border-radius:10px;font-size:13px;text-align:left;transition:background .16s}
@media (hover:hover) and (pointer:fine){.dm-trv-r:hover{background:var(--dm-row)}}
.dm-trv-r[aria-expanded="true"]{background:var(--dm-sel)}
.dm-trv-r[aria-expanded="true"] .dm-trv-tk{color:var(--dm-fg2)}
.dm-trv-r:focus-visible{outline:0;box-shadow:inset 0 0 0 2px var(--dm-focus)}
.dm-trv-n{position:relative;display:flex;align-items:center;gap:6px;min-width:0;align-self:stretch;padding-left:calc(var(--dm-lv,0) * 16px)}
.dm-trv-k{position:relative;z-index:1;width:18px;height:18px;display:grid;place-items:center;flex:none;border-radius:5px;color:color-mix(in srgb,var(--dm-k) 70%,var(--dm-fg));background:color-mix(in srgb,var(--dm-k) 13%,var(--dm-surface))}
.dm-trv-nm{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-trv-r.dm-root .dm-trv-nm{font-weight:500}
.dm-trv-n .dm-tag{flex:none}
/* the tree: a guide from each parent's mark down past its children, with an elbow into each child */
.dm-trv-g{position:absolute;top:0;bottom:0;left:0;pointer-events:none}
.dm-trv-g i{position:absolute;top:0;bottom:0;left:calc(var(--dm-x) * 16px + 8.5px);width:1px;background:color-mix(in srgb,var(--dm-rail) 60%,transparent)}
.dm-trv-g i.dm-end{bottom:50%}
.dm-trv-g i.dm-el::after{content:"";position:absolute;left:0;top:calc(50% - 1px);width:7px;height:1px;background:inherit}
.dm-trv-g i.dm-end.dm-el::after{top:auto;bottom:-1px}
.dm-trv-g i.dm-dn{top:50%}
/* a bar per span on the shared axis: faint lines mark the ticks */
.dm-trv-b{position:relative;height:20px;background:linear-gradient(to right,var(--dm-line) 1px,transparent 1px) 0 0/20% 100%}
/* a bar is laid out once at its full length; while it runs, --f (0 to 1) reveals it with a clip and moves its bright edge with a
transform, so a streaming run repaints without laying the page out again */
.dm-trv-b i{position:absolute;top:5px;height:10px;min-width:3px;border-radius:3px;clip-path:inset(-6px calc((1 - var(--dm-f,1)) * 100% - 2px) -6px -6px);background:linear-gradient(90deg,color-mix(in srgb,var(--dm-k) 70%,var(--dm-surface)),var(--dm-k));transition:background .3s,opacity .3s}
/* a running bar has a light passing along it and a bright leading edge */
.dm-trv-r.dm-run .dm-trv-b i{background:linear-gradient(90deg,color-mix(in srgb,var(--dm-k) 55%,var(--dm-surface)) 0 30%,color-mix(in srgb,var(--dm-k) 25%,var(--dm-surface)) 45%,var(--dm-k) 60% 100%) 0 0/250% 100%;animation:dm-trvsheen 1.4s linear infinite}
.dm-trv-r.dm-run:not([data-k="agent"]) .dm-trv-b i::after{content:"";position:absolute;left:0;right:0;top:-2px;bottom:-2px;--dm-e:var(--dm-k);background:linear-gradient(to left,var(--dm-e) 0 2px,transparent 2px);filter:drop-shadow(0 0 3px color-mix(in srgb,var(--dm-e) 70%,transparent));transform:translateX(calc(var(--dm-f,1) * 100% - 100% + 1px))}
@keyframes dm-trvsheen{from{background-position:100% 0}to{background-position:-150% 0}}
/* an agent span holds the others, so it is drawn as a bracket over the time they take */
.dm-trv-r[data-k="agent"] .dm-trv-b i,.dm-trv-r.dm-run[data-k="agent"] .dm-trv-b i{top:9px;height:2px;border-radius:1px;background:var(--dm-faint);animation:none}
.dm-trv-r[data-k="agent"] .dm-trv-b i::before,.dm-trv-r[data-k="agent"] .dm-trv-b i::after{content:"";position:absolute;top:-3px;width:2px;height:8px;border-radius:1px;background:var(--dm-faint)}
.dm-trv-r[data-k="agent"] .dm-trv-b i::before{left:0}.dm-trv-r[data-k="agent"] .dm-trv-b i::after{right:0}
.dm-trv-r.dm-run[data-k="agent"] .dm-trv-b i::after{background:var(--dm-muted)}
/* over budget: amber, hatched */
.dm-trv-r.dm-slow .dm-trv-b i,.dm-trv-r.dm-run.dm-slow .dm-trv-b i{background:repeating-linear-gradient(135deg,var(--dm-warn) 0 3px,color-mix(in srgb,var(--dm-warn) 55%,var(--dm-surface)) 3px 6px)}
.dm-trv-r.dm-run.dm-slow .dm-trv-b i::after{--dm-e:var(--dm-warn)}
.dm-trv-r .dm-trv-tk{font-size:12px;color:var(--dm-muted)}
.dm-trv-r .dm-trv-ms{font-size:12.5px;color:var(--dm-fg2)}
.dm-trv-r.dm-slow .dm-trv-ms{color:var(--dm-warn)}
/* with a span open, the other bars step back */
.dm-trv-rows.dm-sel .dm-trv-r:not([aria-expanded="true"]) .dm-trv-b i{opacity:.35}
/* the playhead: where the run is now, one line down every bar's track while it streams */
.dm-trv-b::after,.dm-trv-sc::after{content:"";position:absolute;left:0;right:0;top:-6px;bottom:-6px;background:linear-gradient(90deg,var(--dm-accent-mark,var(--dm-accent)) 0 1px,transparent 1px);transform:translateX(calc(var(--dm-now,0) * 100% - .5px));opacity:0;transition:opacity .4s;pointer-events:none}
.dm-trv-sc::after{top:12px;bottom:-6px}
.dm-trv.dm-live .dm-trv-b::after,.dm-trv.dm-live .dm-trv-sc::after{opacity:.75}
.dm-trv-dc{display:flex;flex-direction:column;gap:10px;margin-top:2px;padding:6px 10px var(--dm-s-2);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-trv-dh{display:flex;align-items:center;gap:var(--dm-s-2);min-width:0;font-size:13px}
.dm-trv-dh b{min-width:0;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-trv-dh .dm-ib{margin:0 -6px 0 auto}
/* where the open span sat in the run */
.dm-trv-pos{position:relative;height:6px;border-radius:3px;background:var(--dm-line)}
.dm-trv-pos i{position:absolute;top:0;bottom:0;min-width:3px;border-radius:3px;background:var(--dm-k);transition:width .25s linear}
.dm-trv-kv{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--dm-s-2)}
.dm-trv-kv div{display:flex;flex-direction:column;min-width:0}
.dm-trv-kv span{font-size:12px;color:var(--dm-muted)}
.dm-trv-kv b{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-trv-io{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:6px 10px;font-size:12px;color:var(--dm-muted)}
.dm-trv-io code{font-family:var(--dm-mono);font-size:12px;color:var(--dm-fg2);background:var(--dm-chip);padding:5px var(--dm-s-2);border-radius:var(--dm-r-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
:root:not([data-style="soft"]) .dm-trv-r:not(.dm-slow) .dm-trv-b i:not(:where([data-style="soft"],[data-style="soft"] *)){background:var(--dm-k)}
:root:not([data-style="soft"]) .dm-trv-r.dm-run:not(.dm-slow):not([data-k="agent"]) .dm-trv-b i:not(:where([data-style="soft"],[data-style="soft"] *)){background:repeating-linear-gradient(90deg,var(--dm-k) 0 4px,color-mix(in srgb,var(--dm-k) 55%,var(--dm-surface)) 4px 6px);animation:none}
:root:not([data-style="soft"]) .dm-trv-r.dm-run:not([data-k="agent"]) .dm-trv-b i:not(:where([data-style="soft"],[data-style="soft"] *))::after{filter:none;background:linear-gradient(to left,var(--dm-e) 0 1px,transparent 1px);transform:translateX(calc(var(--dm-f,1) * 100% - 100%))}
:root:not([data-style="soft"]) .dm-trv-g i:not(:where([data-style="soft"],[data-style="soft"] *)),:root:not([data-style="soft"]) .dm-trv-g i.dm-el:not(:where([data-style="soft"],[data-style="soft"] *))::after{background:var(--dm-faint)}
/* narrow: the names keep their room. Tokens move into each span's details, the slow tag leaves the amber bar and time to
say it (the time keeps its tooltip), the tree draws closer, and the details give the start its own line */
@container (max-width:330px){.dm-trv-ax,.dm-trv-r{grid-template-columns:minmax(0,1fr) 72px 44px}.dm-trv-tk,.dm-trv-n .dm-tag{display:none}.dm-trv-n{padding-left:calc(var(--dm-lv,0) * 10px)}.dm-trv-g{display:none}.dm-trv-kv{grid-template-columns:repeat(2,minmax(0,1fr))}}
trace-view.htmlHTML · 12 lines
<div class="card wide" data-c="trace-view">
<div class="stage-in">
<div class="trv panel">
<div class="trv-h"><div class="trv-ht"><b>Pricing research</b><span class="muted trv-st" aria-live="polite"></span></div><span class="trv-tot num">0.00s</span></div>
<div class="trv-lg" role="list" aria-label="Spans by kind"></div>
<div class="trv-ax" aria-hidden="true"><span>Span</span><span class="trv-sc"></span><span class="trv-tk">Tokens</span><span class="trv-ms">Time</span></div>
<div class="trv-rows" role="group" aria-label="Spans"></div>
<div class="fold shut trv-dt" inert><div><div class="trv-dc" role="region" aria-label="Span details"></div></div></div>
</div>
</div>
</div>
trace-view.cssHTML · 88 lines
/* ---------- Trace view ---------- */
/* one agent run as a waterfall: every span on a shared time axis, nested under the span that started it, with its tokens
and latency. Each kind has its colour (the chart palette's identity slots): its mark, its bar and its count in the key.
While the run streams, a playhead marks now and the running bars glow at their leading edge. A span over its time budget
turns amber and hatched, and any span opens to show where it sat in the run, its input and its output */
.trv{width:100%;max-width:720px;margin:0 auto;padding:6px;container-type:inline-size;--cols:minmax(0,11fr) minmax(0,8fr) 40px 44px}
.trv-h{display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);padding:var(--s-2) 10px 6px}
.trv-ht{display:flex;flex-direction:column;min-width:0}
.trv-ht b{font-weight:500;font-size:14px}
.trv-st{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.trv-tot{font-size:14px;font-weight:500;flex:none}
/* the key: each kind with its colour and how many of its spans have started */
.trv-lg{display:flex;flex-wrap:wrap;gap:2px var(--s-3);padding:0 10px var(--s-2);font-size:12px;color:var(--muted)}
.trv-lg span{display:inline-flex;align-items:center;gap:5px;white-space:nowrap;transition:color .3s}
.trv-lg i{width:8px;height:8px;border-radius:2px;background:var(--k);flex:none}
.trv-lg b{font-weight:500;color:var(--fg2)}
.trv-lg span.z{color:var(--dis)}.trv-lg span.z b{color:inherit}.trv-lg span.z i{opacity:.45}
.trv-ax,.trv-r{display:grid;grid-template-columns:var(--cols);align-items:center;gap:var(--s-2);padding:0 10px}
.trv-ax{height:30px;border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent);font-size:12px;color:var(--muted)}
.trv-tk,.trv-ms{text-align:right;white-space:nowrap}
.trv-sc{position:relative;height:18px}
.trv-sc span{position:absolute;top:0;transform:translateX(-50%);font-size:12px}
.trv-sc span:first-child{transform:none}
.trv-rows{display:flex;flex-direction:column;padding-bottom:var(--s-1)}
.trv-r{width:100%;min-height:32px;border-radius:10px;font-size:13px;text-align:left;transition:background .16s}
@media (hover:hover) and (pointer:fine){.trv-r:hover{background:var(--row)}}
.trv-r[aria-expanded="true"]{background:var(--sel)}
.trv-r[aria-expanded="true"] .trv-tk{color:var(--fg2)}
.trv-r:focus-visible{outline:0;box-shadow:inset 0 0 0 2px var(--focus)}
.trv-n{position:relative;display:flex;align-items:center;gap:6px;min-width:0;align-self:stretch;padding-left:calc(var(--lv,0) * 16px)}
.trv-k{position:relative;z-index:1;width:18px;height:18px;display:grid;place-items:center;flex:none;border-radius:5px;color:color-mix(in srgb,var(--k) 70%,var(--fg));background:color-mix(in srgb,var(--k) 13%,var(--surface))}
.trv-nm{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.trv-r.root .trv-nm{font-weight:500}
.trv-n .tag{flex:none}
/* the tree: a guide from each parent's mark down past its children, with an elbow into each child */
.trv-g{position:absolute;top:0;bottom:0;left:0;pointer-events:none}
.trv-g i{position:absolute;top:0;bottom:0;left:calc(var(--x) * 16px + 8.5px);width:1px;background:color-mix(in srgb,var(--rail) 60%,transparent)}
.trv-g i.end{bottom:50%}
.trv-g i.el::after{content:"";position:absolute;left:0;top:calc(50% - 1px);width:7px;height:1px;background:inherit}
.trv-g i.end.el::after{top:auto;bottom:-1px}
.trv-g i.dn{top:50%}
/* a bar per span on the shared axis: faint lines mark the ticks */
.trv-b{position:relative;height:20px;background:linear-gradient(to right,var(--line) 1px,transparent 1px) 0 0/20% 100%}
/* a bar is laid out once at its full length; while it runs, --f (0 to 1) reveals it with a clip and moves its bright edge with a
transform, so a streaming run repaints without laying the page out again */
.trv-b i{position:absolute;top:5px;height:10px;min-width:3px;border-radius:3px;clip-path:inset(-6px calc((1 - var(--f,1)) * 100% - 2px) -6px -6px);background:linear-gradient(90deg,color-mix(in srgb,var(--k) 70%,var(--surface)),var(--k));transition:background .3s,opacity .3s}
/* a running bar has a light passing along it and a bright leading edge */
.trv-r.run .trv-b i{background:linear-gradient(90deg,color-mix(in srgb,var(--k) 55%,var(--surface)) 0 30%,color-mix(in srgb,var(--k) 25%,var(--surface)) 45%,var(--k) 60% 100%) 0 0/250% 100%;animation:trvsheen 1.4s linear infinite}
.trv-r.run:not([data-k="agent"]) .trv-b i::after{content:"";position:absolute;left:0;right:0;top:-2px;bottom:-2px;--e:var(--k);background:linear-gradient(to left,var(--e) 0 2px,transparent 2px);filter:drop-shadow(0 0 3px color-mix(in srgb,var(--e) 70%,transparent));transform:translateX(calc(var(--f,1) * 100% - 100% + 1px))}
@keyframes trvsheen{from{background-position:100% 0}to{background-position:-150% 0}}
/* an agent span holds the others, so it is drawn as a bracket over the time they take */
.trv-r[data-k="agent"] .trv-b i,.trv-r.run[data-k="agent"] .trv-b i{top:9px;height:2px;border-radius:1px;background:var(--faint);animation:none}
.trv-r[data-k="agent"] .trv-b i::before,.trv-r[data-k="agent"] .trv-b i::after{content:"";position:absolute;top:-3px;width:2px;height:8px;border-radius:1px;background:var(--faint)}
.trv-r[data-k="agent"] .trv-b i::before{left:0}.trv-r[data-k="agent"] .trv-b i::after{right:0}
.trv-r.run[data-k="agent"] .trv-b i::after{background:var(--muted)}
/* over budget: amber, hatched */
.trv-r.slow .trv-b i,.trv-r.run.slow .trv-b i{background:repeating-linear-gradient(135deg,var(--warn) 0 3px,color-mix(in srgb,var(--warn) 55%,var(--surface)) 3px 6px)}
.trv-r.run.slow .trv-b i::after{--e:var(--warn)}
.trv-r .trv-tk{font-size:12px;color:var(--muted)}
.trv-r .trv-ms{font-size:12.5px;color:var(--fg2)}
.trv-r.slow .trv-ms{color:var(--warn)}
/* with a span open, the other bars step back */
.trv-rows.sel .trv-r:not([aria-expanded="true"]) .trv-b i{opacity:.35}
/* the playhead: where the run is now, one line down every bar's track while it streams */
.trv-b::after,.trv-sc::after{content:"";position:absolute;left:0;right:0;top:-6px;bottom:-6px;background:linear-gradient(90deg,var(--accent-mark,var(--accent)) 0 1px,transparent 1px);transform:translateX(calc(var(--now,0) * 100% - .5px));opacity:0;transition:opacity .4s;pointer-events:none}
.trv-sc::after{top:12px;bottom:-6px}
.trv.live .trv-b::after,.trv.live .trv-sc::after{opacity:.75}
.trv-dc{display:flex;flex-direction:column;gap:10px;margin-top:2px;padding:6px 10px var(--s-2);border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.trv-dh{display:flex;align-items:center;gap:var(--s-2);min-width:0;font-size:13px}
.trv-dh b{min-width:0;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.trv-dh .ib{margin:0 -6px 0 auto}
/* where the open span sat in the run */
.trv-pos{position:relative;height:6px;border-radius:3px;background:var(--line)}
.trv-pos i{position:absolute;top:0;bottom:0;min-width:3px;border-radius:3px;background:var(--k);transition:width .25s linear}
.trv-kv{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--s-2)}
.trv-kv div{display:flex;flex-direction:column;min-width:0}
.trv-kv span{font-size:12px;color:var(--muted)}
.trv-kv b{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.trv-io{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:6px 10px;font-size:12px;color:var(--muted)}
.trv-io code{font-family:var(--mono);font-size:12px;color:var(--fg2);background:var(--chip);padding:5px var(--s-2);border-radius:var(--r-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
:root:not([data-style="soft"]) .trv-r:not(.slow) .trv-b i:not(:where([data-style="soft"],[data-style="soft"] *)){background:var(--k)}
:root:not([data-style="soft"]) .trv-r.run:not(.slow):not([data-k="agent"]) .trv-b i:not(:where([data-style="soft"],[data-style="soft"] *)){background:repeating-linear-gradient(90deg,var(--k) 0 4px,color-mix(in srgb,var(--k) 55%,var(--surface)) 4px 6px);animation:none}
:root:not([data-style="soft"]) .trv-r.run:not([data-k="agent"]) .trv-b i:not(:where([data-style="soft"],[data-style="soft"] *))::after{filter:none;background:linear-gradient(to left,var(--e) 0 1px,transparent 1px);transform:translateX(calc(var(--f,1) * 100% - 100%))}
:root:not([data-style="soft"]) .trv-g i:not(:where([data-style="soft"],[data-style="soft"] *)),:root:not([data-style="soft"]) .trv-g i.el:not(:where([data-style="soft"],[data-style="soft"] *))::after{background:var(--faint)}
/* narrow: the names keep their room. Tokens move into each span's details, the slow tag leaves the amber bar and time to
say it (the time keeps its tooltip), the tree draws closer, and the details give the start its own line */
@container (max-width:330px){.trv-ax,.trv-r{grid-template-columns:minmax(0,1fr) 72px 44px}.trv-tk,.trv-n .tag{display:none}.trv-n{padding-left:calc(var(--lv,0) * 10px)}.trv-g{display:none}.trv-kv{grid-template-columns:repeat(2,minmax(0,1fr))}}
trace-view.jsHTML · 122 lines
DM.def('trace-view', (r, c) => {
// the kinds of span, each with its mark and colour: the identity slots of the chart palette, and grey for the agents
// that hold the other spans. The name is always on the mark, in a tooltip, and in the span's label
const K = { agent:['Agent','cpu','var(--muted)'], llm:['LLM call','spark','var(--c2)'], tool:['Tool call','term','var(--c6)'], ret:['Retrieval','search','var(--c3)'] };
// one run, in seconds from its start. A tool call that takes longer than SLOW seconds is flagged
const SPAN = 5, SLOW = 2, PACE = 1.5;
const S = [
{ id:'run', k:'agent', n:'Pricing research', s:0, d:4.82, lv:0, in:"Compare Northwind's plans with ours", out:'3 prices saved to Pricing research' },
{ id:'plan', k:'llm', n:'Plan the steps', s:.04, d:.62, tk:1240, lv:1, in:'The task and 2 earlier messages', out:'Search notes, fetch the page, summarise' },
{ id:'notes', k:'ret', n:'Search saved notes', s:.7, d:.38, tk:910, lv:1, in:'"northwind pricing", top 4', out:'4 notes, best match 0.82' },
{ id:'fetch', k:'tool', n:'fetch_page', s:1.12, d:2.46, tk:3120, lv:1, in:'GET northwind.app/pricing', out:'200 OK, 48 KB, 3 plans' },
{ id:'sum', k:'agent', n:'Summariser', s:3.62, d:1.02, lv:1, in:'The page and 4 notes', out:'A 120-word summary' },
{ id:'write', k:'llm', n:'Write the summary', s:3.66, d:.94, tk:2380, lv:2, in:'Page text and notes, 2,050 tokens', out:'120 words, 3 prices' },
{ id:'ans', k:'llm', n:'Final answer', s:4.66, d:.16, tk:560, lv:1, in:'The summary', out:'3 prices saved to Pricing research' }
];
const END = Math.max(...S.map(x => x.s + x.d));
// an agent span counts the tokens of every span under it
S.forEach((x, i) => { x.kids = []; for (let j = i + 1; j < S.length && S[j].lv > x.lv; j++) x.kids.push(S[j]); });
const root = $('.trv', r), rows = $('.trv-rows', r), stEl = $('.trv-st', r), tot = $('.trv-tot', r), dt = $('.trv-dt', r), dc = $('.trv-dc', r), lg = $('.trv-lg', r);
// the key: every kind but the agents, with its colour and how many of its spans have started
const KEYS = ['llm', 'tool', 'ret'];
lg.innerHTML = KEYS.map(k => `<span role="listitem" class="z" data-k="${k}" style="--k:${K[k][2]}"><i></i>${K[k][0]} <b class="num">0</b></span>`).join('');
// each span's parent, for the tree's guides
S.forEach((x, i) => { for (let j = i - 1; j >= 0; j--) if (S[j].lv === x.lv - 1){ x.up = S[j]; break; } });
$('.trv-sc', r).innerHTML = Array.from({ length:Math.ceil(SPAN / 2) }, (_, i) => `<span style="left:${i * 2 / SPAN * 100}%">${i * 2}s</span>`).join('');
const sec = v => v.toFixed(2) + 's', tks = v => v >= 1000 ? (v / 1000).toFixed(1) + 'k' : String(Math.round(v));
let T = 0, sel = null, said = '';
S.forEach(x => {
const [kind, icon, col] = K[x.k];
x.el = html(`<button type="button" class="trv-r${x.lv ? '' : ' root'}" data-k="${x.k}" aria-expanded="false" style="--k:${col};--lv:${x.lv}" hidden><span class="trv-n"><span class="trv-g" aria-hidden="true"></span><span class="trv-k" data-tip="${kind}">${I(icon,'xs')}</span><span class="trv-nm">${x.n}</span></span><span class="trv-b"><i style="left:${x.s / SPAN * 100}%;width:${x.d / SPAN * 100}%"></i></span><span class="trv-tk num"></span><span class="trv-ms num"></span></button>`);
x.el.onclick = () => open(x === sel ? null : x);
rows.appendChild(x.el);
});
// where the run is: each span shows once it starts, its bar and its numbers grow while it runs
const took = x => Math.max(0, Math.min(T, x.s + x.d) - x.s);
const used = x => x.k === 'agent' ? x.kids.reduce((a, k) => a + (k.k === 'agent' ? 0 : used(k)), 0) : x.tk * Math.min(1, took(x) / x.d);
// the tree's guides for the spans on show: a line on down past a parent's mark, an elbow into each child, and the lines of
// ancestors whose own siblings are still to come
const vis = x => !x.el.hidden, later = x => S.some(y => y !== x && y.up === x.up && S.indexOf(y) > S.indexOf(x) && vis(y));
function tree(){
S.forEach(x => {
if (!vis(x)) return;
let h = '';
for (let a = x.up; a && a.up; a = a.up) if (later(a)) h += `<i style="--x:${a.lv - 1}"></i>`;
if (x.up) h += `<i class="el${later(x) ? '' : ' end'}" style="--x:${x.lv - 1}"></i>`;
if (S.some(y => y.up === x && vis(y))) h += `<i class="dn" style="--x:${x.lv}"></i>`;
$('.trv-g', x.el).innerHTML = h;
});
}
let had = 0, txtAt = -1e9;
function paint(){
let shown = 0;
// the times and token counts change ten times a second, not every frame: each change of text lays the page out
const now = performance.now(), txt = T >= END || now - txtAt >= 100; if (txt) txtAt = now;
S.forEach(x => {
if (T < x.s) return; shown++;
if (x.el.hidden){ x.el.hidden = false; enter(x.el); }
const w = took(x), run = T < x.s + x.d, kind = K[x.k][0];
x.el.classList.toggle('run', run);
const f = x.d ? w / x.d : 1; if (f !== x.f){ x.f = f; $('.trv-b i', x.el).style.setProperty('--f', f.toFixed(4)); }
const say = txt || (!run && !x.fin); if (!run) x.fin = true;
if (say){ const ms = sec(w), tk = tks(used(x)); if (x.ms !== ms){ x.ms = ms; $('.trv-ms', x.el).textContent = ms; } if (x.tk !== tk){ x.tk = tk; $('.trv-tk', x.el).textContent = tk; } }
if (x.k === 'tool' && w > SLOW && !x.slow){
x.slow = true; x.el.classList.add('slow');
const why = `Over the ${SLOW}s budget for a tool call`, tg = html(`<span class="tag warn" data-tip="${why}">slow</span>`); $('.trv-n', x.el).appendChild(tg); enter(tg);
$('.trv-ms', x.el).dataset.tip = why;
}
if (say){ const al = `${x.n}, ${kind}, ${run ? 'running, ' : ''}${sec(w)}, ${Math.round(used(x)).toLocaleString('en-US')} tokens${x.slow ? ', slow' : ''}`; if (x.al !== al){ x.al = al; x.el.setAttribute('aria-label', al); } }
});
if (shown !== had){ had = shown; tree();
KEYS.forEach(k => { const n = S.filter(x => x.k === k && vis(x)).length, el = $(`[data-k="${k}"]`, lg), b = $('b', el); el.classList.toggle('z', !n); if (b.textContent !== String(n)){ b.textContent = n; if (n) enter(b); } }); }
// the playhead runs down every track while the run streams, and fades once it is done
root.style.setProperty('--now', Math.min(T, END) / SPAN); root.classList.toggle('live', T > 0 && T < END);
if (txt) tot.textContent = sec(Math.min(T, END));
const slow = S.filter(x => x.slow).length;
const line = T >= END ? `Done · ${S.length} spans${slow ? `, ${slow} slow` : ''}` : `Running · ${shown} span${shown > 1 ? 's' : ''}`;
if (line !== said){ said = line; swap($('.m', stEl), line); }
if (sel) fill();
}
// a span's details: what went in, what came out, how long it took and what it used
function open(x){
if (sel) sel.el.setAttribute('aria-expanded', 'false');
sel = x; dt.classList.toggle('shut', !x); dt.inert = !x; rows.classList.toggle('sel', !!x);
if (!x) return;
x.el.setAttribute('aria-expanded', 'true');
const [kind, icon, col] = K[x.k];
dc.innerHTML = `<div class="trv-dh"><span class="trv-k" style="--k:${col}">${I(icon,'xs')}</span><b>${x.n}</b>${x.slow ? '<span class="tag warn">slow</span>' : ''}<button class="ib trv-x" aria-label="Close details" data-tip="Close">${I('x')}</button></div>
<div class="trv-pos" aria-hidden="true" style="--k:${col}"><i style="left:${x.s / SPAN * 100}%"></i></div>
<div class="trv-kv"><div><span>Kind</span><b>${kind}</b></div><div><span>Starts at</span><b class="num">${sec(x.s)}</b></div><div><span>Latency</span><b class="num lt"></b></div><div><span>Tokens</span><b class="num tk"></b></div></div>
<div class="trv-io"><span>Input</span><code>${x.in}</code><span>Output</span><code class="out"></code></div>`;
// closing hands focus back to the span, if it was inside the details
$('.trv-x', dc).onclick = () => { const el = sel.el, back = dc.contains(document.activeElement); open(null); if (back) el.focus({ preventScroll:true }); };
fill(); enter(dc);
}
function fill(){
const x = sel, done = T >= x.s + x.d;
$('.lt', dc).textContent = sec(took(x)); $('.tk', dc).textContent = Math.round(used(x)).toLocaleString('en-US'); $('.trv-pos i', dc).style.width = (took(x) / SPAN * 100) + '%';
const o = $('.out', dc), t = done ? x.out : 'Running…'; if (o.textContent !== t) o.textContent = t;
}
// the arrow keys move between spans, and Escape shuts the details
c.on(r, 'keydown', e => {
if (e.key === 'Escape' && sel){ const el = sel.el; open(null); el.focus({ preventScroll:true }); return; }
const vis = S.filter(x => !x.el.hidden), i = vis.findIndex(x => x.el === document.activeElement); if (i < 0) return;
const go = e.key === 'ArrowDown' ? vis[i + 1] : e.key === 'ArrowUp' ? vis[i - 1] : e.key === 'Home' ? vis[0] : e.key === 'End' ? vis[vis.length - 1] : undefined;
if (go === undefined && !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
e.preventDefault(); if (go) go.el.focus({ preventScroll:true });
});
stEl.innerHTML = '<span class="m">Running</span>';
// the run streams in at a pace a person can follow: one second of the run every PACE seconds
c.later(600, () => { const t0 = performance.now(); c.loop(t => { T = Math.min(END, (t - t0) / 1000 / PACE); paint(); return T < END ? undefined : false; }); });
// the preview's story: the fetch runs long and turns amber, the person reads why, opens it, then closes it
c.story(async s => {
const f = S.find(x => x.id === 'fetch');
await s.until(() => f.slow); await s.beat(2);
// on a narrow card the tag is hidden, and the time carries the same tooltip
const tg = $('.tag', f.el); await s.point(tg.offsetWidth ? tg : $('.trv-ms', f.el)); await s.beat(3); s.away();
await s.until(() => T >= END); await s.beat(1);
await s.tap(f.el); s.away(); await s.beat(8);
await s.tap($('.trv-x', r)); s.away(); await s.beat(2);
});
});
Questions
What people ask about it.
- Where do the spans come from?
- From your own tracing: OpenTelemetry, your model provider's logs or your agent framework's events. Map each to a name, kind, start, duration and tokens, nest them under their parent, and pass them in.
- Why are only tool calls flagged slow?
- In the demo a tool call that waits on another service is the usual culprit.
slowAftersets the budget in seconds; flagging other kinds would take a change to the component. - What scale does the time axis use?
- It covers the run's length, rounded up to a whole second, unless you set
axis. A faint line marks each tick and every other tick is labelled. - Can the details open on the side instead of below?
- Not as it is. They open under the waterfall, so the card stays narrow enough to sit beside a chat.
More in Agent status
Show what the agent is doing, how far along it is, and what it is working with.