{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-steps",
  "title": "Agent steps",
  "description": "Shows the steps an AI agent is working through — done, running, waiting or failed — with a count in the header and rows that open to show what happened.",
  "registryDependencies": [
    "button",
    "https://www.quilldesignsystem.com/r/icon.json",
    "https://www.quilldesignsystem.com/r/ai-mark.json"
  ],
  "files": [
    {
      "path": "registry/lib/agent-steps.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { Button } from '@/components/ui/button'\nimport { Icon } from '@/components/ui/icon'\nimport { AiMark } from '@/components/ui/ai-mark'\nimport { cn } from '@/lib/utils'\n\nexport type AgentStepStatus = 'done' | 'running' | 'waiting' | 'failed'\n\nexport type AgentStep = {\n  /** What the step is, in a few words: \"Read signups-sept.csv\". */\n  label: string\n  status: AgentStepStatus\n  /** What happened in the step. A step with detail opens to show it; one without is a plain row. */\n  detail?: React.ReactNode\n  /** A short note at the end of the row, such as how long it took (\"4 s\"). A running step says \"running\" on its own. */\n  meta?: string\n  /** Start with the detail open. */\n  defaultOpen?: boolean\n}\n\nconst STATUSES: AgentStepStatus[] = ['done', 'running', 'waiting', 'failed']\nconst STATE_LABEL: Record<AgentStepStatus, string> = { done: 'Done', running: 'Running', waiting: 'Waiting', failed: 'Failed' }\n// Done goes muted (never struck through); the running step is the one at full strength.\nconst TONE: Record<AgentStepStatus, string> = {\n  done: 'text-muted-foreground',\n  running: 'font-semibold text-foreground',\n  waiting: 'text-ink-soft',\n  failed: 'text-ink-soft',\n}\n// items-start, with the dot in a box one line tall, keeps the dot on the first line of a label that wraps.\nconst ROW = 'flex min-w-0 flex-1 items-start gap-2.5 rounded-md px-1 py-1.5 text-left'\n\n// A status this version does not know (a newer agent, a typo) reads as waiting rather than drawing nothing.\nconst statusOf = (step: AgentStep): AgentStepStatus => (STATUSES.includes(step.status) ? step.status : 'waiting')\nconst hasDetail = (step: AgentStep) => step.detail !== undefined && step.detail !== null && step.detail !== false && step.detail !== ''\n\n// Progress is moss and terracotta, never the AI gradient. The deep cuts clear 3:1 on the card in every theme.\nfunction StepDot({ status }: { status: AgentStepStatus }) {\n  if (status === 'running') return <Icon name=\"progress_activity\" className=\"size-icon-md animate-spin text-moss-deep motion-reduce:animate-none\" />\n  if (status === 'done') return <span className=\"grid size-4 place-items-center rounded-full bg-moss-deep text-paper\"><Icon name=\"check\" className=\"size-icon-xs\" /></span>\n  if (status === 'failed') return <span className=\"grid size-4 place-items-center rounded-full bg-terracotta-deep text-paper\"><Icon name=\"close\" className=\"size-icon-xs\" /></span>\n  return <span className=\"size-4 rounded-full border-2 border-input\" />\n}\n\n/** Shows the steps an AI agent is working through — done, running, waiting or failed — with a count in the header and rows that open to show what happened. */\nexport function AgentSteps({ title, steps, onRetry, className }: {\n  title: string\n  steps: AgentStep[]\n  /** Shows Retry on failed steps; called with the step's position in the list. */\n  onRetry?: (index: number) => void\n  className?: string\n}) {\n  const baseId = React.useId()\n  // Open rows are tracked by position: labels may repeat, positions cannot.\n  const [open, setOpen] = React.useState<Set<number>>(\n    () => new Set(steps.flatMap((step, index) => (step.defaultOpen && hasDetail(step) ? [index] : []))),\n  )\n  // The status region is on the page from the first paint but stays empty until just after mount: a live region\n  // that arrives already filled is announced unreliably, one whose text changes is.\n  const [announcing, setAnnouncing] = React.useState(false)\n  React.useEffect(() => {\n    const timer = window.setTimeout(() => setAnnouncing(true), 100)\n    return () => window.clearTimeout(timer)\n  }, [])\n\n  const statuses = steps.map(statusOf)\n  const doneCount = statuses.filter((status) => status === 'done').length\n  const runningIndex = statuses.indexOf('running')\n  const failedIndex = statuses.indexOf('failed')\n  const active = runningIndex !== -1 || failedIndex !== -1\n  // A list that was never active while on the page (restored from history, already finished) must not announce itself.\n  const [wasActive, setWasActive] = React.useState(active)\n  if (active && !wasActive) setWasActive(true)\n\n  if (!steps.length) return null\n\n  const count = `${doneCount} of ${steps.length} done`\n  const spoken = !announcing || !(active || wasActive) ? ''\n    : failedIndex !== -1 ? `Failed: ${steps[failedIndex].label}. ${count}`\n    : runningIndex !== -1 ? `Running: ${steps[runningIndex].label}. ${count}`\n    : count\n  const toggle = (index: number) => setOpen((current) => {\n    const next = new Set(current)\n    if (!next.delete(index)) next.add(index)\n    return next\n  })\n\n  return (\n    <div data-slot=\"agent-steps\" role=\"group\" aria-labelledby={`${baseId}-title`}\n      className={cn('grid gap-2.5 rounded-xl border border-border bg-card px-4 py-3.5 text-sm shadow-sm', className)}>\n      {/* px-1 matches the rows' inset, so the mark lines up with the dots and the count with the times. */}\n      <div className=\"flex items-center gap-2 px-1\">\n        <AiMark size=\"icon-md\" />\n        <p id={`${baseId}-title`} className=\"min-w-0 flex-1 font-semibold text-ink-soft\">{title}</p>\n        {/* Hidden from screen readers only while the status region below is saying the same count. */}\n        <p data-slot=\"step-count\" aria-hidden={spoken ? true : undefined} className=\"shrink-0 text-xs font-medium text-muted-foreground tabular-nums\">{count}</p>\n      </div>\n      {/* sr-only makes the region a real box that takes no space; it carries the one copy a screen reader hears. */}\n      <div role=\"status\" className=\"sr-only\">{spoken}</div>\n      <ol className=\"grid gap-0.5\">\n        {steps.map((step, index) => {\n          const status = statuses[index]\n          const expandable = hasDetail(step)\n          const isOpen = expandable && open.has(index)\n          const detailId = `${baseId}-detail-${index}`\n          const meta = step.meta ?? (status === 'running' ? 'running' : undefined)\n          const row = (\n            <>\n              <span data-slot=\"step-dot\" data-status={status} className=\"flex h-[1lh] shrink-0 items-center\"><StepDot status={status} /></span>\n              <span className=\"min-w-0 flex-1\">\n                <span className=\"sr-only\">{STATE_LABEL[status]}: </span>\n                <span data-slot=\"step-text\">{step.label}</span>\n                {expandable ? <Icon name=\"chevron_right\" className={cn('size-icon-sm', 'ml-1 align-[-0.15em] text-muted-foreground transition-transform motion-reduce:transition-none', isOpen && 'rotate-90')} /> : null}\n              </span>\n              {meta ? (\n                // The outer box is one row-line tall so the smaller note still sits on the label's first line.\n                // The automatic \"running\" is hidden from screen readers: the row already starts with \"Running:\".\n                <span aria-hidden={step.meta === undefined ? true : undefined} className=\"flex h-[1lh] shrink-0 items-center\">\n                  <span className=\"text-xs font-medium text-muted-foreground tabular-nums\">{meta}</span>\n                </span>\n              ) : null}\n            </>\n          )\n          return (\n            <li key={index}>\n              <div className=\"flex items-start gap-1\">\n                {expandable ? (\n                  <button type=\"button\" aria-expanded={isOpen} aria-controls={detailId} onClick={() => toggle(index)}\n                    className={cn(ROW, TONE[status], 'hover:bg-muted outline-hidden focus-visible:ring-3 focus-visible:ring-ring/50')}>\n                    {row}\n                  </button>\n                ) : (\n                  <div className={cn(ROW, TONE[status])}>{row}</div>\n                )}\n                {status === 'failed' && onRetry ? (\n                  <Button type=\"button\" variant=\"ghost\" size=\"xs\" className=\"mt-1 shrink-0 text-destructive\" aria-label={`Retry: ${step.label}`} onClick={() => onRetry(index)}>Retry</Button>\n                ) : null}\n              </div>\n              {expandable ? (\n                <div id={detailId} hidden={!isOpen} className=\"mt-0.5 mb-1.5 ml-8 border-l-2 border-border pl-2.5 text-xs text-muted-foreground\">{step.detail}</div>\n              ) : null}\n            </li>\n          )\n        })}\n      </ol>\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/agent-steps.tsx"
    }
  ],
  "docs": "Shows the steps an AI agent is working through — done, running, waiting or failed — with a count in the header and rows that open to show what happened. Do: Leave the ticks and the spinner in moss; the AI mark in the header already says an agent is working. Don't: Paint progress with the AI gradient — the gradient marks AI, moss marks progress. Do: Let finished steps go muted so the running step stands out. Don't: Strike finished steps through — long labels become hard to read. Do: Put what a step found in its detail, opened by pressing the row. Don't: Print every step's detail in the list — the list has to stay scannable. Use ai-thinking when the AI is only thinking before one answer; there are no separate steps to follow. Use progress when you know a percentage and there is nothing to read step by step. Use wizard when the person, not the AI, moves through the steps.",
  "type": "registry:ui"
}