Review and approvePlan review
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callPlan review
Check the agent's plan, edit steps, then let it run. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add plan-reviewReact
npx designme add plan-review --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Plan review shows the plan an agent proposes before it starts work, as a numbered list a person can change. They can rewrite a step, skip one, add their own, and then approve so the agent runs through the steps in the same view.
- When a task is large enough that people should agree on the steps before the agent touches anything.
- When a person wants to drop a step or rewrite it in their own words without starting over.
- When you want the approved plan to double as a progress list while the agent runs.
- When the person needs a Stop button once the agent is under way.
- Three phases:
reviewlets people edit steps,runningshows progress and a Stop button, anddoneshows See changes. - In review, clicking a step makes its text editable. Enter saves it, and the step is marked as edited only if the words really changed.
- Each step has a skip button. Skipped steps stay in the list, struck through, and lose their number until they are included again.
- Add a step appends a new row and puts the cursor in it with the text selected.
- Steps can show
state: 'run'while the agent works on them, with a spinner and a shimmer on the text,'retry'in amber while it waits out a problem like a rate limit, and a check oncedone. - Each step can report a
detailline under it, like "6 frames and 42 tokens read". It opens under the step and crossfades when it changes. - The status line defaults to how many steps are left in and about how many minutes they take.
statusandnotereplace the defaults.metaadds quiet text at the end of the header, like an elapsed time.
- In review, each step's text has
role="textbox"and anaria-labellike "Step 2". - The skip button's
aria-labelswitches between "Skip step" and "Include step". - Pressing Enter in a step ends the edit instead of adding a new line.
Usage
Import it and drive it from your own state.
Import
import { PlanReview } from '@/components/designme/plan-review/PlanReview';Example
The demo above as a React component, 73 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useEffect, useRef, useState } from 'react';
import { PlanReview, type PlanPhase, type PlanReviewStep } from './PlanReview';
import { useScript, useStory } from '../lib/useScript';
/* ---------- Plan review: rewrite a step, approve, and the agent works through a rate limit to the end ---------- */
const PLAN: [text: string, done: string][] = [
['Read the pricing frames and the brand tokens', '6 frames and 42 tokens read'],
['Compare against the three competitor pages', '3 pages compared'],
['Simplify the plan cards to one CTA each', 'Plan cards now have one CTA each'],
['Move the FAQ below the comparison table', 'FAQ now sits under the table'],
['Write a summary of every change', 'Summary added to Pricing v4']
];
const DONE = new Map(PLAN.map(([text, done], i) => ['s' + i, { text, done }]));
const clock = (ms: number) => { const s = Math.floor(ms / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
export default function PlanReviewDemo() {
const ref = useRef<HTMLDivElement>(null);
const [steps, setSteps] = useState<PlanReviewStep[]>(() => PLAN.map(([text], i) => ({ id: 's' + i, text })));
const [phase, setPhase] = useState<PlanPhase>('review');
const [status, setStatus] = useState<string>();
const [note, setNote] = useState<string>();
const [ms, setMs] = useState<number>();
const live = useRef(steps);
useEffect(() => { live.current = steps; });
const stop = useRef(false);
useScript(async ({ wait }) => {
if (phase !== 'running') return;
const t0 = performance.now(), tick = setInterval(() => setMs(performance.now() - t0), 250); setMs(0);
const on = live.current.filter(s => !s.skipped);
const mark = (id: string, change: Partial<PlanReviewStep>) => setSteps(ss => ss.map(s => s.id === id ? { ...s, ...change } : s));
try {
for (let i = 0; i < on.length && !stop.current; i++) {
const s = on[i], plan = DONE.get(s.id);
let done = !plan ? 'Done' : s.edited ? 'Done as you wrote it' : plan.done;
mark(s.id, { state: 'run' }); setStatus(`Step ${i + 1} of ${on.length}`); await wait(900);
// the second step hits a rate limit: the agent waits it out, counting down, and tries again
if (s.id === 's1') {
for (let k = 3; k > 0 && !stop.current; k--) { mark(s.id, { state: 'retry', detail: `Rate limited by the browser tool. Retrying in ${k}s` }); await wait(1000); }
if (stop.current) { mark(s.id, { state: undefined, detail: 'Not started' }); break; }
mark(s.id, { state: 'run', detail: 'Retrying' }); await wait(700);
done += ', after 1 retry';
}
mark(s.id, { state: 'done', detail: done });
}
} finally { clearInterval(tick); }
setMs(performance.now() - t0);
setStatus(stop.current ? 'Stopped, progress kept' : `Done in ${clock(performance.now() - t0)}. 4 frames updated`); setPhase('done');
}, [phase === 'running']);
// the preview's story: the person rewrites the third step, then approves
useStory(ref, async ({ wait }) => {
await wait(2000);
const was = PLAN[2][0], now = 'Simplify the plan cards to one CTA, annual price first';
for (let n = was.length; n >= was.length - 5; n--) { setSteps(ss => ss.map(s => s.id === 's2' ? { ...s, text: was.slice(0, n) } : s)); await wait(60); }
for (let n = was.length - 5; n <= now.length; n++) { setSteps(ss => ss.map(s => s.id === 's2' ? { ...s, text: now.slice(0, n), edited: true } : s)); await wait(45); }
await wait(1500);
stop.current = false; setNote(undefined); setPhase('running');
});
return (
<div ref={ref} style={{ width: '100%' }}>
<PlanReview title="Plan: redesign the pricing page" steps={steps} onStepsChange={setSteps} phase={phase} status={status} note={note}
meta={ms === undefined ? undefined : clock(ms)}
onRequestChanges={() => setNote('Tell the agent what to change in chat')}
onApprove={() => { stop.current = false; setNote(undefined); setPhase('running'); }}
onStop={() => { stop.current = true; }} onSeeChanges={() => {}} />
</div>
);
}
Props
Everything PlanReview takes.
| Prop | Type | What it does |
|---|---|---|
titlerequired | string | Like "Plan: redesign the pricing page" |
steps | PlanReviewStep[] | The steps. Leave `steps` out to let the person edit, skip and add steps on their own |
defaultSteps | PlanReviewStep[] | |
onStepsChange | (steps: PlanReviewStep[]) => void | |
phase | PlanPhase | review (steps can be edited), running (the agent works through them) or done |
status | string | The line under the title. Defaults to how many steps are left in and roughly how long they take |
note | string | The quiet line in the footer. Defaults to a hint for each phase |
meta | ReactNode | Quiet text at the end of the header, like an elapsed time "0:04" |
onApprove | () => void | |
onRequestChanges | () => void | |
onStop | () => void | |
onSeeChanges | () => void | |
bare | boolean | Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own |
className | string |
Code
The full source, as the CLI installs it.
PlanReview.tsxReact · 129 lines
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Spin, Swap, Think, Tip } from '../lib/motion';
import '../designme.css';
import './plan-review.css';
export interface PlanReviewStep {
id: string;
text: string;
/** Left out of the plan; it stays in the list, struck through */
skipped?: boolean;
/** Marks a step the person rewrote */
edited?: boolean;
/** run while the agent works on it, retry while it waits out a problem (a rate limit, a timeout), done once it has */
state?: 'run' | 'retry' | 'done';
/** The line the step reports, under it: what it found, or what it waits on. Opens and crossfades when it changes */
detail?: string;
}
export type PlanPhase = 'review' | 'running' | 'done';
export interface PlanReviewProps {
/** Like "Plan: redesign the pricing page" */
title: string;
/** The steps. Leave `steps` out to let the person edit, skip and add steps on their own */
steps?: PlanReviewStep[];
defaultSteps?: PlanReviewStep[];
onStepsChange?: (steps: PlanReviewStep[]) => void;
/** review (steps can be edited), running (the agent works through them) or done */
phase?: PlanPhase;
/** The line under the title. Defaults to how many steps are left in and roughly how long they take */
status?: string;
/** The quiet line in the footer. Defaults to a hint for each phase */
note?: string;
/** Quiet text at the end of the header, like an elapsed time "0:04" */
meta?: ReactNode;
onApprove?: () => void;
onRequestChanges?: () => void;
onStop?: () => void;
onSeeChanges?: () => void;
/** Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own */
bare?: boolean;
className?: string;
}
const NOTE: Record<PlanPhase, string> = { review: 'Click a step to edit it', running: 'Running the approved plan', done: '' };
let added = 0;
/** A plan the agent proposes before it starts: edit a step, skip it, add one, then approve. */
export function PlanReview({ title, steps, defaultSteps = [], onStepsChange, phase = 'review', status, note, meta, onApprove, onRequestChanges, onStop, onSeeChanges, bare = true, className }: PlanReviewProps) {
const [list, setList] = useControllable(steps, defaultSteps, onStepsChange);
const live = useRef(list);
useEffect(() => { live.current = list; });
// each step remembers the words it came with, so "edited" shows only when they really changed
const original = useRef(new Map<string, string>());
list.forEach(s => { if (!original.current.has(s.id)) original.current.set(s.id, s.text); });
// the rows stagger in once; after that the entrance comes off so a skipped step can fade
const [entered, setEntered] = useState<Set<string>>(() => new Set());
const first = useRef(new Set(list.map(s => s.id)));
const focusId = useRef<string | null>(null);
const textRefs = useRef(new Map<string, HTMLSpanElement>());
useEffect(() => {
const id = focusId.current, el = id && textRefs.current.get(id); if (!el) return;
focusId.current = null; el.focus(); getSelection()?.selectAllChildren(el);
});
const update = (id: string, change: Partial<PlanReviewStep>) => setList(live.current.map(s => s.id === id ? { ...s, ...change } : s));
const included = list.filter(s => !s.skipped);
const editable = phase === 'review';
let n = 0;
return (
<div data-dm="" className={cx('dm-plx', !bare && 'dm-panel', className)}>
<div className="dm-plx-h">
<Think on={phase === 'running'} className="dm-plx-mk" />
<div className="dm-plx-ht"><b>{title}</b><Swap className="dm-muted dm-plx-st">{status ?? `${included.length} steps, about ${Math.max(1, Math.round(included.length * .8))} min`}</Swap></div>
{meta !== undefined && <span className="dm-plx-tm dm-num">{meta}</span>}
</div>
<ol className="dm-plx-list">
{list.map((s, i) => {
const num = s.skipped ? '–' : String(++n);
const entering = !entered.has(s.id);
return (
<li key={s.id} className={cx('dm-plx-s', entering && 'dm-enter', s.skipped && 'dm-off', s.state && 'dm-' + s.state, s.edited && 'dm-edited')}
style={entering ? { animationDelay: (first.current.has(s.id) ? 100 + i * 60 : 0) + 'ms' } : undefined}
onAnimationEnd={e => { if (e.target === e.currentTarget) setEntered(prev => new Set(prev).add(s.id)); }}>
<span className="dm-n">{s.state === 'done' ? <Icon name="check" size="xs" /> : s.state === 'run' ? <Spin /> : s.state === 'retry' ? <Icon name="clock" size="xs" /> : num}</span>
<span className="dm-b">
<span className={cx('dm-t', s.state === 'run' && 'dm-shim')} ref={el => { if (el) textRefs.current.set(s.id, el); else textRefs.current.delete(s.id); }}
contentEditable={editable} suppressContentEditableWarning spellCheck={false} role={editable ? 'textbox' : undefined} aria-label={editable ? `Step ${num}` : undefined}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
onBlur={e => { const text = e.currentTarget.textContent ?? ''; if (text !== s.text) update(s.id, { text, edited: text.trim() !== original.current.get(s.id) }); }}>
{s.text}
</span>
<span className={cx('dm-d', s.detail && 'dm-on', s.state === 'retry' && 'dm-warn')}><Swap>{s.detail ?? ''}</Swap></span>
</span>
{editable && (
<Tip label={s.skipped ? 'Include step' : 'Skip step'}>
<button type="button" className="dm-ib dm-plx-x" aria-label={s.skipped ? 'Include step' : 'Skip step'} onClick={() => update(s.id, { skipped: !s.skipped })}>
<Icon name={s.skipped ? 'undo' : 'x'} size="xs" />
</button>
</Tip>
)}
</li>
);
})}
</ol>
{editable && (
<button type="button" className="dm-plx-add" onClick={() => { const id = `new-${++added}`; focusId.current = id; setList([...live.current, { id, text: 'New step' }]); }}>
<Icon name="plus" size="xs" />Add a step
</button>
)}
<div className="dm-plx-f" key={phase}>
<Swap className="dm-muted dm-plx-n">{note ?? NOTE[phase]}</Swap>
{phase === 'review' && <>
<button type="button" className="dm-pill dm-ghost dm-sm dm-plx-chg" onClick={onRequestChanges}>Ask for changes</button>
<button type="button" className="dm-pill dm-primary dm-sm dm-plx-go" onClick={onApprove}>Approve plan</button>
</>}
{phase === 'running' && <button type="button" className="dm-pill dm-ghost dm-sm dm-plx-stop dm-enter" style={{ animationDelay: '50ms' }} onClick={onStop}><Icon name="stop" size="xs" />Stop</button>}
{phase === 'done' && <button type="button" className="dm-pill dm-soft dm-sm dm-enter" onClick={onSeeChanges}><Icon name="eye" size="xs" />See changes</button>}
</div>
</div>
);
}
plan-review.cssReact · 41 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Plan review ---------- */
/* the plan the agent posts in the chat: no container; with .panel it becomes a card */
.dm-plx{padding:14px 0;display:flex;flex-direction:column;gap:10px}
.dm-plx.dm-panel{padding:14px}
.dm-plx-h{display:flex;align-items:center;gap:10px}
.dm-plx-mk{color:var(--dm-accent-fg,var(--dm-accent))}
.dm-plx-ht{display:flex;flex-direction:column;min-width:0}
.dm-plx-ht b{font-weight:500;font-size:13.5px}
.dm-plx-st{font-size:12.5px}
.dm-plx-list{list-style:none;margin:0 -6px;padding:0;display:flex;flex-direction:column;counter-reset:s}
.dm-plx-s{display:flex;align-items:flex-start;gap:10px;padding:7px 6px;border-radius:10px;font-size:13px;transition:background .25s,opacity .35s;position:relative}
@media (hover:hover) and (pointer:fine){.dm-plx-s:hover{background:var(--dm-row)}}
.dm-plx-s .dm-n{width:20px;height:20px;border-radius:50%;background:var(--dm-chip);display:grid;place-items:center;font-size:11px;font-family:var(--dm-mono);color:var(--dm-fg2);flex:none;transition:background .35s,color .35s}
@media (hover:hover) and (pointer:fine){.dm-plx-s:hover .dm-n{background:var(--dm-surface)}}
.dm-plx-s .dm-b{flex:1;min-width:0;display:flex;flex-direction:column}
.dm-plx-s .dm-t{min-width:0;outline:0;border-radius:5px;padding:1px 3px;margin:0 -3px;line-height:1.5}
/* the line a step reports: it opens under the step, in the step's colour while it waits on something */
.dm-plx-s .dm-d{font-size:12px;color:var(--dm-muted);display:grid;grid-template-rows:0fr;transition:grid-template-rows .32s var(--dm-ease)}
.dm-plx-s .dm-d.dm-on{grid-template-rows:1fr}
.dm-plx-s .dm-d>span{overflow:hidden;min-height:0}
.dm-plx-s .dm-d.dm-warn{color:var(--dm-warn)}
.dm-plx-tm{margin-left:auto;font-size:12px;color:var(--dm-muted)}
.dm-plx-tm:empty{display:none}
.dm-plx-s .dm-n .dm-spin{width:10px;height:10px}
.dm-plx-s .dm-t:focus{background:var(--dm-surface);box-shadow:0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-plx-s .dm-ib{width:24px;height:24px;opacity:0;transition:opacity .2s}
.dm-plx-s .dm-ib:focus-visible{opacity:1}
@media (hover:hover) and (pointer:fine){.dm-plx-s:hover .dm-ib{opacity:1}}
.dm-plx-s.dm-off{opacity:.45}
.dm-plx-s.dm-off .dm-t{text-decoration:line-through}
.dm-plx-s.dm-run .dm-n{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
.dm-plx-s.dm-done .dm-n{background:var(--dm-ok-bg);color:var(--dm-ok)}
.dm-plx-s.dm-retry .dm-n{background:var(--dm-warn-bg);color:var(--dm-warn)}
.dm-plx-s.dm-edited .dm-t::after{content:"edited";margin-left:6px;font-size:11px;color:var(--dm-accent-fg,var(--dm-accent))}
.dm-plx-add{display:flex;align-items:center;gap:var(--dm-s-2);font-size:12.5px;color:var(--dm-fg2);padding:var(--dm-s-1) 10px;margin-inline:-10px;align-self:flex-start;border-radius:var(--dm-r-pill)}
@media (hover:hover) and (pointer:fine){.dm-plx-add:hover{color:var(--dm-fg);background:var(--dm-row)}}
.dm-plx-f{display:flex;align-items:center;gap:6px}
.dm-plx-n{flex:1;font-size:12px;min-width:0}
@media (max-width:480px){.dm-plx-n{display:none}}
plan-review.htmlHTML · 11 lines
<div class="card bare" data-c="plan-review">
<div class="stage-in">
<div class="plx">
<div class="plx-h"><span class="think plx-mk"><i></i><i></i><i></i><i></i></span><div class="plx-ht"><b>Plan: redesign the pricing page</b><span class="muted plx-st">5 steps, about 4 min</span></div><span class="plx-tm num" aria-label="Elapsed"></span></div>
<ol class="plx-list"></ol>
<button class="plx-add"><svg class="i xs"><use href="#i-plus"/></svg>Add a step</button>
<div class="plx-f"><span class="muted plx-n">Click a step to edit it</span><button class="pill ghost sm plx-chg">Ask for changes</button><button class="pill primary sm plx-go">Approve plan</button></div>
</div>
</div>
</div>
plan-review.cssHTML · 40 lines
/* ---------- Plan review ---------- */
/* the plan the agent posts in the chat: no container; with .panel it becomes a card */
.plx{padding:14px 0;display:flex;flex-direction:column;gap:10px}
.plx.panel{padding:14px}
.plx-h{display:flex;align-items:center;gap:10px}
.plx-mk{color:var(--accent-fg,var(--accent))}
.plx-ht{display:flex;flex-direction:column;min-width:0}
.plx-ht b{font-weight:500;font-size:13.5px}
.plx-st{font-size:12.5px}
.plx-list{list-style:none;margin:0 -6px;padding:0;display:flex;flex-direction:column;counter-reset:s}
.plx-s{display:flex;align-items:flex-start;gap:10px;padding:7px 6px;border-radius:10px;font-size:13px;transition:background .25s,opacity .35s;position:relative}
@media (hover:hover) and (pointer:fine){.plx-s:hover{background:var(--row)}}
.plx-s .n{width:20px;height:20px;border-radius:50%;background:var(--chip);display:grid;place-items:center;font-size:11px;font-family:var(--mono);color:var(--fg2);flex:none;transition:background .35s,color .35s}
@media (hover:hover) and (pointer:fine){.plx-s:hover .n{background:var(--surface)}}
.plx-s .b{flex:1;min-width:0;display:flex;flex-direction:column}
.plx-s .t{min-width:0;outline:0;border-radius:5px;padding:1px 3px;margin:0 -3px;line-height:1.5}
/* the line a step reports: it opens under the step, in the step's colour while it waits on something */
.plx-s .d{font-size:12px;color:var(--muted);display:grid;grid-template-rows:0fr;transition:grid-template-rows .32s var(--ease)}
.plx-s .d.on{grid-template-rows:1fr}
.plx-s .d>span{overflow:hidden;min-height:0}
.plx-s .d.warn{color:var(--warn)}
.plx-tm{margin-left:auto;font-size:12px;color:var(--muted)}
.plx-tm:empty{display:none}
.plx-s .n .spin{width:10px;height:10px}
.plx-s .t:focus{background:var(--surface);box-shadow:0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.plx-s .ib{width:24px;height:24px;opacity:0;transition:opacity .2s}
.plx-s .ib:focus-visible{opacity:1}
@media (hover:hover) and (pointer:fine){.plx-s:hover .ib{opacity:1}}
.plx-s.off{opacity:.45}
.plx-s.off .t{text-decoration:line-through}
.plx-s.run .n{background:var(--accent-bg);color:var(--accent-fg,var(--accent))}
.plx-s.done .n{background:var(--ok-bg);color:var(--ok)}
.plx-s.retry .n{background:var(--warn-bg);color:var(--warn)}
.plx-s.edited .t::after{content:"edited";margin-left:6px;font-size:11px;color:var(--accent-fg,var(--accent))}
.plx-add{display:flex;align-items:center;gap:var(--s-2);font-size:12.5px;color:var(--fg2);padding:var(--s-1) 10px;margin-inline:-10px;align-self:flex-start;border-radius:var(--r-pill)}
@media (hover:hover) and (pointer:fine){.plx-add:hover{color:var(--fg);background:var(--row)}}
.plx-f{display:flex;align-items:center;gap:6px}
.plx-n{flex:1;font-size:12px;min-width:0}
@media (max-width:480px){.plx-n{display:none}}
plan-review.jsHTML · 66 lines
DM.def('plan-review', (r, c) => {
r.innerHTML = r.innerHTML.replace('{plus}', I('plus','xs'));
// each step, and the line it reports when it's done
const S = [
['Read the pricing frames and the brand tokens', '6 frames and 42 tokens read'],
['Compare against the three competitor pages', '3 pages compared'],
['Simplify the plan cards to one CTA each', 'Plan cards now have one CTA each'],
['Move the FAQ below the comparison table', 'FAQ now sits under the table'],
['Write a summary of every change', 'Summary added to Pricing v4']
];
const list = $('.plx-list', r), st = $('.plx-st', r), tm = $('.plx-tm', r), go = $('.plx-go', r), f = $('.plx-f', r);
const row = (t, i) => `<li class="plx-s"><span class="n">${i + 1}</span><span class="b"><span class="t" contenteditable="true" spellcheck="false" role="textbox" aria-label="Step ${i + 1}">${t[0]}</span><span class="d"><span></span></span></span><button class="ib plx-x" aria-label="Skip step" data-tip="Skip step">${I('x','xs')}</button></li>`;
list.innerHTML = S.map(row).join(''); stagger($$('.plx-s', list), 60, 100);
const count = () => { const on = $$('.plx-s:not(.off)', list); on.forEach((s, i) => $('.n', s).textContent = i + 1); $$('.plx-s.off .n', list).forEach(n => n.textContent = '–'); swap(st, on.length + ' steps, about ' + Math.max(1, Math.round(on.length * .8)) + ' min'); };
function bind(s, done){
const t = $('.t', s), orig = t.textContent; s._done = done || 'Done';
t.onkeydown = e => { if (e.key === 'Enter'){ e.preventDefault(); t.blur(); } };
t.onblur = () => { const ed = t.textContent.trim() !== orig; s.classList.toggle('edited', ed); if (ed) s._done = 'Done as you wrote it'; };
$('.plx-x', s).onclick = () => { const off = s.classList.toggle('off'); $('.plx-x', s).dataset.tip = off ? 'Include step' : 'Skip step'; $('.plx-x', s).setAttribute('aria-label', off ? 'Include step' : 'Skip step'); $('.plx-x', s).innerHTML = I(off ? 'undo' : 'x', 'xs'); count(); };
}
$$('.plx-s', list).forEach((s, i) => bind(s, S[i][1]));
$('.plx-add', r).onclick = () => { const s = html(row(['New step'], $$('.plx-s', list).length)); list.appendChild(s); enter(s); bind(s); count(); const t = $('.t', s); t.focus({ preventScroll:true }); document.getSelection().selectAllChildren(t); };
$('.plx-chg', r).onclick = () => { swap($('.plx-n', r), 'Tell the agent what to change in chat'); };
const clock = ms => { const s = Math.floor(ms / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
const note = (s, text, tone) => { const d = $('.d', s); d.className = 'd on' + (tone ? ' ' + tone : ''); swap(d.firstElementChild, text); };
go.onclick = () => c.run(async () => {
$$('.t', list).forEach(t => { t.contentEditable = 'false'; t.removeAttribute('role'); }); $('.plx-add', r).hidden = true;
// the approved plan is fixed: the skip buttons keep their space but can't be used
$$('.plx-x', list).forEach(b => { b.disabled = true; b.style.visibility = 'hidden'; });
f.innerHTML = `<span class="muted plx-n">Running the approved plan</span><button class="pill ghost sm plx-stop">${I('stop','xs')}Stop</button>`; stagger([...f.children], 50);
let stop = false; $('.plx-stop', f).onclick = () => stop = true;
$('.plx-mk', r).classList.add('on');
const t0 = performance.now(), tick = c.every(250, () => tm.textContent = clock(performance.now() - t0)); tm.textContent = '0:00'; enter(tm);
const on = $$('.plx-s:not(.off)', list), n = s => $('.n', s), txt = s => $('.t', s);
try {
for (let i = 0; i < on.length && !stop; i++){
const s = on[i]; s.classList.add('run'); txt(s).classList.add('shim'); n(s).innerHTML = '<span class="spin"></span>'; swap(st, 'Step ' + (i + 1) + ' of ' + on.length); await c.wait(900);
// the second step hits a rate limit: the agent waits it out, counting down, and tries again
if (s === $$('.plx-s', list)[1] && !stop){
s.classList.replace('run', 'retry'); txt(s).classList.remove('shim'); n(s).innerHTML = I('clock','xs');
for (let k = 3; k > 0 && !stop; k--){ note(s, `Rate limited by the browser tool. Retrying in ${k}s`, 'warn'); await c.wait(1000); }
if (stop) break;
s.classList.replace('retry', 'run'); txt(s).classList.add('shim'); n(s).innerHTML = '<span class="spin"></span>'; note(s, 'Retrying'); await c.wait(700);
s._done += ', after 1 retry';
}
s.classList.remove('run'); txt(s).classList.remove('shim'); s.classList.add('done'); n(s).innerHTML = I('check','xs'); enter(n(s).firstElementChild); note(s, s._done);
}
} finally { clearInterval(tick); }
$('.plx-mk', r).classList.remove('on');
$$('.plx-s.run', list).forEach(s => { s.classList.remove('run', 'retry'); txt(s).classList.remove('shim'); n(s).textContent = '–'; note(s, 'Not started'); });
swap(st, stop ? 'Stopped, progress kept' : 'Done in ' + clock(performance.now() - t0) + '. 4 frames updated'); f.innerHTML = `<span class="muted plx-n"></span><button class="pill soft sm">${I('eye','xs')}See changes</button>`; enter(f.lastElementChild);
});
// the preview's story: rewrite a step, approve, and watch the agent work through a rate limit to the end
c.story(async s => {
await s.beat(3);
const step = $$('.plx-s', list)[2], t = $('.t', step);
// the demo types into the step without taking the focus: a caret would make the page follow it
await s.tap(t); await s.beat(1);
for (const ch of ' each'){ t.textContent = t.textContent.slice(0, -1); await s.beat(.12); }
for (const ch of ', annual price first'){ t.textContent += ch; await s.beat(.09); }
await s.beat(1); t.onblur(); await s.beat(2);
await s.tap(go); s.away();
await s.until(() => $('.plx-f .pill.soft', r));
});
});
Questions
What people ask about it.
- How do I start the agent once the plan is approved?
- Handle
onApprove, setphasetorunning, and mark steps withstate: 'run'and'done'as your agent works. Setphasetodoneat the end. - How do I get the edited plan back?
- Pass
stepsandonStepsChangeto control the list. Each step comes back with its newtext, pluseditedandskippedflags you can send to the agent. - Can I stop the agent halfway?
- In the
runningphase the footer shows a Stop button that callsonStop. Stopping the agent and updating the phase is up to your code. - Can it sit on its own instead of inside a chat?
- Yes. It is flat by default to sit in a chat thread. Set
bare={false}to give it its own card.
More in Review and approve
Moments where a person checks, steers or signs off on the agent's work.