Skip to content
Seamless UI

Handoff Graph

Save

Task 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
  • Delegate or escalate
  • Review or return

Installation

npx @seamlessui/cli add handoff-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 handoff-graph --dry-run

Usage

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

PropTypeDefaultDescription
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 / onSelectstring | null / (id: string | null) => void—Controlled selection of a transfer id. Clicking an actor selects their latest transfer.
defaultSelectedIdstring | nullnullInitial selection when uncontrolled.
defaultView"graph" | "list""graph"Which view shows first.
heightnumber440Height 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.tsxShow
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>
  );
}