{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"reasoning","title":"Reasoning","description":"Collapsible model reasoning trace with streaming-aware labels and markdown body","dependencies":["@hugeicons/react","@hugeicons/core-free-icons","streamdown"],"registryDependencies":["collapsible"],"files":[{"path":"components/nexus-ui/reasoning.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AiBrain01Icon, ArrowDown01Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Streamdown } from \"streamdown\";\n\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport { TextShimmer } from \"@/components/nexus-ui/text-shimmer\";\nimport { useOnChange } from \"@/lib/use-on-change\";\nimport { cn } from \"@/lib/utils\";\n\ntype ReasoningContextValue = {\n  isStreaming: boolean;\n  label: string;\n};\n\nconst ReasoningContext = React.createContext<ReasoningContextValue | null>(\n  null,\n);\n\nfunction useReasoningContext(component: string): ReasoningContextValue {\n  const ctx = React.useContext(ReasoningContext);\n  if (!ctx) {\n    throw new Error(`${component} must be used within <Reasoning>`);\n  }\n  return ctx;\n}\n\ntype ReasoningProps = Omit<\n  React.ComponentProps<typeof Collapsible>,\n  \"open\" | \"defaultOpen\" | \"onOpenChange\"\n> & {\n  isStreaming?: boolean;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n};\n\nfunction Reasoning({\n  className,\n  isStreaming = false,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  children,\n  ...props\n}: ReasoningProps) {\n  const isControlled = openProp !== undefined;\n  const [internalOpen, setInternalOpen] = React.useState(\n    defaultOpen || isStreaming,\n  );\n  const open = isControlled ? openProp : internalOpen;\n  const [durationLabel, setDurationLabel] = React.useState<string | null>(null);\n  const [hasStreamed, setHasStreamed] = React.useState(isStreaming);\n  const startedAtRef = React.useRef<number | null>(null);\n\n  React.useEffect(() => {\n    if (isStreaming) {\n      startedAtRef.current = Date.now();\n      onOpenChange?.(true);\n    }\n  }, [isStreaming, onOpenChange]);\n\n  useOnChange(isStreaming, (current, previous) => {\n    if (!previous && current) {\n      setHasStreamed(true);\n      startedAtRef.current = Date.now();\n      setDurationLabel(null);\n      if (!isControlled) {\n        setInternalOpen(true);\n      }\n      onOpenChange?.(true);\n    }\n\n    if (previous && !current) {\n      const startedAt = startedAtRef.current;\n      const elapsedSeconds =\n        startedAt != null\n          ? Math.max(1, Math.round((Date.now() - startedAt) / 1000))\n          : null;\n      setDurationLabel(\n        elapsedSeconds != null ? String(elapsedSeconds) : \"a few\",\n      );\n      startedAtRef.current = null;\n      if (!isControlled) {\n        setInternalOpen(false);\n      }\n      onOpenChange?.(false);\n    }\n  });\n\n  const label = React.useMemo(() => {\n    if (!hasStreamed || isStreaming) return \"Thinking...\";\n    if (durationLabel != null) {\n      const unit = durationLabel === \"1\" ? \"second\" : \"seconds\";\n      return `Thought for ${durationLabel} ${unit}`;\n    }\n    return \"Thought for a few seconds\";\n  }, [durationLabel, hasStreamed, isStreaming]);\n\n  const contextValue = React.useMemo(\n    () => ({ isStreaming, label }),\n    [isStreaming, label],\n  );\n\n  const handleOpenChange = React.useCallback(\n    (nextOpen: boolean) => {\n      const resolvedOpen = isStreaming ? true : nextOpen;\n      if (!isControlled) {\n        setInternalOpen(resolvedOpen);\n      }\n      onOpenChange?.(resolvedOpen);\n    },\n    [isControlled, isStreaming, onOpenChange],\n  );\n\n  return (\n    <ReasoningContext.Provider value={contextValue}>\n      <Collapsible\n        data-slot=\"reasoning\"\n        className={cn(\"not-prose w-full\", className)}\n        data-streaming={isStreaming ? \"true\" : \"false\"}\n        open={open}\n        onOpenChange={handleOpenChange}\n        {...props}\n      >\n        {children}\n      </Collapsible>\n    </ReasoningContext.Provider>\n  );\n}\n\ntype ReasoningTriggerProps = React.ComponentProps<typeof CollapsibleTrigger>;\n\nfunction ReasoningTrigger({\n  className,\n  children,\n  ...props\n}: ReasoningTriggerProps) {\n  const { isStreaming, label } = useReasoningContext(\"ReasoningTrigger\");\n\n  return (\n    <CollapsibleTrigger\n      data-slot=\"reasoning-trigger\"\n      data-streaming={isStreaming ? \"true\" : \"false\"}\n      className={cn(\n        \"group flex cursor-pointer items-center gap-1.25 text-muted-foreground transition-colors hover:text-foreground\",\n        className,\n      )}\n      {...props}\n    >\n      <HugeiconsIcon\n        icon={AiBrain01Icon}\n        strokeWidth={1.75}\n        className=\"size-4\"\n      />\n      <TextShimmer\n        className=\"text-sm leading-6\"\n        spread={10}\n        invertLight\n        disableShimmer={!isStreaming}\n      >\n        {children ?? label}\n      </TextShimmer>\n      <HugeiconsIcon\n        icon={ArrowDown01Icon}\n        strokeWidth={2.0}\n        className=\"ml-0.5 size-4 opacity-0 transition-all group-hover:opacity-100 group-data-[state=open]:rotate-180 group-data-[state=open]:group-data-[streaming=false]:opacity-100\"\n      />\n    </CollapsibleTrigger>\n  );\n}\n\ntype ReasoningContentProps = Omit<\n  React.ComponentProps<typeof CollapsibleContent>,\n  \"children\"\n> & {\n  children: string;\n};\n\nfunction ReasoningContent({\n  className,\n  children,\n  ...props\n}: ReasoningContentProps) {\n  return (\n    <CollapsibleContent\n      data-slot=\"reasoning-content\"\n      className={cn(\n        \"mt-2 ml-2 overflow-hidden border-l pl-3 data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down\",\n        className,\n      )}\n      {...props}\n    >\n      <Streamdown\n        className={cn(\n          \"prose max-w-none text-sm leading-6 font-normal text-muted-foreground\",\n          // body text\n          \"[&_p]:mb-2.5\",\n          // strong\n          \"prose-strong:font-medium prose-strong:text-foreground\",\n          // lists\n          \"**:data-[streamdown=list-item]:py-0.25 **:data-[streamdown=list-item]:pl-4 **:data-[streamdown=list-item]:marker:text-muted-foreground/50 prose-ol:my-0 prose-ol:pl-3 prose-ul:my-0 prose-li:my-[-0.5px]\",\n          \"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0\",\n        )}\n      >\n        {children}\n      </Streamdown>\n    </CollapsibleContent>\n  );\n}\n\nexport { Reasoning, ReasoningTrigger, ReasoningContent };\n","type":"registry:file","target":"~/components/nexus-ui/reasoning.tsx"},{"path":"lib/use-on-change.ts","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * Runs `onChange` when `value` changes (compared against previous render).\n */\nexport function useOnChange<T>(\n  value: T,\n  onChange: (current: T, previous: T) => void,\n  isUpdated: (previous: T, current: T) => boolean = Object.is,\n) {\n  const previousRef = React.useRef(value);\n\n  React.useEffect(() => {\n    const previous = previousRef.current;\n    if (!isUpdated(previous, value)) {\n      onChange(value, previous);\n    }\n    previousRef.current = value;\n  }, [value, onChange, isUpdated]);\n}\n","type":"registry:file","target":"~/lib/use-on-change.ts"}],"css":{"@import \"tw-shimmer\"":{},"@layer utilities":{"@keyframes reasoning-expand":{"from":{"height":"0"},"to":{"height":"var(--radix-collapsible-content-height)"}},"@keyframes reasoning-collapse":{"from":{"height":"var(--radix-collapsible-content-height)"},"to":{"height":"0"}},".animate-collapsible-down":{"animation":"reasoning-expand 0.2s ease-out"},".animate-collapsible-up":{"animation":"reasoning-collapse 0.2s ease-out"}}},"type":"registry:ui"}