{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "relay",
  "type": "registry:ui",
  "title": "Relay",
  "description": "Tool-call card for agent UIs — a status glyph spins an accent arc while running, closes into a drawn check on success or a rust shake on error, with a ticking elapsed timer and expandable tinted-JSON input/output. RelayGroup chains calls along a progress spine that fills as each completes.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/ai/relay/relay.tsx",
      "type": "registry:ui",
      "target": "components/crucible/relay.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, type Transition } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\n/** Lifecycle of a single tool invocation. */\nexport type RelayStatus = \"pending\" | \"running\" | \"success\" | \"error\";\n\nconst DEFAULT_ACCENT = \"#a78bfa\";\nconst RUST = \"#f87171\";\n\nconst STATUS_WORD: Record<RelayStatus, string> = {\n  pending: \"pending\",\n  running: \"running\",\n  success: \"succeeded\",\n  error: \"failed\",\n};\n\nexport interface RelayProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** Tool name, rendered monospace in the header (e.g. `\"search_docs\"`). */\n  name: string;\n  /** Short human summary shown after the name, truncated to one line. */\n  summary?: React.ReactNode;\n  /** Current lifecycle state — drives the glyph, sheen, shake and timer. @default \"pending\" */\n  status?: RelayStatus;\n  /** Tool arguments. Objects/arrays render as tinted, pretty-printed JSON. */\n  input?: unknown;\n  /** Tool result. Strings render as plain text; anything else as tinted JSON. `undefined` means \"not arrived yet\". */\n  output?: unknown;\n  /** Epoch ms when the call started. Enables the elapsed-time readout. */\n  startedAt?: number;\n  /** Epoch ms when the call finished. Freezes the elapsed readout at an exact duration. */\n  endedAt?: number;\n  /** Start with the Input/Output body expanded. @default false */\n  defaultOpen?: boolean;\n  /** Accent for the running arc, success glyph, sheen, JSON keys and focus ring. @default \"#a78bfa\" (ion violet) */\n  accentColor?: string;\n}\n\n/* ------------------------------------------------------------------ */\n/* Helpers                                                             */\n/* ------------------------------------------------------------------ */\n\nfunction formatElapsed(ms: number): string {\n  const s = Math.max(0, ms) / 1000;\n  if (s < 60) return `${s.toFixed(1)}s`;\n  const m = Math.floor(s / 60);\n  if (m >= 60) {\n    // Long-running (or stale `startedAt`) calls: hours + minutes, not \"1440m\".\n    const h = Math.floor(m / 60);\n    return `${h}h ${(m % 60).toString().padStart(2, \"0\")}m`;\n  }\n  const rest = Math.floor(s % 60);\n  return `${m}m ${rest.toString().padStart(2, \"0\")}s`;\n}\n\nfunction stringify(value: unknown): string {\n  if (typeof value === \"string\") return value;\n  try {\n    return JSON.stringify(value, null, 2) ?? String(value);\n  } catch {\n    return String(value);\n  }\n}\n\nconst TOKEN_RE =\n  /(\"(?:\\\\.|[^\"\\\\])*\")(\\s*:)?|\\b(true|false|null)\\b|(-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)/g;\n\n/** Tokenizes pretty-printed JSON into tinted spans (keys / strings / numbers / literals). */\nfunction JsonTokens({ value, accent }: { value: unknown; accent: string }) {\n  const text = stringify(value);\n  const nodes: React.ReactNode[] = [];\n  let last = 0;\n  let i = 0;\n  for (const m of text.matchAll(TOKEN_RE)) {\n    const start = m.index ?? 0;\n    if (start > last) nodes.push(text.slice(last, start));\n    if (m[1] && m[2]) {\n      nodes.push(\n        <span key={i++} style={{ color: accent }}>\n          {m[1]}\n        </span>,\n        m[2]\n      );\n    } else if (m[1]) {\n      nodes.push(\n        <span key={i++} className=\"text-neutral-200\">\n          {m[1]}\n        </span>\n      );\n    } else if (m[3]) {\n      nodes.push(\n        <span key={i++} className=\"text-neutral-500 italic\">\n          {m[3]}\n        </span>\n      );\n    } else if (m[4]) {\n      nodes.push(\n        <span\n          key={i++}\n          style={{ color: `color-mix(in srgb, ${accent} 55%, #e5e5e5)` }}\n        >\n          {m[4]}\n        </span>\n      );\n    }\n    last = start + m[0].length;\n  }\n  if (last < text.length) nodes.push(text.slice(last));\n  return <>{nodes}</>;\n}\n\nfunction Pane({\n  label,\n  children,\n  tone,\n}: {\n  label: string;\n  children: React.ReactNode;\n  tone?: \"error\";\n}) {\n  return (\n    <div className=\"min-w-0\">\n      <div className=\"mb-1.5 text-[10px] font-medium tracking-[0.14em] text-neutral-500 uppercase\">\n        {label}\n      </div>\n      <pre\n        className={cn(\n          \"max-h-56 overflow-auto rounded-md border border-white/[0.06] bg-black/40 px-3 py-2.5 font-mono text-[12px] leading-relaxed whitespace-pre-wrap break-words\",\n          tone === \"error\" ? \"text-red-300/90\" : \"text-neutral-300\"\n        )}\n      >\n        {children}\n      </pre>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Status glyph                                                        */\n/* ------------------------------------------------------------------ */\n\nfunction StatusGlyph({\n  status,\n  accent,\n  reducedMotion,\n}: {\n  status: RelayStatus;\n  accent: string;\n  reducedMotion: boolean;\n}) {\n  const fade: Transition = { duration: reducedMotion ? 0.15 : 0.12 };\n  const layer = \"absolute inset-0 flex items-center justify-center\";\n\n  return (\n    <span aria-hidden className=\"relative inline-block size-4 shrink-0\">\n      <AnimatePresence initial={false}>\n        {status === \"pending\" && (\n          <motion.span\n            key=\"pending\"\n            className={layer}\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            transition={fade}\n          >\n            <svg viewBox=\"0 0 16 16\" className=\"size-4\">\n              <circle\n                cx=\"8\"\n                cy=\"8\"\n                r=\"6.25\"\n                fill=\"none\"\n                stroke=\"rgba(255,255,255,0.28)\"\n                strokeWidth=\"1.5\"\n              />\n            </svg>\n          </motion.span>\n        )}\n\n        {status === \"running\" && (\n          <motion.span\n            key=\"running\"\n            className={layer}\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            transition={fade}\n          >\n            <svg viewBox=\"0 0 16 16\" className=\"absolute size-4\">\n              <circle\n                cx=\"8\"\n                cy=\"8\"\n                r=\"6.25\"\n                fill=\"none\"\n                stroke=\"rgba(255,255,255,0.1)\"\n                strokeWidth=\"1.5\"\n              />\n            </svg>\n            <motion.svg\n              viewBox=\"0 0 16 16\"\n              className=\"absolute size-4\"\n              animate={reducedMotion ? { rotate: 0 } : { rotate: 360 }}\n              transition={\n                reducedMotion\n                  ? { duration: 0 }\n                  : { duration: 0.9, ease: \"linear\", repeat: Infinity }\n              }\n            >\n              <circle\n                cx=\"8\"\n                cy=\"8\"\n                r=\"6.25\"\n                fill=\"none\"\n                stroke={accent}\n                strokeWidth=\"1.5\"\n                strokeLinecap=\"round\"\n                pathLength={1}\n                strokeDasharray=\"0.28 1\"\n              />\n            </motion.svg>\n          </motion.span>\n        )}\n\n        {status === \"success\" && (\n          <motion.span\n            key=\"success\"\n            className={layer}\n            initial={{ opacity: reducedMotion ? 0 : 1 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            transition={fade}\n          >\n            <svg viewBox=\"0 0 16 16\" className=\"size-4\">\n              <motion.circle\n                cx=\"8\"\n                cy=\"8\"\n                r=\"6.25\"\n                stroke={accent}\n                strokeWidth=\"1.5\"\n                strokeLinecap=\"round\"\n                initial={\n                  reducedMotion\n                    ? false\n                    : { pathLength: 0.28, fillOpacity: 0 }\n                }\n                animate={{ pathLength: 1, fillOpacity: 0.16 }}\n                style={{ fill: accent, rotate: -90 }}\n                transition={{ duration: 0.2, ease: \"easeOut\" }}\n              />\n              <motion.path\n                d=\"M5.3 8.3 7.1 10.1 10.8 6.1\"\n                fill=\"none\"\n                stroke={accent}\n                strokeWidth=\"1.6\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                initial={reducedMotion ? false : { pathLength: 0 }}\n                animate={{ pathLength: 1 }}\n                transition={{ duration: 0.3, delay: 0.16, ease: \"easeOut\" }}\n              />\n            </svg>\n          </motion.span>\n        )}\n\n        {status === \"error\" && (\n          <motion.span\n            key=\"error\"\n            className={layer}\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            transition={fade}\n          >\n            <svg viewBox=\"0 0 16 16\" className=\"size-4\">\n              <circle cx=\"8\" cy=\"8\" r=\"7\" fill={RUST} />\n              <path\n                d=\"M5.6 5.6 10.4 10.4M10.4 5.6 5.6 10.4\"\n                stroke=\"#0a0a0a\"\n                strokeWidth=\"1.6\"\n                strokeLinecap=\"round\"\n              />\n            </svg>\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Relay                                                               */\n/* ------------------------------------------------------------------ */\n\n/**\n * Relay — a compact card for one tool invocation. The header reads\n * status glyph · monospace tool name · summary · elapsed time, and the\n * status lifecycle drives the motion: `pending` is a dim hollow ring,\n * `running` spins an accent arc and sweeps a faint sheen across the header,\n * `success` closes the arc into a full circle and draws a check, `error`\n * fills the ring rust with a single 3px shake. Clicking (or Enter/Space on)\n * the header expands Input/Output panes with tinted JSON; a result arriving\n * while expanded grows in with a height spring.\n *\n * Fully controlled by `status`/`output`/timestamps — drive it from your\n * agent loop. Elapsed time ticks only after mount (no hydration mismatch)\n * and freezes when the call ends.\n *\n * Accessibility: the header is a `button` with `aria-expanded` +\n * `aria-controls`; status is conveyed in sr-only text, and transitions are\n * announced politely (\"read_file succeeded\"). Honors prefers-reduced-motion:\n * no spin, sheen or shake — glyphs swap with a short fade and the body opens\n * with an opacity fade.\n */\nexport function Relay({\n  name,\n  summary,\n  status = \"pending\",\n  input,\n  output,\n  startedAt,\n  endedAt,\n  defaultOpen = false,\n  accentColor = DEFAULT_ACCENT,\n  className,\n  style,\n  ...props\n}: RelayProps) {\n  const reducedMotion = useReducedMotion();\n  const [open, setOpen] = React.useState(defaultOpen);\n  const bodyId = React.useId();\n\n  // Elapsed ticker — only after mount, only while running.\n  const [now, setNow] = React.useState<number | null>(null);\n  React.useEffect(() => {\n    if (status !== \"running\" || startedAt == null) return;\n    const first = window.setTimeout(() => setNow(Date.now()), 0);\n    const id = window.setInterval(() => setNow(Date.now()), 100);\n    return () => {\n      window.clearTimeout(first);\n      window.clearInterval(id);\n    };\n  }, [status, startedAt]);\n\n  let elapsed: string | null = null;\n  if (startedAt != null) {\n    if (status !== \"running\" && status !== \"pending\" && endedAt != null) {\n      elapsed = formatElapsed(endedAt - startedAt);\n    } else if (status !== \"pending\" && now != null) {\n      elapsed = formatElapsed(now - startedAt);\n    }\n  }\n\n  // Announce transitions (not the initial state) via a polite live region.\n  const [prevStatus, setPrevStatus] = React.useState(status);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  if (prevStatus !== status) {\n    setPrevStatus(status);\n    setAnnouncement(`${name} ${STATUS_WORD[status]}`);\n  }\n\n  const hasOutput = output !== undefined;\n  const isError = status === \"error\";\n  const settle: Transition = reducedMotion\n    ? { duration: 0.15 }\n    : { type: \"spring\", stiffness: 380, damping: 34, mass: 0.8 };\n\n  return (\n    <motion.div\n      {...(props as React.ComponentPropsWithoutRef<typeof motion.div>)}\n      data-status={status}\n      aria-busy={status === \"running\" || undefined}\n      initial={false}\n      animate={\n        isError && !reducedMotion ? { x: [0, -3, 3, -2, 1, 0] } : { x: 0 }\n      }\n      transition={{ duration: 0.32, ease: \"easeOut\" }}\n      className={cn(\n        \"relative w-full overflow-hidden rounded-lg border bg-neutral-900/70 text-sm shadow-[0_1px_0_rgba(255,255,255,0.04)_inset] transition-colors duration-300\",\n        isError ? \"border-red-400/25\" : \"border-white/10\",\n        className\n      )}\n      style={{ [\"--relay-accent\" as string]: accentColor, ...style }}\n    >\n      <button\n        type=\"button\"\n        aria-expanded={open}\n        aria-controls={bodyId}\n        onClick={() => setOpen((o) => !o)}\n        className=\"relative flex h-10 w-full items-center gap-2.5 rounded-lg px-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-[var(--relay-accent)] focus-visible:ring-inset\"\n      >\n        {status === \"running\" && !reducedMotion && (\n          <span\n            aria-hidden\n            className=\"pointer-events-none absolute inset-0 overflow-hidden\"\n          >\n            <motion.span\n              className=\"absolute inset-y-0 left-0 w-1/2\"\n              style={{\n                background: `linear-gradient(90deg, transparent, color-mix(in srgb, ${accentColor} 12%, transparent), transparent)`,\n              }}\n              initial={{ x: \"-100%\" }}\n              animate={{ x: \"220%\" }}\n              transition={{\n                duration: 1.1,\n                ease: \"easeInOut\",\n                repeat: Infinity,\n                repeatDelay: 0.7,\n              }}\n            />\n          </span>\n        )}\n\n        <StatusGlyph\n          status={status}\n          accent={accentColor}\n          reducedMotion={reducedMotion}\n        />\n        <span className=\"sr-only\">{STATUS_WORD[status]}:</span>\n\n        <span\n          className={cn(\n            \"shrink-0 font-mono text-[13px] transition-colors duration-300\",\n            status === \"pending\" ? \"text-neutral-500\" : \"text-neutral-100\"\n          )}\n        >\n          {name}\n        </span>\n\n        <span\n          className={cn(\n            \"min-w-0 flex-1 truncate transition-colors duration-300\",\n            status === \"pending\" ? \"text-neutral-600\" : \"text-neutral-400\"\n          )}\n        >\n          {summary}\n        </span>\n\n        <span className=\"shrink-0 font-mono text-xs text-neutral-500 tabular-nums\">\n          {elapsed}\n        </span>\n\n        <svg\n          aria-hidden\n          viewBox=\"0 0 16 16\"\n          className={cn(\n            \"size-3.5 shrink-0 text-neutral-500 transition-transform duration-200\",\n            open && \"rotate-180\",\n            reducedMotion && \"transition-none\"\n          )}\n        >\n          <path\n            d=\"M4 6l4 4 4-4\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.5\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        </svg>\n      </button>\n\n      <span role=\"status\" className=\"sr-only\">\n        {announcement}\n      </span>\n\n      <div id={bodyId}>\n        <AnimatePresence initial={false}>\n          {open && (\n            <motion.div\n              key=\"body\"\n              initial={reducedMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}\n              animate={reducedMotion ? { opacity: 1 } : { height: \"auto\", opacity: 1 }}\n              exit={reducedMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}\n              transition={settle}\n              className=\"overflow-hidden\"\n            >\n              <div className=\"flex flex-col gap-3 border-t border-white/[0.06] px-3 pt-3 pb-3\">\n                <Pane label=\"Input\">\n                  {input === undefined ? (\n                    <span className=\"text-neutral-600\">No arguments</span>\n                  ) : (\n                    <JsonTokens value={input} accent={accentColor} />\n                  )}\n                </Pane>\n\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  {hasOutput ? (\n                    <motion.div\n                      key=\"output\"\n                      initial={\n                        reducedMotion ? { opacity: 0 } : { height: 0, opacity: 0 }\n                      }\n                      animate={\n                        reducedMotion\n                          ? { opacity: 1 }\n                          : { height: \"auto\", opacity: 1 }\n                      }\n                      exit={{ opacity: 0 }}\n                      transition={settle}\n                      className=\"overflow-hidden\"\n                    >\n                      <Pane label=\"Output\" tone={isError ? \"error\" : undefined}>\n                        {typeof output === \"string\" ? (\n                          output\n                        ) : (\n                          <JsonTokens value={output} accent={accentColor} />\n                        )}\n                      </Pane>\n                    </motion.div>\n                  ) : (\n                    <motion.p\n                      key=\"waiting\"\n                      initial={{ opacity: 0 }}\n                      animate={{ opacity: 1 }}\n                      exit={{ opacity: 0 }}\n                      transition={{ duration: 0.12 }}\n                      className=\"text-xs text-neutral-500\"\n                    >\n                      {status === \"running\"\n                        ? \"Waiting for output…\"\n                        : status === \"pending\"\n                          ? \"Not started yet.\"\n                          : \"No output.\"}\n                    </motion.p>\n                  )}\n                </AnimatePresence>\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </motion.div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* RelayGroup                                                          */\n/* ------------------------------------------------------------------ */\n\nexport interface RelayGroupProps\n  extends Omit<React.ComponentPropsWithoutRef<\"ol\">, \"children\"> {\n  /** `<Relay>` elements, in call order. Give each a stable `key` so added/removed calls animate. */\n  children?: React.ReactNode;\n  /** Accent for the progress spine; also passed to child cards that don't set their own. @default \"#a78bfa\" */\n  accentColor?: string;\n  /** Accessible name for the list of calls. @default \"Tool calls\" */\n  label?: string;\n}\n\nconst GAP_PX = 8;\n\n/**\n * RelayGroup — stacks `<Relay>` cards along a vertical progress spine. Each\n * card gets a node on the spine; the segment leading to the next card fills\n * with accent (rust for a failure) as the card completes, so a chain of calls\n * reads as progress at a glance. Cards added later slide in; removed ones\n * fade out. Rendered as an ordered list.\n */\nexport function RelayGroup({\n  children,\n  accentColor = DEFAULT_ACCENT,\n  label = \"Tool calls\",\n  className,\n  style,\n  ...props\n}: RelayGroupProps) {\n  const reducedMotion = useReducedMotion();\n  const items = React.Children.toArray(children).filter(\n    React.isValidElement\n  ) as React.ReactElement<RelayProps>[];\n\n  return (\n    <ol\n      aria-label={label}\n      {...props}\n      className={cn(\"relative flex w-full flex-col\", className)}\n      style={{ gap: GAP_PX, ...style }}\n    >\n      <AnimatePresence initial={false}>\n        {items.map((child, index) => {\n          const status = child.props.status ?? \"pending\";\n          const done = status === \"success\" || status === \"error\";\n          const isLast = index === items.length - 1;\n          const nodeColor =\n            status === \"error\"\n              ? RUST\n              : status === \"pending\"\n                ? undefined\n                : accentColor;\n          const card =\n            child.props.accentColor === undefined\n              ? React.cloneElement(child, { accentColor })\n              : child;\n\n          return (\n            <motion.li\n              key={child.key ?? index}\n              layout={reducedMotion ? false : \"position\"}\n              initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -6 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0 }}\n              transition={\n                reducedMotion\n                  ? { duration: 0.15 }\n                  : { type: \"spring\", stiffness: 420, damping: 34 }\n              }\n              className=\"relative pl-6\"\n            >\n              {!isLast && (\n                <span\n                  aria-hidden\n                  className=\"absolute top-5 left-[5px] w-px overflow-hidden bg-white/10\"\n                  style={{ height: `calc(100% + ${GAP_PX}px)` }}\n                >\n                  <motion.span\n                    className=\"absolute inset-0 origin-top\"\n                    style={{ background: status === \"error\" ? RUST : accentColor }}\n                    initial={false}\n                    animate={{ scaleY: done ? 1 : 0, opacity: done ? 1 : 0 }}\n                    transition={\n                      reducedMotion\n                        ? { duration: 0.15 }\n                        : { duration: 0.5, ease: [0.22, 1, 0.36, 1] }\n                    }\n                  />\n                </span>\n              )}\n              <span\n                aria-hidden\n                className=\"absolute top-[15px] left-0 size-[11px] rounded-full border transition-colors duration-300\"\n                style={{\n                  borderColor: nodeColor ?? \"rgba(255,255,255,0.18)\",\n                  background: done ? nodeColor : \"#0a0a0a\",\n                }}\n              >\n                {status === \"running\" && !reducedMotion && (\n                  <motion.span\n                    className=\"absolute -inset-px rounded-full\"\n                    style={{ boxShadow: `0 0 0 1px ${accentColor}` }}\n                    animate={{ scale: [1, 2.2], opacity: [0.7, 0] }}\n                    transition={{ duration: 1.4, ease: \"easeOut\", repeat: Infinity }}\n                  />\n                )}\n              </span>\n              {card}\n            </motion.li>\n          );\n        })}\n      </AnimatePresence>\n    </ol>\n  );\n}\n"
    }
  ]
}