{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-thinking",
  "title": "AI thinking",
  "description": "Shows an AI working before its answer — a shimmering \"Thinking\" with what it is doing — then folds into one quiet \"Thought for 8 s\" row that opens the reasoning.",
  "registryDependencies": [
    "https://www.quilldesignsystem.com/r/icon.json"
  ],
  "files": [
    {
      "path": "registry/lib/ai-thinking.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { Icon } from '@/components/ui/icon'\nimport { cn } from '@/lib/utils'\n\n/** \"8 s\", \"1 min 38 s\", \"2 min\" — how long the AI thought, as the finished row says it. Under a second, negative, NaN or Infinity all read \"1 s\", the shortest it says; AiThinking itself shows \"Thought it through\" instead of a duration it cannot trust. */\nexport function formatThoughtFor(seconds: number) {\n  const s = Number.isFinite(seconds) ? Math.max(1, Math.round(seconds)) : 1\n  if (s < 60) return `${s} s`\n  const m = Math.floor(s / 60)\n  const r = s % 60\n  return r ? `${m} min ${r} s` : `${m} min`\n}\n\nexport type AiThinkingProps = {\n  status: 'working' | 'done'\n  /** The working label. */\n  label?: string\n  /** What the AI is doing right now, shown under the label while working. */\n  activity?: string\n  /** How long it thought; shown once done. */\n  seconds?: number\n  /** The reasoning, opened from the finished row. */\n  steps?: React.ReactNode[]\n  defaultOpen?: boolean\n  className?: string\n}\n\n/** Shows an AI working before its answer — a shimmering \"Thinking\" with what it is doing — then folds into one quiet \"Thought for 8 s\" row that opens the reasoning. */\nexport function AiThinking({ status, label = 'Thinking', activity, seconds, steps, defaultOpen = false, className }: AiThinkingProps) {\n  const [open, setOpen] = React.useState(defaultOpen)\n  const listId = React.useId()\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. The timer (not a direct\n  // setState in the effect) is what lets the first paint go out empty.\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 working = status === 'working'\n  // A duration that is missing, not a number or negative would read as nonsense, so it is treated as unknown.\n  const unknownSeconds = seconds === undefined || !Number.isFinite(seconds) || seconds < 0\n  const summary = unknownSeconds ? 'Thought it through' : `Thought for ${formatThoughtFor(seconds)}`\n  const hasSteps = Boolean(steps?.length)\n  return (\n    <div data-slot=\"ai-thinking\" data-status={status} className={cn(working ? 'grid gap-0.5' : 'grid gap-1', className)}>\n      {/* One live region stays mounted across both states, so the working text changes into nothing instead of a\n          fresh region arriving already filled. It is empty once done: the finished row says the rest, and a done\n          reply restored from history must not announce itself. sr-only makes it a real box that takes no space,\n          and carries the only copy a screen reader hears; the visible label below is hidden from it. */}\n      <div role=\"status\" className=\"sr-only\">\n        {working && announcing ? [label, activity].filter(Boolean).join('. ') : ''}\n      </div>\n      {working ? (\n        <>\n          <span aria-hidden className=\"ai-shimmer w-fit text-sm font-semibold\">{label}</span>\n          {activity ? <span aria-hidden className=\"text-xs text-muted-foreground\">{activity}</span> : null}\n          <span aria-hidden className=\"ai-line mt-1.5\" />\n        </>\n      ) : null}\n      {working ? null : hasSteps ? (\n        <button type=\"button\" aria-expanded={open} aria-controls={listId} onClick={() => setOpen((o) => !o)}\n          // px-1 gives the focus ring room around the chevron and the last letter; -mx-1 keeps the chevron in line with the steps below.\n          className=\"-mx-1 inline-flex w-fit items-center gap-1 rounded-sm px-1 text-sm text-muted-foreground hover:text-foreground outline-hidden focus-visible:ring-3 focus-visible:ring-ring/50\">\n          <Icon name=\"chevron_right\" className={cn('size-icon-sm', 'transition-transform motion-reduce:transition-none', open && 'rotate-90')} />\n          {summary}\n        </button>\n      ) : (\n        <span className=\"text-sm text-muted-foreground\">{summary}</span>\n      )}\n      {!working && hasSteps ? (\n        <ol id={listId} hidden={!open} className=\"ml-1.5 grid gap-1 border-l-2 border-border pl-3.5 text-xs text-muted-foreground\">\n          {steps!.map((step, i) => <li key={i}>{step}</li>)}\n        </ol>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/ai-thinking.tsx"
    }
  ],
  "docs": "Shows an AI working before its answer — a shimmering \"Thinking\" with what it is doing — then folds into one quiet \"Thought for 8 s\" row that opens the reasoning. Do: Switch to status=\"done\" the moment the answer starts, so the shimmer and line stop. Don't: Leave the shimmer running beside a finished answer — motion means the AI is still working. Do: Keep the reasoning folded behind \"Thought for N s\" once the answer is there. Don't: Show the reasoning open by default after the answer lands — it pushes the answer down. Use spinner when something that is not AI is loading. Use skeleton when a page or card is loading its layout, not an AI thinking.",
  "type": "registry:ui"
}