Handoff Graph
SaveTask transfers between agents and people. Actors are nodes and transfers are edges; select a transfer to see who owned the task before and after, and the outcome.
- Category
- Agent
- Version
- 0.2.0
- Frameworks
- Next.js, Vite + React
- Styling
- Tailwind CSS v4
Preview
Handoff Graph preview
Selected transfer: h5
Ticket #4821: invoice won't download
5 actors7 transfersOwner: Triage
Installation
npx @seamlessui/cli add handoff-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 handoff-graph --dry-runUsage
handoff-graph-example.tsx
import { HandoffGraph, type HandoffEdge, type HandoffNode } from "@/components/seamless/graph/handoff-graph";
const nodes: HandoffNode[] = [
{ id: "maya", name: "Maya Chen", kind: "person", role: "Support lead" },
{ id: "triage", name: "Triage", kind: "agent", role: "Triage agent" },
{ id: "billing", name: "Billing", kind: "agent", role: "Billing agent" },
{ id: "sam", name: "Sam Ortiz", kind: "person", role: "Refund approver" },
];
// In the order they happened.
const edges: HandoffEdge[] = [
{ id: "h1", source: "maya", target: "triage", type: "delegate", task: "Invoice won't download", status: "completed", outcome: "Classified as billing." },
{ id: "h2", source: "triage", target: "billing", type: "delegate", task: "Investigate the invoice", status: "completed", outcome: "Regenerated the PDF." },
{ id: "h3", source: "billing", target: "sam", type: "escalate", task: "Approve a $640 refund", status: "in-progress" },
{ id: "h4", source: "billing", target: "triage", type: "return", task: "Invoice fixed", status: "pending" },
];
export default function HandoffGraphExample() {
return <HandoffGraph title="Ticket #4821" nodes={nodes} edges={edges} defaultSelectedId="h3" height={360} />;
}API
| Prop | Type | Default | Description |
|---|---|---|---|
| nodes | { id, name, kind: "agent" | "person" | "system", role? }[] | — | Actors with stable ids. |
| edges | { id, source, target, type: "delegate" | "escalate" | "review" | "return", task, status: "pending" | "in-progress" | "completed" | "declined" | "failed", outcome?, at? }[] | — | Transfers in the order they happened. Cycles (hand-backs) are fine. |
| selectedId / onSelect | string | null / (id: string | null) => void | — | Controlled selection of a transfer id. Clicking an actor selects their latest transfer. |
| defaultSelectedId | string | null | null | Initial selection when uncontrolled. |
| defaultView | "graph" | "list" | "graph" | Which view shows first. |
| height | number | 440 | Height of the graph and list in pixels. |
| ownerAfter(edge) / currentOwner(edges) | => string | null | — | Ownership rules: the receiver owns the task once a transfer is in progress or completed; otherwise the sender keeps it. |
| 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
- Every transfer label shows its number, task, and a status icon; the details panel states the status, owner before and after, and outcome in text.
- The list view lists actors and every transfer in order (from, to, type, status) as a keyboard listbox.
- Delegate and escalate edges are solid; review and return edges are dashed, explained in a text legend.
- Times are shown in UTC so server and client render the same text.
Files
Installed into src/components/seamless, or components/seamless when the project has no src directory.
src/components/seamless/graph/handoff-graph.tsxhandoff-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/handoff-graph/0.2.0.json.
graph/handoff-graph.tsxShowHide
graph/handoff-graph.tsx
"use client";
import { type ComponentProps, useMemo, useState } from "react";
import { GraphCanvas, type GraphCanvasEdge, type GraphCanvasNode, useGraphLayout } from "./graph-canvas";
import { type GraphDirection, type GraphStatus, layoutEdges } from "./graph-layout";
import {
cx,
DetailHeader,
DetailProperties,
DetailSection,
DetailStack,
divider,
EdgeLegend,
EmptyDetails,
ErrorNote,
GraphListbox,
GraphListRow,
GraphShell,
type GraphView,
GraphViewSwitch,
useControllableState,
} from "./graph-ui";
export type HandoffActorKind = "agent" | "person" | "system";
/** Someone who can own the task: an agent, a person, or a system. */
export interface HandoffNode {
/** Stable id. Keep it the same across updates so the actor keeps its place. */
id: string;
name: string;
kind: HandoffActorKind;
/** Shown under the name, for example "Billing agent" or "Support lead". */
role?: string;
}
/** delegate: passes work on; escalate: hands up for a decision; review: asks for a check; return: hands work back. */
export type HandoffType = "delegate" | "escalate" | "review" | "return";
export type HandoffStatus = "pending" | "in-progress" | "completed" | "declined" | "failed";
/** A transfer of the task from `source` to `target`. List transfers in the order they happened. */
export interface HandoffEdge {
id: string;
source: string;
target: string;
type: HandoffType;
/** What was handed over. */
task: string;
status: HandoffStatus;
/** What came of it, for example the decision or the result. */
outcome?: string;
/** ISO 8601 time of the transfer. Shown in UTC so server and client render the same text. */
at?: string;
}
export interface HandoffGraphProps extends Omit<ComponentProps<"section">, "children" | "onSelect" | "title"> {
nodes: readonly HandoffNode[];
edges: readonly HandoffEdge[];
title?: string;
/** Controlled selection of a transfer id. 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?: number;
direction?: GraphDirection;
}
export const HANDOFF_STATUS: Record<HandoffStatus, { label: string; icon: GraphStatus }> = {
pending: { label: "Pending", icon: "queued" },
"in-progress": { label: "In progress", icon: "running" },
completed: { label: "Completed", icon: "succeeded" },
declined: { label: "Declined", icon: "skipped" },
failed: { label: "Failed", icon: "failed" },
};
export const HANDOFF_TYPE_LABELS: Record<HandoffType, string> = {
delegate: "Delegate",
escalate: "Escalate",
review: "Review",
return: "Return",
};
const ACTOR_KIND_LABELS: Record<HandoffActorKind, string> = { agent: "Agent", person: "Person", system: "System" };
/** Who owns the task after a transfer: the receiver once it is accepted, otherwise the sender. */
export function ownerAfter(edge: Pick<HandoffEdge, "source" | "target" | "status">): string {
return edge.status === "in-progress" || edge.status === "completed" ? edge.target : edge.source;
}
/** The current owner: the owner after the latest transfer, or null with no transfers. */
export function currentOwner(edges: readonly Pick<HandoffEdge, "source" | "target" | "status">[]): string | null {
const last = edges[edges.length - 1];
return last ? ownerAfter(last) : null;
}
function initials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0]!.toUpperCase())
.join("");
}
function formatTime(iso: string): string {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? "" : `${date.toISOString().slice(11, 16)} UTC`;
}
function ActorBadge({ actor }: { actor: HandoffNode | undefined }) {
if (!actor) return null;
return (
<span
aria-hidden="true"
className={cx(
"grid size-5 shrink-0 place-items-center border border-zinc-950/15 text-[9px] font-semibold text-zinc-700 dark:border-white/20 dark:text-zinc-200",
actor.kind === "person" ? "rounded-full" : "rounded-[5px]",
)}
>
{initials(actor.name)}
</span>
);
}
/** Task transfers between agents and people. Select a transfer to see who owned the task before and after, and the outcome. */
export function HandoffGraph({
nodes,
edges,
title = "Hand-offs",
selectedId,
defaultSelectedId = null,
onSelect,
defaultView = "graph",
height = 440,
direction = "TB",
...props
}: HandoffGraphProps) {
const [selected, setSelected] = useControllableState<string | null>(selectedId, defaultSelectedId, onSelect);
const [view, setView] = useState<GraphView>(defaultView);
const transfers = useMemo(() => layoutEdges(nodes, edges), [nodes, edges]);
const labelled = useMemo(() => transfers.map((edge, index) => ({ ...edge, label: `${index + 1}. ${edge.task}` })), [transfers]);
const layout = useGraphLayout(nodes, labelled, { direction, rankSep: 64, nodeSep: 48 });
const actors = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
const current = selected ? labelled.find((edge) => edge.id === selected) : undefined;
const owner = currentOwner(transfers);
const name = (id: string) => actors.get(id)?.name ?? id;
const canvasNodes = useMemo<GraphCanvasNode[]>(
() =>
nodes.map((node) => {
const role = current ? (node.id === current.source ? "From" : node.id === current.target ? "To" : null) : null;
return {
id: node.id,
title: node.name,
subtitle: node.role ?? ACTOR_KIND_LABELS[node.kind],
avatar: initials(node.name),
avatarShape: node.kind === "person" ? "circle" : "square",
tag: role ?? (node.id === owner ? "Owner" : undefined),
tone: current && !role ? "muted" : "default",
};
}),
[nodes, current, owner],
);
const canvasEdges = useMemo<GraphCanvasEdge[]>(
() =>
labelled.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
type: edge.type,
label: edge.label,
labelStatus: HANDOFF_STATUS[edge.status].icon,
dashed: edge.type === "review" || edge.type === "return",
selectable: true,
animated: edge.status === "in-progress",
tone: current && edge.id !== current.id ? "muted" : "default",
})),
[labelled, current],
);
const select = (id: string) => setSelected(id);
// Clicking an actor selects their latest transfer.
const selectActor = (id: string) => {
const latest = [...transfers].reverse().find((edge) => edge.source === id || edge.target === id);
if (latest) setSelected(latest.id);
};
const actorLine = (id: string) => {
const actor = actors.get(id);
return (
<span className="inline-flex max-w-full items-center gap-1.5">
<ActorBadge actor={actor} />
<span className="truncate">
{actor?.name ?? id}
{actor ? <span className="text-zinc-500"> · {actor.role ?? ACTOR_KIND_LABELS[actor.kind]}</span> : null}
</span>
</span>
);
};
return (
<GraphShell
aria-label={title}
title={title}
meta={
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1">
<span>
<span className="font-mono text-zinc-700 tabular-nums dark:text-zinc-300">{nodes.length}</span> actors
</span>
<span>
<span className="font-mono text-zinc-700 tabular-nums dark:text-zinc-300">{transfers.length}</span> transfers
</span>
{owner ? (
<span>
Owner: <span className="font-medium text-zinc-700 dark:text-zinc-300">{name(owner)}</span>
</span>
) : null}
</span>
}
controls={<GraphViewSwitch view={view} onChange={setView} />}
detailsLabel="Transfer details"
height={height}
footer={<EdgeLegend items={[{ label: "Delegate or escalate" }, { label: "Review or return", dashed: true }]} />}
details={
current ? (
<DetailStack>
<DetailHeader
title={current.task}
status={HANDOFF_STATUS[current.status].icon}
statusLabel={HANDOFF_STATUS[current.status].label}
meta={[`Transfer ${labelled.indexOf(current) + 1} of ${labelled.length}`, HANDOFF_TYPE_LABELS[current.type]]}
/>
<DetailSection label="Ownership">
<DetailProperties
items={[
{ label: "From", value: actorLine(current.source) },
{ label: "To", value: actorLine(current.target) },
{ label: "Owner after", value: <span className="font-medium">{name(ownerAfter(current))}</span> },
...(current.at ? [{ label: "At", value: formatTime(current.at), mono: true }] : []),
]}
/>
</DetailSection>
<DetailSection label="Outcome">
{current.outcome ? (
current.status === "failed" ? (
<ErrorNote>{current.outcome}</ErrorNote>
) : (
<p className="text-pretty">{current.outcome}</p>
)
) : (
<p className="text-zinc-500">{current.status === "pending" ? "Not accepted yet." : "No outcome recorded."}</p>
)}
</DetailSection>
</DetailStack>
) : (
<EmptyDetails hint="Selecting a person or agent selects their latest transfer.">
Select a transfer, in the graph or the list, to see who owned the task before and after, and what came of it.
</EmptyDetails>
)
}
{...props}
>
{view === "graph" ? (
<GraphCanvas
label={`${title}: ${nodes.length} actors and ${transfers.length} transfers. Use the list view to move through transfers with the keyboard.`}
nodes={canvasNodes}
edges={canvasEdges}
layout={layout}
selectedEdgeId={selected}
onEdgeClick={select}
onNodeClick={selectActor}
onPaneClick={() => setSelected(null)}
height={height}
/>
) : (
<div style={{ height }} className="overflow-y-auto">
<div className={cx("border-b px-4 py-3", divider)}>
<p className="mb-2 text-xs font-medium text-zinc-500">Actors</p>
<ul className="flex flex-wrap gap-1.5">
{nodes.map((node) => (
<li
key={node.id}
className={cx(
"inline-flex h-7 items-center gap-1.5 rounded-lg border pr-2 pl-1 text-xs text-zinc-700 dark:text-zinc-300",
node.id === owner ? "border-zinc-950/30 dark:border-white/35" : "border-zinc-950/10 dark:border-white/10",
)}
>
<ActorBadge actor={node} />
<span className="font-medium text-zinc-950 dark:text-white">{node.name}</span>
<span className="text-zinc-500">{node.role ?? ACTOR_KIND_LABELS[node.kind]}</span>
{node.id === owner ? <span className="font-medium text-zinc-950 dark:text-white">· Owner</span> : null}
</li>
))}
</ul>
</div>
<GraphListbox
label={`${title}: transfers in order`}
items={labelled}
getId={(edge) => edge.id}
selectedId={selected}
onSelect={select}
empty="No transfers yet."
renderItem={(edge) => (
<GraphListRow
status={HANDOFF_STATUS[edge.status].icon}
title={edge.label}
muted={edge.status === "pending" || edge.status === "declined"}
trailing={edge.at ? formatTime(edge.at) : undefined}
meta={
<>
{name(edge.source)} <span aria-hidden="true">→</span>
<span className="sr-only">to</span> {name(edge.target)} · {HANDOFF_TYPE_LABELS[edge.type]} · {HANDOFF_STATUS[edge.status].label}
</>
}
/>
)}
/>
</div>
)}
</GraphShell>
);
}