Skip to content
Seamless UI

Run Graph

Save

A 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
Data

Selected step: fetch

Update the rate-limit docs

11 steps1needs approval1failed5succeeded3queued1skipped
  • Sequence or parallel
  • Approval gate or result

Installation

npx @seamlessui/cli add run-graph

Pre-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-run

Usage

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

PropTypeDefaultDescription
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 / onSelectstring | null / (id: string | null) => void—Controlled selection shared by the graph, the list, and the details panel. Leave selectedId undefined for uncontrolled use.
defaultSelectedIdstring | nullnullInitial selection when uncontrolled.
defaultView"graph" | "list""graph"Which view shows first. Both share selection.
heightnumber440Height 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.
titlestring"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.tsxShow
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>
  );
}