Plan
The agent's plan: a card + collapsible shell whose steps are the TASK organism's own rows — composed by the consumer, never a fork of task's internals. Title and description shimmer while streaming, at web parity, on the Shimmer organism's house pulse; the footer badge keeps the remaining count glanceable even collapsed. defaultOpen stays true — a plan is the overview the user asked for, unlike task's phone-density collapse.
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/plan.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/plan.jsonDependencies
react-native-reanimated
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 |
https://reactnativereusables.com/r/uniwind/badge.json |
https://reactnativereusables.com/r/uniwind/button.json |
https://reactnativereusables.com/r/uniwind/card.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/status.json |
https://rnraielements.com/r/uniwind/shimmer.json |
https://rnraielements.com/r/uniwind/task.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
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 — 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 — 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
| Name | Type | Default | Description |
|---|---|---|---|
| isStreaming | boolean | undefined | false | — |
| defaultOpen | boolean | undefined | true | Web parity: a plan arrives open. |
| onOpenChange | ((open: boolean) => void) | undefined | — | — |
| children | React.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>
)