/**
* 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,
});