A LangGraph run is a series of named nodes, and useStream from @langchain/react discovers each one as it runs in stream.subgraphs, with a status and a start time. Map each node to a timeline step: running is active, complete is done, error is failed. When stream.interrupt is set, add a waiting step and put the approval card inside it. The timeline then reads like the graph, at the speed it runs.
How LangGraph reports progress#
LangGraph's Graph execution page shows the pattern: every graph is built from named nodes, and the frontend hook exposes a snapshot per node it has seen, so the UI needs no hardcoded list of steps. In the @langchain/langgraph-sdk 1.12.8 types we checked, a snapshot carries nodeName, status (running, complete or error), startedAt and completedAt. The docs page also names a pending status; treat any status you do not know as pending.
The page renders nodes as a row of colored pills. A timeline does the same job with more room: a past-tense line per finished step, a live timer on the running one, and a place for the step that waits on a person.
From node snapshot to step#
| LangGraph | Run timeline step |
|---|---|
node.nodeName |
id, and the key into your titles |
status: "running" |
status="active", startedAt from node.startedAt |
status: "complete" |
status="done", endedAt from node.completedAt |
status: "error" |
status="error", with what failed in detail |
stream.interrupt set |
An extra step with status="waiting" and an approval card as children |
| Not reached yet | Leave it out, or list it as pending if you know the plan |
The code#
The graph is any compiled StateGraph; this one plans, reads, drafts and then waits for a person before it publishes.
import { Annotation, END, MemorySaver, START, StateGraph, interrupt } from "@langchain/langgraph";
const State = Annotation.Root({
prs: Annotation<number>(),
draft: Annotation<string>(),
approved: Annotation<boolean>(),
});
export const graph = new StateGraph(State)
.addNode("collect", async () => ({ prs: 46 }))
.addNode("draft", async (state) => ({ draft: `Release notes for ${state.prs} merged pull requests` }))
.addNode("review", (state) => ({ approved: interrupt<{ draft: string }, boolean>({ draft: state.draft }) }))
.addNode("publish", async () => ({}))
.addEdge(START, "collect")
.addEdge("collect", "draft")
.addEdge("draft", "review")
.addConditionalEdges("review", (state) => (state.approved ? "publish" : END), ["publish", END])
.addEdge("publish", END)
.compile({ checkpointer: new MemorySaver() });The client turns snapshots into steps and keeps one source of truth: the stream.
"use client";
import { useStream } from "@langchain/react";
import { ApprovalCard } from "@/components/pixilui/approval-card";
import { RunTimeline, type RunTimelineStep } from "@/components/pixilui/run-timeline";
import type { graph } from "../agent/release-notes";
// Present tense while it runs, past tense with the evidence once it is done.
const copy: Record<string, { active: string; done: (prs: number) => string }> = {
collect: { active: "Reading merged pull requests", done: (prs) => `Read ${prs} merged pull requests` },
draft: { active: "Drafting the notes", done: () => "Drafted the notes" },
review: { active: "Waiting for review", done: () => "Reviewed" },
publish: { active: "Publishing to the changelog", done: () => "Published to the changelog" },
};
const statusOf = (status: string): RunTimelineStep["status"] =>
status === "running" ? "active" : status === "complete" ? "done" : status === "error" ? "error" : "pending";
export function ReleaseRun() {
const stream = useStream<typeof graph, { draft: string }>({ apiUrl: "http://localhost:2024", assistantId: "release-notes" });
const pending = stream.interrupt;
const steps: RunTimelineStep[] = [...stream.subgraphs.values()]
.filter((node) => node.nodeName !== "review")
.map((node) => {
const status = statusOf(node.status);
const text = copy[node.nodeName];
return {
id: node.id,
title: status === "done" ? (text?.done(stream.values.prs ?? 0) ?? node.nodeName) : (text?.active ?? node.nodeName),
detail: status === "error" ? "This step failed. Run it again from the last checkpoint." : undefined,
status,
startedAt: node.startedAt.getTime(),
endedAt: node.completedAt?.getTime(),
};
});
if (pending) {
steps.push({
id: "review",
title: "Waiting for your review",
status: "waiting",
children: (
<ApprovalCard
title="Publish the release notes"
items={[{ label: "Pull requests", value: String(stream.values.prs ?? 0), numeric: true }]}
source="Release agent"
labels={{ approve: "Approve and publish" }}
onApprove={() => stream.respond(true)}
onReject={() => stream.respond(false)}
/>
),
});
}
return <RunTimeline steps={steps} aria-label="Release notes run" />;
}Install both with npx shadcn@latest add @pixilui/run-timeline @pixilui/approval-card.
What the timeline adds to the snapshots#
- Live timers. Pass
startedAtand the running step counts up; passendedAtand it settles to a duration, so a slow node explains a slow run. - A waiting state.
waitingreads "Needs approval" and holds the card under the step, so the person approves in context instead of in a modal. - Evidence lines.
detailsits under the title for the counts and links a step produced. - Keyboard. Steps are a list; with
onStepSelectandselectedIda step opens its details in a side pane.
When the framework already ships a UI#
LangGraph's docs list no timeline component. They build the progress bar and node cards from plain markup in the docs page, and Deep Agents has its own frontend patterns (a todo list, subagent cards) for runs that plan their own steps. LangSmith Studio shows the graph and each node's state for developers. The run timeline is for the people using your product, who need to know what the agent did, not the graph's shape.
When not to use this#
- One node. A graph that only calls a model once needs a thinking line, not a timeline.
- Hundreds of steps. Long loops read better grouped by phase, with the tool calls folded under each.
- Debugging a graph. For node inputs, outputs and token counts, use LangSmith traces; the timeline is for users, not for you.
Questions#
How do I show LangGraph node progress in React?
Use useStream from @langchain/react and read stream.subgraphs, which holds a snapshot per node with its name, status and start time. Map each snapshot to a timeline step; no hardcoded node list is needed.
How do I show an interrupt in the run timeline?
When stream.interrupt is set, add a step with status waiting and render the approval card as that step's children. Resume with stream.respond(value) and the step moves on as the graph continues.
Can the timeline survive a page reload mid-run?
LangGraph keeps the run on the server, tied to a thread; the frontend docs describe joining and rejoining a running stream. When the hook reconnects, the snapshots come back and the timeline rebuilds from them.
Related#
- Showing a multi-step agent run as a timeline: what a run timeline needs, framework aside.
- Human approval with LangGraph: the interrupt in the waiting step, in detail.
- Agent run timeline with the Vercel AI SDK: the same timeline from data parts.
- Tool call UI with LangGraph: the rows that go under a step.
Sources#
- LangGraph frontend: Graph execution, read 4 November 2026
- LangGraph: Interrupts, read 4 November 2026
- LangChain frontend: Join and rejoin streams, read 4 November 2026
- Deep Agents frontend: Overview, read 4 November 2026
- LangSmith Studio, read 4 November 2026
- @langchain/langgraph-sdk on npm (version 1.12.8, its
SubgraphDiscoverySnapshottype), read 4 November 2026