AI

Agent Handoff

An agent-to-agent handoff marker — from/to avatars, a track that marches while the transfer is in flight, the reason control moved, chips for the context passed along, and who holds the run; compact divider and detailed card variants.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowRight, Undo2 } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Keyframes
 *
 * Shipped with the component through a React 19 hoisted <style> — no Tailwind
 * config edit, and every marker in a long transcript dedupes on the same href.
 *
 * `ah-flow` marches the dashed track by exactly one dash period, so the loop is
 * seamless at any track width; `ah-nudge` leans the arrowhead a couple of pixels

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/agent-handoff.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "AgentHandoff" component (lucide-react for
the two glyphs, no other dependency): the marker a multi-agent run drops into its
log when one agent hands the run to another.

Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
  children; spread the rest onto the root and publish data-status / data-variant
  so a host can style rows by state.
- from / to: Party = { id?, name, role?, avatar?, initials?, tone?: 1..5 }.
- status?: "pending" | "complete" | "failed" (default "complete").
- variant?: "divider" | "card" (default "divider").
- reason?: string — why control moved.
- context?: { label: string; value?: string }[] — the manifest of what travelled
  with the run (thread window, tool results, budget, deadline).
- maxContext?: number (default 3, 0 = no cap) — chips shown before the expander.
- at?: Date | number | string; formatTime?: (date: Date) => string.
- labels?: Partial<Labels>, where Labels carries every string plus two functions,
  more(count) and returned(name), so counts and names interpolate in any language.
- announce?: boolean (default false).
- actions?: ReactNode — card-only footer slot; the consumer wires the handlers,
  the component never invents one.

Behavior — the control token (the whole point)
- Exactly one party holds the run at a time, and the holder is DERIVED from
  status, never passed in: "complete" hands it to the receiver, "pending" and
  "failed" leave it with the sender. Render the holder with a ring around its
  avatar and an sr-only "(holds the run)" after its name.
- The party that does not hold it yet — the receiver while pending, and the
  receiver of a failed handoff — gets a DASHED, dimmed avatar. Never encode that
  difference in colour alone.
- "failed" is not "cancelled": keep both parties, swap the arrow for a return
  glyph, paint the track and the status word with the destructive token, and add
  one muted line saying the sender keeps the run.

Behavior — the transfer track
- One flex line between the parties: a hairline plus an arrowhead.
- pending: the dash pattern marches from sender to receiver. Draw it as a
  repeating-linear-gradient whose background-size equals exactly one dash period
  and animate background-position by that same period, so the loop is seamless at
  any measured width — no JS, no ResizeObserver, no width math.
- complete: one solid linear-gradient from the sender's accent to the receiver's,
  which is what keeps the direction readable with no motion at all.
- failed: the same dashes, static, in the destructive token.

Behavior — the one-shot arrival lock
- The pending → complete edge plays a single expanding ring on the receiver's
  avatar. Implement it by bumping a numeric key and putting that key on the ring
  element: a CSS animation fires when its element is INSERTED and can never be
  replayed by a re-render, so a parent re-rendering 60x/s cannot retrigger it.
- Detect the edge with a render-phase state adjustment (compare the previous
  status with the incoming one and bump inside render), not an effect — an effect
  leaves the first painted frame one status behind.
- Mounting straight into "complete" must NOT pulse: the key starts at 0 and the
  ring only renders above 0. There is nothing to celebrate about history.

Behavior — context chips
- Render the manifest as a <ul> with an accessible name; each chip is a label plus
  an optional monospace value.
- Past maxContext, collapse the tail behind a real <button type="button"> with
  aria-expanded and a focus-visible ring, labelled "+N more" / "Show less".
  Nothing is ever silently truncated.
- Reset the expanded flag whenever the manifest's CONTENT changes (a signature of
  the labels and values, not just the count): a virtualised log recycles this
  instance for a different handoff whose chips are usually the same kinds with
  different values, and inherited expansion would expose another run's chips.
  Reset in render, so the first frame is collapsed.

Behavior — identity, avatars, time
- Accent per party: use `tone` when given, else hash `id ?? name` with FNV-1a into
  --chart-1..5. Integer-only and deterministic, so server and client agree and one
  agent keeps the same colour across every handoff in a log.
- Avatar: put the remote <img> ON TOP of the initials so a dead or slow URL
  degrades to initials, never to a broken-image glyph. Also re-check
  `complete && naturalWidth === 0` in a ref callback: a cached or already-failed
  image finishes before React attaches onError on every hydrated page.
- Initials: first glyph of the first and last word (or the first two of a single
  word), split with Array.from so an emoji or astral glyph is not cut in half.
- Timestamp: a string prop is printed verbatim (the caller already formatted it);
  a Date/number renders <time dateTime={ISO}> — identical on both sides of
  hydration — with the human-readable local time filled in only after mount,
  because the server cannot know the reader's zone. An unparseable value renders
  nothing at all rather than leaving a dangling separator in the meta line.

Behavior — accessibility
- The arrow is aria-hidden; between the two names put an sr-only connective
  ("handed off to" / "is handing off to" / "failed to hand off to") so the row
  reads as one sentence.
- announce mounts a permanent sr-only role="status" aria-live="polite" region
  carrying that sentence plus the reason and, on failure, where control landed.
  Default it OFF: the marker's own text is already in the reading order, and one
  region per row in a long log talks over itself.
- The only focusable element is the chip expander; everything else is text.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground, border, bg-border,
  bg-muted/50, text-muted-foreground, text-destructive + border-destructive/40,
  ring for focus, and var(--chart-1..5) for the per-agent accents (mixed through
  color-mix(in oklab, …) for the avatar fill, its border and the control ring).
  No hex, no rgb(), no oklch() literals.
- divider: hairlines left and right of a centred from → to row, then a meta line
  (status word when not complete · reason · time) with the chips underneath. The
  reason is line-clamped to one line so the marker stays one row tall at any
  width; the full text stays in the title attribute.
- card: a status pill + time header, then a wrapping parties row — give each
  party ONE arbitrary shorthand (flex-[1_1_9rem]) rather than `flex-1 basis-36`,
  because those are two utilities writing the same longhand and the stylesheet
  order, not the class string, decides the winner — so the row degrades to
  stacked lines instead of squeezing the avatars; then the reason, the chips, the
  "keeps the run" line and the actions slot.
- Every name and role truncates with min-w-0 + truncate and keeps a title;
  nothing in this component may widen the row that hosts it.
- Motion: the marching track and the arrowhead nudge are
  motion-reduce:[animation:none] (the dashes and the direction stay legible), and
  the arrival ring is motion-reduce:hidden because it is pure decoration. Ship the
  keyframes in a React 19 hoisted <style href precedence> so dozens of markers on
  one page dedupe instead of each injecting a tag.

Customization levers
- Ownership signal: the ring + sr-only note is the minimum. Swap it for a badge
  ("owner"), or drop it when your runs are strictly sequential and nobody ever
  reads a handoff out of order.
- Track physics: the dash period (0.5rem) and duty cycle (0.1875rem) set the
  texture, the 0.55s loop sets the speed. Slow it to ~1.2s for a calmer log, or
  replace the whole track with a solid line plus a pulsing arrowhead.
- Density: divider vs card is the coarse knob; inside the card, drop the status
  pill, the role line or the chips independently — each is one JSX branch.
- Chip cap: maxContext={0} shows the whole manifest, 2–3 keeps a dense log
  scannable. The chip itself is one class constant, shared with the expander.
- Palette: tones are --chart-1..5 by convention; pin `tone` per agent to match an
  existing legend, or map roles (planner / tool / critic) to fixed indices.
- Failure copy: labels.returned(name) is where "keeps the run" becomes "stays with
  the orchestrator" or "escalated to a human", and labels.more(count) localises
  the expander.
- Timestamps: pass a preformatted string to reuse the relative-time formatter you
  already ship ("3 min ago"), or a Date plus formatTime for absolute clocks.

Concepts

  • Control token — the run is owned by exactly one agent at a time and the owner is derived from status rather than passed in: a transfer still in flight, or one that failed, leaves the sender in charge — so the marker answers "who do I chase right now" without the caller having to say it twice.
  • In-flight vs settled — "pending" is a real state, not a spinner: the track marches, the receiver's avatar stays dashed and dimmed, and the meta line names the phase, so a stalled delegation looks like a stalled delegation instead of a finished one.
  • One-shot arrival lock — the arrival ring is keyed, and a CSS animation only plays when its element is inserted; the pulse therefore fires exactly on the pending → complete edge, and never on a re-render, a remount into complete, or a parent list re-key.
  • Context manifest — what travelled with the run (thread window, tool results, budget, deadline) is data, not prose: chips make it scannable, and anything past the cap hides behind a labelled, keyboard-reachable expander instead of being silently cut.
  • Derived identity accent — each agent's colour comes from hashing its id into --chart-1..5, so the same agent keeps the same accent across every handoff in a log without a palette registry, while the host theme still owns the actual hues.
  • Hydration-safe stamp — the machine-readable datetime is UTC and identical on both sides of hydration, while the human-readable local time lands one commit later, because the reader's time zone is a fact the server never has.

On This Page