# Agent Checkpoint
A human-approval card for AI agents: answer questions, allow a command, or confirm a plan.
## 1. Setup
// npm install framer-motion
File: components-workspace/agent-checkpoint/index.tsx · 'use client' · export default function AgentCheckpoint()
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import type * as React from 'react'
import {
AnimatePresence,
motion,
useMotionValue,
} from 'framer-motion'
import type { MotionValue } from 'framer-motion'
import { RotateCcw } from '@/app/_icons/icons/rotate-ccw'
import { ChevronRight } from '@/app/_icons/icons/chevron-right'
import { Check } from '@/app/_icons/icons/check'
import { Pen2 } from '@/app/_icons/icons/pen-2'
import { X } from '@/app/_icons/icons/x'
import { ClockDuotone } from '@/app/_icons/icons/clock-duotone'
import { FingerprintDuotone } from '@/app/_icons/icons/fingerprint-duotone'
import { FolderDuotone } from '@/app/_icons/icons/folder-duotone'
import { CircleMinusDuotone } from '@/app/_icons/icons/circle-minus-duotone'
Styling is Tailwind CSS v4 (the bg-(--var) and text-(color:--var) syntax, pointer-coarse: and class-based dark:). The @/app/_icons imports are the AI Canvas icon set; without it, use any icons that accept the same size, weight and className props.
## 2. Constants
export type CheckpointTuning = {
hoverMs: number
stateMs: number
slideInMs: number
slideOutMs: number
bodyExitMs: number
receiptTextMs: number
receiptTextDelayMs: number
undoMs: number
undoFadeMs: number
demoResetMs: number
destructiveArmMs: number
ringFlashMs: number
ringFadeMs: number
touchResumeMs: number
strikeMs: number
expandMs: number
headIconPx: number
defaultAutoSeconds: number
cardMaxW: number
cardRadius: number
receiptH: number
receiptIconPx: number
ringInset: number
ringRadius: number
ringStroke: number
visibleSteps: number
bodySwapY: number
cardResizeMs: number
bodySwapMs: number
bodySwapExitMs: number
layoutStiffness: number
layoutDamping: number
thumbStiffness: number
thumbDamping: number
lightMs: number
}
export const CHECKPOINT_TUNING: CheckpointTuning = {
hoverMs: 150,
stateMs: 200,
slideInMs: 200,
slideOutMs: 150,
bodyExitMs: 120,
receiptTextMs: 200,
receiptTextDelayMs: 68,
undoMs: 5000,
undoFadeMs: 150,
// Equals undoMs so the undo window closes before the demo card comes back.
demoResetMs: 5000,
destructiveArmMs: 3000,
ringFlashMs: 128,
ringFadeMs: 200,
touchResumeMs: 1500,
strikeMs: 200,
expandMs: 280,
headIconPx: 16,
defaultAutoSeconds: 12,
cardMaxW: 480,
cardRadius: 24,
receiptH: 40,
receiptIconPx: 16,
ringInset: 4,
ringRadius: 20,
ringStroke: 2,
visibleSteps: 5,
bodySwapY: 6,
cardResizeMs: 280,
bodySwapMs: 280,
bodySwapExitMs: 200,
layoutStiffness: 696,
layoutDamping: 53,
thumbStiffness: 696,
thumbDamping: 53,
lightMs: 600,
}
const QUESTION_KEY_MAX = 3
const EASE = [0.23, 1, 0.32, 1] as const
const layoutSpring = (t: CheckpointTuning) => ({
type: 'spring' as const,
stiffness: t.layoutStiffness,
damping: t.layoutDamping,
mass: 1,
})
const thumbSpring = (t: CheckpointTuning) => ({
type: 'spring' as const,
stiffness: t.thumbStiffness,
damping: t.thumbDamping,
})
const COARSE_POINTER = '(pointer: coarse)'
function subscribeCoarsePointer(onChange: () => void) {
if (typeof window === 'undefined' || !window.matchMedia) return () => {}
const mq = window.matchMedia(COARSE_POINTER)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}
function getCoarsePointer() {
if (typeof window === 'undefined' || !window.matchMedia) return false
return window.matchMedia(COARSE_POINTER).matches
}
function getCoarsePointerServer() {
return false
}
// Reduced motion as an external store too: no hydration mismatch on the first paint.
const REDUCED_QUERY = '(prefers-reduced-motion: reduce)'
function subscribeReduced(onChange: () => void) {
if (typeof window === 'undefined' || !window.matchMedia) return () => {}
const mq = window.matchMedia(REDUCED_QUERY)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}
function getReduced() {
return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(REDUCED_QUERY).matches
}
function getReducedServer() {
return false
}
type Mode = 'question' | 'command' | 'plan'
type Risk = 'read' | 'writes' | 'destructive'
type ApproveReason = 'user' | 'timeout'
type RejectReason = 'user' | 'skip'
type Question = {
prompt: string
options: string[]
allowCustom?: boolean
}
type CheckpointPayload =
| { mode: 'question'; answers: { prompt: string; answer: string }[] }
| { mode: 'command'; command: string; cwd: string }
| { mode: 'plan'; steps: { text: string; kept: boolean }[] }
type AgentCheckpointProps = {
mode?: Mode
title?: string
questions?: Question[]
command?: string
cwd?: string
risk?: Risk
why?: string
steps?: string[]
autoApproveSeconds?: number
labels?: { approve?: string; reject?: string }
onApprove?: (payload: CheckpointPayload & { reason: ApproveReason }) => void
onReject?: (reason: RejectReason) => void
onUndo?: () => void
className?: string
tuning?: Partial<CheckpointTuning>
}
const DEFAULT_TITLE = 'Needs your OK'
const DEFAULT_COMMAND = 'npm run build -- --filter=web'
const DEFAULT_CWD = '~/aicanvas'
const DEFAULT_WHY = 'Verifies the change compiles before I open the PR.'
const DEFAULT_QUESTIONS: Question[] = [
{ prompt: 'Which package manager?', options: ['npm', 'pnpm', 'bun'] },
{ prompt: 'Run tests after install?', options: ['Yes', 'No'] },
]
const DEFAULT_STEPS = [
'Read the failing test',
'Reproduce locally',
'Patch the date parser',
'Add a regression test',
'Run the suite',
'Open the PR',
]
const NO_QUESTIONS: Question[] = []
const NO_STEPS: string[] = []
const MODES: { id: Mode; label: string }[] = [
{ id: 'question', label: 'Question' },
{ id: 'command', label: 'Command' },
{ id: 'plan', label: 'Plan' },
]
const THEME_VARS = [
'[--ck-page:#EEEEF3] dark:[--ck-page:#0E0E0F]',
'[--ck-panel:#F4F4FA] dark:[--ck-panel:#1B1B1C]',
'[--ck-well:#EEEEF3] dark:[--ck-well:#0E0E0F]',
'[--ck-raised:#DFDFE3] dark:[--ck-raised:#2D2D2E]',
'[--ck-raised-hover:#CACACD] dark:[--ck-raised-hover:#373738]',
'[--ck-lift:#FFFFFF] dark:[--ck-lift:#373738]',
'[--ck-border:#DFDFE3] dark:[--ck-border:#2D2D2E]',
'[--ck-border-strong:#CACACD] dark:[--ck-border-strong:#373738]',
'[--ck-hairline:#DFDFE3] dark:[--ck-hairline:#2D2D2E]',
'[--ck-rim:#CACACD] dark:[--ck-rim:#F4F4FA2E]',
'[--ck-text:#1B1B1C] dark:[--ck-text:#F4F4FA]',
'[--ck-body:#373738] dark:[--ck-body:#CACACD]',
'[--ck-muted:#575759] dark:[--ck-muted:#9B9B9E]',
'[--ck-faint:#7B7B7D]',
'[--ck-selection:#A0A0A233] dark:[--ck-selection:#5E5E6333]',
'[--ck-success:#047857] dark:[--ck-success:#34D399]',
'[--ck-ai:#0369A1] dark:[--ck-ai:#7DD3FC]',
'[--ck-attention:#B45309] dark:[--ck-attention:#FFB574]',
'[--ck-red:#C8262C] dark:[--ck-red:#E5484D]',
'[--ck-new:#6D28D9] dark:[--ck-new:#B79CFF]',
'[--ck-primary:#1B1B1C] dark:[--ck-primary:#F4F4FA]',
'[--ck-primary-hover:#373738] dark:[--ck-primary-hover:#FFFFFF]',
'[--ck-on-primary:#F4F4FA] dark:[--ck-on-primary:#0E0E0F]',
'[--ck-focus:#1B1B1C99] dark:[--ck-focus:#F4F4FA99]',
'[--ck-focus-gap:#F4F4FA] dark:[--ck-focus-gap:#1B1B1C]',
'[--ck-dot:#1B1B1C] dark:[--ck-dot:#F4F4FA]',
'[--ck-elev-card:0_2px_6px_rgba(0,0,0,0.06),0_28px_56px_-16px_rgba(0,0,0,0.25)] dark:[--ck-elev-card:0_2px_6px_rgba(0,0,0,0.5),0_36px_72px_-18px_rgba(0,0,0,0.9)]',
'[--ck-elev-1:0_1px_2px_rgba(0,0,0,0.08)] dark:[--ck-elev-1:0_1px_2px_rgba(0,0,0,0.5)]',
].join(' ')
const FOCUS =
'outline-none focus-visible:ring-2 focus-visible:ring-(color:--ck-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(color:--ck-focus-gap)'
// Touch reach via pseudo-element: buttons 40px, chips 48px, step badges 44px.
const HIT = "relative pointer-coarse:after:absolute pointer-coarse:after:content-['']"
const HIT_BUTTON = `${HIT} pointer-coarse:after:-inset-1`
const HIT_CHIP = `${HIT} pointer-coarse:after:-inset-2`
const HIT_BADGE = `${HIT} pointer-coarse:after:-inset-2.5`
// 18px line + py-1 = 26px, and 26 + 2x9 = 44.
const HIT_TEXT = `${HIT} pointer-coarse:after:-inset-[9px]`
// Undo lives in the 40px receipt row under overflow-hidden, so 40 is its ceiling.
const HIT_UNDO = `${HIT} pointer-coarse:after:-inset-y-1`
const BTN = `${HIT_BUTTON} ${FOCUS} inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-full px-4 text-[12px] leading-4 font-semibold transition-[background-color,color,border-color] duration-150`
const BTN_SECONDARY = `${BTN} border border-(color:--ck-border-strong) text-(color:--ck-text) hover:bg-(--ck-raised)`
const TITLE = 'min-w-0 truncate text-[15px] leading-6 font-semibold text-(color:--ck-text)'
const META = 'shrink-0 text-[12px] leading-4 tabular-nums text-(color:--ck-muted)'
const MONO = 'font-mono text-[12px] leading-[18px]'
type Tone = 'neutral' | 'attention' | 'success' | 'red'
const TONE_VAR: Record<Tone, string> = {
neutral: 'var(--ck-dot)',
attention: 'var(--ck-attention)',
success: 'var(--ck-success)',
red: 'var(--ck-red)',
}
const TONE_STRENGTH: Record<Tone, number> = { neutral: 6, attention: 14, success: 10, red: 12 }
function timeStamp() {
const d = new Date()
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
/**
* @typedef {object} CheckpointQuestion
* @property {string} prompt The question shown in 14px 600.
* @property {string[]} options Up to three option chips; keys 1 to 3 pick them.
* @property {boolean} [allowCustom=true] Adds a "Something else" chip that reveals a text input.
*/
/**
* @typedef {object} AgentCheckpointProps
* @property {'question'|'command'|'plan'} [mode] Which decision the card asks for. Left out, it follows the data: questions, then steps, else command. With no data props at all the card plays its demo, with a mode switch above it.
* @property {string} [title] Header title next to the fingerprint mark.
* @property {CheckpointQuestion[]} [questions] Questions asked one at a time in question mode.
* @property {string} [command] Shell command shown in command mode; editable inline, the edited text is what gets approved. A new value starts a new request.
* @property {string} [cwd] Working directory shown on the first line of the command box.
* @property {'read'|'writes'|'destructive'} [risk] Risk grade chip; destructive turns the approve button red and requires two presses.
* @property {string} [why] One-sentence reason shown under the command.
* @property {string[]} [steps] Plan steps; each can be struck out before confirming.
* @property {number} [autoApproveSeconds] Draws a ring around the approve button that approves with reason 'timeout' when it completes; pauses while the card is hovered or focused. Left out or 0, nothing auto-approves; ignored when risk is destructive.
* @property {{approve?: string, reject?: string}} [labels] Overrides the approve and reject button labels in every mode.
* @property {(payload: CheckpointPayload & {reason: 'user'|'timeout'}) => void} [onApprove] Receives exactly what the user agreed to: the edited command, the kept steps, or the answers.
* @property {(reason: 'user'|'skip') => void} [onReject] Called on reject (or skip in question mode). Passing onApprove, onReject or onUndo disables the demo auto-reset and demo auto-approve.
* @property {() => void} [onUndo] Called when the user undoes a decision. Left out in host use, no Undo is offered.
* @property {string} [className] Extra classes merged onto the outermost root element.
* @property {Partial<CheckpointTuning>} [tuning] Overrides any number in CHECKPOINT_TUNING (timings, sizes, springs); keys left out keep their defaults.
*/State
- every hook, handler, effect and disposal
- the animation loop, frame by frame
Tree
- the JSX, every className and inline style
Why · Remix · Check
- the mechanism, the tuning points, the checks
Unlock Checkpoint
The full source and the remix prompt ship with Premium.