{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-status",
  "type": "registry:ui",
  "title": "Agent Status",
  "description": "A live pill for a background agent that opens into its run and ends on a tick.",
  "categories": [
    "ai-agents"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/agent-status.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport NumberFlow, { NumberFlowGroup } from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type AgentRunStatus = \"running\" | \"waiting\" | \"done\" | \"failed\" | \"stopped\";\ntype Time = number | Date;\nconst toMs = (t: Time | undefined) => (t == null ? undefined : typeof t === \"number\" ? t : t.getTime());\n\n// One clock for every live pill on the page, on the second boundary, asleep while the tab is hidden.\nconst clock = (() => {\n  let now = 0;\n  let timer = 0;\n  const subs = new Set<() => void>();\n  const emit = () => {\n    now = Date.now();\n    subs.forEach((fn) => fn());\n  };\n  const schedule = () => {\n    window.clearTimeout(timer);\n    if (document.hidden) return;\n    timer = window.setTimeout(() => {\n      emit();\n      schedule();\n    }, 1000 - (Date.now() % 1000) + 4);\n  };\n  const onVisibility = () => {\n    if (!document.hidden) emit();\n    schedule();\n  };\n  return {\n    subscribe(fn: () => void) {\n      subs.add(fn);\n      if (subs.size === 1) {\n        now = Date.now();\n        schedule();\n        document.addEventListener(\"visibilitychange\", onVisibility);\n      }\n      return () => {\n        subs.delete(fn);\n        if (subs.size) return;\n        window.clearTimeout(timer);\n        document.removeEventListener(\"visibilitychange\", onVisibility);\n        now = 0;\n      };\n    },\n    get: () => (now ||= Date.now()),\n  };\n})();\nconst idle = () => () => {};\n\n/** Milliseconds since startedAt (live, once a second) or until endedAt. Null on the server. */\nexport function useRunElapsed(startedAt?: Time, endedAt?: Time) {\n  const start = toMs(startedAt);\n  const end = toMs(endedAt);\n  const live = start != null && end == null;\n  const now = useSyncExternalStore(live ? clock.subscribe : idle, clock.get, () => null);\n  if (start == null) return null;\n  if (end != null) return Math.max(0, end - start);\n  return now == null ? null : Math.max(0, now - start);\n}\n\nexport type AgentStatusProps = Omit<React.ComponentProps<\"button\">, \"title\" | \"children\"> & {\n  status: AgentRunStatus;\n  /** What the agent is doing, as a short task name: “Refactor the auth module”. */\n  title: React.ReactNode;\n  /** 1-based index of the current step. */\n  step?: number;\n  totalSteps?: number;\n  /** The step being worked on now, shown in the details. */\n  currentStep?: React.ReactNode;\n  startedAt?: Time;\n  endedAt?: Time;\n  /** Adds a Stop button to the details while it runs or waits. */\n  onStop?: () => void;\n  /** More controls for the details footer: View run, Open pull request. */\n  actions?: React.ReactNode;\n  /** Anything else the details should show, under the progress. */\n  children?: React.ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Where the details portal to. Defaults to the body. */\n  container?: Popover.Portal.Props[\"container\"];\n};\n\nconst word: Record<AgentRunStatus, string> = { running: \"Running\", waiting: \"Needs your input\", done: \"Done\", failed: \"Failed\", stopped: \"Stopped\" };\n\n/**\n * A small live pill for an agent working in the background. It carries the\n * status, elapsed time and step on one line; pressing it opens the details.\n */\nexport function AgentStatus({\n  status,\n  title,\n  step,\n  totalSteps,\n  currentStep,\n  startedAt,\n  endedAt,\n  onStop,\n  actions,\n  children,\n  open,\n  defaultOpen,\n  onOpenChange,\n  container,\n  className,\n  ...rest\n}: AgentStatusProps) {\n  const reduce = useReducedMotion();\n  const elapsed = useRunElapsed(startedAt, endedAt);\n  const live = status === \"running\" || status === \"waiting\";\n  const hasSteps = step != null && totalSteps != null && totalSteps > 0;\n\n  // The pill's width follows its content, so a label change glides instead of jumping.\n  const inner = useRef<HTMLSpanElement>(null);\n  const popup = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState<number>();\n  useEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => setWidth(Math.ceil(entry.borderBoxSize?.[0]?.inlineSize ?? el.offsetWidth)));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  const spokenState = `${word[status]}${elapsed != null && status !== \"failed\" && status !== \"stopped\" ? `, ${spokenDuration(elapsed)}` : \"\"}${hasSteps ? `, step ${step} of ${totalSteps}` : \"\"}`;\n\n  return (\n    <Popover.Root open={open} defaultOpen={defaultOpen} onOpenChange={(next) => onOpenChange?.(next)}>\n      <Popover.Trigger\n        data-status={status}\n        aria-label={`${typeof title === \"string\" ? `${title}: ` : \"\"}${spokenState}`}\n        className={cn(\n          \"group/pill relative inline-flex h-8 max-w-full select-none items-center overflow-hidden rounded-full border bg-raised text-[12.5px] shadow-[var(--shadow)]\",\n          \"touch-manipulation outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"transition-[border-color,background-color,scale] duration-200 ease-out active:scale-[0.97] active:duration-75\",\n          \"hover:bg-hover data-[popup-open]:bg-hover\",\n          status === \"done\" ? \"border-success/30\" : status === \"failed\" ? \"border-danger/30\" : status === \"waiting\" ? \"border-warning/30\" : \"border-line-2\",\n          \"pointer-coarse:before:absolute pointer-coarse:before:-inset-y-1.5 pointer-coarse:before:inset-x-0 pointer-coarse:before:content-['']\",\n          className,\n        )}\n        {...rest}\n      >\n        <motion.span\n          className=\"flex min-w-0 items-center\"\n          initial={false}\n          animate={{ width: width ?? \"auto\" }}\n          transition={reduce ? { duration: 0 } : { duration: 0.34, ease: ease.inOut }}\n        >\n          <span ref={inner} className=\"flex w-max shrink-0 items-center gap-2 whitespace-nowrap pl-2.5 pr-3\">\n            <Glyph status={status} reduce={!!reduce} />\n            <span className=\"grid\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  key={status}\n                  className=\"col-start-1 row-start-1 font-medium text-fg\"\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n                  animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n                  transition={{ duration: reduce ? 0.15 : 0.24, ease: ease.out }}\n                >\n                  {status === \"done\" ? \"Done in\" : status === \"failed\" ? \"Failed at\" : status === \"stopped\" ? \"Stopped at\" : status === \"waiting\" ? \"Needs input\" : \"Running\"}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n            {(status === \"running\" || status === \"done\") && elapsed != null && (\n              <span className=\"tabular text-fg-2\">\n                <Duration value={elapsed} />\n              </span>\n            )}\n            {hasSteps && (\n              <>\n                {(status === \"running\" || status === \"waiting\") && (\n                  <span aria-hidden className=\"text-fg-4\">\n                    ·\n                  </span>\n                )}\n                {status !== \"done\" && (\n                  <span className=\"tabular text-fg-2\">\n                    step <NumberFlow value={step!} />/{totalSteps}\n                  </span>\n                )}\n              </>\n            )}\n          </span>\n        </motion.span>\n      </Popover.Trigger>\n\n      <Popover.Portal container={container}>\n        <Popover.Positioner side=\"bottom\" align=\"start\" sideOffset={8} collisionPadding={12} className=\"z-(--z-popover)\">\n          <Popover.Popup\n            ref={popup}\n            // Focus lands on the details, not on Stop, so a second Enter can't end the run by accident.\n            initialFocus={popup}\n            className={cn(\n              \"flex w-[320px] max-w-[var(--available-width)] flex-col rounded-xl border border-line-2 bg-raised text-[13px] text-fg shadow-pop outline-none\",\n              \"origin-[var(--transform-origin)] transition-[opacity,scale,translate,filter] duration-200 ease-out-expo\",\n              \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-starting-style:blur-[2px]\",\n              \"data-[side=bottom]:data-starting-style:-translate-y-1 data-[side=top]:data-starting-style:translate-y-1\",\n              \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-150 data-ending-style:ease-out-quart\",\n              \"data-instant:transition-none\",\n              \"motion-reduce:data-starting-style:translate-0 motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:blur-none motion-reduce:data-ending-style:scale-100\",\n            )}\n          >\n            <div className=\"flex flex-col gap-3 p-3.5\">\n              <div className=\"flex items-start gap-2.5\">\n                <span className=\"mt-0.5\">\n                  <Glyph status={status} reduce={!!reduce} />\n                </span>\n                <div className=\"min-w-0 flex-1\">\n                  <Popover.Title className=\"text-pretty text-[14px] font-medium leading-5 tracking-[-0.012em] text-fg\">{title}</Popover.Title>\n                  <Popover.Description className=\"mt-0.5 text-[12px] leading-[18px] text-fg-3\">\n                    <span className={cn(status === \"failed\" ? \"text-danger\" : status === \"waiting\" ? \"text-warning\" : status === \"done\" ? \"text-success\" : \"text-fg-2\")}>{word[status]}</span>\n                    {elapsed != null && (\n                      <>\n                        {\" · \"}\n                        <span className=\"tabular\">{formatRunDuration(elapsed)}</span>\n                      </>\n                    )}\n                    {startedAt != null && (\n                      <>\n                        {\" · started \"}\n                        <Clock time={startedAt} />\n                      </>\n                    )}\n                  </Popover.Description>\n                </div>\n              </div>\n\n              {hasSteps && (\n                <div className=\"flex flex-col gap-2\">\n                  <Segments step={step!} total={totalSteps!} status={status} />\n                  <div className=\"flex items-baseline justify-between gap-3 text-[12px]\">\n                    <span className=\"min-w-0 truncate text-fg-2\">\n                      {currentStep != null ? (\n                        live ? (\n                          <Sheen active={status === \"running\"}>{currentStep}</Sheen>\n                        ) : (\n                          currentStep\n                        )\n                      ) : null}\n                    </span>\n                    <span className=\"shrink-0 tabular text-fg-3\">\n                      {status === \"done\" ? totalSteps : step} of {totalSteps}\n                    </span>\n                  </div>\n                </div>\n              )}\n\n              {children}\n            </div>\n\n            {((onStop && live) || actions) && (\n              <div className=\"flex items-center justify-end gap-2 border-t border-line px-3 py-2.5\">\n                {onStop && live && (\n                  <button\n                    type=\"button\"\n                    onClick={onStop}\n                    className={cn(\n                      \"mr-auto inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium text-fg-2 outline-none\",\n                      \"transition-[background-color,color,scale] duration-150 hover:bg-danger-soft hover:text-danger active:scale-[0.97]\",\n                      \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                    )}\n                  >\n                    <svg width=\"12\" height=\"12\" viewBox=\"0 0 16 16\" aria-hidden>\n                      <rect x=\"3.5\" y=\"3.5\" width=\"9\" height=\"9\" rx=\"2\" fill=\"currentColor\" />\n                    </svg>\n                    Stop\n                  </button>\n                )}\n                {actions}\n              </div>\n            )}\n          </Popover.Popup>\n        </Popover.Positioner>\n      </Popover.Portal>\n\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {live ? \"\" : `${typeof title === \"string\" ? `${title}: ` : \"\"}${word[status]}`}\n      </span>\n    </Popover.Root>\n  );\n}\n\n/** 42s · 3m 07s · 1h 12m. */\nexport function formatRunDuration(ms: number) {\n  const s = Math.floor(ms / 1000);\n  if (s < 60) return `${s}s`;\n  if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, \"0\")}s`;\n  return `${Math.floor(s / 3600)}h ${String(Math.floor(s / 60) % 60).padStart(2, \"0\")}m`;\n}\n\nconst spokenDuration = (ms: number) => {\n  const s = Math.floor(ms / 1000);\n  const m = Math.floor(s / 60);\n  return m ? `${m} minute${m === 1 ? \"\" : \"s\"} ${s % 60} seconds` : `${s} seconds`;\n};\n\n// Seconds roll like an odometer; minutes join once they exist.\nfunction Duration({ value }: { value: number }) {\n  const s = Math.floor(value / 1000);\n  if (s >= 3600) return <>{formatRunDuration(value)}</>;\n  return (\n    <NumberFlowGroup>\n      <span className=\"inline-flex items-baseline gap-[0.3em]\">\n        {s >= 60 && <NumberFlow value={Math.floor(s / 60)} suffix=\"m\" />}\n        <NumberFlow value={s % 60} suffix=\"s\" format={s >= 60 ? { minimumIntegerDigits: 2 } : undefined} />\n      </span>\n    </NumberFlowGroup>\n  );\n}\n\n// Local wall-clock time, formatted only on the client so server HTML never disagrees with it.\nfunction Clock({ time }: { time: Time }) {\n  const text = useSyncExternalStore(\n    idle,\n    () => new Intl.DateTimeFormat(undefined, { hour: \"numeric\", minute: \"2-digit\" }).format(toMs(time)),\n    () => \"\",\n  );\n  return <span className=\"tabular\">{text}</span>;\n}\n\n/** One segment per step: done ones filled, the current one breathing, the rest empty. */\nfunction Segments({ step, total, status }: { step: number; total: number; status: AgentRunStatus }) {\n  const done = status === \"done\" ? total : step - 1;\n  return (\n    <div aria-hidden className=\"flex h-1.5 gap-[3px]\">\n      {Array.from({ length: total }, (_, i) => {\n        const current = i === step - 1 && status !== \"done\";\n        return (\n          <span key={i} className=\"relative flex-1 overflow-hidden rounded-full bg-line-2\">\n            <span\n              className={cn(\n                \"absolute inset-0 origin-left rounded-full transition-[scale,background-color] duration-500 ease-in-out-quart motion-reduce:transition-none\",\n                i < done ? \"scale-x-100\" : current ? \"scale-x-100\" : \"scale-x-0\",\n                status === \"done\" ? \"bg-success\" : current ? (status === \"failed\" ? \"bg-danger\" : status === \"waiting\" ? \"bg-warning\" : status === \"stopped\" ? \"bg-fg-4\" : \"animate-pulse-soft bg-fg-2\") : \"bg-fg-2\",\n              )}\n              style={{ transitionDelay: status === \"done\" ? `${i * 30}ms` : undefined }}\n            />\n          </span>\n        );\n      })}\n    </div>\n  );\n}\n\nconst glyphBase = { width: 14, height: 14, viewBox: \"0 0 16 16\", fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.8, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const, \"aria-hidden\": true };\n\nfunction Glyph({ status, reduce }: { status: AgentRunStatus; reduce: boolean }) {\n  const draw = (delay = 0.1) => ({\n    initial: { pathLength: reduce ? 1 : 0 },\n    animate: { pathLength: 1 },\n    transition: reduce ? { duration: 0 } : { duration: 0.34, ease: ease.out, delay },\n  });\n  return (\n    <span className=\"relative grid size-3.5 shrink-0 place-items-center\">\n      <AnimatePresence initial={false}>\n        <motion.span\n          key={status}\n          className=\"absolute inset-0 grid place-items-center\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n          animate={{ opacity: 1, scale: 1 }}\n          exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.6, transition: { duration: 0.12, ease: ease.in } }}\n          transition={reduce ? { duration: 0.15 } : spring.pop}\n        >\n          {status === \"running\" ? (\n            <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"size-3.5 animate-spin [animation-duration:0.9s]\">\n              <circle cx=\"8\" cy=\"8\" r=\"6.25\" stroke=\"var(--line-2)\" strokeWidth=\"2\" />\n              <path d=\"M8 1.75A6.25 6.25 0 0 1 14.25 8\" stroke=\"var(--fg)\" strokeWidth=\"2\" strokeLinecap=\"round\" />\n            </svg>\n          ) : status === \"waiting\" ? (\n            <span className=\"relative grid size-3.5 place-items-center\">\n              <span className=\"absolute size-2 animate-ping-soft rounded-full bg-warning\" />\n              <span className=\"relative size-2 rounded-full bg-warning\" />\n            </span>\n          ) : status === \"done\" ? (\n            <svg {...glyphBase} className=\"text-success\">\n              <motion.path d=\"M3 8.5 6.5 12 13 4.5\" {...draw()} />\n            </svg>\n          ) : status === \"failed\" ? (\n            <svg {...glyphBase} className=\"text-danger\">\n              <motion.path d=\"m4 4 8 8\" {...draw()} />\n              <motion.path d=\"m12 4-8 8\" {...draw(0.18)} />\n            </svg>\n          ) : (\n            <span className=\"size-2.5 rounded-[3px] bg-fg-3\" />\n          )}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction Sheen({ active, children }: { active: boolean; children: React.ReactNode }) {\n  if (!active) return <>{children}</>;\n  return (\n    <span\n      className={cn(\n        \"bg-[linear-gradient(90deg,var(--fg-2)_0%,var(--fg-2)_40%,var(--fg)_50%,var(--fg-2)_60%,var(--fg-2)_100%)]\",\n        \"animate-shine bg-clip-text [background-size:250%_100%] [-webkit-text-fill-color:transparent] rtl:[animation-direction:reverse]\",\n        \"motion-reduce:animate-none motion-reduce:bg-none motion-reduce:[-webkit-text-fill-color:currentColor]\",\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/agent-status"
}
