{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"reasoning-disclosure","type":"registry:component","title":"Reasoning Disclosure","description":"A model's reasoning, shimmering \"Thinking\" while it streams and folding into \"Thought for 8s\" once the answer starts. Reopen it any time.","author":"Ryan","dependencies":["clsx","lucide-react","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/reasoning-disclosure.tsx","type":"registry:component","target":"@components/motion/reasoning-disclosure.tsx","content":"\"use client\";\n// easeui.dev/components/agents/reasoning-disclosure\n\nimport { Brain, ChevronDown } from \"lucide-react\";\nimport { type ReactNode, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ReasoningStatus = \"thinking\" | \"done\";\n\nexport interface ReasoningDisclosureProps {\n  /** \"thinking\" while the model reasons, \"done\" once the answer starts. */\n  status: ReasoningStatus;\n  /** The reasoning text so far. Append to it as it streams. */\n  children: ReactNode;\n  /** Seconds spent thinking, shown once done. Leave it out and the component times it. */\n  duration?: number;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Starting open state when uncontrolled. Default true, so the reasoning shows while it streams. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Folds the reasoning away once status turns \"done\", unless someone opened or closed it by hand. Default true. */\n  collapseOnDone?: boolean;\n  /** Header text while thinking. Default \"Thinking\". */\n  thinkingLabel?: string;\n  className?: string;\n}\n\n/** With a 200% wide gradient, a 200 point position swing moves it one full tile, so the loop never jumps. */\nconst SHIMMER_SWEEP: Keyframe[] = [{ backgroundPosition: \"200% 0\" }, { backgroundPosition: \"0% 0\" }];\nconst SHIMMER_MS = 2000;\n\n// Only the shown label takes up space, so the chevron hugs it. The other one fades out on top of it.\nconst SWAP = \"whitespace-nowrap text-left transition-[opacity,translate] duration-200 ease-out motion-reduce:transition-none\";\nconst SWAP_SHOWN = \"relative translate-y-0 opacity-100\";\nconst SWAP_GONE = \"pointer-events-none absolute left-0 top-0 opacity-0\";\n\nfunction prefersReducedMotion() {\n  return typeof window !== \"undefined\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\n\nfunction formatDuration(seconds: number): string {\n  const whole = Math.max(1, Math.round(seconds));\n  if (whole < 60) return `${whole}s`;\n  const minutes = Math.floor(whole / 60);\n  const rest = whole % 60;\n  return rest ? `${minutes}m ${rest}s` : `${minutes}m`;\n}\n\n/** Tracks how long status stayed \"thinking\", restarting whenever it goes back to thinking. */\nfunction useThinkingTime(status: ReasoningStatus) {\n  const startedAt = useRef<number | null>(null);\n  const [seconds, setSeconds] = useState<number | null>(null);\n\n  useEffect(() => {\n    if (status === \"thinking\") {\n      startedAt.current = Date.now();\n      setSeconds(null);\n      return;\n    }\n    if (startedAt.current !== null) setSeconds((Date.now() - startedAt.current) / 1000);\n  }, [status]);\n\n  return seconds;\n}\n\n/**\n * A model's reasoning, shown while it thinks and folded away once it answers. The header\n * shimmers \"Thinking\" while it works, then crossfades to \"Thought for 8s\". The reasoning\n * stays pinned to its newest line as it streams, and can be reopened any time.\n */\nexport function ReasoningDisclosure({\n  status,\n  children,\n  duration,\n  open,\n  defaultOpen = true,\n  onOpenChange,\n  collapseOnDone = true,\n  thinkingLabel = \"Thinking\",\n  className,\n}: ReasoningDisclosureProps) {\n  const panelId = useId();\n  const shimmerRef = useRef<HTMLSpanElement>(null);\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const touched = useRef(false);\n  // A message loaded from history is already done, so it starts folded rather than folding on mount.\n  const [uncontrolled, setUncontrolled] = useState(defaultOpen && !(status === \"done\" && collapseOnDone));\n  const isControlled = open !== undefined;\n  const isOpen = isControlled ? open : uncontrolled;\n  const thinking = status === \"thinking\";\n  const timed = useThinkingTime(status);\n  const seconds = duration ?? timed;\n  const doneLabel = seconds === null ? \"Thought for a moment\" : `Thought for ${formatDuration(seconds)}`;\n\n  const setOpen = (next: boolean) => {\n    if (!isControlled) setUncontrolled(next);\n    onOpenChange?.(next);\n  };\n\n  // Folding on its own only happens if nobody has opened or closed it by hand.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: only the change to \"done\" should fold it, not every render.\n  useEffect(() => {\n    if (thinking || !collapseOnDone || touched.current) return;\n    setOpen(false);\n  }, [thinking, collapseOnDone]);\n\n  // While thinking, keep the newest line in view as the text grows.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: children is the trigger, the effect reads the DOM.\n  useEffect(() => {\n    const scroller = scrollRef.current;\n    if (thinking && scroller) scroller.scrollTop = scroller.scrollHeight;\n  }, [children, thinking]);\n\n  useEffect(() => {\n    const element = shimmerRef.current;\n    if (!thinking || !element || typeof element.animate !== \"function\" || prefersReducedMotion()) return;\n    const animation = element.animate(SHIMMER_SWEEP, {\n      duration: SHIMMER_MS,\n      iterations: Number.POSITIVE_INFINITY,\n      easing: \"linear\",\n    });\n    return () => animation.cancel();\n  }, [thinking]);\n\n  return (\n    <div className={cn(\"flex flex-col\", className)}>\n      <button\n        type=\"button\"\n        aria-expanded={isOpen}\n        aria-controls={panelId}\n        onClick={() => {\n          touched.current = true;\n          setOpen(!isOpen);\n        }}\n        className={cn(\n          \"group relative -ml-1 inline-flex min-h-9 w-fit touch-manipulation items-center gap-1.5 rounded-full px-1 text-sm text-muted-foreground outline-none\",\n          \"transition-colors duration-150 ease-out hover:text-foreground\",\n          \"focus-visible:ring-2 focus-visible:ring-foreground/40\",\n          \"after:absolute after:-inset-y-1 after:-inset-x-1\",\n        )}\n      >\n        <Brain aria-hidden=\"true\" className=\"h-4 w-4 shrink-0\" />\n        <span className=\"relative\">\n          <span\n            ref={shimmerRef}\n            aria-hidden={!thinking}\n            className={cn(\n              SWAP,\n              \"block bg-clip-text font-medium [-webkit-background-clip:text]\",\n              thinking ? cn(SWAP_SHOWN, \"text-transparent\") : cn(SWAP_GONE, \"-translate-y-1\"),\n            )}\n            style={{\n              backgroundImage:\n                \"linear-gradient(90deg, var(--muted-foreground) 40%, var(--foreground) 50%, var(--muted-foreground) 60%)\",\n              backgroundSize: \"200% 100%\",\n            }}\n          >\n            {thinkingLabel}\n          </span>\n          <span aria-hidden={thinking} className={cn(SWAP, \"block\", thinking ? cn(SWAP_GONE, \"translate-y-1\") : SWAP_SHOWN)}>\n            {doneLabel}\n          </span>\n        </span>\n        <ChevronDown\n          aria-hidden=\"true\"\n          className={cn(\n            \"h-3.5 w-3.5 shrink-0 transition-transform duration-200 ease-out motion-reduce:transition-none\",\n            isOpen && \"rotate-180\",\n          )}\n        />\n        <span aria-live=\"polite\" className=\"sr-only\">\n          {thinking ? \"\" : doneLabel}\n        </span>\n      </button>\n\n      {/* Grows from zero rows to its natural height with a grid transition, so nothing is measured. */}\n      <section\n        id={panelId}\n        aria-label=\"Reasoning\"\n        aria-busy={thinking}\n        inert={!isOpen}\n        className={cn(\n          \"grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none\",\n          isOpen ? \"grid-rows-[1fr]\" : \"grid-rows-[0fr]\",\n        )}\n      >\n        <div className=\"overflow-hidden\">\n          <div\n            ref={scrollRef}\n            className={cn(\n              \"ml-2 mt-1.5 max-h-60 overflow-y-auto border-l-2 border-border pl-3.5 text-sm leading-6 text-muted-foreground\",\n              \"transition-opacity duration-200 ease-out motion-reduce:transition-none\",\n              isOpen ? \"opacity-100\" : \"opacity-0\",\n            )}\n          >\n            {children}\n          </div>\n        </div>\n      </section>\n    </div>\n  );\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}