"use client"; import { Brain, ChevronDown } from "lucide-react"; import { type ReactNode, useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export type ReasoningStatus = "thinking" | "done"; export interface ReasoningDisclosureProps { /** "thinking" while the model reasons, "done" once the answer starts. */ status: ReasoningStatus; /** The reasoning text so far. Append to it as it streams. */ children: ReactNode; /** Seconds spent thinking, shown once done. Leave it out and the component times it. */ duration?: number; /** Controlled open state. */ open?: boolean; /** Starting open state when uncontrolled. Default true, so the reasoning shows while it streams. */ defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** Folds the reasoning away once status turns "done", unless someone opened or closed it by hand. Default true. */ collapseOnDone?: boolean; /** Header text while thinking. Default "Thinking". */ thinkingLabel?: string; className?: string; } /** With a 200% wide gradient, a 200 point position swing moves it one full tile, so the loop never jumps. */ const SHIMMER_SWEEP: Keyframe[] = [{ backgroundPosition: "200% 0" }, { backgroundPosition: "0% 0" }]; const SHIMMER_MS = 2000; // Only the shown label takes up space, so the chevron hugs it. The other one fades out on top of it. const SWAP = "whitespace-nowrap text-left transition-[opacity,translate] duration-200 ease-out motion-reduce:transition-none"; const SWAP_SHOWN = "relative translate-y-0 opacity-100"; const SWAP_GONE = "pointer-events-none absolute left-0 top-0 opacity-0"; function prefersReducedMotion() { return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; } function formatDuration(seconds: number): string { const whole = Math.max(1, Math.round(seconds)); if (whole < 60) return `${whole}s`; const minutes = Math.floor(whole / 60); const rest = whole % 60; return rest ? `${minutes}m ${rest}s` : `${minutes}m`; } /** Tracks how long status stayed "thinking", restarting whenever it goes back to thinking. */ function useThinkingTime(status: ReasoningStatus) { const startedAt = useRef(null); const [seconds, setSeconds] = useState(null); useEffect(() => { if (status === "thinking") { startedAt.current = Date.now(); setSeconds(null); return; } if (startedAt.current !== null) setSeconds((Date.now() - startedAt.current) / 1000); }, [status]); return seconds; } /** * A model's reasoning, shown while it thinks and folded away once it answers. The header * shimmers "Thinking" while it works, then crossfades to "Thought for 8s". The reasoning * stays pinned to its newest line as it streams, and can be reopened any time. */ export function ReasoningDisclosure({ status, children, duration, open, defaultOpen = true, onOpenChange, collapseOnDone = true, thinkingLabel = "Thinking", className, }: ReasoningDisclosureProps) { const panelId = useId(); const shimmerRef = useRef(null); const scrollRef = useRef(null); const touched = useRef(false); // A message loaded from history is already done, so it starts folded rather than folding on mount. const [uncontrolled, setUncontrolled] = useState(defaultOpen && !(status === "done" && collapseOnDone)); const isControlled = open !== undefined; const isOpen = isControlled ? open : uncontrolled; const thinking = status === "thinking"; const timed = useThinkingTime(status); const seconds = duration ?? timed; const doneLabel = seconds === null ? "Thought for a moment" : `Thought for ${formatDuration(seconds)}`; const setOpen = (next: boolean) => { if (!isControlled) setUncontrolled(next); onOpenChange?.(next); }; // Folding on its own only happens if nobody has opened or closed it by hand. // biome-ignore lint/correctness/useExhaustiveDependencies: only the change to "done" should fold it, not every render. useEffect(() => { if (thinking || !collapseOnDone || touched.current) return; setOpen(false); }, [thinking, collapseOnDone]); // While thinking, keep the newest line in view as the text grows. // biome-ignore lint/correctness/useExhaustiveDependencies: children is the trigger, the effect reads the DOM. useEffect(() => { const scroller = scrollRef.current; if (thinking && scroller) scroller.scrollTop = scroller.scrollHeight; }, [children, thinking]); useEffect(() => { const element = shimmerRef.current; if (!thinking || !element || typeof element.animate !== "function" || prefersReducedMotion()) return; const animation = element.animate(SHIMMER_SWEEP, { duration: SHIMMER_MS, iterations: Number.POSITIVE_INFINITY, easing: "linear", }); return () => animation.cancel(); }, [thinking]); return (
{/* Grows from zero rows to its natural height with a grid transition, so nothing is measured. */}
{children}
); }