Chain of Thought
The multi-step reasoning list — distinct from reasoning, which stays the single collapsible trace. Ordered steps with per-step status joined by a rail; the disclosure reuses the reasoning lifecycle (auto-open on stream, auto-close after, user toggle stands it down) rather than forking it. The collapsed badge counts steps from data — the collapsed content is unmounted, so the count must not live in the tree.
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/chain-of-thought.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/chain-of-thought.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/native-only-animated-view.json |
https://reactnativereusables.com/r/nativewind/text.json |
https://rnraielements.com/r/nativewind/reasoning-lifecycle.json |
https://rnraielements.com/r/nativewind/status.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/native-only-animated-view.json |
https://reactnativereusables.com/r/uniwind/text.json |
https://rnraielements.com/r/uniwind/reasoning-lifecycle.json |
https://rnraielements.com/r/uniwind/status.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
import { Badge } from '@/registry/nativewind/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/registry/nativewind/components/ui/collapsible';
import { Icon } from '@/registry/nativewind/components/ui/icon';
import { NativeOnlyAnimatedView } from '@/registry/nativewind/components/ui/native-only-animated-view';
import { Text } from '@/registry/nativewind/components/ui/text';
import {
initReasoningLifecycle,
reasoningLifecycleReducer,
REASONING_AUTO_CLOSE_DELAY,
shouldAutoClose,
} from '@/registry/nativewind/lib/reasoning-lifecycle';
import { cn } from '@/registry/nativewind/lib/utils';
import {
ChevronDownIcon,
CircleCheckIcon,
CircleIcon,
GlobeIcon,
LoaderCircleIcon,
ListOrderedIcon,
type LucideIcon,
} from 'lucide-react-native';
import * as React from 'react';
import { Image, Platform, View, type ViewProps } from 'react-native';
import Animated, {
FadeIn,
ReduceMotion,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import {
chainOfThoughtProgress,
chainOfThoughtProgressLabel,
chainOfThoughtStepMeta,
type ChainOfThoughtStepIconName,
type ChainOfThoughtStepStatus,
} from './chain-of-thought.logic';
/**
* ChainOfThought — the multi-step reasoning list.
*
* DISTINCT FROM `reasoning`, WHICH STAYS THE SINGLE COLLAPSIBLE TRACE. Reasoning streams
* one text body; this renders ordered steps, each with its own status, joined by a rail.
* The web original's shape is preserved: a collapsible whose header carries the label
* and a collapsed summary, with steps inside.
*
* THE DISCLOSURE LIFECYCLE IS REUSED, NOT FORKED. lib/reasoning-lifecycle.ts is the one
* lifecycle for thinking disclosures, and this wires it exactly as reasoning.tsx does:
* stream-start auto-opens, stream-end auto-closes after 1000ms, defaultOpen pins it
* open forever, and any user toggle stands the automation down. The only difference is
* the collapsed summary: reasoning freezes a duration, this shows the step progress
* (UC-AGENT-02 AC-4's "follow a multi-step chain with per-step status" at a glance) —
* which is also why the count comes in as data (the collapsed content is UNMOUNTED, so
* a count derived from the tree would forget itself the moment it closed).
*
* THE RAIL. The web draws a connected vertical line between steps with CSS borders; a
* React Native step draws its own segment — icon on top, `w-px flex-1 bg-border` below —
* and the LAST step hides its segment via `isLast`, because React Native has no
* :last-child selector.
*
* Icons arrive with a 200ms fade on status change (task.tsx's animated-completion beat,
* ReduceMotion.System chained, bare children on web). No looping animation: the active
* step's loader mark is static, same reasoning as task — motion that never ends chatters.
*/
type ChainOfThoughtContextValue = {
open: boolean;
streaming: boolean;
};
const ChainOfThoughtContext = React.createContext<ChainOfThoughtContextValue | null>(null);
function useChainOfThought() {
const ctx = React.useContext(ChainOfThoughtContext);
if (!ctx) throw new Error('ChainOfThought sub-components must be used within <ChainOfThought>');
return ctx;
}
type ChainOfThoughtProps = Omit<ViewProps, 'children'> & {
isStreaming?: boolean;
/** Pinned open. Never auto-closes, and suppresses the auto-open timer entirely. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
};
function ChainOfThought({
isStreaming = false,
defaultOpen = false,
onOpenChange,
className,
children,
...props
}: ChainOfThoughtProps) {
const [lifecycle, dispatch] = React.useReducer(
reasoningLifecycleReducer,
defaultOpen,
initReasoningLifecycle,
);
// null = not yet observed, so mounting with isStreaming=true still opens the chain.
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]);
return (
<ChainOfThoughtContext.Provider value={{ open: lifecycle.open, streaming: isStreaming }}>
<Collapsible
open={lifecycle.open}
onOpenChange={(open) => {
dispatch({ type: 'user-toggle', open });
onOpenChange?.(open);
}}
className={cn('w-full', className)}
{...props}
>
{children}
</Collapsible>
</ChainOfThoughtContext.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>
);
}
type ChainOfThoughtHeaderProps = {
/** The header label. Defaults to "Thinking…" mid-stream, "Chain of thought" at rest. */
label?: string;
/**
* The same steps array the content renders — the collapsed badge's count. The collapsed
* disclosure unmounts its content, so this cannot be derived from the tree.
*/
steps?: ReadonlyArray<{ status?: ChainOfThoughtStepStatus }>;
className?: string;
};
function ChainOfThoughtHeader({ label, steps, className }: ChainOfThoughtHeaderProps) {
const { open, streaming } = useChainOfThought();
const progress = steps ? chainOfThoughtProgress(steps) : null;
return (
<CollapsibleTrigger
accessibilityState={{ busy: streaming, expanded: open }}
className={cn(
'flex-row items-center gap-2 rounded-md py-1.5 active:bg-accent',
Platform.select({ web: 'transition-colors hover:bg-accent/50' }),
className,
)}
>
<Icon
as={ListOrderedIcon}
size={14}
className={cn(streaming ? 'text-foreground' : 'text-muted-foreground')}
/>
<Text numberOfLines={1} className="flex-1 text-left text-sm font-medium text-foreground">
{label ?? (streaming ? 'Thinking…' : 'Chain of thought')}
</Text>
{progress ? (
<Badge
variant="outline"
accessibilityLabel={`${chainOfThoughtProgressLabel(progress)}, ${progress.total} steps`}
accessibilityLiveRegion="polite"
>
<Text className="text-xs text-muted-foreground">
{chainOfThoughtProgressLabel(progress)}
</Text>
</Badge>
) : null}
<Chevron open={open} />
</CollapsibleTrigger>
);
}
function ChainOfThoughtContent({ className, children, ...props }: ViewProps & { children?: React.ReactNode }) {
return (
<CollapsibleContent className={cn('pt-1 pb-1', className)} {...props}>
<View className="gap-1">{children}</View>
</CollapsibleContent>
);
}
/** iconName (from chain-of-thought.logic.ts) → Lucide component. Exhaustive by type. */
const STEP_ICONS: Record<ChainOfThoughtStepIconName, LucideIcon> = {
circle: CircleIcon,
'loader-circle': LoaderCircleIcon,
'circle-check': CircleCheckIcon,
};
/**
* The step's status icon, keyed by status so a change remounts and the 200ms fade plays —
* task.tsx's animated-completion beat, static under reduced motion, bare on web.
*/
function StepStatusIcon({ status }: { status: ChainOfThoughtStepStatus }) {
const meta = chainOfThoughtStepMeta(status);
return (
<NativeOnlyAnimatedView
key={status}
entering={FadeIn.duration(200).reduceMotion(ReduceMotion.System)}
accessible
accessibilityLabel={`Status: ${meta.label}`}
>
<Icon as={STEP_ICONS[meta.iconName]} size={14} className={cn(meta.className)} />
</NativeOnlyAnimatedView>
);
}
type ChainOfThoughtStepProps = ViewProps & {
/** The step's label — what the model is doing. */
label: string;
/** One line of detail under the label. */
description?: string;
/** Defaults to 'pending' — a step that has not started is pending, not absent. */
status?: ChainOfThoughtStepStatus;
/**
* The last step draws no rail segment below its icon — React Native has no
* :last-child, so the consumer says it.
*/
isLast?: boolean;
children?: React.ReactNode;
};
function ChainOfThoughtStep({
label,
description,
status = 'pending',
isLast = false,
className,
children,
...props
}: ChainOfThoughtStepProps) {
return (
<View className={cn('flex-row gap-2.5', className)} {...props}>
<View className="items-center gap-1">
<StepStatusIcon status={status} />
{/* width in plain style: `w-px` rides uniwind's compiled stylesheet, whose
candidate extraction has been observed on device to drop classes from newly
added files between incremental rebuilds — a dropped `w-px` collapses the
rail to zero width and the line silently vanishes. */}
{!isLast ? <View className="w-px flex-1 bg-border" style={{ width: 1 }} /> : null}
</View>
<View className="min-w-0 flex-1 gap-0.5 pb-2">
<Text numberOfLines={1} className="text-sm font-medium text-foreground">
{label}
</Text>
{description ? (
<Text className="text-xs leading-4 text-muted-foreground">{description}</Text>
) : null}
{children}
</View>
</View>
);
}
/** Sources a step consulted, as wrapping chips — task.tsx's chips-flow-beside rule. */
function ChainOfThoughtSearchResults({ className, ...props }: ViewProps) {
return <View className={cn('flex-row flex-wrap gap-1 pt-1', className)} {...props} />;
}
function ChainOfThoughtSearchResult({ children, className }: { children: string; className?: string }) {
return (
<View
className={cn(
'flex-row items-center gap-1 self-start rounded-full border border-border bg-background px-2 py-0.5 max-w-full',
className,
)}
>
<Icon as={GlobeIcon} size={12} className="text-muted-foreground" />
{/* shrink + numberOfLines: a long title truncates instead of stretching the row.
max-w-full (not an arbitrary width): the default Tailwind v3 max-w scale that
the nativewind emit resolves has no numeric steps. */}
<Text numberOfLines={1} className="shrink text-xs text-muted-foreground">
{children}
</Text>
</View>
);
}
type ChainOfThoughtImageProps = {
/** RN Image source — a generated image, usually a data URI from the model result. */
source: { uri: string };
caption?: string;
className?: string;
};
function ChainOfThoughtImage({ source, caption, className }: ChainOfThoughtImageProps) {
return (
<View className={cn('gap-1 pt-1.5', className)}>
<Image
source={source}
resizeMode="cover"
accessibilityLabel={caption}
className="aspect-video w-full rounded-md border border-border bg-muted/50"
/>
{caption ? (
<Text numberOfLines={1} className="text-xs text-muted-foreground">
{caption}
</Text>
) : null}
</View>
);
}
export {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtHeader,
ChainOfThoughtImage,
ChainOfThoughtSearchResult,
ChainOfThoughtSearchResults,
ChainOfThoughtStep,
useChainOfThought,
};
/**
* ChainOfThought — pure logic. Zero react-native imports, so the Vitest tier owns the
* step map and the progress counter; chain-of-thought.tsx holds only the static
* iconName → Lucide table, typed exhaustively against `iconName` (see tool.logic.ts for
* the full rationale).
*
* THE STEP STATUS UNION IS DECLARED LOCALLY, not imported from `ai` — message.tsx's
* precedent: the registry is stateless about the model and takes no runtime dependency
* on any SDK. The three states are the web original's step states (pending / active /
* complete); an errored chain step is a tool concern and belongs to Tool's own state
* machine, which is why no `error` state exists here.
*
* The disclosure lifecycle is NOT redefined here — lib/reasoning-lifecycle.ts is THE
* lifecycle for both thinking disclosures, and chain-of-thought.tsx wires it unchanged.
*/
import { statusColor, type StatusTone } from '@/registry/nativewind/lib/status';
export type ChainOfThoughtStepStatus = 'pending' | 'active' | 'complete';
export const CHAIN_OF_THOUGHT_STEP_STATUS_KEYS = [
'pending',
'active',
'complete',
] as const satisfies readonly ChainOfThoughtStepStatus[];
/** Lucide icon NAME (kebab-case); the component resolves the component. */
export type ChainOfThoughtStepIconName = 'circle' | 'loader-circle' | 'circle-check';
export type ChainOfThoughtStepMeta = {
/** Badge/label text. House vocabulary: in-flight reads "Running", everywhere. */
label: string;
tone: StatusTone;
iconName: ChainOfThoughtStepIconName;
/** Precomposed text class from the shared statusColor map. */
className: string;
};
export const CHAIN_OF_THOUGHT_STEP_META: Record<ChainOfThoughtStepStatus, ChainOfThoughtStepMeta> = {
pending: { label: 'Pending', tone: 'pending', iconName: 'circle', className: statusColor.pending },
active: { label: 'Running', tone: 'running', iconName: 'loader-circle', className: statusColor.running },
complete: { label: 'Completed', tone: 'success', iconName: 'circle-check', className: statusColor.success },
};
export function chainOfThoughtStepMeta(status: ChainOfThoughtStepStatus): ChainOfThoughtStepMeta {
return CHAIN_OF_THOUGHT_STEP_META[status];
}
export type ChainOfThoughtProgress = {
complete: number;
total: number;
};
/**
* The collapsed header's glanceable count. Steps are declarative children, so the count
* cannot be derived from the tree — the collapsed disclosure UNMOUNTS its content, and
* a header that forgets the count the moment it closes is useless. The consumer passes
* the same steps array it renders; this counts what it is given. A step with no status
* yet is not complete, and never inflates the denominator's honesty: total is the
* array's length.
*/
export function chainOfThoughtProgress(
steps: ReadonlyArray<{ status?: ChainOfThoughtStepStatus }>,
): ChainOfThoughtProgress {
let complete = 0;
for (const step of steps) {
if (step.status === 'complete') complete++;
}
return { complete, total: steps.length };
}
/** The header badge's text. Noun-free so the singular never reads wrong. */
export function chainOfThoughtProgressLabel(progress: ChainOfThoughtProgress): string {
if (progress.total === 0) return 'No steps yet';
return `${progress.complete} of ${progress.total} complete`;
}
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. |
| onOpenChange | ((open: boolean) => void) | undefined | — | — |
| children | React.ReactNode | — | — |
Theming
Themed via bg-border, text-muted-foreground, bg-accent, text-foreground, border-border, bg-background, bg-muted.
Usage
(o) => (
<ChainOfThought defaultOpen={o.defaultOpen === true} isStreaming={o.isStreaming === true}>
<ChainOfThoughtHeader
label="Porting web-preview"
steps={[{ status: 'completed' }, { status: 'running' }, { status: 'pending' }]}
/>
<ChainOfThoughtContent>
<ChainOfThoughtStep label="Read the web original" description="web-preview.tsx:1-260" status="completed" />
<ChainOfThoughtStep label="Swap the iframe" description="react-native-webview with the scheme allowlist" status="running" />
<ChainOfThoughtStep label="Declare the peer" description="react-native-webview in registry.json" status="pending" isLast />
</ChainOfThoughtContent>
</ChainOfThought>
)