Run Graph
SaveA run's plan, parallel tool calls, approvals, and results as a graph, with zoom, pan, fit-to-view, a details panel, and a keyboard list view with the same information. Budget at 100 nodes and 150 edges: first render under 150 ms and selection under 50 ms on a current laptop; under 500 ms and 200 ms at 4x CPU slowdown.
- Category
- Agent
- Version
- 0.2.0
- Frameworks
- Next.js, Vite + React
- Styling
- Tailwind CSS v4
Preview
Run Graph preview
Selected step: fetch
Update the rate-limit docs
11 steps1needs approval1failed5succeeded3queued1skipped
Installation
npx @seamlessui/cli add run-graphPre-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 run-graph --dry-runUsage
run-graph-example.tsx
import { RunGraph, type RunGraphEdge, type RunGraphNode } from "@/components/seamless/graph/run-graph";
const nodes: RunGraphNode[] = [
{ id: "plan", label: "Plan the update", kind: "plan", status: "succeeded" },
{ id: "search", label: "search_docs", kind: "tool", status: "succeeded", input: { query: "rate limits" }, output: "Found 3 pages", durationMs: 640 },
{ id: "fetch", label: "fetch_page", kind: "tool", status: "failed", error: "504 Gateway Timeout", durationMs: 10000 },
{ id: "draft", label: "Draft the edits", kind: "step", status: "succeeded" },
{ id: "approve", label: "Apply edits to 3 files?", kind: "approval", status: "waiting-for-approval" },
{ id: "pr", label: "open_pull_request", kind: "tool", status: "queued" },
];
const edges: RunGraphEdge[] = [
{ id: "e1", source: "plan", target: "search", type: "parallel" },
{ id: "e2", source: "plan", target: "fetch", type: "parallel" },
{ id: "e3", source: "search", target: "draft", type: "result" },
{ id: "e4", source: "fetch", target: "draft", type: "result" },
{ id: "e5", source: "draft", target: "approve", type: "approval" },
{ id: "e6", source: "approve", target: "pr", type: "sequence" },
];
export default function RunGraphExample() {
// Pass selectedId and onSelect to control the selection from your own state.
return <RunGraph title="Update the rate-limit docs" nodes={nodes} edges={edges} defaultSelectedId="fetch" height={360} />;
}API
| Prop | Type | Default | Description |
|---|---|---|---|
| nodes | { id, label, kind: "plan" | "step" | "tool" | "approval" | "result", status, summary?, input?, output?, error?, durationMs? }[] | — | Steps with stable ids. Changing a status never moves a node; adding or removing nodes re-runs the layout. |
| edges | { id, source, target, type: "sequence" | "parallel" | "approval" | "result", label? }[] | — | Explicitly typed connections. Approval and result edges are dashed. |
| selectedId / onSelect | string | null / (id: string | null) => void | — | Controlled selection shared by the graph, the list, and the details panel. Leave selectedId undefined for uncontrolled use. |
| defaultSelectedId | string | null | null | Initial selection when uncontrolled. |
| defaultView | "graph" | "list" | "graph" | Which view shows first. Both share selection. |
| height | number | 440 | Height of the graph and list in pixels. React Flow needs a sized container. |
| direction | "LR" | "TB" | "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. |
| title | string | "Run graph" | Heading and accessible name. |
Accessibility
- The list view has the same steps as the graph, with status, kind, and connections in text. It is a listbox with one tab stop: arrow keys, Home, and End move the selection, and the graph follows.
- Statuses are shown with an icon and text on every node and list row, never color alone. The selected step has a solid foreground border and ring; its connections are drawn brighter.
- Zoom in, zoom out, and fit-to-view are labelled buttons; with reduced motion the viewport jumps instead of animating and edge dashes never move. Edges into a running step are drawn in the running color instead of animating.
- Related steps in the details panel are buttons, so you can walk the graph from the keyboard.
- Performance budget, measured in Chrome on the 100-node, 150-edge fixture: first render of the graph within 150 ms (500 ms at 4x CPU slowdown), click or arrow key to updated details within 50 ms (200 ms at 4x), and no node movement on status-only updates.
Files
Installed into src/components/seamless, or components/seamless when the project has no src directory.
src/components/seamless/graph/run-graph.tsxrun-graph- 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/run-graph/0.2.0.json.
graph/run-graph.tsxShowHide
graph/run-graph.tsx
"use client";
import { type ComponentProps, useMemo, useState } 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 {
DetailHeader,
DetailNote,
DetailSection,
DetailStack,
EdgeLegend,
EmptyDetails,
ErrorNote,
formatDuration,
GraphListbox,
GraphListRow,
GraphShell,
type GraphView,
GraphViewSwitch,
nameList,
RelatedButton,
StatusCounts,
useControllableState,
} from "./graph-ui";
export type RunGraphNodeKind = "plan" | "step" | "tool" | "approval" | "result";
/** sequence: runs after; parallel: one of several calls started together; approval: gated by a decision; result: output passed on. */
export type RunGraphEdgeType = "sequence" | "parallel" | "approval" | "result";
export interface RunGraphNode {
/** Stable id. Keep it the same across updates so the node keeps its place. */
id: string;
/** Tool name for tool calls, or a short label. */
label: string;
kind: RunGraphNodeKind;
status: GraphStatus;
summary?: string;
/** Tool arguments, rendered as formatted JSON. */
input?: unknown;
/** Result text, rendered as text, never as HTML. */
output?: string;
error?: string;
durationMs?: number;
}
export interface RunGraphEdge {
id: string;
source: string;
target: string;
type: RunGraphEdgeType;
label?: string;
}
export interface RunGraphProps extends Omit<ComponentProps<"section">, "children" | "onSelect" | "title"> {
nodes: readonly RunGraphNode[];
edges: readonly RunGraphEdge[];
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;
defaultView?: GraphView;
/** Height of the graph and list area in pixels. */
height?: number;
direction?: GraphDirection;
}
export const RUN_GRAPH_KIND_LABELS: Record<RunGraphNodeKind, string> = {
plan: "Plan",
step: "Step",
tool: "Tool call",
approval: "Approval",
result: "Result",
};
export const RUN_GRAPH_EDGE_LABELS: Record<RunGraphEdgeType, string> = {
sequence: "Sequence",
parallel: "Parallel",
approval: "Approval gate",
result: "Result",
};
const dashedTypes = new Set<RunGraphEdgeType>(["approval", "result"]);
/** A run's plan, parallel tool calls, approvals, and results as a graph, with a matching list and a details panel. */
export function RunGraph({
nodes,
edges,
title = "Run graph",
selectedId,
defaultSelectedId = null,
onSelect,
defaultView = "graph",
height = 440,
direction = "TB",
...props
}: RunGraphProps) {
const [selected, setSelected] = useControllableState<string | null>(selectedId, defaultSelectedId, onSelect);
const [view, setView] = useState<GraphView>(defaultView);
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 current = selected ? byId.get(selected) : undefined;
const canvasNodes = useMemo<GraphCanvasNode[]>(
() =>
nodes.map((node) => ({
id: node.id,
title: node.label,
subtitle: RUN_GRAPH_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: dashedTypes.has(edge.type),
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);
const label = (id: string) => byId.get(id)?.label ?? id;
// Rows do not depend on the selection, so reuse them: selecting re-renders only the option wrappers.
const rows = useMemo(() => {
const name = (id: string) => byId.get(id)?.label ?? id;
return new Map(
nodes.map((node) => {
const before = links.incoming.get(node.id) ?? [];
const after = links.outgoing.get(node.id) ?? [];
return [
node.id,
<GraphListRow
status={node.status}
title={node.label}
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) : undefined}
meta={
<>
{GRAPH_STATUS_LABELS[node.status]} · {RUN_GRAPH_KIND_LABELS[node.kind]}
{before.length ? ` · after ${nameList(before.map((edge) => name(edge.source)))}` : ""}
{after.length ? ` · then ${nameList(after.map((edge) => name(edge.target)))}` : ""}
</>
}
/>,
] as const;
}),
);
}, [nodes, links, byId]);
return (
<GraphShell
aria-label={title}
title={title}
meta={<StatusCounts lead={`${nodes.length} ${nodes.length === 1 ? "step" : "steps"}`} statuses={nodes.map((node) => node.status)} />}
controls={<GraphViewSwitch view={view} onChange={setView} />}
detailsLabel="Step details"
height={height}
footer={<EdgeLegend items={[{ label: "Sequence or parallel" }, { label: "Approval gate or result", dashed: true }]} />}
details={
current ? (
<DetailStack>
<DetailHeader
title={current.label}
mono={current.kind === "tool"}
status={current.status}
meta={[
RUN_GRAPH_KIND_LABELS[current.kind],
current.durationMs != null ? <span className="font-mono tabular-nums">{formatDuration(current.durationMs)}</span> : null,
]}
/>
{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}
{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">
<DetailSection label="Comes after">
{links.incoming.get(current.id)?.length ? (
links.incoming.get(current.id)!.map((edge) => (
<RelatedButton
key={edge.id}
label={label(edge.source)}
mono={byId.get(edge.source)?.kind === "tool"}
status={byId.get(edge.source)?.status}
note={RUN_GRAPH_EDGE_LABELS[edge.type]}
onClick={() => select(edge.source)}
/>
))
) : (
<DetailNote>Nothing. This is a starting step.</DetailNote>
)}
</DetailSection>
<DetailSection label="Leads to">
{links.outgoing.get(current.id)?.length ? (
links.outgoing.get(current.id)!.map((edge) => (
<RelatedButton
key={edge.id}
label={label(edge.target)}
mono={byId.get(edge.target)?.kind === "tool"}
status={byId.get(edge.target)?.status}
note={RUN_GRAPH_EDGE_LABELS[edge.type]}
onClick={() => select(edge.target)}
/>
))
) : (
<DetailNote>Nothing. This is a final step.</DetailNote>
)}
</DetailSection>
</div>
</DetailStack>
) : (
<EmptyDetails hint="In the list view, arrow keys, Home, and End move the selection.">
Select a step in the graph or the list to see its status, input, output, and connections.
</EmptyDetails>
)
}
{...props}
>
{view === "graph" ? (
<GraphCanvas
label={`${title}: ${nodes.length} steps. Use the list view to move through steps with the keyboard.`}
nodes={canvasNodes}
edges={canvasEdges}
layout={layout}
selectedNodeId={selected}
onNodeClick={select}
onPaneClick={() => setSelected(null)}
height={height}
/>
) : (
<div style={{ height }} className="overflow-y-auto">
<GraphListbox
label={`${title} steps`}
items={nodes}
getId={(node) => node.id}
selectedId={selected}
onSelect={select}
empty="No steps yet."
renderItem={(node) => rows.get(node.id)}
/>
</div>
)}
</GraphShell>
);
}