Reasoning
The collapsible thinking trace. Auto-opens while streaming, auto-closes one second after the answer begins, never overrides the user, and shows the frozen duration on the collapsed header. Markdown is injected via renderMarkdown and defaults to plain Text, keeping the core Expo Go-clean.
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/reasoning.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/reasoning.jsonDependencies
react-native-reanimated
https://reactnativereusables.com/r/nativewind/badge.json |
https://reactnativereusables.com/r/nativewind/collapsible.json |
https://reactnativereusables.com/r/nativewind/icon.json |
https://reactnativereusables.com/r/nativewind/text.json |
https://rnraielements.com/r/nativewind/reasoning-lifecycle.json |
https://rnraielements.com/r/nativewind/markdown.json |
https://reactnativereusables.com/r/uniwind/badge.json |
https://reactnativereusables.com/r/uniwind/collapsible.json |
https://reactnativereusables.com/r/uniwind/icon.json |
https://reactnativereusables.com/r/uniwind/text.json |
https://rnraielements.com/r/uniwind/reasoning-lifecycle.json |
https://rnraielements.com/r/uniwind/markdown.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/registry/nativewind/components/ui/collapsible';
import { Icon } from '@/registry/nativewind/components/ui/icon';
import { Text } from '@/registry/nativewind/components/ui/text';
import { repairIncompleteMarkdown } from '@/registry/nativewind/lib/markdown';
import {
initReasoningLifecycle,
reasoningLabel,
reasoningLifecycleReducer,
REASONING_AUTO_CLOSE_DELAY,
shouldAutoClose,
} from '@/registry/nativewind/lib/reasoning-lifecycle';
import { cn } from '@/registry/nativewind/lib/utils';
import { BrainIcon, ChevronDownIcon } from 'lucide-react-native';
import * as React from 'react';
import { View, type ViewProps } from 'react-native';
import Animated, {
ReduceMotion,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
/**
* Reasoning — the collapsible thinking trace.
*
* THE LIFECYCLE IS THE COMPONENT. It lives in lib/reasoning-lifecycle.ts as a pure,
* time-injected reducer precisely so the Vitest tier can own it: auto-open when
* `isStreaming` starts, auto-close 1000ms after it ends, `defaultOpen` pinned open and
* never auto-closed, a mount with `isStreaming={false}` starting closed (loaded history
* must not spring open), and — the rule that protects the reader — any user interaction
* standing the automation down for good. This file only wires it: dispatch on
* isStreaming transitions, arm a timer while the reducer says one should be armed.
*
* THE DURATION IS FROZEN, NOT TICKING. `durationSeconds` is computed once at stream end
* and shown on the collapsed header ("Thought for 5 seconds"); while streaming the
* header reads "Thinking…". A per-second live counter would chatter to screen readers —
* the design contract bans it explicitly. A caller who already knows the duration can
* pass it and the tracked value yields.
*
* MARKDOWN IS INJECTED, NOT BUNDLED — message.tsx's seam, for the same reason: the
* native markdown renderer is a Fabric module that would make the whole component
* dev-client-only. Default is plain RNR Text, with the streamed-half-token repair that
* keeps `**bold` from flashing literal asterisks mid-stream.
*
* NO CONTROLLED `open` PROP, a deliberate divergence from the web original: the
* lifecycle reducer is the component's value and a controlled open would fork the exact
* logic the tests own. `onOpenChange` still reports every open change (including the
* automatic ones) for consumers that want to mirror state; `defaultOpen` covers the
* stay-open case.
*
* MOTION, RECORDED (remediation row 10 KEEP): the web's content enters/exits with
* `animate-in/out` slide-fade utility classes (reasoning.tsx:214); RNR Collapsible's
* native layout animation stands in for it — no animation code here on purpose. The
* trigger label is the web's bare `text-sm text-muted-foreground` inline text
* (reasoning.tsx:179), not a badge.
*/
type ReasoningContextValue = {
streaming: boolean;
open: boolean;
/** Seconds, or null while streaming / before the first stream. */
durationSeconds: number | null;
};
const ReasoningContext = React.createContext<ReasoningContextValue | null>(null);
function useReasoning() {
const ctx = React.useContext(ReasoningContext);
if (!ctx) throw new Error('Reasoning components must be used within Reasoning');
return ctx;
}
type ReasoningProps = Omit<ViewProps, 'children'> & {
isStreaming?: boolean;
/** Pinned open. Never auto-closes, and suppresses the auto-open timer entirely. */
defaultOpen?: boolean;
/** Overrides the tracked duration — for callers who already know it. */
duration?: number;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
};
function Reasoning({
isStreaming = false,
defaultOpen = false,
duration,
onOpenChange,
className,
children,
...props
}: ReasoningProps) {
const [lifecycle, dispatch] = React.useReducer(
reasoningLifecycleReducer,
defaultOpen,
initReasoningLifecycle,
);
// null = not yet observed, so mounting with isStreaming=true still opens the trace.
const prevStreaming = React.useRef<boolean | null>(null);
React.useEffect(() => {
if (prevStreaming.current !== null && isStreaming === prevStreaming.current) return;
if (isStreaming) dispatch({ type: 'stream-start', at: Date.now() });
else if (prevStreaming.current !== null) dispatch({ type: 'stream-end', at: Date.now() });
prevStreaming.current = isStreaming;
}, [isStreaming]);
React.useEffect(() => {
if (!shouldAutoClose(lifecycle)) return;
const timer = setTimeout(() => dispatch({ type: 'auto-close' }), REASONING_AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}, [lifecycle]);
const effectiveDuration = duration ?? lifecycle.durationSeconds;
return (
<ReasoningContext.Provider value={{ streaming: isStreaming, open: lifecycle.open, durationSeconds: effectiveDuration }}>
<Collapsible
open={lifecycle.open}
onOpenChange={(open) => {
dispatch({ type: 'user-toggle', open });
onOpenChange?.(open);
}}
className={cn('w-full', className)}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
/** House motion scale for the chevron: 250ms open, 200ms close, system-reduced. */
function Chevron({ open }: { open: boolean }) {
const rotation = useSharedValue(open ? 1 : 0);
React.useEffect(() => {
rotation.value = withTiming(open ? 1 : 0, {
duration: open ? 250 : 200,
reduceMotion: ReduceMotion.System,
});
}, [open, rotation]);
const style = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value * 180}deg` }],
}));
return (
<Animated.View style={style}>
<Icon as={ChevronDownIcon} size={14} className="text-muted-foreground" />
</Animated.View>
);
}
function ReasoningTrigger({ className }: { className?: string }) {
const { streaming, open, durationSeconds } = useReasoning();
return (
<CollapsibleTrigger
accessibilityState={{ busy: streaming, expanded: open }}
className={cn(
'flex-row items-center gap-2 rounded-md py-1.5 active:bg-accent',
className,
)}
>
<Icon
as={BrainIcon}
size={14}
className={cn(streaming ? 'text-foreground' : 'text-muted-foreground')}
/>
<Text accessibilityLiveRegion="polite" className="text-sm text-muted-foreground">
{reasoningLabel({ streaming, durationSeconds })}
</Text>
<View className="flex-1" />
<Chevron open={open} />
</CollapsibleTrigger>
);
}
export type RenderMarkdown = (markdown: string) => React.ReactNode;
type ReasoningContentProps = {
/** The reasoning trace, as a (possibly half-streamed) markdown string. */
children: string;
/** Inject a markdown renderer. Omit it and the body renders as plain Text. */
renderMarkdown?: RenderMarkdown;
/** Repair a half-streamed token mid-stream. On by default, as in message. */
parseIncompleteMarkdown?: boolean;
className?: string;
};
function ReasoningContent({
children,
renderMarkdown,
parseIncompleteMarkdown = true,
className,
}: ReasoningContentProps) {
const text = React.useMemo(
() => (parseIncompleteMarkdown ? repairIncompleteMarkdown(children) : children),
[children, parseIncompleteMarkdown],
);
return (
<CollapsibleContent className={cn('pb-1', className)}>
{renderMarkdown ? (
renderMarkdown(text)
) : (
<Text className="text-sm leading-5 text-muted-foreground">{text}</Text>
)}
</CollapsibleContent>
);
}
export { Reasoning, ReasoningContent, ReasoningTrigger, useReasoning };
Props
| Name | Type | Default | Description |
|---|---|---|---|
| isStreaming | boolean | undefined | false | — |
| defaultOpen | boolean | undefined | false | Pinned open. Never auto-closes, and suppresses the auto-open timer entirely. |
| duration | number | undefined | — | Overrides the tracked duration — for callers who already know it. |
| onOpenChange | ((open: boolean) => void) | undefined | — | — |
| children | React.ReactNode | — | — |
Theming
Themed via text-muted-foreground, bg-accent, text-foreground.
Usage
(o) => (
<Reasoning
isStreaming={o.isStreaming === true}
defaultOpen={o.defaultOpen === true}
duration={4200}
>
<ReasoningContent>
<Text className="text-sm text-muted-foreground">
The consumer installs from raw GitHub at the v0.2.0 tag, so the registry URLs must pin the release, not main.
</Text>
</ReasoningContent>
</Reasoning>
)