PlanApproval
Review a plan before the agent starts working, for example after plan mode. The Markdown plan is collapsed to collapsedLines (8 by default) and expands on demand. Approve, Approve with edits and Reject with feedback show a loader while their promise runs and an error when it rejects; pass decision to render the final state instead of the buttons. The plan uses the same shape as PlanTool.
Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-reactExamples
Review a plan
Failed action
Source
The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.
message-actions/PlanApproval/PlanApproval.tsx
import React, { memo, useState } from 'react';
import {
Alert,
Button,
Divider,
Group,
Menu,
Paper,
Spoiler,
Stack,
Text,
Textarea,
} from '@mantine/core';
import {
IconAlertCircle,
IconCheck,
IconChevronDown,
IconFileDescription,
IconPencil,
IconX,
} from '@tabler/icons-react';
import { Markdown } from '../../Markdown/Markdown';
import { StatusBadge } from '../../primitives/StatusBadge/StatusBadge';
import type { Plan } from '../../tools/PlanTool';
import {
getApproveModeLabel,
getCollapsedPlanHeight,
getPlanFileName,
type PlanApproveOption,
} from '../plan-approval';
import type { PlanDecision } from '../types';
import { useAsyncAction } from '../use-async-action';
import classes from './PlanApproval.module.css';
export interface PlanApprovalLabels {
showFull: string;
showLess: string;
empty: string;
approve: string;
approveWithEdits: string;
reject: string;
rejectWithFeedback: string;
back: string;
editsLabel: string;
editsPlaceholder: string;
feedbackLabel: string;
feedbackPlaceholder: string;
approved: string;
approvedWithEdits: string;
rejected: string;
error: string;
moreApproveOptions: string;
}
export const DEFAULT_PLAN_APPROVAL_LABELS: PlanApprovalLabels = {
showFull: 'Show full plan',
showLess: 'Show less',
empty: 'No plan details provided.',
approve: 'Approve',
approveWithEdits: 'Approve with edits',
reject: 'Reject',
rejectWithFeedback: 'Reject with feedback',
back: 'Back',
editsLabel: 'Edits to the plan',
editsPlaceholder: 'Describe what to change before the agent starts',
feedbackLabel: 'Why is the plan rejected?',
feedbackPlaceholder: 'Tell the agent what to do instead',
approved: 'Approved',
approvedWithEdits: 'Approved with edits',
rejected: 'Rejected',
error: 'Could not send the decision',
moreApproveOptions: 'More approval options',
};
type Mode = 'idle' | 'edits' | 'reject';
export interface PlanApprovalProps {
/** Plan in the same shape as `PlanTool`: `summary` is rendered as Markdown */
plan: Plan;
/** Approves the plan as is, with the picked `approveOptions` value when options are set; buttons show a loader until the promise settles */
onApprove: (mode?: string) => void | Promise<void>;
/** Approval modes such as auto-accept edits; the approve button uses the first one and a menu offers all of them */
approveOptions?: PlanApproveOption[];
/** Adds "Approve with edits", called with the trimmed edits */
onApproveWithEdits?: (edits: string) => void | Promise<void>;
/** Adds "Reject with feedback", called with the trimmed feedback */
onReject?: (feedback: string) => void | Promise<void>;
/** Decision already made, renders the final state instead of the actions */
decision?: PlanDecision | null;
/** Number of lines the plan is collapsed to, `8` by default */
collapsedLines?: number;
/** Overrides of the default English labels */
labels?: Partial<PlanApprovalLabels>;
/** Class name added to the root element */
className?: string;
/** Inline styles added to the root element */
style?: React.CSSProperties;
}
/** Plan review card: collapsible Markdown plan with approve, approve with edits and reject with feedback */
export const PlanApproval = memo(function PlanApproval({
plan,
onApprove,
approveOptions,
onApproveWithEdits,
onReject,
decision: decisionProp,
collapsedLines = 8,
labels: labelsProp,
className,
style,
}: PlanApprovalProps) {
const labels = { ...DEFAULT_PLAN_APPROVAL_LABELS, ...labelsProp };
const [mode, setMode] = useState<Mode>('idle');
const [note, setNote] = useState('');
const [internalDecision, setInternalDecision] = useState<PlanDecision | null>(null);
const { pendingKey, error, run, clearError } = useAsyncAction(labels.error);
const decision = decisionProp === undefined ? internalDecision : decisionProp;
const isPending = pendingKey !== null;
const summary = plan.summary?.trim() ?? '';
const trimmedNote = note.trim();
const decide = async (next: PlanDecision, action: () => void | Promise<void>) => {
const ok = await run(next.kind, action);
if (ok) {
setInternalDecision(next);
setMode('idle');
}
};
const switchMode = (next: Mode) => {
clearError();
setNote('');
setMode(next);
};
const approve = (approveMode?: string) =>
decide(
approveMode === undefined ? { kind: 'approved' } : { kind: 'approved', mode: approveMode },
() => (approveMode === undefined ? onApprove() : onApprove(approveMode))
);
const approveButton = (
<Button
size="xs"
leftSection={<IconCheck size={14} />}
loading={pendingKey === 'approved'}
disabled={isPending}
onClick={() => approve(approveOptions?.[0]?.value)}
>
{labels.approve}
</Button>
);
let footer: React.ReactNode;
if (decision) {
const edits = decision.kind === 'approved-with-edits' ? decision.edits : undefined;
const feedback = decision.kind === 'rejected' ? decision.feedback : undefined;
const modeLabel =
decision.kind === 'rejected' ? undefined : getApproveModeLabel(approveOptions, decision.mode);
const decisionLabel =
decision.kind === 'approved'
? labels.approved
: decision.kind === 'approved-with-edits'
? labels.approvedWithEdits
: labels.rejected;
footer = (
<Stack gap="xs" role="status">
<Group gap="xs">
<StatusBadge
status={decision.kind === 'rejected' ? 'error' : 'success'}
label={modeLabel ? `${decisionLabel} · ${modeLabel}` : decisionLabel}
/>
</Group>
{(edits || feedback) && (
<Stack gap={2} className={classes.note} data-tone={feedback ? 'danger' : 'info'}>
<Text size="xs" c="dimmed">
{feedback ? labels.feedbackLabel : labels.editsLabel}
</Text>
<Text size="sm" className={classes.noteText}>
{edits || feedback}
</Text>
</Stack>
)}
</Stack>
);
} else if (mode !== 'idle') {
const isEdits = mode === 'edits';
footer = (
<Stack gap="xs">
<Textarea
label={isEdits ? labels.editsLabel : labels.feedbackLabel}
placeholder={isEdits ? labels.editsPlaceholder : labels.feedbackPlaceholder}
autosize
minRows={2}
maxRows={8}
autoFocus
value={note}
readOnly={isPending}
onChange={(event) => setNote(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Escape' && !isPending) {
switchMode('idle');
}
}}
/>
<Group gap="xs" justify="flex-end">
<Button
size="xs"
variant="subtle"
color="gray"
onClick={() => switchMode('idle')}
disabled={isPending}
>
{labels.back}
</Button>
{isEdits ? (
<Button
size="xs"
leftSection={<IconPencil size={14} />}
disabled={!trimmedNote}
loading={isPending}
onClick={() =>
decide({ kind: 'approved-with-edits', edits: trimmedNote }, () =>
onApproveWithEdits?.(trimmedNote)
)
}
>
{labels.approveWithEdits}
</Button>
) : (
<Button
size="xs"
color="var(--ae-danger-fill)"
leftSection={<IconX size={14} />}
disabled={!trimmedNote}
loading={isPending}
onClick={() =>
decide({ kind: 'rejected', feedback: trimmedNote }, () => onReject?.(trimmedNote))
}
>
{labels.reject}
</Button>
)}
</Group>
</Stack>
);
} else {
footer = (
<Group gap="xs" justify="flex-end">
{onReject && (
<Button
size="xs"
variant="subtle"
color="gray"
onClick={() => switchMode('reject')}
disabled={isPending}
>
{labels.rejectWithFeedback}
</Button>
)}
{onApproveWithEdits && (
<Button
size="xs"
variant="subtle"
color="gray"
onClick={() => switchMode('edits')}
disabled={isPending}
>
{labels.approveWithEdits}
</Button>
)}
{approveOptions && approveOptions.length > 0 ? (
<Button.Group>
{approveButton}
<Menu position="bottom-end" disabled={isPending}>
<Menu.Target>
<Button
size="xs"
px={6}
disabled={isPending}
aria-label={labels.moreApproveOptions}
>
<IconChevronDown size={14} />
</Button>
</Menu.Target>
<Menu.Dropdown>
{approveOptions.map((option) => (
<Menu.Item key={option.value} onClick={() => approve(option.value)}>
<Text size="xs">{option.label}</Text>
{option.description && (
<Text size="xs" c="dimmed">
{option.description}
</Text>
)}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
</Button.Group>
) : (
approveButton
)}
</Group>
);
}
return (
<Paper withBorder radius="md" className={className} style={style}>
<Stack gap={4} px="md" pt="sm" pb="xs">
<Group gap={6} wrap="nowrap" c="dimmed">
<IconFileDescription size={14} aria-hidden />
<Text size="xs" c="dimmed" ff="monospace" truncate="end" miw={0}>
{getPlanFileName(plan)}
</Text>
</Group>
<Text size="sm" fw={500}>
{plan.title}
</Text>
</Stack>
<Stack gap={0} px="md" pb="sm">
{summary ? (
<Spoiler
maxHeight={getCollapsedPlanHeight(collapsedLines)}
showLabel={labels.showFull}
hideLabel={labels.showLess}
classNames={{ control: classes.spoilerControl }}
>
<Markdown content={summary} />
</Spoiler>
) : (
<Text size="sm" c="dimmed">
{labels.empty}
</Text>
)}
</Stack>
<Divider />
<Stack gap="xs" p="sm">
{error && (
<Alert color="red" variant="light" p="xs" icon={<IconAlertCircle size={16} />}>
{error}
</Alert>
)}
{footer}
</Stack>
</Paper>
);
});
PlanApproval.displayName = 'PlanApproval';
Depends on:
API reference
Prop
Type
Required
plan
Plan
Yes
Plan in the same shape as `PlanTool`: `summary` is rendered as Markdown
onApprove
(mode?: string) => void | Promise<void>
Yes
Approves the plan as is, with the picked `approveOptions` value when options are set; buttons show a loader until the promise settles
approveOptions
PlanApproveOption[]
No
Approval modes such as auto-accept edits; the approve button uses the first one and a menu offers all of them
onApproveWithEdits
(edits: string) => void | Promise<void>
No
Adds "Approve with edits", called with the trimmed edits
onReject
(feedback: string) => void | Promise<void>
No
Adds "Reject with feedback", called with the trimmed feedback
decision
PlanDecision | null
No
Decision already made, renders the final state instead of the actions
collapsedLines
number
No
Number of lines the plan is collapsed to, `8` by default
labels
Partial<PlanApprovalLabels>
No
Overrides of the default English labels
className
string
No
Class name added to the root element
style
React.CSSProperties
No
Inline styles added to the root element