Plan
What an agent intends to do, before it does it.
What an agent intends to do, before it does it.
A card rather than a run of rows, because a plan is something the reader is being asked to approve: it needs an edge around it so 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. The reader's question is always which one is running, and a paragraph describing four steps cannot be glanced at to answer that. The rail fills in behind the steps that are done, so progress is legible from its left edge alone.
isStreaming shimmers the title, the description and the step that is running — the alternative is a title that grows a word at a time and reads as finished at every intermediate length.
Installation
Plan ships with the library — no separate install.
import { Plan, Button, Text, Task, Badge, ListChecksIcon } from 'panelui-native';Or copy the source into your project, to own and edit it:
npx panelui-cli@latest add planUsage
<Plan isStreaming={isLoading}>
<Plan.Header>
<Plan.Icon>
<ListChecksIcon size={16} />
</Plan.Icon>
<Plan.Title>{plan.title}</Plan.Title>
<Plan.Description>{plan.summary}</Plan.Description>
<Plan.Action>
<Plan.Progress />
<Plan.Trigger />
</Plan.Action>
</Plan.Header>
<Plan.Content>
<Plan.Steps>
{plan.steps.map((step) => (
<Plan.Step key={step.title} status={step.status} meta={step.file}>
{step.title}
</Plan.Step>
))}
</Plan.Steps>
</Plan.Content>
<Plan.Footer>
<Button variant="outline">Revise</Button>
<Button>Approve</Button>
</Plan.Footer>
</Plan>Composition
<Plan>
<Plan.Header>
<Plan.Icon />
<Plan.Title />
<Plan.Description />
<Plan.Action>
<Plan.Progress />
<Plan.Trigger />
</Plan.Action>
</Plan.Header>
<Plan.Content>
<Plan.Steps>
<Plan.Step />
</Plan.Steps>
</Plan.Content>
<Plan.Footer />
</Plan>Plan.Step takes its position from Plan.Steps rather than from a prop, so steps produced by a .map() through a component of your own still know which one is last and drop the connector. Pass last yourself only for a step rendered outside Plan.Steps.
Examples
A rail of steps
The four statuses are pending, active, done and skipped. Only one step is usually active; the rail behind the finished ones is filled, which is what makes progress readable without counting. meta takes the file the step touches and renders it as a small mono chip.
<Plan>
<Plan.Header>
<Plan.Icon>
<ListChecksIcon size={16} />
</Plan.Icon>
<Plan.Title>Fix the calendar range</Plan.Title>
<Plan.Description>Four files, no API change.</Plan.Description>
<Plan.Action>
<Plan.Progress />
<Plan.Trigger />
</Plan.Action>
</Plan.Header>
<Plan.Content>
<Plan.Steps>
<Plan.Step status="done" meta="utils/date.ts">
Make the in-range test inclusive
</Plan.Step>
<Plan.Step status="done" meta="calendar/index.tsx">
Round the band only where it stops
</Plan.Step>
<Plan.Step status="active">Square the discs against the band</Plan.Step>
<Plan.Step meta="scripts/gen.mjs">Regenerate the docs page</Plan.Step>
</Plan.Steps>
</Plan.Content>
<Plan.Footer>
<Button variant="outline">Revise</Button>
<Button>Approve</Button>
</Plan.Footer>
</Plan>Streaming in
With experimental_useObject the fields arrive one at a time — a title before there is anything else — which is exactly what the shimmer is for. Only the active step shimmers: a finished one is not still being written, and shimmering the whole rail would say nothing at all.
const { object, isLoading } = useObject({
api: '/api/plan',
schema: planSchema,
});
<Plan isStreaming={isLoading}>
<Plan.Header>
<Plan.Title>{object?.title ?? 'Planning…'}</Plan.Title>
<Plan.Description>{object?.summary ?? ''}</Plan.Description>
<Plan.Action>
<Plan.Progress />
</Plan.Action>
</Plan.Header>
<Plan.Content>
<Plan.Steps>
{object?.steps?.map((step, i) => (
<Plan.Step key={i} status={step?.status ?? 'pending'}>
{step?.title ?? ''}
</Plan.Step>
))}
</Plan.Steps>
</Plan.Content>
</Plan>Approving it
The footer is the reason this is a card. A plan the reader has to agree to needs somewhere unambiguous for the agreement to live, separated from the steps by a rule. The actions split the row evenly, because on a phone the answer to shall I do this is the most important control on the screen and should be hit without aiming — pass layout="end" to pack them against the trailing edge instead.
<Plan>
<Plan.Header>
<Plan.Title>Migrate 4 components</Plan.Title>
<Plan.Description>Reversible. Nothing is published.</Plan.Description>
<Plan.Action>
<Plan.Progress />
<Plan.Trigger />
</Plan.Action>
</Plan.Header>
<Plan.Content>…</Plan.Content>
<Plan.Footer>
<Button variant="outline">Revise</Button>
<Button>Approve</Button>
</Plan.Footer>
</Plan>Steps that are tasks
A step that has a story of its own — files read, commands run — outgrows the rail. Put Task rows in Plan.Content instead and the card becomes a live account of the work rather than a list of intentions.
<Plan>
<Plan.Header>
<Plan.Title>Fix the calendar range</Plan.Title>
<Plan.Action>
<Badge variant="secondary">2 of 3</Badge>
<Plan.Trigger />
</Plan.Action>
</Plan.Header>
<Plan.Content>
<Task status="complete">
<Task.Trigger title="Compute the band inclusively" />
<Task.Content>
<Task.Item>
Edited <Task.File>calendar/index.tsx</Task.File>
</Task.Item>
</Task.Content>
</Task>
<Task status="running">
<Task.Trigger title="Regenerate the docs" />
<Task.Content>
<Task.Item>Running docs:generate…</Task.Item>
</Task.Content>
</Task>
</Plan.Content>
</Plan>API Reference
Plan
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
isStreaming | boolean | false | Whether the plan is still being written. Shimmers the title and description. |
open | boolean | — | Controlled open state of the body. |
defaultOpen | boolean | true | Initial state when uncontrolled. |
onOpenChange | (open: boolean) => void | — |
Plan.Header
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Plan.Icon
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Plan.Title
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Plan.Description
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Plan.Action
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Plan.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Plan.Content
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Plan.Steps
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Plan.Step
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
status | PlanStepStatus | 'pending' | How far this step has got. Decides the marker, the title and the rail below it. |
description | ReactNode | — | A line under the title — what the step will touch, or what it found. |
meta | ReactNode | — | A file path, a count, a duration. Rendered as a small mono chip. |
last | boolean | — | Drop the connector below this step. Plan.Steps sets it for you. |
Plan.Progress
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
value | number | — | Steps settled so far. Defaults to what Plan.Steps counted. |
total | number | — | Steps in total. Defaults to what Plan.Steps counted. |
Plan.Footer
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
layout | 'stretch' | 'end' | — | 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. |
Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.
Notes
The rail counts itself. Plan.Steps reports how many of its steps are settled, which is what lets Plan.Progress sit up in the header — above the rail, with no way to reach it — without the total being stated twice. done and skipped both count as settled: a plan reporting 2 of 4 while two more were deliberately passed over is reporting the wrong thing. Pass value and total to Plan.Progress to override the count entirely.
Only string children shimmer. Plan.Title, Plan.Description and the active Plan.Step wrap text in a shimmer while isStreaming, and leave an element alone — an element may already be animating, and two animations over one heading is a mess rather than an emphasis.
Plan.Icon and Plan.Action are lifted out of the heading column by the header, to the leading and trailing edges respectively, so neither rides down with a title that wraps to a second line.
The footer stretches its actions. A pair of small buttons hugging the trailing corner is a pointer-and-cursor shape; here each action takes an equal share of the row so both clear a thumb. layout="end" gives you the packed arrangement for a plan sitting inside something denser.
The body collapses rather than unmounting, for the same reason as everywhere else here: a streaming plan is still growing while it is folded.
Nothing here depends on the AI SDK. isStreaming is a boolean; it happens to be useObject’s isLoading.
Public exports
Values: Plan
Types: PlanProps, PlanHeaderProps, PlanIconProps, PlanTitleProps, PlanDescriptionProps, PlanActionProps, PlanProgressProps, PlanTriggerProps, PlanContentProps, PlanStepsProps, PlanStepProps, PlanStepStatus, PlanStepCounts, PlanFooterProps