{
  "schemaVersion": 1,
  "target": "react",
  "name": "trace-inspector",
  "version": "0.2.0",
  "category": "agent",
  "title": "Trace Inspector",
  "description": "A 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.",
  "tags": [
    "agent",
    "trace",
    "observability",
    "timeline",
    "graph",
    "inspector"
  ],
  "requires": {
    "projectTypes": [
      "next",
      "vite-react"
    ],
    "styling": "tailwind-v4"
  },
  "npmDependencies": {},
  "registryDependencies": [
    {
      "name": "graph-canvas",
      "version": "0.2.0"
    },
    {
      "name": "agent-run",
      "version": "0.1.1"
    },
    {
      "name": "step-list",
      "version": "0.2.0"
    }
  ],
  "docsPath": "/components/trace-inspector",
  "props": [
    {
      "name": "nodes",
      "type": "{ id, name, kind: \"agent\" | \"llm\" | \"tool\" | \"approval\" | \"handoff\" | \"step\", status, startMs, durationMs?, summary?, input?, output?, error?, attributes? }[]",
      "description": "Steps (spans) with stable ids. startMs is relative to the trace start; omit durationMs while running."
    },
    {
      "name": "edges",
      "type": "{ id, source, target, type: \"calls\" | \"follows\" | \"handoff\", label? }[]",
      "description": "How steps relate. Follows and hand-off edges are dashed."
    },
    {
      "name": "selectedId / onSelect",
      "type": "string | null / (id: string | null) => void",
      "description": "Controlled selection shared by the timeline, the graph, and the details. Leave selectedId undefined for uncontrolled use."
    },
    {
      "name": "defaultSelectedId",
      "type": "string | null",
      "default": "null",
      "description": "Initial selection when uncontrolled."
    },
    {
      "name": "height",
      "type": "number",
      "default": "440",
      "description": "Height of the timeline and graph in pixels."
    },
    {
      "name": "timelineOrder(steps)",
      "type": "=> TraceStep[]",
      "description": "The list's order: by startMs, ties in input order."
    },
    {
      "name": "direction",
      "type": "\"TB\" | \"LR\"",
      "default": "\"TB\"",
      "description": "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."
  ],
  "states": [
    "queued",
    "running",
    "waiting-for-approval",
    "succeeded",
    "failed",
    "skipped",
    "cancelled"
  ],
  "files": [
    {
      "path": "graph/trace-inspector.tsx",
      "content": "\"use client\";\n\nimport { type ComponentProps, useMemo } from \"react\";\nimport { GraphCanvas, type GraphCanvasEdge, type GraphCanvasNode, useGraphLayout } from \"./graph-canvas\";\nimport { adjacency, GRAPH_STATUS_LABELS, type GraphDirection, type GraphStatus, layoutEdges } from \"./graph-layout\";\nimport {\n  cx,\n  DetailHeader,\n  DetailNote,\n  DetailProperties,\n  DetailSection,\n  DetailStack,\n  divider,\n  EdgeLegend,\n  EmptyDetails,\n  ErrorNote,\n  formatDuration,\n  GraphHeader,\n  GraphListbox,\n  GraphListRow,\n  graphSurfaceClass,\n  RelatedButton,\n  StatusCounts,\n  useControllableState,\n} from \"./graph-ui\";\n\nexport type TraceStepKind = \"agent\" | \"llm\" | \"tool\" | \"approval\" | \"handoff\" | \"step\";\n/** calls: the source started the target as a child; follows: the target ran after the source; handoff: work passed to another agent. */\nexport type TraceEdgeType = \"calls\" | \"follows\" | \"handoff\";\n\nexport interface TraceStep {\n  /** Stable id, for example the span id. */\n  id: string;\n  name: string;\n  kind: TraceStepKind;\n  status: GraphStatus;\n  /** Start time in milliseconds from the start of the trace. */\n  startMs: number;\n  /** Omit while the step is still running. */\n  durationMs?: number;\n  summary?: string;\n  input?: unknown;\n  /** Rendered as text, never as HTML. */\n  output?: string;\n  error?: string;\n  /** Extra key-value details such as model, tokens, or cache hits. */\n  attributes?: Record<string, string | number | boolean>;\n}\n\nexport interface TraceEdge {\n  id: string;\n  source: string;\n  target: string;\n  type: TraceEdgeType;\n  label?: string;\n}\n\nexport interface TraceInspectorProps extends Omit<ComponentProps<\"section\">, \"children\" | \"onSelect\" | \"title\"> {\n  nodes: readonly TraceStep[];\n  edges: readonly TraceEdge[];\n  title?: string;\n  /** Controlled selection. Pass `null` for none; leave undefined to let the component manage it. */\n  selectedId?: string | null;\n  defaultSelectedId?: string | null;\n  onSelect?: (id: string | null) => void;\n  /** Height of the timeline and graph in pixels. */\n  height?: number;\n  direction?: GraphDirection;\n}\n\nexport const TRACE_KIND_LABELS: Record<TraceStepKind, string> = {\n  agent: \"Agent\",\n  llm: \"Model call\",\n  tool: \"Tool call\",\n  approval: \"Approval\",\n  handoff: \"Hand-off\",\n  step: \"Step\",\n};\n\nexport const TRACE_EDGE_LABELS: Record<TraceEdgeType, string> = { calls: \"Calls\", follows: \"Follows\", handoff: \"Hand-off\" };\n\n// Bars stay neutral for finished work; color marks only what needs a look.\nconst barColor: Record<GraphStatus, string> = {\n  queued: \"bg-zinc-950/15 dark:bg-white/15\",\n  running: \"bg-sky-500\",\n  \"waiting-for-approval\": \"bg-amber-500\",\n  succeeded: \"bg-zinc-950/40 dark:bg-white/45\",\n  failed: \"bg-red-500\",\n  skipped: \"bg-zinc-950/15 dark:bg-white/15\",\n  cancelled: \"bg-zinc-950/15 dark:bg-white/15\",\n};\n\n/** Steps in start order; ties keep their input order. This is the timeline the list shows. */\nexport function timelineOrder<T extends Pick<TraceStep, \"startMs\">>(steps: readonly T[]): T[] {\n  return steps\n    .map((step, index) => ({ step, index }))\n    .sort((a, b) => a.step.startMs - b.step.startMs || a.index - b.index)\n    .map(({ step }) => step);\n}\n\n/** A trace's timeline and graph side by side, with selection kept in sync, and details for the selected step. */\nexport function TraceInspector({\n  nodes,\n  edges,\n  title = \"Trace\",\n  selectedId,\n  defaultSelectedId = null,\n  onSelect,\n  height = 440,\n  direction = \"TB\",\n  className,\n  ...props\n}: TraceInspectorProps) {\n  const [selected, setSelected] = useControllableState<string | null>(selectedId, defaultSelectedId, onSelect);\n  const shownEdges = useMemo(() => layoutEdges(nodes, edges), [nodes, edges]);\n  const layout = useGraphLayout(nodes, shownEdges, { direction });\n  const byId = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);\n  const links = useMemo(() => adjacency(shownEdges), [shownEdges]);\n  const timeline = useMemo(() => timelineOrder(nodes), [nodes]);\n  const total = useMemo(() => Math.max(1, ...nodes.map((node) => node.startMs + (node.durationMs ?? 0))), [nodes]);\n  const current = selected ? byId.get(selected) : undefined;\n  const name = (id: string) => byId.get(id)?.name ?? id;\n\n  const canvasNodes = useMemo<GraphCanvasNode[]>(\n    () =>\n      nodes.map((node) => ({\n        id: node.id,\n        title: node.name,\n        subtitle: node.durationMs != null ? `${TRACE_KIND_LABELS[node.kind]} · ${formatDuration(node.durationMs)}` : TRACE_KIND_LABELS[node.kind],\n        mono: node.kind === \"tool\",\n        status: node.status,\n      })),\n    [nodes],\n  );\n  const canvasEdges = useMemo<GraphCanvasEdge[]>(\n    () =>\n      shownEdges.map((edge) => ({\n        id: edge.id,\n        source: edge.source,\n        target: edge.target,\n        type: edge.type,\n        label: edge.label,\n        dashed: edge.type !== \"calls\",\n        animated: byId.get(edge.target)?.status === \"running\",\n        tone: selected && (edge.source === selected || edge.target === selected) ? \"highlight\" : \"default\",\n      })),\n    [shownEdges, byId, selected],\n  );\n\n  const select = (id: string) => setSelected(id);\n\n  // Rows do not depend on the selection, so reuse them: selecting re-renders only the option wrappers.\n  const rows = useMemo(() => {\n    const label = (id: string) => byId.get(id)?.name ?? id;\n    return new Map(\n      timeline.map((node) => {\n        const parents = links.incoming.get(node.id) ?? [];\n        return [\n          node.id,\n          <GraphListRow\n            status={node.status}\n            title={node.name}\n            mono={node.kind === \"tool\"}\n            muted={node.status === \"queued\" || node.status === \"skipped\" || node.status === \"cancelled\"}\n            struck={node.status === \"skipped\"}\n            trailing={node.durationMs != null ? formatDuration(node.durationMs) : node.status === \"running\" ? \"running\" : node.status === \"waiting-for-approval\" ? \"waiting\" : undefined}\n            meta={\n              <>\n                {GRAPH_STATUS_LABELS[node.status]} · {TRACE_KIND_LABELS[node.kind]} · +{formatDuration(node.startMs)}\n                {parents.length ? ` · ${parents.map((edge) => `${TRACE_EDGE_LABELS[edge.type].toLowerCase()} from ${label(edge.source)}`).join(\", \")}` : \"\"}\n              </>\n            }\n          >\n            <div aria-hidden=\"true\" className=\"relative mt-2 h-1 rounded-full bg-zinc-950/[0.06] dark:bg-white/[0.07]\">\n              <div\n                className={cx(\"absolute inset-y-0 min-w-[3px] rounded-full\", barColor[node.status])}\n                style={{\n                  left: `${(node.startMs / total) * 100}%`,\n                  width: `${Math.min(100 - (node.startMs / total) * 100, ((node.durationMs ?? total - node.startMs) / total) * 100)}%`,\n                }}\n              />\n            </div>\n          </GraphListRow>,\n        ] as const;\n      }),\n    );\n  }, [timeline, links, byId, total]);\n\n  const related = (label: string, list: { id: string; other: string; type: TraceEdgeType }[]) =>\n    list.length ? (\n      <DetailSection label={label}>\n        {list.map((item) => (\n          <RelatedButton\n            key={item.id}\n            label={name(item.other)}\n            mono={byId.get(item.other)?.kind === \"tool\"}\n            status={byId.get(item.other)?.status}\n            note={TRACE_EDGE_LABELS[item.type]}\n            onClick={() => select(item.other)}\n          />\n        ))}\n      </DetailSection>\n    ) : null;\n\n  const timing = current\n    ? [\n        { label: \"Kind\", value: TRACE_KIND_LABELS[current.kind] },\n        { label: \"Started\", value: `+${formatDuration(current.startMs)}`, mono: true },\n        { label: \"Duration\", value: current.durationMs != null ? formatDuration(current.durationMs) : \"Still running\", mono: current.durationMs != null },\n      ]\n    : [];\n\n  return (\n    <section aria-label={title} className={cx(graphSurfaceClass, className)} {...props}>\n      <GraphHeader\n        title={title}\n        meta={\n          <StatusCounts\n            lead={\n              <>\n                {nodes.length} {nodes.length === 1 ? \"step\" : \"steps\"} · <span className=\"font-mono tabular-nums\">{formatDuration(total)}</span>\n              </>\n            }\n            statuses={nodes.map((node) => node.status)}\n          />\n        }\n      />\n      <div className=\"grid @2xl:grid-cols-[19rem_minmax(0,1fr)]\">\n        <div style={{ maxHeight: height }} className={cx(\"min-w-0 overflow-y-auto border-b @2xl:border-r @2xl:border-b-0\", divider)}>\n          <div\n            aria-hidden=\"true\"\n            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)}\n          >\n            <span>0</span>\n            <span>{formatDuration(total / 2)}</span>\n            <span>{formatDuration(total)}</span>\n          </div>\n          <GraphListbox\n            label={`${title} timeline`}\n            items={timeline}\n            getId={(node) => node.id}\n            selectedId={selected}\n            onSelect={select}\n            empty=\"No steps yet.\"\n            renderItem={(node) => rows.get(node.id)}\n          />\n        </div>\n        <GraphCanvas\n          label={`${title} graph: ${nodes.length} steps. The timeline list beside it has the same steps and is keyboard accessible.`}\n          nodes={canvasNodes}\n          edges={canvasEdges}\n          layout={layout}\n          selectedNodeId={selected}\n          onNodeClick={select}\n          onPaneClick={() => setSelected(null)}\n          height={height}\n        />\n      </div>\n      <div role=\"region\" aria-label=\"Step details\" className={cx(\"border-t\", divider)}>\n        {current ? (\n          <div className=\"grid @2xl:grid-cols-2 @2xl:divide-x @2xl:divide-zinc-950/[0.07] @2xl:dark:divide-white/[0.08]\">\n            <DetailStack>\n              <DetailHeader title={current.name} mono={current.kind === \"tool\"} status={current.status} meta={[TRACE_KIND_LABELS[current.kind]]} />\n              {current.summary || (current.status === \"failed\" && current.error) ? (\n                <div className=\"space-y-3\">\n                  {current.summary ? <p className=\"text-sm text-pretty text-zinc-600 dark:text-zinc-400\">{current.summary}</p> : null}\n                  {current.status === \"failed\" && current.error ? <ErrorNote>{current.error}</ErrorNote> : null}\n                </div>\n              ) : null}\n              <div className=\"space-y-3.5\">\n                <DetailProperties items={timing} />\n                {current.attributes && Object.keys(current.attributes).length ? (\n                  <DetailSection label=\"Attributes\">\n                    <DetailProperties items={Object.entries(current.attributes).map(([key, value]) => ({ label: key, value: String(value), mono: true }))} />\n                  </DetailSection>\n                ) : null}\n              </div>\n            </DetailStack>\n            <DetailStack className={cx(\"border-t @2xl:border-t-0\", divider)}>\n              {current.input !== undefined || current.output ? (\n                <div className=\"space-y-3.5\">\n                  {current.input !== undefined ? <DetailSection label=\"Input\">{JSON.stringify(current.input, null, 2)}</DetailSection> : null}\n                  {current.output ? <DetailSection label=\"Output\">{current.output}</DetailSection> : null}\n                </div>\n              ) : null}\n              <div className=\"space-y-3.5\">\n                {related(\n                  \"From\",\n                  (links.incoming.get(current.id) ?? []).map((edge) => ({ id: edge.id, other: edge.source, type: edge.type })),\n                ) ?? (\n                  <DetailSection label=\"From\">\n                    <DetailNote>Nothing. This step starts the trace.</DetailNote>\n                  </DetailSection>\n                )}\n                {related(\n                  \"Leads to\",\n                  (links.outgoing.get(current.id) ?? []).map((edge) => ({ id: edge.id, other: edge.target, type: edge.type })),\n                ) ?? (\n                  <DetailSection label=\"Leads to\">\n                    <DetailNote>Nothing comes after this step.</DetailNote>\n                  </DetailSection>\n                )}\n              </div>\n            </DetailStack>\n          </div>\n        ) : (\n          <EmptyDetails hint=\"In the timeline, arrow keys, Home, and End move the selection.\">\n            Select a step in the timeline or the graph to see its timing, attributes, input, and output.\n          </EmptyDetails>\n        )}\n      </div>\n      <footer className={cx(\"border-t px-4 py-2.5\", divider)}>\n        <EdgeLegend items={[{ label: \"Calls\" }, { label: \"Follows or hand-off\", dashed: true }]} />\n      </footer>\n    </section>\n  );\n}\n"
    }
  ],
  "example": {
    "content": "import { type TraceEdge, TraceInspector, type TraceStep } from \"@/components/seamless/graph/trace-inspector\";\n\nconst nodes: TraceStep[] = [\n  { id: \"run\", name: \"support_agent\", kind: \"agent\", status: \"succeeded\", startMs: 0, durationMs: 4200 },\n  { id: \"plan\", name: \"plan\", kind: \"llm\", status: \"succeeded\", startMs: 40, durationMs: 1100, attributes: { model: \"example-model\", \"tokens.out\": 164 } },\n  { id: \"search\", name: \"search_docs\", kind: \"tool\", status: \"succeeded\", startMs: 1200, durationMs: 420, output: \"2 articles\" },\n  { id: \"invoice\", name: \"get_invoice_pdf\", kind: \"tool\", status: \"failed\", startMs: 1210, durationMs: 1500, error: \"Renderer timed out\" },\n  { id: \"reply\", name: \"draft_reply\", kind: \"llm\", status: \"succeeded\", startMs: 2800, durationMs: 1300 },\n];\n\nconst edges: TraceEdge[] = [\n  { id: \"e1\", source: \"run\", target: \"plan\", type: \"calls\" },\n  { id: \"e2\", source: \"plan\", target: \"search\", type: \"calls\" },\n  { id: \"e3\", source: \"plan\", target: \"invoice\", type: \"calls\" },\n  { id: \"e4\", source: \"search\", target: \"reply\", type: \"follows\" },\n  { id: \"e5\", source: \"invoice\", target: \"reply\", type: \"follows\" },\n];\n\nexport default function TraceInspectorExample() {\n  return <TraceInspector title=\"Trace 7f3a\" nodes={nodes} edges={edges} defaultSelectedId=\"invoice\" height={320} />;\n}\n"
  }
}
