Install

npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/plan.json

Dependencies

https://reactnativereusables.com/r/nativewind/badge.json
https://reactnativereusables.com/r/nativewind/button.json
https://reactnativereusables.com/r/nativewind/card.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/status.json
https://rnraielements.com/r/nativewind/shimmer.json
https://rnraielements.com/r/nativewind/task.json

AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92

Source

plan.tsx
import { Button } from '@/registry/nativewind/components/ui/button'; import { Card } from '@/registry/nativewind/components/ui/card'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/registry/nativewind/components/ui/collapsible'; import { Badge } from '@/registry/nativewind/components/ui/badge'; import { Icon } from '@/registry/nativewind/components/ui/icon'; import { Shimmer } from '@/registry/nativewind/components/ai/shimmer'; import { Text } from '@/registry/nativewind/components/ui/text'; import { cn } from '@/registry/nativewind/lib/utils'; import { ChevronDownIcon, RouteIcon } 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'; import { planProgress, planProgressLabel } from './plan.logic'; import type { TaskStatus } from './task.logic'; /** * Plan — the agent's plan, as an ordered, status-bearing step list that updates in place * (UC-AGENT-03). * * COMPOSITION IS THE WEB ORIGINAL'S SHAPE, WITH TASK'S PRIMITIVES INSIDE. The web Plan * is a Card + Collapsible whose PlanContent hosts consumer-rendered steps; this port * keeps that shell exactly and steps are the TASK organism's own rows — TaskItem / * TaskItemFile, composed directly, never re-implemented. The step status map is * task.logic's, the one map this registry has for step-shaped work; plan.logic only * counts it. * * STREAMING SHIMMER, AT WEB PARITY. PlanTitle and PlanDescription shimmer while * isStreaming, on the Shimmer organism's house pulse (RNR Skeleton's 1000ms signature, * reduced-motion gated) — the web original's exact behavior, ported to the substitute * the design lens chose. * * DEFAULTOPEN STAYS TRUE, AT WEB PARITY — unlike `task`, which collapses by default on * a phone. A plan is the overview the user explicitly asked the agent to commit to; the * phone-density rule that hides a task group's body does not reach this far. The * trigger row still carries the progress badge, so a user-collapsed plan keeps its * count (UC-AGENT-03 AC-4) glanceable. */ type PlanContextValue = { open: boolean; isStreaming: boolean; }; const PlanContext = React.createContext<PlanContextValue | null>(null); function usePlan() { const ctx = React.useContext(PlanContext); if (!ctx) throw new Error('Plan sub-components must be used within <Plan>'); return ctx; } type PlanProps = Omit<ViewProps, 'children'> & { isStreaming?: boolean; /** Web parity: a plan arrives open. */ defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; children?: React.ReactNode; }; function Plan({ isStreaming = false, defaultOpen = true, onOpenChange, className, children, ...props }: PlanProps) { const [open, setOpen] = React.useState(defaultOpen); function handleOpenChange(next: boolean) { setOpen(next); onOpenChange?.(next); } return ( <PlanContext.Provider value={{ open, isStreaming }}> <Card className={cn('gap-2 p-3', className)} {...props}> <Collapsible open={open} onOpenChange={handleOpenChange} className="gap-1"> {children} </Collapsible> </Card> </PlanContext.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> ); } /** * The disclosure control, as a pill — checkpoint.tsx's vendored-ghost-Button form, the * house shape for pill buttons. Raw Pressable would skip Button's TextClassContext and * its press states; the checkpoint precedent exists precisely so this file never * hand-rolls one. */ function PlanTrigger({ label = 'Plan', className }: { label?: string; className?: string }) { const { open } = usePlan(); return ( <CollapsibleTrigger asChild> <Button variant="ghost" accessibilityLabel={`${label}. ${open ? 'Collapse' : 'Expand'} the plan steps.`} accessibilityState={{ expanded: open }} hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }} className={cn('h-auto self-start gap-1.5 rounded-full border border-border px-2.5 py-1', className)} > <Icon as={RouteIcon} size={14} className="text-muted-foreground" /> <Text className="text-xs text-muted-foreground">{label}</Text> <Chevron open={open} /> </Button> </CollapsibleTrigger> ); } /** The header row: trigger left, actions right, title/description flowing under. */ function PlanHeader({ className, ...props }: ViewProps) { return <View className={cn('gap-1.5', className)} {...props} />; } function PlanTitle({ children, className }: { children: string; className?: string }) { const { isStreaming } = usePlan(); const classes = cn('text-base font-semibold text-foreground', className); // The web original shimmers title and description while streaming; the shimmer is // the Shimmer organism's house pulse — not a second motion vocabulary. if (isStreaming) { return ( <Shimmer className={classes} numberOfLines={2}> {children} </Shimmer> ); } return ( <Text numberOfLines={2} className={classes}> {children} </Text> ); } function PlanDescription({ children, className }: { children: string; className?: string }) { const { isStreaming } = usePlan(); const classes = cn('text-sm text-muted-foreground', className); if (isStreaming) { return ( <Shimmer className={classes} numberOfLines={2}> {children} </Shimmer> ); } return ( <Text numberOfLines={2} className={classes}> {children} </Text> ); } /** Header-right slot — a custom trigger, a progress badge, whatever the caller adds. */ function PlanAction({ className, ...props }: ViewProps) { return <View className={cn('flex-row items-center gap-2', className)} {...props} />; } /** * The steps' body. Steps are the TASK organism's own rows, composed by the consumer: * * <PlanContent> * <TaskItem status="completed"><Text className="text-sm">…</Text></TaskItem> * <TaskItem status="running"><Text className="text-sm">…</Text> * <TaskItemFile>src/retry.ts</TaskItemFile></TaskItem> * </PlanContent> * * — TaskItem / TaskItemFile come from the task registry item (a registryDependency of * this one), so the status map and the animated arrival stay task's, never a fork. */ function PlanContent({ className, children, ...props }: ViewProps & { children?: React.ReactNode }) { return ( <CollapsibleContent className={cn('pt-1', className)} {...props}> <View className="gap-1">{children}</View> </CollapsibleContent> ); } /** Footer row — the progress badge lives here by convention, with any trailing actions. */ function PlanFooter({ className, ...props }: ViewProps) { return <View className={cn('flex-row flex-wrap items-center gap-2 pt-1', className)} {...props} />; } type PlanProgressBadgeProps = { /** The same steps array the content renders. */ steps: ReadonlyArray<{ status?: TaskStatus }>; className?: string; }; /** UC-AGENT-03 AC-4: the remaining count, glanceable even while the plan is collapsed. */ function PlanProgressBadge({ steps, className }: PlanProgressBadgeProps) { const label = planProgressLabel(planProgress(steps)); return ( <Badge variant="outline" accessibilityLabel={`Plan progress: ${label}`} accessibilityLiveRegion="polite" className={className} > <Text className="text-xs text-muted-foreground">{label}</Text> </Badge> ); } export { Plan, PlanAction, PlanContent, PlanDescription, PlanFooter, PlanHeader, PlanProgressBadge, PlanTitle, PlanTrigger, usePlan, };
plan.logic.ts
/** * Plan — pure logic. Zero react-native imports, so the Vitest tier owns the progress * arithmetic; plan.tsx holds only presentation. * * THE STEP STATUS UNION IS TASK'S, NOT A SECOND ONE. Plan steps are task rows (plan.tsx * composes the task organism's own primitives — TaskItem / TaskItemFile — and never * re-derives their status map), so the plan counts `TaskStatus` verbatim, including the * web schema's `in_progress` alias. Counting rules, on the record: * - `completed` counts toward DONE. * - `running` / `in_progress` count toward IN FLIGHT. * - `pending` and `rejected` count toward REMAINING — a rejected step is unfinished * work the user declined, and hiding it from the remaining count would make the * plan read complete when it is not. */ import type { TaskStatus } from './task.logic'; export type PlanProgress = { total: number; completed: number; running: number; remaining: number; }; export function planProgress(steps: ReadonlyArray<{ status?: TaskStatus }>): PlanProgress { let completed = 0; let running = 0; for (const step of steps) { if (step.status === 'completed') completed++; else if (step.status === 'running' || step.status === 'in_progress') running++; } return { total: steps.length, completed, running, remaining: steps.length - completed }; } /** * UC-AGENT-03 AC-4 — "tell at a glance how many steps remain in a running plan". The * remaining clause only appears while work remains; a finished plan reads as a plain * "N of N complete" and never as "0 remaining". */ export function planProgressLabel(progress: PlanProgress): string { if (progress.total === 0) return 'No steps yet'; const base = `${progress.completed} of ${progress.total} complete`; return progress.remaining > 0 ? `${base} · ${progress.remaining} remaining` : base; }
task.logic.ts
/** * Task — pure logic. Zero react-native imports, so the Vitest tier owns the * status→label/icon/color map; task.tsx holds only the static iconName → Lucide table, * typed exhaustively against `iconName` (see tool.logic.ts for the full rationale). * * The status union is the web original's gen-ui task schema (`pending` | `in_progress` | * `completed`) widened by this port's two additions: `running` (the PRD's own word for * the in-flight state — UC-AGENT-03: "pending to running to complete") and `rejected` * (an agent plan step the user declined). `in_progress` is kept as an accepted alias so * a consumer feeding the web schema verbatim still typechecks; it renders identically * to `running`. * * Color is never the sole channel: every status carries a distinct icon and a text * label, and the label is what a screen reader announces. */ import { statusColor, type StatusTone } from '@/registry/nativewind/lib/status'; export type TaskStatus = 'pending' | 'in_progress' | 'running' | 'completed' | 'rejected'; export const TASK_STATUS_KEYS = [ 'pending', 'in_progress', 'running', 'completed', 'rejected', ] as const satisfies readonly TaskStatus[]; /** Lucide icon NAME (kebab-case); the component resolves the component. */ export type TaskStatusIconName = 'circle' | 'loader-circle' | 'circle-check' | 'circle-x'; export type TaskStatusMeta = { /** Badge text. `in_progress` borrows the PRD's word, "Running". */ label: string; tone: StatusTone; iconName: TaskStatusIconName; /** Precomposed text class from the shared statusColor map. */ className: string; }; export const TASK_STATUS_META: Record<TaskStatus, TaskStatusMeta> = { pending: { label: 'Pending', tone: 'pending', iconName: 'circle', className: statusColor.pending }, in_progress: { label: 'Running', tone: 'running', iconName: 'loader-circle', className: statusColor.running }, running: { label: 'Running', tone: 'running', iconName: 'loader-circle', className: statusColor.running }, completed: { label: 'Completed', tone: 'success', iconName: 'circle-check', className: statusColor.success }, rejected: { label: 'Rejected', tone: 'denied', iconName: 'circle-x', className: statusColor.denied }, }; export function taskStatusMeta(status: TaskStatus): TaskStatusMeta { return TASK_STATUS_META[status]; }

Props

NameTypeDefaultDescription
isStreamingboolean | undefinedfalse—
defaultOpenboolean | undefinedtrueWeb parity: a plan arrives open.
onOpenChange((open: boolean) => void) | undefined——
childrenReact.ReactNode——

Theming

Themed via text-muted-foreground, border-border, text-foreground.

Usage

(o) => ( <Plan defaultOpen={o.defaultOpen !== false} isStreaming={o.isStreaming === true}> <PlanHeader> <PlanTitle>Ship the v0.2.0 registry</PlanTitle> <PlanDescription>Four steps, all verified against the real CLI</PlanDescription> <PlanProgressBadge steps={[{ status: 'completed' }, { status: 'completed' }, { status: 'in_progress' }, { status: 'pending' }]} /> </PlanHeader> <PlanContent> <TaskItem status="completed"> <Text className="text-sm text-foreground">Fix the six dependency declarations</Text> </TaskItem> <TaskItem status="completed"> <Text className="text-sm text-foreground">Regenerate both engine trees</Text> </TaskItem> <TaskItem status="in_progress"> <Text className="text-sm text-foreground">Install the full set into apps/example</Text> </TaskItem> <TaskItem status="pending"> <Text className="text-sm text-foreground">Cut and push the release tag</Text> </TaskItem> </PlanContent> </Plan> )