Agent Trace New
A run inspector with a collapsible span tree, a timing waterfall, and the input, output, tokens and cost of each span.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/ai-agent-trace.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/ai/AgentTrace.tsx
import { useEffect, useRef, useState } from 'react';
import { Bot, CheckCircle2, ChevronRight, Coins, Database, Loader2, RotateCcw, Sparkles, Timer, Wrench, XCircle } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useInView } from '@/components/niceui/pages/landing/shared/motion';
export type SpanKind = 'agent' | 'llm' | 'tool' | 'retrieval';
export type TraceSpan = {
id: string;
parent?: string;
kind: SpanKind;
name: string;
start: number;
end: number;
status?: 'ok' | 'error';
model?: string;
tokensIn?: number;
tokensOut?: number;
input: unknown;
output: unknown;
};
const KIND: Record<SpanKind, { icon: typeof Bot; tint: string; bar: string }> = {
agent: { icon: Bot, tint: 'text-brand-300 bg-brand-500/15', bar: 'bg-brand-400/70' },
llm: { icon: Sparkles, tint: 'text-violet-300 bg-violet-500/15', bar: 'bg-violet-400/70' },
tool: { icon: Wrench, tint: 'text-amber-300 bg-amber-500/15', bar: 'bg-amber-400/70' },
retrieval: { icon: Database, tint: 'text-teal-300 bg-teal-500/15', bar: 'bg-teal-400/70' },
};
export const DEMO_TRACE: TraceSpan[] = [
{ id: 'root', kind: 'agent', name: 'support-agent', start: 0, end: 6400, input: { ticket: 4821, message: 'My order never arrived, can I get a refund?' }, output: { resolved: true, refund: '$64.00' } },
{ id: 'plan', parent: 'root', kind: 'llm', name: 'plan', model: 'nova-pro', start: 80, end: 1220, tokensIn: 1240, tokensOut: 182, input: { system: 'You are a support agent…', tools: 4 }, output: { calls: ['search_orders', 'get_shipping_status'] } },
{ id: 'orders', parent: 'root', kind: 'tool', name: 'search_orders', start: 1280, end: 2020, input: { customer_id: 'cus_93f2' }, output: { orders: [{ id: 4821, total: 64, status: 'shipped' }] } },
{ id: 'ship', parent: 'root', kind: 'tool', name: 'get_shipping_status', start: 1300, end: 2380, input: { order_id: 4821 }, output: { carrier: 'UPS', status: 'lost_in_transit', days: 12 } },
{ id: 'reason', parent: 'root', kind: 'llm', name: 'decide', model: 'nova-pro', start: 2440, end: 3600, tokensIn: 2310, tokensOut: 240, input: { context: '2 tool results' }, output: { action: 'issue_refund', amount: 64 } },
{ id: 'kb', parent: 'reason', kind: 'retrieval', name: 'kb.search “refund policy”', start: 2600, end: 3040, input: { query: 'refund policy lost parcel', k: 4 }, output: { hits: 4, top: 'Lost parcels are refunded in full after 10 days.' } },
{ id: 'refund1', parent: 'root', kind: 'tool', name: 'issue_refund', status: 'error', start: 3680, end: 4120, input: { order_id: 4821, amount: 64 }, output: { error: 'Gateway timeout after 400 ms' } },
{ id: 'refund2', parent: 'root', kind: 'tool', name: 'issue_refund · retry', start: 4160, end: 4900, input: { order_id: 4821, amount: 64, idempotency_key: 'rf_4821' }, output: { refund_id: 're_7Hx2', status: 'succeeded' } },
{ id: 'reply', parent: 'root', kind: 'llm', name: 'respond', model: 'nova-mini', start: 4980, end: 6320, tokensIn: 1780, tokensOut: 156, input: { tone: 'warm, concise' }, output: { message: 'Sorry about that! I’ve refunded $64.00…' } },
];
const PRICE = { 'nova-pro': [3, 15], 'nova-mini': [1, 5] } as Record<string, [number, number]>;
const cost = (s: TraceSpan) => (s.model && PRICE[s.model] ? ((s.tokensIn ?? 0) * PRICE[s.model][0] + (s.tokensOut ?? 0) * PRICE[s.model][1]) / 1e6 : 0);
function useReducedMotion() {
const [reduce] = useState(() => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
return reduce;
}
function Json({ value }: { value: unknown }) {
const text = JSON.stringify(value, null, 2);
return (
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-ink-950/60 p-2.5 font-mono text-[11px] leading-relaxed text-ink-300 ring-1 ring-white/5">
{text.split(/("(?:[^"\\]|\\.)*"(?=:)?|\b\d+(?:\.\d+)?\b|true|false)/g).map((part, i) =>
/^".*"$/.test(part) ? (
<span key={i} className={text.includes(part + ':') ? 'text-brand-300' : 'text-emerald-300'}>
{part}
</span>
) : /^(\d|true|false)/.test(part) ? (
<span key={i} className="text-amber-300">
{part}
</span>
) : (
part
),
)}
</pre>
);
}
export type AgentTraceProps = {
spans?: TraceSpan[];
/** Replay the run live instead of showing it finished. */
live?: boolean;
className?: string;
};
/** A run inspector: span tree, waterfall timing, and per-span input, output, tokens and cost. */
export function AgentTrace({ spans = DEMO_TRACE, live = true, className }: AgentTraceProps) {
const reduce = useReducedMotion();
const total = Math.max(...spans.map((s) => s.end));
const { ref, inView } = useInView<HTMLDivElement>(0.2);
const [t, setT] = useState(live && !reduce ? 0 : total);
const [run, setRun] = useState(0);
const [closed, setClosed] = useState<Set<string>>(new Set());
const [sel, setSel] = useState('plan');
useEffect(() => {
if (!live || reduce || !inView) return;
const start = performance.now();
let raf = 0;
const tick = () => {
const now = performance.now() - start;
setT(Math.min(total, now));
if (now < total) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [live, reduce, inView, run, total]);
const childrenOf = (id?: string) => spans.filter((s) => s.parent === id);
const depth = (s: TraceSpan): number => (s.parent ? 1 + depth(spans.find((p) => p.id === s.parent)!) : 0);
const visible: TraceSpan[] = [];
const walk = (id?: string) => {
for (const s of childrenOf(id)) {
if (s.start > t) continue;
visible.push(s);
if (!closed.has(s.id)) walk(s.id);
}
};
walk(undefined);
const started = spans.filter((s) => s.start <= t);
const tokens = started.reduce((a, s) => a + (s.end <= t ? (s.tokensIn ?? 0) + (s.tokensOut ?? 0) : 0), 0);
const usd = started.reduce((a, s) => a + (s.end <= t ? cost(s) : 0), 0);
const done = t >= total;
const selected = spans.find((s) => s.id === sel) ?? spans[0];
const selDone = selected.end <= t;
const ticks = [0, 0.25, 0.5, 0.75, 1];
return (
<div ref={ref} className={cn('w-full overflow-hidden rounded-2xl bg-ink-900 text-left shadow-card', className)}>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-white/5 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<span className="relative flex h-2 w-2">
{!done && <span className="absolute inset-0 animate-pulse-ring rounded-full bg-brand-400" />}
<span className={cn('relative h-2 w-2 rounded-full', done ? 'bg-emerald-400' : 'bg-brand-400')} />
</span>
<span className="text-sm font-medium text-ink-100">Trace</span>
<span className="truncate font-mono text-[11px] text-ink-500">run_8f3a2c</span>
</div>
<div className="flex items-center gap-3 font-mono text-[11px] tabular-nums text-ink-400">
<span className="flex items-center gap-1">
<Timer className="h-3 w-3 text-ink-500" />
{(t / 1000).toFixed(2)}s
</span>
<span>{tokens.toLocaleString()} tok</span>
<span className="flex items-center gap-1">
<Coins className="h-3 w-3 text-ink-500" />${usd.toFixed(4)}
</span>
</div>
<button
onClick={() => {
setT(0);
setRun((r) => r + 1);
}}
disabled={!done}
aria-label="Replay the run"
className="focus-ring ml-auto flex h-7 items-center gap-1.5 rounded-md px-2 text-xs text-ink-400 transition hover:bg-white/5 hover:text-ink-100 disabled:opacity-30"
>
<RotateCcw className="h-3.5 w-3.5" /> Replay
</button>
</div>
<div className="grid md:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)]">
<div className="min-w-0 border-white/5 md:border-r">
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,42%)] items-end border-b border-white/5 px-3 py-1.5 font-mono text-[10px] text-ink-600">
<span>Span</span>
<span className="relative h-3">
{ticks.map((k) => (
<span key={k} className={cn('absolute -translate-x-1/2 first:translate-x-0 last:-translate-x-full', (k === 0.25 || k === 0.75) && 'hidden sm:inline')} style={{ left: `${k * 100}%` }}>
{((total * k) / 1000).toFixed(1)}s
</span>
))}
</span>
</div>
<ul className="py-1">
{visible.map((s) => {
const K = KIND[s.kind];
const running = s.end > t;
const kids = childrenOf(s.id).length > 0;
const w = ((Math.min(t, s.end) - s.start) / total) * 100;
return (
<li key={s.id} className="animate-fade-in">
<div
role="button"
tabIndex={0}
onClick={() => setSel(s.id)}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), setSel(s.id))}
aria-pressed={sel === s.id}
className={cn('focus-ring grid cursor-pointer grid-cols-[minmax(0,1fr)_minmax(0,42%)] items-center gap-2 px-3 py-1.5 transition-colors', sel === s.id ? 'bg-brand-500/[0.08]' : 'hover:bg-white/[0.03]')}
>
<span className="flex min-w-0 items-center gap-1.5" style={{ paddingLeft: depth(s) * 14 }}>
{kids ? (
<button
onClick={(e) => {
e.stopPropagation();
setClosed((c) => {
const n = new Set(c);
if (n.has(s.id)) n.delete(s.id);
else n.add(s.id);
return n;
});
}}
aria-label={closed.has(s.id) ? `Expand ${s.name}` : `Collapse ${s.name}`}
className="grid h-4 w-4 shrink-0 place-items-center rounded text-ink-500 hover:bg-white/5 hover:text-ink-200"
>
<ChevronRight className={cn('h-3 w-3 transition-transform duration-200', !closed.has(s.id) && 'rotate-90')} />
</button>
) : (
<span className="w-4 shrink-0" />
)}
<span className={cn('grid h-5 w-5 shrink-0 place-items-center rounded-md', K.tint)}>
<K.icon className="h-3 w-3" />
</span>
<span className={cn('min-w-0 truncate font-mono text-[11.5px]', running ? 'shimmer-text' : 'text-ink-200')}>{s.name}</span>
{running ? <Loader2 className="h-3 w-3 shrink-0 animate-spin text-brand-300" /> : s.status === 'error' ? <XCircle className="h-3 w-3 shrink-0 text-rose-400" /> : null}
</span>
<span className="relative h-4">
<span
className={cn('absolute top-1/2 h-2 -translate-y-1/2 rounded-full', s.status === 'error' && !running ? 'bg-rose-400/80' : K.bar, running && 'animate-pulse')}
style={{ left: `${(s.start / total) * 100}%`, width: `max(${w}%, 3px)` }}
/>
{!running && (
<span className="absolute top-1/2 -translate-y-1/2 pl-1 font-mono text-[9.5px] text-ink-500" style={{ left: `min(${((s.end / total) * 100).toFixed(1)}%, calc(100% - 34px))` }}>
{((s.end - s.start) / 1000).toFixed(2)}s
</span>
)}
</span>
</div>
</li>
);
})}
</ul>
</div>
<div key={selected.id} className="min-w-0 space-y-3 border-t border-white/5 p-4 animate-fade-in md:border-t-0">
<div className="flex items-center gap-2">
<span className={cn('grid h-6 w-6 place-items-center rounded-md', KIND[selected.kind].tint)}>
{(() => {
const I = KIND[selected.kind].icon;
return <I className="h-3.5 w-3.5" />;
})()}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[12.5px] text-ink-50">{selected.name}</span>
{!selDone ? (
<span className="flex items-center gap-1 text-[11px] text-brand-300">
<Loader2 className="h-3 w-3 animate-spin" /> running
</span>
) : selected.status === 'error' ? (
<span className="flex items-center gap-1 text-[11px] text-rose-300">
<XCircle className="h-3 w-3" /> error
</span>
) : (
<span className="flex items-center gap-1 text-[11px] text-emerald-300">
<CheckCircle2 className="h-3 w-3" /> ok
</span>
)}
</div>
<dl className="grid grid-cols-3 gap-2">
{[
['Latency', selDone ? `${(selected.end - selected.start).toLocaleString()} ms` : '…'],
['Tokens', selected.tokensIn ? `${selected.tokensIn.toLocaleString()} / ${selected.tokensOut}` : '—'],
['Cost', cost(selected) ? `$${cost(selected).toFixed(4)}` : '—'],
].map(([k, v]) => (
<div key={k} className="rounded-lg bg-white/[0.03] px-2.5 py-2 ring-1 ring-white/5">
<dt className="text-[10px] uppercase tracking-wider text-ink-600">{k}</dt>
<dd className="mt-0.5 truncate font-mono text-[11.5px] tabular-nums text-ink-100">{v}</dd>
</div>
))}
</dl>
{selected.model && (
<p className="font-mono text-[10.5px] text-ink-500">
model <span className="text-ink-300">{selected.model}</span>
</p>
)}
<div>
<p className="mb-1.5 text-[10.5px] font-medium uppercase tracking-wider text-ink-500">Input</p>
<Json value={selected.input} />
</div>
<div>
<p className="mb-1.5 text-[10.5px] font-medium uppercase tracking-wider text-ink-500">Output</p>
{selDone ? <Json value={selected.output} /> : <div className="h-10 rounded-lg bg-white/[0.03] ring-1 ring-white/5 shimmer-text" />}
</div>
</div>
</div>
</div>
);
}
export function AgentTraceDemo() {
return <AgentTrace />;
}components/niceui/pages/landing/shared/motion.tsx
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/cn';
export function useInView<T extends Element>(threshold = 0.2) {
const ref = useRef<T>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
io.disconnect();
}
},
{ threshold },
);
io.observe(el);
return () => io.disconnect();
}, [threshold]);
return { ref, inView };
}
export function Reveal({ children, delay = 0, className }: { children: ReactNode; delay?: number; className?: string }) {
const { ref, inView } = useInView<HTMLDivElement>(0.15);
return (
<div
ref={ref}
className={cn('transition-all duration-1000 ease-smooth', inView ? 'translate-y-0 opacity-100 blur-0' : 'translate-y-6 opacity-0 blur-[2px]', className)}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}
export function useCountUp(target: number, active: boolean, duration = 1800) {
const [val, setVal] = useState(0);
useEffect(() => {
if (!active) return;
let raf = 0;
const start = performance.now();
const loop = (now: number) => {
const t = Math.min(1, (now - start) / duration);
setVal(target * (1 - Math.pow(2, -10 * t)));
if (t < 1) raf = requestAnimationFrame(loop);
else setVal(target);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [target, active, duration]);
return val;
}