Skip to content
Star on GitHub
Review and approvePlan review
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Plan 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.

$npx designme add plan-review
Plan: redesign the pricing page5 steps, about 4 min
    Click a step to edit it
    01

    Install

    Add it to your project with the CLI.

    HTML, CSS and JavaScript

    $npx designme add plan-review

    React

    $npx designme add plan-review --react

    The 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.

    02

    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.
    03

    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>
      );
    }
    
    04

    Props

    Everything PlanReview takes.

    PropTypeWhat it does
    titlerequiredstringLike "Plan: redesign the pricing page"
    stepsPlanReviewStep[]The steps. Leave `steps` out to let the person edit, skip and add steps on their own
    defaultStepsPlanReviewStep[]
    onStepsChange(steps: PlanReviewStep[]) => void
    phasePlanPhasereview (steps can be edited), running (the agent works through them) or done
    statusstringThe line under the title. Defaults to how many steps are left in and roughly how long they take
    notestringThe quiet line in the footer. Defaults to a hint for each phase
    metaReactNodeQuiet text at the end of the header, like an elapsed time "0:04"
    onApprove() => void
    onRequestChanges() => void
    onStop() => void
    onSeeChanges() => void
    barebooleanSits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own
    classNamestring
    05

    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));
      });
    });
    
    06

    Questions

    What people ask about it.

    How do I start the agent once the plan is approved?
    Handle onApprove, set phase to running, and mark steps with state: 'run' and 'done' as your agent works. Set phase to done at the end.
    How do I get the edited plan back?
    Pass steps and onStepsChange to control the list. Each step comes back with its new text, plus edited and skipped flags you can send to the agent.
    Can I stop the agent halfway?
    In the running phase the footer shows a Stop button that calls onStop. 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.
    07

    More in Review and approve

    Moments where a person checks, steers or signs off on the agent's work.

    Change reviewAccept or reject each change the agent made, file by file.
    Permission gateThe agent asks before it touches something new.
    Hand-off to a personThe agent hands a conversation to a person, with what they need to pick it up.
    Decision promptThe agent asks a few quick questions before it starts.
    Proposal cardA suggested change with confidence, alternatives and undo.
    Design tunerTweak a component by hand or let the agent tune it.
    Rewrite toolbarSelect a sentence and let the agent rewrite it in place.
    Launch checklistThe agent checks a design before it ships, and fixes what fails.
    All 14 in Review and approve →