Plan Approval Card
A proposed plan with clear steps and controls to edit, cancel, or start the work.
Usage
import { PlanApprovalCard } from "@/components/ai/plan-approval-card"
import { PlanResearchCard } from "@/components/ai/plan-research-card"
export function Example() {
return (
<PlanApprovalCard
title="Research a topic"
steps={[
{ id: "news", label: "Gather relevant information from trusted sources." },
{ id: "summary", label: "Summarize findings with sources." },
]}
onEdit={() => console.log("Edit plan")}
onCancel={() => console.log("Cancel plan")}
onStart={() => console.log("Start plan")}
/>
)
}
export function ResearchingExample() {
return (
<PlanResearchCard
title="Research a topic"
steps={[
{ id: "sources", label: "Gather trusted sources.", status: "complete" },
{ id: "verify", label: "Verify the findings.", status: "running" },
{ id: "summary", label: "Write the summary.", status: "pending" },
]}
progress={40}
searchCount={225}
statusText="Organizing findings and checking sources…"
onUpdate={() => console.log("Update research")}
onStop={() => console.log("Stop research")}
/>
)
}
Install
pnpm dlx shadcn@latest add @pastaui/plan-approval-cardnpx shadcn@latest add @pastaui/plan-approval-cardyarn dlx shadcn@latest add @pastaui/plan-approval-cardbunx --bun shadcn@latest add @pastaui/plan-approval-cardSource
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { cn } from 'cn'
interface PlanApprovalStep {
id: string
label: string
}
interface PlanApprovalCardProps extends React.ComponentProps<'section'> {
title: string
steps: PlanApprovalStep[]
onEdit?: () => void
onCancel?: () => void
onStart?: () => void
disabled?: boolean
/** Controlled display only; the card never starts a plan automatically. */
countdownSeconds?: number
countdownDuration?: number
}
function PlanApprovalCard({
className,
title,
steps,
onEdit,
onCancel,
onStart,
disabled = false,
countdownSeconds,
countdownDuration = 60,
...props
}: PlanApprovalCardProps) {
const titleId = React.useId()
const seconds =
countdownSeconds === undefined || !Number.isFinite(countdownSeconds)
? undefined
: Math.max(0, Math.ceil(countdownSeconds))
const progress =
seconds === undefined || !Number.isFinite(countdownDuration) || countdownDuration <= 0
? 0
: Math.min(1, seconds / countdownDuration)
return (
<section
aria-labelledby={titleId}
data-slot="plan-approval-card"
className={cn(
'w-fit max-w-full rounded-[16px] bg-card p-5 font-sans text-sm leading-5 font-[450] tracking-[-0.05px] text-muted-foreground shadow-card transition-[opacity,translate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] sm:p-6 starting:translate-y-1 starting:opacity-0 motion-reduce:starting:translate-y-0',
className,
)}
{...props}
>
<h3 id={titleId} className="card-heading leading-[19.25px]">
{title}
</h3>
<ol className="mt-6 space-y-5">
{steps.map((step) => (
<li key={step.id} className="flex items-start gap-4">
<span
aria-hidden="true"
className="mt-0.5 size-4 shrink-0 rounded-full border-2 border-dashed border-muted-foreground/50"
/>
<span className="min-w-0 break-words">{step.label}</span>
</li>
))}
</ol>
<div className="mt-8 flex flex-wrap items-center justify-between gap-3">
<Button
type="button"
variant="ghost"
className="h-7 rounded-full px-3 text-sm font-[450] text-muted-foreground hover:text-muted-foreground"
disabled={disabled}
onClick={onEdit}
>
Edit
</Button>
<div className="ml-auto flex items-center gap-1.5">
<Button
type="button"
variant="ghost"
className="h-7 rounded-full px-3 text-sm font-[450] text-muted-foreground hover:text-muted-foreground"
disabled={disabled}
onClick={onCancel}
>
Cancel
</Button>
<Button
type="button"
variant="default"
className="h-7 gap-2 rounded-full border-0 bg-primary bg-linear-to-b from-[color-mix(in_oklch,var(--primary),var(--primary-foreground)_30%)] to-primary px-3 text-sm font-[450] text-primary-foreground shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_1px_2px_rgba(0,0,0,0.18)] shadow-none hover:brightness-110 dark:bg-none"
disabled={disabled || steps.length === 0}
onClick={onStart}
>
Start
{seconds !== undefined && (
<span
className="relative flex size-5 items-center justify-center text-[10px] tabular-nums"
aria-label={`${seconds} seconds remaining`}
>
<svg
aria-hidden="true"
viewBox="0 0 32 32"
className="absolute inset-0 size-5 -rotate-90 fill-none stroke-current"
>
<circle cx="16" cy="16" r="14" strokeWidth="1.5" className="opacity-15" />
<circle
cx="16"
cy="16"
r="14"
strokeWidth="1.5"
strokeLinecap="round"
pathLength="1"
strokeDasharray={`${progress} 1`}
className="transition-[stroke-dasharray] duration-1000 ease-linear motion-reduce:transition-none"
/>
</svg>
{seconds}
</span>
)}
</Button>
</div>
</div>
</section>
)
}
export { PlanApprovalCard }
export type { PlanApprovalCardProps, PlanApprovalStep }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| PlanResearchCard | component | = — | Researching variant, imported from components/ai/plan-research-card. |
| progress / searchCount | number | = — | Research variant: progress from 0–100 and number of searches performed. |
| statusText | string | = — | Research variant: current activity or stopped-state message. |
| onUpdate / onStop | () => void | = — | Research variant: update and stop callbacks. |
| steps[].status | pending | running | complete | = — | Research variant: controls each step indicator. |
| title | string | = — | Names the proposed task. |
| steps | PlanApprovalStep[] | = — | Ordered steps with a stable id and label. Start is disabled for an empty plan. |
| onEdit | () => void | = — | Opens your plan editor. |
| onCancel | () => void | = — | Cancels the proposed plan. |
| onStart | () => void | = — | Approves and starts the plan. |
| disabled | boolean | = false | Disables all three actions. |
| countdownSeconds | number | = — | Optional remaining seconds displayed inside Start. Controlled by the parent; never automatically starts the plan. |
| countdownDuration | number | = 60 | Total countdown duration used to draw the remaining-time ring. |