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