Trace Inspector
SaveA trace's timeline and graph side by side with selection kept in sync, and details for the selected step: timing, attributes, input, output, and connections.
- Category
- Agent
- Version
- 0.2.0
- Frameworks
- Next.js, Vite + React
- Styling
- Tailwind CSS v4
Preview
Selected step: t-invoice
support_agent run
support_agent
runningRunning · Agent · +0 ms
plan
1.2 sSucceeded · Model call · +40 ms · calls from support_agent
search_docs
420 msSucceeded · Tool call · +1.2 s · calls from plan
get_account
180 msSucceeded · Tool call · +1.2 s · calls from plan
get_invoice_pdf
3.0 sFailed · Tool call · +1.3 s · calls from plan
regenerate_invoice
960 msSucceeded · Tool call · +4.3 s · follows from get_invoice_pdf
draft_reply
1.6 sSucceeded · Model call · +5.3 s · follows from search_docs, follows from get_account, follows from regenerate_invoice
Send the reply?
waitingNeeds approval · Approval · +7.0 s · follows from draft_reply
send_email
Queued · Tool call · +7.0 s · follows from Send the reply?
get_invoice_pdf
Renderer timed out after 3 s
- Kind
- Tool call
- Started
- +1.3 s
- Duration
- 3.0 s
Attributes
- retries
- 0
Input
{
"invoiceId": "inv_889"
}From
Leads to
Installation
npx @seamlessui/cli add trace-inspectorPre-release@seamlessui/cli is not on npm yet. Install from a local build.
Review the plan first. A dry run lists every file and package without changing anything:
npx @seamlessui/cli add trace-inspector --dry-runUsage
import { type TraceEdge, TraceInspector, type TraceStep } from "@/components/seamless/graph/trace-inspector";
const nodes: TraceStep[] = [
{ id: "run", name: "support_agent", kind: "agent", status: "succeeded", startMs: 0, durationMs: 4200 },
{ id: "plan", name: "plan", kind: "llm", status: "succeeded", startMs: 40, durationMs: 1100, attributes: { model: "example-model", "tokens.out": 164 } },
{ id: "search", name: "search_docs", kind: "tool", status: "succeeded", startMs: 1200, durationMs: 420, output: "2 articles" },
{ id: "invoice", name: "get_invoice_pdf", kind: "tool", status: "failed", startMs: 1210, durationMs: 1500, error: "Renderer timed out" },
{ id: "reply", name: "draft_reply", kind: "llm", status: "succeeded", startMs: 2800, durationMs: 1300 },
];
const edges: TraceEdge[] = [
{ id: "e1", source: "run", target: "plan", type: "calls" },
{ id: "e2", source: "plan", target: "search", type: "calls" },
{ id: "e3", source: "plan", target: "invoice", type: "calls" },
{ id: "e4", source: "search", target: "reply", type: "follows" },
{ id: "e5", source: "invoice", target: "reply", type: "follows" },
];
export default function TraceInspectorExample() {
return <TraceInspector title="Trace 7f3a" nodes={nodes} edges={edges} defaultSelectedId="invoice" height={320} />;
}API
| Prop | Type | Default | Description |
|---|---|---|---|
| nodes | { id, name, kind: "agent" | "llm" | "tool" | "approval" | "handoff" | "step", status, startMs, durationMs?, summary?, input?, output?, error?, attributes? }[] | — | Steps (spans) with stable ids. startMs is relative to the trace start; omit durationMs while running. |
| edges | { id, source, target, type: "calls" | "follows" | "handoff", label? }[] | — | How steps relate. Follows and hand-off edges are dashed. |
| selectedId / onSelect | string | null / (id: string | null) => void | — | Controlled selection shared by the timeline, the graph, and the details. Leave selectedId undefined for uncontrolled use. |
| defaultSelectedId | string | null | null | Initial selection when uncontrolled. |
| height | number | 440 | Height of the timeline and graph in pixels. |
| timelineOrder(steps) | => TraceStep[] | — | The list's order: by startMs, ties in input order. |
| direction | "TB" | "LR" | "TB" | Layout direction. Top to bottom suits deep, narrow runs in a side-by-side layout; left to right suits wide graphs such as the 100-node fixture. |
Accessibility
- The timeline is a keyboard listbox (one tab stop; arrow keys, Home, End) with each step's status, kind, start, duration, and parents in text.
- Selecting in the timeline selects and, if needed, pans to the step in the graph; clicking the graph selects and scrolls the timeline.
- Duration bars are decorative; the same timing is in text.
- Failures are shown as text in the details, not only red.
Files
Installed into src/components/seamless, or components/seamless when the project has no src directory.
src/components/seamless/graph/trace-inspector.tsxtrace-inspector- Also installs graph-canvas@0.2.0registry dependency
- Also installs agent-run@0.1.1registry dependency
- Also installs step-list@0.2.0registry dependency
- npm packages: noneReact and Tailwind CSS v4 required
Source
The exact source the CLI installs, from /r/items/react/trace-inspector/0.2.0.json.
graph/trace-inspector.tsxShowHide
"use client";
import { type ComponentProps, useMemo } from "react";
import { GraphCanvas, type GraphCanvasEdge, type GraphCanvasNode, useGraphLayout } from "./graph-canvas";
import { adjacency, GRAPH_STATUS_LABELS, type GraphDirection, type GraphStatus, layoutEdges } from "./graph-layout";
import {
cx,
DetailHeader,
DetailNote,
DetailProperties,
DetailSection,
DetailStack,
divider,
EdgeLegend,
EmptyDetails,
ErrorNote,
formatDuration,
GraphHeader,
GraphListbox,
GraphListRow,
graphSurfaceClass,
RelatedButton,
StatusCounts,
useControllableState,
} from "./graph-ui";
export type TraceStepKind = "agent" | "llm" | "tool" | "approval" | "handoff" | "step";
/** calls: the source started the target as a child; follows: the target ran after the source; handoff: work passed to another agent. */
export type TraceEdgeType = "calls" | "follows" | "handoff";
export interface TraceStep {
/** Stable id, for example the span id. */
id: string;
name: string;
kind: TraceStepKind;
status: GraphStatus;
/** Start time in milliseconds from the start of the trace. */
startMs: number;
/** Omit while the step is still running. */
durationMs?: number;
summary?: string;
input?: unknown;
/** Rendered as text, never as HTML. */
output?: string;
error?: string;
/** Extra key-value details such as model, tokens, or cache hits. */
attributes?: Record<string, string | number | boolean>;
}
export interface TraceEdge {
id: string;
source: string;
target: string;
type: TraceEdgeType;
label?: string;
}
export interface TraceInspectorProps extends Omit<ComponentProps<"section">, "children" | "onSelect" | "title"> {
nodes: readonly TraceStep[];
edges: readonly TraceEdge[];
title?: string;
/** Controlled selection. Pass `null` for none; leave undefined to let the component manage it. */
selectedId?: string | null;
defaultSelectedId?: string | null;
onSelect?: (id: string | null) => void;
/** Height of the timeline and graph in pixels. */
height?: number;
direction?: GraphDirection;
}
export const TRACE_KIND_LABELS: Record<TraceStepKind, string> = {
agent: "Agent",
llm: "Model call",
tool: "Tool call",
approval: "Approval",
handoff: "Hand-off",
step: "Step",
};
export const TRACE_EDGE_LABELS: Record<TraceEdgeType, string> = { calls: "Calls", follows: "Follows", handoff: "Hand-off" };
// Bars stay neutral for finished work; color marks only what needs a look.
const barColor: Record<GraphStatus, string> = {
queued: "bg-zinc-950/15 dark:bg-white/15",
running: "bg-sky-500",
"waiting-for-approval": "bg-amber-500",
succeeded: "bg-zinc-950/40 dark:bg-white/45",
failed: "bg-red-500",
skipped: "bg-zinc-950/15 dark:bg-white/15",
cancelled: "bg-zinc-950/15 dark:bg-white/15",
};
/** Steps in start order; ties keep their input order. This is the timeline the list shows. */
export function timelineOrder<T extends Pick<TraceStep, "startMs">>(steps: readonly T[]): T[] {
return steps
.map((step, index) => ({ step, index }))
.sort((a, b) => a.step.startMs - b.step.startMs || a.index - b.index)
.map(({ step }) => step);
}
/** A trace's timeline and graph side by side, with selection kept in sync, and details for the selected step. */
export function TraceInspector({
nodes,
edges,
title = "Trace",
selectedId,
defaultSelectedId = null,
onSelect,
height = 440,
direction = "TB",
className,
...props
}: TraceInspectorProps) {
const [selected, setSelected] = useControllableState<string | null>(selectedId, defaultSelectedId, onSelect);
const shownEdges = useMemo(() => layoutEdges(nodes, edges), [nodes, edges]);
const layout = useGraphLayout(nodes, shownEdges, { direction });
const byId = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
const links = useMemo(() => adjacency(shownEdges), [shownEdges]);
const timeline = useMemo(() => timelineOrder(nodes), [nodes]);
const total = useMemo(() => Math.max(1, ...nodes.map((node) => node.startMs + (node.durationMs ?? 0))), [nodes]);
const current = selected ? byId.get(selected) : undefined;
const name = (id: string) => byId.get(id)?.name ?? id;
const canvasNodes = useMemo<GraphCanvasNode[]>(
() =>
nodes.map((node) => ({
id: node.id,
title: node.name,
subtitle: node.durationMs != null ? `${TRACE_KIND_LABELS[node.kind]} · ${formatDuration(node.durationMs)}` : TRACE_KIND_LABELS[node.kind],
mono: node.kind === "tool",
status: node.status,
})),
[nodes],
);
const canvasEdges = useMemo<GraphCanvasEdge[]>(
() =>
shownEdges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
type: edge.type,
label: edge.label,
dashed: edge.type !== "calls",
animated: byId.get(edge.target)?.status === "running",
tone: selected && (edge.source === selected || edge.target === selected) ? "highlight" : "default",
})),
[shownEdges, byId, selected],
);
const select = (id: string) => setSelected(id);
// Rows do not depend on the selection, so reuse them: selecting re-renders only the option wrappers.
const rows = useMemo(() => {
const label = (id: string) => byId.get(id)?.name ?? id;
return new Map(
timeline.map((node) => {
const parents = links.incoming.get(node.id) ?? [];
return [
node.id,
<GraphListRow
status={node.status}
title={node.name}
mono={node.kind === "tool"}
muted={node.status === "queued" || node.status === "skipped" || node.status === "cancelled"}
struck={node.status === "skipped"}
trailing={node.durationMs != null ? formatDuration(node.durationMs) : node.status === "running" ? "running" : node.status === "waiting-for-approval" ? "waiting" : undefined}
meta={
<>
{GRAPH_STATUS_LABELS[node.status]} · {TRACE_KIND_LABELS[node.kind]} · +{formatDuration(node.startMs)}
{parents.length ? ` · ${parents.map((edge) => `${TRACE_EDGE_LABELS[edge.type].toLowerCase()} from ${label(edge.source)}`).join(", ")}` : ""}
</>
}
>
<div aria-hidden="true" className="relative mt-2 h-1 rounded-full bg-zinc-950/[0.06] dark:bg-white/[0.07]">
<div
className={cx("absolute inset-y-0 min-w-[3px] rounded-full", barColor[node.status])}
style={{
left: `${(node.startMs / total) * 100}%`,
width: `${Math.min(100 - (node.startMs / total) * 100, ((node.durationMs ?? total - node.startMs) / total) * 100)}%`,
}}
/>
</div>
</GraphListRow>,
] as const;
}),
);
}, [timeline, links, byId, total]);
const related = (label: string, list: { id: string; other: string; type: TraceEdgeType }[]) =>
list.length ? (
<DetailSection label={label}>
{list.map((item) => (
<RelatedButton
key={item.id}
label={name(item.other)}
mono={byId.get(item.other)?.kind === "tool"}
status={byId.get(item.other)?.status}
note={TRACE_EDGE_LABELS[item.type]}
onClick={() => select(item.other)}
/>
))}
</DetailSection>
) : null;
const timing = current
? [
{ label: "Kind", value: TRACE_KIND_LABELS[current.kind] },
{ label: "Started", value: `+${formatDuration(current.startMs)}`, mono: true },
{ label: "Duration", value: current.durationMs != null ? formatDuration(current.durationMs) : "Still running", mono: current.durationMs != null },
]
: [];
return (
<section aria-label={title} className={cx(graphSurfaceClass, className)} {...props}>
<GraphHeader
title={title}
meta={
<StatusCounts
lead={
<>
{nodes.length} {nodes.length === 1 ? "step" : "steps"} · <span className="font-mono tabular-nums">{formatDuration(total)}</span>
</>
}
statuses={nodes.map((node) => node.status)}
/>
}
/>
<div className="grid @2xl:grid-cols-[19rem_minmax(0,1fr)]">
<div style={{ maxHeight: height }} className={cx("min-w-0 overflow-y-auto border-b @2xl:border-r @2xl:border-b-0", divider)}>
<div
aria-hidden="true"
className={cx("sticky top-0 z-10 flex justify-between border-b bg-white py-2 pr-4 pl-[2.625rem] font-mono text-[10px] text-zinc-500 tabular-nums dark:bg-black", divider)}
>
<span>0</span>
<span>{formatDuration(total / 2)}</span>
<span>{formatDuration(total)}</span>
</div>
<GraphListbox
label={`${title} timeline`}
items={timeline}
getId={(node) => node.id}
selectedId={selected}
onSelect={select}
empty="No steps yet."
renderItem={(node) => rows.get(node.id)}
/>
</div>
<GraphCanvas
label={`${title} graph: ${nodes.length} steps. The timeline list beside it has the same steps and is keyboard accessible.`}
nodes={canvasNodes}
edges={canvasEdges}
layout={layout}
selectedNodeId={selected}
onNodeClick={select}
onPaneClick={() => setSelected(null)}
height={height}
/>
</div>
<div role="region" aria-label="Step details" className={cx("border-t", divider)}>
{current ? (
<div className="grid @2xl:grid-cols-2 @2xl:divide-x @2xl:divide-zinc-950/[0.07] @2xl:dark:divide-white/[0.08]">
<DetailStack>
<DetailHeader title={current.name} mono={current.kind === "tool"} status={current.status} meta={[TRACE_KIND_LABELS[current.kind]]} />
{current.summary || (current.status === "failed" && current.error) ? (
<div className="space-y-3">
{current.summary ? <p className="text-sm text-pretty text-zinc-600 dark:text-zinc-400">{current.summary}</p> : null}
{current.status === "failed" && current.error ? <ErrorNote>{current.error}</ErrorNote> : null}
</div>
) : null}
<div className="space-y-3.5">
<DetailProperties items={timing} />
{current.attributes && Object.keys(current.attributes).length ? (
<DetailSection label="Attributes">
<DetailProperties items={Object.entries(current.attributes).map(([key, value]) => ({ label: key, value: String(value), mono: true }))} />
</DetailSection>
) : null}
</div>
</DetailStack>
<DetailStack className={cx("border-t @2xl:border-t-0", divider)}>
{current.input !== undefined || current.output ? (
<div className="space-y-3.5">
{current.input !== undefined ? <DetailSection label="Input">{JSON.stringify(current.input, null, 2)}</DetailSection> : null}
{current.output ? <DetailSection label="Output">{current.output}</DetailSection> : null}
</div>
) : null}
<div className="space-y-3.5">
{related(
"From",
(links.incoming.get(current.id) ?? []).map((edge) => ({ id: edge.id, other: edge.source, type: edge.type })),
) ?? (
<DetailSection label="From">
<DetailNote>Nothing. This step starts the trace.</DetailNote>
</DetailSection>
)}
{related(
"Leads to",
(links.outgoing.get(current.id) ?? []).map((edge) => ({ id: edge.id, other: edge.target, type: edge.type })),
) ?? (
<DetailSection label="Leads to">
<DetailNote>Nothing comes after this step.</DetailNote>
</DetailSection>
)}
</div>
</DetailStack>
</div>
) : (
<EmptyDetails hint="In the timeline, arrow keys, Home, and End move the selection.">
Select a step in the timeline or the graph to see its timing, attributes, input, and output.
</EmptyDetails>
)}
</div>
<footer className={cx("border-t px-4 py-2.5", divider)}>
<EdgeLegend items={[{ label: "Calls" }, { label: "Follows or hand-off", dashed: true }]} />
</footer>
</section>
);
}