/**
* Plan — what an agent intends to do, before it does it.
*
* A card rather than a run of rows, because a plan is a thing the reader is
* being asked to approve: it needs an edge around it so that where it starts
* and stops is not a matter of interpretation, and a footer that can hold the
* button that approves it.
*
* The body is a rail of steps rather than prose, because a plan is an ordered
* list of things that will happen and the reader's question is always *which
* one is running*. A paragraph describing the same four steps cannot be glanced
* at to answer that; a column of markers can. The rail fills behind the steps
* that are done, so progress is legible from its left edge alone, and
* `Plan.Steps` counts itself so `Plan.Progress` can say `2 of 4` up in the
* header without the total being stated twice.
*
* It streams. `isStreaming` puts a shimmer on the title, the description and
* the one step that is running, which is the honest way to render text that is
* still arriving — the alternative is a title that grows a word at a time and
* reads as finished at every intermediate length.
*
* ```tsx
*
*
*
* Migrate the calendar
* Four files, no API change.
*
*
*
*
*
*
*
* Read the date utils
* Replace the month grid
* Update the docs page
*
*
*
*
*
*
*
* ```
*
* ## Where the props come from
*
* With the AI SDK a plan is usually an `experimental_useObject` stream, where
* `isStreaming` is the hook's `isLoading` and the fields arrive one at a time —
* which is exactly the case the shimmer exists for, since a partial object has
* a title before it has anything else.
*/
import {
Children,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactElement,
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 { useCSSVariable } from 'uniwind';
import { CheckIcon, ChevronDownIcon } from '../../icons';
import { Collapse } from '../../primitives/collapse';
import { Text, textChildren, type TextProps } from '../../primitives/text';
import { cn } from '../../utils/cn';
import { Shimmer } from '../shimmer';
const planVariants = tv({
slots: {
root: 'w-full overflow-hidden rounded-2xl border border-border bg-card shadow-sm',
header: 'flex-row items-start gap-3 p-4',
icon: 'h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted',
heading: 'min-w-0 flex-1 gap-1',
title: 'text-base font-semibold text-card-foreground',
description: 'text-sm text-muted-foreground',
action: 'shrink-0 flex-row items-center gap-1',
progress: 'text-xs font-medium text-muted-foreground',
trigger: 'h-8 w-8 items-center justify-center rounded-full active:bg-accent',
content: 'gap-2 px-4 pb-4',
steps: 'w-full',
step: 'w-full flex-row gap-3',
stepRail: 'w-4 items-center pt-px',
stepMarker: 'h-4 w-4 items-center justify-center rounded-full',
stepConnector: 'w-px flex-1 rounded-full',
stepBody: 'min-w-0 flex-1 gap-0.5 pb-3',
stepTitle: 'text-sm',
stepDescription: 'text-xs text-muted-foreground',
stepMeta:
'self-start rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground',
footer: 'flex-row items-center gap-2 border-t border-border p-4',
},
});
export type PlanStepStatus = 'pending' | 'active' | 'done' | 'skipped';
/**
* What each state does to a step's marker, its title and the rail below it.
*
* A lookup rather than a `tv()` variant because it belongs to `Plan.Step`, not
* to `Plan` — a variant here would be read off the root and documented as
* something you could pass to the card.
*
* The connector is filled behind a step that is finished, which is what turns
* the left-hand column into a progress bar stood on its end: how far the plan
* has got is readable from the rail alone, without counting markers.
*/
const stepStatus: Record<
PlanStepStatus,
{ marker: string; title: string; connector: string }
> = {
pending: {
marker: 'border border-border',
title: 'text-muted-foreground',
connector: 'bg-border',
},
active: {
marker: 'border-2 border-primary',
title: 'font-medium text-foreground',
connector: 'bg-border',
},
done: {
marker: 'bg-primary',
title: 'text-foreground',
connector: 'bg-primary',
},
skipped: {
marker: 'border border-dashed border-border',
title: 'text-muted-foreground line-through',
connector: 'bg-border',
},
};
/** How far down the rail the plan has got. Reported by `Plan.Steps`. */
export interface PlanStepCounts {
done: number;
total: number;
}
interface PlanContextValue {
isStreaming: boolean;
open: boolean;
setOpen: (open: boolean) => void;
counts: PlanStepCounts | null;
reportCounts: (counts: PlanStepCounts) => void;
}
const PlanContext = createContext(null);
function usePlan(component: string): PlanContextValue {
const context = useContext(PlanContext);
if (!context) {
throw new Error(`${component} must be used within a `);
}
return context;
}
export interface PlanProps extends Omit {
className?: string;
/** Whether the plan is still being written. Shimmers the title and description. */
isStreaming?: boolean;
/** Controlled open state of the body. */
open?: boolean;
/** Initial state when uncontrolled. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children?: ReactNode;
}
function PlanRoot({
className,
isStreaming = false,
open: openProp,
defaultOpen = true,
onOpenChange,
children,
...props
}: PlanProps) {
const { root } = planVariants();
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = openProp !== undefined;
const open = isControlled ? openProp : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
);
// `Plan.Steps` counts its own children and reports up, so `Plan.Progress` can
// sit in the header — above the rail, and with no way to reach it — without
// the caller having to say how many steps there are twice.
const [counts, setCounts] = useState(null);
const reportCounts = useCallback((next: PlanStepCounts) => {
setCounts((current) =>
current && current.done === next.done && current.total === next.total ? current : next
);
}, []);
const context = useMemo(
() => ({ isStreaming, open, setOpen, counts, reportCounts }),
[isStreaming, open, setOpen, counts, reportCounts]
);
return (
{children}
);
}
PlanRoot.displayName = 'Plan';
export interface PlanHeaderProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* The title, the description and whatever acts on them.
*
* The heading is wrapped in a column of its own so the action stays pinned to
* the trailing edge as the title wraps, rather than riding down with it. An
* icon is pulled out to the leading edge for the same reason, in the other
* direction — a badge that rode down with a wrapping title would stop reading
* as a badge for the plan.
*/
function PlanHeader({ className, children, ...props }: PlanHeaderProps) {
const { header, heading } = planVariants();
const icons: ReactNode[] = [];
const headings: ReactNode[] = [];
const actions: ReactNode[] = [];
for (const child of Array.isArray(children) ? children : [children]) {
if (isType(child, PlanAction)) actions.push(child);
else if (isType(child, PlanIcon)) icons.push(child);
else headings.push(child);
}
return (
{icons}
{headings}
{actions}
);
}
function isType(child: ReactNode, type: unknown): boolean {
return (
typeof child === 'object' &&
child !== null &&
'type' in child &&
(child as { type?: unknown }).type === type
);
}
export interface PlanIconProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/** A badge on the header's leading edge — what kind of plan this is. */
function PlanIcon({ className, children, ...props }: PlanIconProps) {
const { icon } = planVariants();
return (
{children}
);
}
export interface PlanTitleProps extends TextProps {
className?: string;
children?: ReactNode;
}
/** The plan's name. Shimmers while it is still being written. */
function PlanTitle({ className, children, ...props }: PlanTitleProps) {
const { isStreaming } = usePlan('Plan.Title');
const { title } = planVariants();
if (isStreaming && typeof children === 'string') {
return (
{children}
);
}
return (
{children}
);
}
export interface PlanDescriptionProps extends TextProps {
className?: string;
children?: ReactNode;
}
function PlanDescription({ className, children, ...props }: PlanDescriptionProps) {
const { isStreaming } = usePlan('Plan.Description');
const { description } = planVariants();
if (isStreaming && typeof children === 'string') {
return {children};
}
return (
{children}
);
}
export interface PlanActionProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/** Pinned to the header's trailing edge — the toggle, a badge, a menu. */
function PlanAction({ className, children, ...props }: PlanActionProps) {
const { action } = planVariants();
return (
{children}
);
}
export interface PlanTriggerProps extends Omit {
className?: string;
children?: ReactNode;
}
/** Folds the body away. Its chevron turns to point at the state it will reach. */
function PlanTrigger({ className, children, onPress, ...props }: PlanTriggerProps) {
const { open, setOpen } = usePlan('Plan.Trigger');
const { trigger } = planVariants();
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` }],
}));
return (
{
onPress?.(event);
setOpen(!open);
}}
className={cn(trigger(), className)}
{...props}
>
{children ?? (
)}
);
}
export interface PlanContentProps extends Omit {
className?: string;
children?: ReactNode;
}
/** The steps. Collapses rather than unmounting, so it can still be growing. */
function PlanContent({ className, children, ...props }: PlanContentProps) {
const { open } = usePlan('Plan.Content');
const { content, description } = planVariants();
return (
{textChildren(children, (text) => (
{text}
))}
);
}
/**
* Where a step sits in the rail. Provided by `Plan.Steps` rather than passed
* down, so steps produced by a `.map()` through a component of your own still
* know they are last — a prop set on that wrapper would never reach the step.
*/
const PlanStepContext = createContext<{ last: boolean }>({ last: false });
export interface PlanStepsProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* The rail. A plan is an ordered list of things that will happen, and this is
* the list — a paragraph describing the same four steps cannot be glanced at to
* see which one is running.
*
* It counts its own steps and reports the count up, which is what lets
* `Plan.Progress` sit in the header without the caller stating the total twice.
*/
function PlanSteps({ className, children, ...props }: PlanStepsProps) {
const { reportCounts } = usePlan('Plan.Steps');
const { steps } = planVariants();
const items = useMemo(
() => Children.toArray(children).filter((child) => isValidElement(child)),
[children]
);
const counts = useMemo(() => {
let done = 0;
let total = 0;
for (const item of items) {
if (!isType(item, PlanStep)) continue;
total += 1;
// A skipped step is settled too — a plan that reports 2 of 4 while two
// more were deliberately passed over is reporting the wrong thing.
const status = (item as ReactElement).props.status;
if (status === 'done' || status === 'skipped') done += 1;
}
return { done, total };
}, [items]);
useEffect(() => {
reportCounts(counts);
}, [counts, reportCounts]);
return (
{items.map((item, index) => (
{item}
))}
);
}
export interface PlanStepProps extends Omit {
className?: string;
/** How far this step has got. Decides the marker, the title and the rail below it. */
status?: PlanStepStatus;
/** A line under the title — what the step will touch, or what it found. */
description?: ReactNode;
/** A file path, a count, a duration. Rendered as a small mono chip. */
meta?: ReactNode;
/** Drop the connector below this step. `Plan.Steps` sets it for you. */
last?: boolean;
children?: ReactNode;
}
/**
* One step. The marker says which of the four states it is in and the rail
* below it is filled once it is done, so the plan's progress is legible from
* the shape of the left-hand column alone.
*/
function PlanStep({
className,
status = 'pending',
description,
meta,
last,
children,
...props
}: PlanStepProps) {
const { isStreaming } = usePlan('Plan.Step');
const position = useContext(PlanStepContext);
const isLast = last ?? position.last;
const {
step,
stepRail,
stepMarker,
stepConnector,
stepBody,
stepTitle,
stepDescription,
stepMeta,
} = planVariants();
const tone = stepStatus[status];
const onPrimary = useCSSVariable('--color-primary-foreground');
const title = stepTitle({ className: tone.title });
// Only the step that is running is still arriving. Shimmering a finished one
// would say it is still being written, and shimmering the whole rail would
// say nothing at all.
const streamingTitle = isStreaming && status === 'active' && typeof children === 'string';
return (
{status === 'done' ? (
) : status === 'active' ? (
) : null}
{isLast ? null : }
{streamingTitle ? (
{children as string}
) : (
textChildren(children, (text) => {text})
)}
{textChildren(description, (text) => (
{text}
))}
{meta === undefined ? null : (
{textChildren(meta, (text) => (
{text}
))}
)}
);
}
export interface PlanProgressProps extends TextProps {
className?: string;
/** Steps settled so far. Defaults to what `Plan.Steps` counted. */
value?: number;
/** Steps in total. Defaults to what `Plan.Steps` counted. */
total?: number;
}
/**
* How far down the rail the plan has got, as `2 of 4`. Renders nothing until
* there is a rail to count — a plan with no steps has no progress to report,
* and `0 of 0` is worse than silence.
*/
function PlanProgress({ className, value, total, ...props }: PlanProgressProps) {
const { counts } = usePlan('Plan.Progress');
const { progress } = planVariants();
const done = value ?? counts?.done;
const all = total ?? counts?.total;
if (done === undefined || !all) return null;
return (
{done} of {all}
);
}
export interface PlanFooterProps extends ViewProps {
className?: string;
/**
* How the actions divide the row. `stretch` splits it between them, which is
* what a phone wants: the decision is the point of the card, and the two
* buttons that make it should be the width of a thumb. `end` packs them
* against the trailing edge for a plan sitting inside something denser.
*/
layout?: 'stretch' | 'end';
children?: ReactNode;
}
/**
* Where the buttons that answer the plan go.
*
* Each action takes an equal share of the row by default. A pair of small
* buttons hugging the trailing corner is a pointer-and-cursor shape; on a phone
* the answer to "shall I do this" is the most important control on the screen
* and wants to be hit without aiming.
*/
function PlanFooter({ className, layout = 'stretch', children, ...props }: PlanFooterProps) {
const { footer } = planVariants();
return (
{layout === 'stretch'
? // A view stretches its children across the cross axis by default, so
// a button inside one of these fills it without being told to.
Children.map(children, (child) =>
isValidElement(child) ? {child} : child
)
: children}
);
}
PlanHeader.displayName = 'Plan.Header';
PlanIcon.displayName = 'Plan.Icon';
PlanTitle.displayName = 'Plan.Title';
PlanDescription.displayName = 'Plan.Description';
PlanAction.displayName = 'Plan.Action';
PlanProgress.displayName = 'Plan.Progress';
PlanTrigger.displayName = 'Plan.Trigger';
PlanContent.displayName = 'Plan.Content';
PlanSteps.displayName = 'Plan.Steps';
PlanStep.displayName = 'Plan.Step';
PlanFooter.displayName = 'Plan.Footer';
export const Plan = Object.assign(PlanRoot, {
Header: PlanHeader,
Icon: PlanIcon,
Title: PlanTitle,
Description: PlanDescription,
Action: PlanAction,
Progress: PlanProgress,
Trigger: PlanTrigger,
Content: PlanContent,
Steps: PlanSteps,
Step: PlanStep,
Footer: PlanFooter,
});