/** * Reasoning — the model's working, shown while it happens and folded away after. * * A reasoning trace is interesting for exactly as long as it is the only thing * arriving. Once the answer starts, it is a wall of text above the thing the * reader actually asked for. So it opens itself when the trace starts streaming * and closes itself about a second after it stops — once, and only if it ever * streamed, because a trace that arrives complete was never a live thing to * watch and should not perform. * * The trigger changes with it: a shimmering "Thinking…" while the tokens are * coming, and "Thought for 8 seconds" afterwards. The duration is measured from * the first streaming frame rather than taken on trust, so it is the time the * reader actually waited. * * ```tsx * * * {text} * * ``` * * ## Where the props come from * * With the AI SDK, `isStreaming` is whether the last part of the last message * is a reasoning part while the request is still streaming, and the content is * every `reasoning` part joined together. Joined, not one component each: * some models emit a run of separate reasoning parts, and one component per * part is a column of "Thinking…" rows for a single thought. */ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from 'react'; import { Pressable, View, type PressableProps, type ViewProps } from 'react-native'; import Animated, { useAnimatedStyle, useReducedMotion, useSharedValue, withTiming, } from 'react-native-reanimated'; import { tv } from 'tailwind-variants'; import { ChevronDownIcon, SparklesIcon } from '../../icons'; import { Collapse } from '../../primitives/collapse'; import { Text, textChildren } from '../../primitives/text'; import { cn } from '../../utils/cn'; import { Shimmer } from '../shimmer'; /** * How long the finished trace stays open before folding away. * * Long enough that the fold reads as a consequence of the trace ending rather * than as part of it, short enough that nobody is waiting on it. */ const AUTO_CLOSE_DELAY = 1000; const reasoningVariants = tv({ slots: { root: 'w-full gap-2', trigger: 'flex-row items-center gap-2 py-0.5', label: 'text-sm text-muted-foreground', content: 'gap-2 ps-6', contentText: 'text-sm leading-relaxed text-muted-foreground', }, }); interface ReasoningContextValue { isStreaming: boolean; open: boolean; setOpen: (open: boolean) => void; /** Seconds the trace took, once it has finished. */ duration: number | undefined; } const ReasoningContext = createContext(null); function useReasoning(component: string): ReasoningContextValue { const context = useContext(ReasoningContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } export interface ReasoningProps extends Omit { className?: string; /** * Whether the trace is still arriving. Drives the shimmer on the trigger, * opens the panel on the way in and closes it a beat after it goes false. */ isStreaming?: boolean; /** * How long the trace took, in seconds. Measured from the first streaming * frame when it is not given, which is the number worth showing — it is what * the reader actually waited. */ duration?: number; /** Controlled open state. */ open?: boolean; /** * Initial state when uncontrolled. Defaults to whether it is streaming, so a * live trace is open and a finished one arrives folded. Passing `false` * explicitly also opts out of the auto-open. */ defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; children?: ReactNode; } function ReasoningRoot({ className, isStreaming = false, duration: durationProp, open: openProp, defaultOpen, onOpenChange, children, ...props }: ReasoningProps) { const { root } = reasoningVariants(); const [internalOpen, setInternalOpen] = useState(defaultOpen ?? isStreaming); const [measured, setMeasured] = useState(); const isControlled = openProp !== undefined; const open = isControlled ? openProp : internalOpen; const setOpen = useCallback( (next: boolean) => { if (!isControlled) setInternalOpen(next); onOpenChange?.(next); }, [isControlled, onOpenChange] ); /* * A caller that said `defaultOpen={false}` meant it. Without this the panel * would spring open the moment the trace started, which is the one thing that * request was asking not to happen. */ const optedOut = defaultOpen === false; const everStreamed = useRef(isStreaming); const startedAt = useRef(null); const [autoClosed, setAutoClosed] = useState(false); // Timed here rather than taken on trust: `duration` is optional, and the // honest number is the wall clock between the first token and the last. useEffect(() => { if (isStreaming) { everStreamed.current = true; startedAt.current ??= Date.now(); return; } if (startedAt.current !== null) { setMeasured(Math.max(1, Math.round((Date.now() - startedAt.current) / 1000))); startedAt.current = null; } }, [isStreaming]); useEffect(() => { if (isStreaming && !open && !optedOut) setOpen(true); }, [isStreaming, open, optedOut, setOpen]); /* * Once only. Without the latch, reopening a finished trace by hand would be * undone a second later by the same effect, and the panel would refuse to * stay open for the one reader who wanted to read it. */ useEffect(() => { if (!everStreamed.current || isStreaming || !open || autoClosed) return; const timer = setTimeout(() => { setOpen(false); setAutoClosed(true); }, AUTO_CLOSE_DELAY); return () => clearTimeout(timer); }, [isStreaming, open, autoClosed, setOpen]); const context = useMemo( () => ({ isStreaming, open, setOpen, duration: durationProp ?? measured }), [isStreaming, open, setOpen, durationProp, measured] ); return ( {children} ); } ReasoningRoot.displayName = 'Reasoning'; export interface ReasoningTriggerProps extends Omit { className?: string; /** * What the row says. Given the streaming state and the measured duration, so * a caller can put its own words to both without reimplementing the timing. */ label?: (isStreaming: boolean, duration?: number) => ReactNode; /** Replaces the whole row, icon and chevron included. */ children?: ReactNode; } /** The row that says how long it thought, and folds the trace away. */ function ReasoningTrigger({ className, label, children, onPress, ...props }: ReasoningTriggerProps) { const { isStreaming, open, setOpen, duration } = useReasoning('Reasoning.Trigger'); const { trigger, label: labelClass } = reasoningVariants(); const reducedMotion = useReducedMotion(); const progress = useSharedValue(open ? 1 : 0); useEffect(() => { progress.value = reducedMotion ? open ? 1 : 0 : withTiming(open ? 1 : 0, { duration: 180 }); }, [open, reducedMotion, progress]); const chevronStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${progress.value * 180}deg` }], })); const body = label ? ( label(isStreaming, duration) ) : isStreaming ? ( Thinking… ) : ( {duration === undefined ? 'Thought for a few seconds' : `Thought for ${duration} ${duration === 1 ? 'second' : 'seconds'}`} ); return ( { onPress?.(event); setOpen(!open); }} className={cn(trigger(), className)} {...props} > {children ?? ( <> {body} )} ); } export interface ReasoningContentProps extends Omit { className?: string; /** The trace. A string is wrapped for you; anything else is left alone. */ children?: ReactNode; } /** * The trace itself. * * Kept mounted and collapsed to nothing rather than unmounted, because it is * usually still growing while it is folded — and a body that remounts on every * open would drop whatever the reader had scrolled to. */ function ReasoningContent({ className, children, ...props }: ReasoningContentProps) { const { open } = useReasoning('Reasoning.Content'); const { content, contentText } = reasoningVariants(); return ( {textChildren(children, (text) => ( {text} ))} ); } ReasoningTrigger.displayName = 'Reasoning.Trigger'; ReasoningContent.displayName = 'Reasoning.Content'; export const Reasoning = Object.assign(ReasoningRoot, { Trigger: ReasoningTrigger, Content: ReasoningContent, });