{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"thinking-trace","type":"registry:component","title":"Thinking trace","description":"An expandable record of what an agent did: steps, reasoning, search or tool calls.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/thinking-trace.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AlertCircle, ChevronDown, CircleSlash, Globe, Search, Sparkle, X } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * THINKING TRACE: an expandable record of what an agent did\n *\n *   steps      a checklist: spinner on the live step, which blurs\n *              into a check that draws itself when it's done\n *   reasoning  sentences of reasoning\n *   search     the query, then the sources read\n *   tools      tool calls: reads, edits with +/− counts, commands\n *\n * The header never swaps: \"Thinking 6s\" morphs into \"Thought for\n * 6s\" (the seconds roll as they pass), \"Searching the web\" into\n * \"Searched the web\", the sparkle into an alert on error. The\n * list eases taller as steps arrive.\n *\n * Drive it from your stream: pass the steps you have so far and a\n * status. It opens while running, folds away once done (stays open\n * on error so the failure is visible), and can always be reopened.\n * ───────────────────────────────────────────────────────── */\n\nexport type ThinkingVariant = \"steps\" | \"reasoning\" | \"search\" | \"tools\";\nexport type ThinkingStatus = \"running\" | \"done\" | \"error\" | \"cancelled\";\nexport type ThinkingStepStatus = \"pending\" | \"running\" | \"done\" | \"error\";\n\nexport interface ThinkingStep {\n  /** A step, a sentence of reasoning, a source title or a tool name. */\n  label: string;\n  /** Supporting text: a count, a domain or a file path. */\n  detail?: string;\n  /** Makes the row a link, e.g. a search source. */\n  href?: string;\n  /** Replaces the row's default mark, e.g. a site favicon. */\n  icon?: React.ReactNode;\n  /** Defaults to \"running\" for the last step while running, \"done\" otherwise. */\n  status?: ThinkingStepStatus;\n  /** Lines added and removed, for edit tool calls. */\n  additions?: number;\n  deletions?: number;\n}\n\nexport interface ThinkingTraceProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n  steps: ThinkingStep[];\n  variant?: ThinkingVariant;\n  status?: ThinkingStatus;\n  /** Header while running. Defaults per variant, e.g. \"Searching the web\". */\n  label?: string;\n  /** Header once settled. Defaults to e.g. \"Thought for 6s\" when timing is known. */\n  doneLabel?: string;\n  /** When the work started (ms). Shows a live timer and times the done label. */\n  startedAt?: number;\n  /** How long it took (ms), if your stream reports it. */\n  duration?: number;\n  /** The search query, shown above the sources. */\n  query?: string;\n  /** Items not listed, shown as \"+N more\". */\n  more?: number;\n  /** Replaces the sparkle in the header. */\n  icon?: React.ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Makes rows without an href clickable, e.g. to show a tool's output. */\n  onStepClick?: (step: ThinkingStep, index: number) => void;\n}\n\nconst RUNNING_LABEL: Record<ThinkingVariant, string> = {\n  steps: \"Thinking\",\n  reasoning: \"Thinking\",\n  search: \"Searching the web\",\n  tools: \"Running tools\",\n};\n\nconst SOURCE_TONES = [\"bg-blue-600\", \"bg-orange-500\", \"bg-emerald-600\", \"bg-violet-600\"];\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst ROW = \"flex min-h-7 w-full gap-2 rounded-md px-1.5 py-0.5 text-left\";\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\n// A light that sweeps across the label. A mask, not a text clip, so it reaches letters that are mid-morph.\nconst SHEEN =\n  \"text-foreground [mask-image:linear-gradient(90deg,rgb(0_0_0/0.45)_35%,#000_50%,rgb(0_0_0/0.45)_65%)] [mask-size:200%_100%] animate-[ui-sheen_1.4s_linear_infinite] motion-reduce:animate-none motion-reduce:[mask-image:none] motion-reduce:text-foreground/70\";\nconst TWO_DIGITS = { minimumIntegerDigits: 2 };\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\n/** A piece of a line that opens out of nothing and folds back into it. */\nfunction Reveal({ show, reduced, className = \"\", children }: { show: boolean; reduced: boolean; className?: string; children: React.ReactNode }) {\n  return (\n    <span\n      aria-hidden={!show || undefined}\n      className={`grid ${className}`}\n      style={{\n        gridTemplateColumns: show ? \"1fr\" : \"0fr\",\n        opacity: show ? 1 : 0,\n        filter: show ? \"none\" : \"blur(3px)\",\n        transition: reduced ? \"none\" : `grid-template-columns 420ms ${MORPH}, opacity ${show ? \"300ms\" : \"160ms\"} ${MORPH}, filter 300ms ${MORPH}`,\n      }}\n    >\n      <span className=\"min-w-0 whitespace-nowrap [clip-path:inset(-4px_0)]\">{children}</span>\n    </span>\n  );\n}\n\n/** Seconds that roll up as they pass; minutes slide open at the first minute. */\nfunction Elapsed({ ms, reduced }: { ms: number; reduced: boolean }) {\n  const total = Math.max(0, Math.round(ms / 1000));\n  const minutes = Math.floor(total / 60);\n  return (\n    <span className=\"inline-flex items-baseline\">\n      <Reveal show={minutes > 0} reduced={reduced}>\n        <NumberRoll value={minutes} suffix=\"m\" duration={500} />\n        &nbsp;\n      </Reveal>\n      <NumberRoll value={minutes ? total % 60 : total} format={minutes ? TWO_DIGITS : undefined} suffix=\"s\" direction=\"up\" duration={500} />\n    </span>\n  );\n}\n\nfunction StepCheck({ drawn, reduced }: { drawn: boolean; reduced: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\">\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={{ strokeDashoffset: drawn ? 0 : 1, transition: drawn && !reduced ? `stroke-dashoffset 380ms ${MORPH} 100ms` : \"none\" }}\n      />\n    </svg>\n  );\n}\n\nfunction toneFor(key: string) {\n  let hash = 0;\n  for (let i = 0; i < key.length; i++) hash = (hash * 31 + key.charCodeAt(i)) | 0;\n  return SOURCE_TONES[Math.abs(hash) % SOURCE_TONES.length];\n}\n\nfunction formatDuration(ms: number) {\n  const s = Math.max(0, Math.round(ms / 1000));\n  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;\n}\n\n/** Spins only while it's showing, so a faded-out spinner never keeps the page busy. */\nfunction Spinner({ spinning = true }: { spinning?: boolean }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={`size-3 shrink-0 rounded-full border-[1.5px] border-border border-t-foreground/70 motion-reduce:animate-none ${spinning ? \"animate-spin\" : \"\"}`}\n    />\n  );\n}\n\nexport function ThinkingTrace({\n  steps,\n  variant = \"steps\",\n  status = \"done\",\n  label,\n  doneLabel,\n  startedAt,\n  duration,\n  query,\n  more,\n  icon,\n  open,\n  defaultOpen,\n  onOpenChange,\n  onStepClick,\n  className = \"\",\n  ...props\n}: ThinkingTraceProps) {\n  const reduced = useReducedMotion();\n  const running = status === \"running\";\n  const [autoOpen, setAutoOpen] = React.useState(running);\n  const [userOpen, setUserOpen] = React.useState<boolean | undefined>(defaultOpen);\n  const expanded = open ?? userOpen ?? autoOpen;\n  const panelId = React.useId();\n  // Rows present on first render stagger in; rows streamed in later appear as they arrive.\n  const initialCount = React.useRef(steps.length);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const [listHeight, setListHeight] = React.useState<number | null>(null);\n\n  // The panel takes the height of its list, so each new step eases it taller instead of jumping.\n  React.useLayoutEffect(() => {\n    const el = listRef.current;\n    if (!el) return;\n    const measure = () => setListHeight((h) => (h === el.offsetHeight ? h : el.offsetHeight));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  // Live timer while running; remember when it settled so the done label can say how long it took.\n  const [now, setNow] = React.useState(startedAt ?? 0);\n  const [endedAt, setEndedAt] = React.useState<number | undefined>(undefined);\n  React.useEffect(() => {\n    if (!running) {\n      setEndedAt((prev) => prev ?? Date.now());\n      return;\n    }\n    setEndedAt(undefined);\n    if (startedAt === undefined) return;\n    setNow(Date.now());\n    const timer = window.setInterval(() => setNow(Date.now()), 1000);\n    return () => window.clearInterval(timer);\n  }, [running, startedAt]);\n\n  // Open while working; fold away after it settles, except on error so the failure stays visible.\n  React.useEffect(() => {\n    if (running || status === \"error\") {\n      setAutoOpen(true);\n      return;\n    }\n    const timer = window.setTimeout(() => setAutoOpen(false), 900);\n    return () => window.clearTimeout(timer);\n  }, [running, status]);\n\n  const toggle = () => {\n    const next = !expanded;\n    if (open === undefined) setUserOpen(next);\n    onOpenChange?.(next);\n  };\n\n  const took = duration ?? (startedAt !== undefined && endedAt !== undefined ? endedAt - startedAt : undefined);\n  // \"Thought for\" keeps the timer beside it, so the seconds that ran become the answer.\n  const timed = status === \"done\" && !doneLabel && (variant === \"steps\" || variant === \"reasoning\") && took !== undefined;\n  const settledLabel =\n    status === \"error\"\n      ? \"Couldn’t finish\"\n      : status === \"cancelled\"\n        ? \"Stopped\"\n        : variant === \"search\"\n          ? \"Searched the web\"\n          : variant === \"tools\"\n            ? `Ran ${steps.length} ${steps.length === 1 ? \"tool\" : \"tools\"}`\n            : timed\n              ? \"Thought for\"\n              : \"Thought it through\";\n  const text = running ? (label ?? RUNNING_LABEL[variant]) : (doneLabel ?? settledLabel);\n  const showTime = (running && startedAt !== undefined) || timed;\n  const time = running ? now - (startedAt ?? now) : (took ?? 0);\n  const spoken = running ? text : timed ? `${text} ${formatDuration(time)}` : text;\n\n  const headerIcons: Record<\"sparkle\" | \"error\" | \"cancelled\", React.ReactNode> = {\n    sparkle: <Sparkle className=\"size-4\" fill=\"currentColor\" strokeWidth={0} />,\n    error: <AlertCircle className=\"size-4 text-red-500\" />,\n    cancelled: <CircleSlash className=\"size-4\" />,\n  };\n  const headerKey = status === \"error\" ? \"error\" : status === \"cancelled\" ? \"cancelled\" : \"sparkle\";\n\n  return (\n    <div className={`flex w-full flex-col ${className}`} {...props}>\n      <button\n        type=\"button\"\n        aria-expanded={expanded}\n        aria-controls={panelId}\n        onClick={toggle}\n        className={`-mx-1.5 flex w-fit max-w-full items-center gap-2 rounded-lg px-1.5 py-1 text-[13px] font-medium transition-colors duration-100 hover:bg-accent ${FOCUS}`}\n      >\n        <span aria-hidden=\"true\" className={`relative flex size-4 shrink-0 transition-colors duration-200 ${running ? \"text-foreground/70\" : \"text-muted-foreground\"}`}>\n          {icon ??\n            (Object.keys(headerIcons) as (keyof typeof headerIcons)[]).map((k) => (\n              <span key={k} className=\"absolute inset-0 flex items-center justify-center\" style={swap(k === headerKey, reduced)}>\n                {headerIcons[k]}\n              </span>\n            ))}\n        </span>\n        <span className=\"flex min-w-0 items-baseline\">\n          <span className={`min-w-0 truncate transition-colors duration-300 ${running ? SHEEN : \"text-foreground/75\"}`}>\n            <TextMorph>{text}</TextMorph>\n          </span>\n          {(startedAt !== undefined || duration !== undefined) && (\n            <Reveal show={showTime} reduced={reduced} className=\"shrink-0\">\n              <span className=\"pl-1.5 text-[12.5px] font-normal tabular-nums text-muted-foreground\">\n                <Elapsed ms={time} reduced={reduced} />\n              </span>\n            </Reveal>\n          )}\n        </span>\n        <ChevronDown\n          aria-hidden=\"true\"\n          className={`size-3.5 shrink-0 text-muted-foreground transition-transform duration-300 motion-reduce:transition-none ${expanded ? \"rotate-180\" : \"\"}`}\n        />\n      </button>\n      {/* Outside the button, so its name stays its label. Never the ticking timer. */}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {spoken}\n      </span>\n\n      <div\n        id={panelId}\n        inert={!expanded}\n        className=\"overflow-hidden\"\n        style={{\n          height: expanded ? (listHeight ?? \"auto\") : 0,\n          opacity: expanded ? 1 : 0,\n          transition: reduced || listHeight === null ? \"none\" : `height 420ms ${MORPH}, opacity 320ms ${MORPH}`,\n        }}\n      >\n        <div ref={listRef}>\n          <div className=\"relative ml-[5px] mt-1 pl-4\">\n            <span aria-hidden=\"true\" className=\"absolute bottom-1.5 left-[3px] top-0 w-px bg-border\" />\n            <ul className=\"flex flex-col gap-0.5 py-1\">\n              {query && (\n                <li className=\"flex h-7 items-center gap-2 px-1.5 text-[12.5px] text-muted-foreground animate-[ui-fade-up_300ms_cubic-bezier(0.23,1,0.32,1)_both] motion-reduce:animate-none\">\n                  <Search className=\"size-3.5 shrink-0\" aria-hidden=\"true\" />\n                  <span className=\"truncate\">{query}</span>\n                </li>\n              )}\n\n              {steps.map((step, i) => {\n                const last = i === steps.length - 1;\n                const stepStatus: ThinkingStepStatus =\n                  step.status ?? (last && running ? \"running\" : last && status === \"error\" ? \"error\" : \"done\");\n                const failed = stepStatus === \"error\";\n                const pending = stepStatus === \"pending\";\n                const align = variant === \"reasoning\" ? \"items-start\" : \"items-center\";\n\n                const mark =\n                  variant === \"steps\" ? (\n                    // Every mark is there at once; the one for this step's status shows, and they trade places through a blur.\n                    <span aria-hidden=\"true\" className={`relative flex size-3.5 shrink-0 items-center justify-center ${align === \"items-start\" ? \"mt-px\" : \"\"}`}>\n                      <span className=\"absolute inset-0 flex items-center justify-center\" style={swap(pending, reduced)}>\n                        <span className=\"size-3 rounded-full border-[1.5px] border-border\" />\n                      </span>\n                      <span className=\"absolute inset-0 flex items-center justify-center\" style={swap(stepStatus === \"running\", reduced)}>\n                        <Spinner spinning={stepStatus === \"running\"} />\n                      </span>\n                      <span className=\"absolute inset-0 flex items-center justify-center text-muted-foreground\" style={swap(stepStatus === \"done\", reduced)}>\n                        <StepCheck drawn={stepStatus === \"done\"} reduced={reduced} />\n                      </span>\n                      <span className=\"absolute inset-0 flex items-center justify-center text-red-500\" style={swap(failed, reduced)}>\n                        <X className=\"size-3.5\" strokeWidth={2.5} />\n                      </span>\n                    </span>\n                  ) : variant === \"search\" ? (\n                    step.icon ? (\n                      <span aria-hidden=\"true\" className=\"flex size-3.5 shrink-0 items-center justify-center overflow-hidden rounded-full\">\n                        {step.icon}\n                      </span>\n                    ) : (\n                      <span\n                        aria-hidden=\"true\"\n                        className={`flex size-3.5 shrink-0 items-center justify-center rounded-full text-white ${failed ? \"bg-muted-foreground/40\" : toneFor(step.detail ?? step.label)}`}\n                      >\n                        <Globe className=\"size-[9px]\" strokeWidth={2.5} />\n                      </span>\n                    )\n                  ) : null;\n\n                const trailing =\n                  variant === \"search\" || variant === \"tools\" ? (\n                    <span className=\"ml-auto flex shrink-0 items-center pl-2\">\n                      <span\n                        className=\"flex justify-end overflow-hidden\"\n                        style={{ width: stepStatus === \"running\" ? 12 : 0, ...swap(stepStatus === \"running\", reduced), transition: reduced ? \"none\" : `width 380ms ${MORPH}, opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}` }}\n                      >\n                        <Spinner spinning={stepStatus === \"running\"} />\n                      </span>\n                      <Reveal show={failed} reduced={reduced}>\n                        <span className=\"text-[11.5px] font-medium text-red-500\">Failed</span>\n                      </Reveal>\n                    </span>\n                  ) : null;\n\n                const content = (\n                  <>\n                    {mark}\n                    {variant === \"tools\" ? (\n                      <>\n                        <span className={`shrink-0 text-[12.5px] font-medium ${pending ? \"text-muted-foreground\" : \"text-foreground\"}`}>{step.label}</span>\n                        {step.detail && <span className=\"min-w-0 truncate font-mono text-[11.5px] text-muted-foreground\">{step.detail}</span>}\n                      </>\n                    ) : (\n                      <>\n                        <span\n                          className={\n                            variant === \"reasoning\"\n                              ? \"text-[12.5px] leading-relaxed text-muted-foreground\"\n                              : `min-w-0 truncate text-[12.5px] font-medium ${pending ? \"text-muted-foreground\" : \"text-foreground\"}`\n                          }\n                        >\n                          {step.label}\n                        </span>\n                        {step.detail && <span className=\"shrink-0 text-[11.5px] text-muted-foreground\">{step.detail}</span>}\n                      </>\n                    )}\n                    {step.additions !== undefined && (\n                      <span className=\"shrink-0 font-mono text-[11px] tabular-nums\">\n                        <span className=\"text-emerald-600\">+{step.additions}</span> <span className=\"text-red-500\">−{step.deletions ?? 0}</span>\n                      </span>\n                    )}\n                    {trailing}\n                  </>\n                );\n                const delay = i < initialCount.current ? i * 90 : 0;\n\n                return (\n                  <li\n                    key={`${i}-${step.label}`}\n                    className=\"animate-[ui-fade-up_320ms_cubic-bezier(0.23,1,0.32,1)_both] motion-reduce:animate-none\"\n                    style={{ animationDelay: `${delay}ms` }}\n                  >\n                    {step.href ? (\n                      <a href={step.href} target=\"_blank\" rel=\"noreferrer\" className={`${ROW} ${align} transition-colors duration-150 hover:bg-accent ${FOCUS}`}>\n                        {content}\n                      </a>\n                    ) : onStepClick ? (\n                      <button\n                        type=\"button\"\n                        onClick={() => onStepClick(step, i)}\n                        className={`${ROW} ${align} cursor-pointer transition-colors duration-150 hover:bg-accent ${FOCUS}`}\n                      >\n                        {content}\n                      </button>\n                    ) : (\n                      <div className={`${ROW} ${align}`}>{content}</div>\n                    )}\n                  </li>\n                );\n              })}\n\n              {more ? (\n                <li className=\"px-1.5 pt-0.5 text-xs text-muted-foreground animate-[ui-fade-in_300ms_ease-out_both] motion-reduce:animate-none\">+{more} more</li>\n              ) : null}\n            </ul>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-sheen":{"from":{"mask-position":"150% 0"},"to":{"mask-position":"-50% 0"}},"@keyframes ui-fade-up":{"from":{"opacity":"0","transform":"translateY(4px)"},"to":{"opacity":"1","transform":"none"}},"@keyframes ui-fade-in":{"from":{"opacity":"0"},"to":{"opacity":"1"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/thinking-trace"}