Skip to content
Star on GitHub
Review and approveSteering
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

Steering

Change the agent's course while it works, without starting over. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add steering
Redesign the pricing pageStep 2 of 4 · 0:00
    01

    Install

    Add it to your project with the CLI.

    HTML, CSS and JavaScript

    $npx designme add steering

    React

    $npx designme add steering --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.

    Steering lets a person change an agent's course while it works, without stopping the run. The steps show what the agent is doing now, and a message box under them takes a correction. The message waits for the step in progress, then applies from the next one, and when it changes work that's already done the agent says so and offers to redo it.

    • When an agent runs for a while and people spot a wrong turn before it finishes, like the wrong colour or the wrong file.
    • When stopping and starting over would throw away good work, and a correction should only change what comes next.
    • When a correction can touch work already done, and the person should decide whether that work is redone.
    • When people should see that their message landed: waiting, then applied.
    03

    Usage

    Import it and drive it from your own state.

    Import

    import { Steering } from '@/components/designme/steering/Steering';

    Example

    The demo above as a React component, 94 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 { useReducer, useRef, useState } from 'react';
    import { Steering, type SteerMessage, type SteerStep } from './Steering';
    import { useScript, useStory, type Script } from '../lib/useScript';
    
    /* ---------- Steering: tell the agent to use the library's blue mid-run, and have it redo the card that was already violet ---------- */
    // a card step paints its card in the current accent and remembers which, so a change of mind can find the cards with the old one
    type Step = SteerStep & { card?: string; hue?: string; ms: number; redo?: string };
    const START: Step[] = [
      { id: 'read', title: 'Read Pricing v4', detail: '3 frames, 14 layers', status: 'done', ms: 0 },
      { id: 'pro', card: 'Pro', title: 'Update the Pro card', status: 'running', ms: 7500 },
      { id: 'team', card: 'Team', title: 'Update the Team card', detail: 'Pricing v4, frame 3', status: 'queued', ms: 3500 },
      { id: 'ship', title: 'Export to staging', detail: 'designme.studio/pricing', status: 'queued', ms: 2500 }
    ];
    
    export default function SteeringDemo() {
      const ref = useRef<HTMLDivElement>(null);
      // the run lives in a ref the agent's loop works on; paint shows it
      const run = useRef({ steps: START.map(s => ({ ...s })), hue: 'violet', queue: [] as string[], busy: false, wait: undefined as Script['wait'] | undefined });
      const [, paint] = useReducer((n: number) => n + 1, 0);
      const [messages, setMessages] = useState<SteerMessage[]>([]);
      const [draft, setDraft] = useState('');
      const ids = useRef(0);
      const nid = () => 'm' + ++ids.current;
      const doing = (x: Step) => x.card ? `Setting the accent to ${run.current.hue}` : x.detail;
      const say = (m: Omit<SteerMessage, 'id' | 'from'>, id = nid()) => setMessages(ms => [...ms, { id, from: 'agent', ...m }]);
      const answer = (id: string, text: string) => setMessages(ms => ms.map(m => m.id === id ? { ...m, answer: text } : m));
    
      // between two steps the agent takes up what you sent
      const apply = () => {
        const r = run.current;
        while (r.queue.length) {
          const text = r.queue.shift()!;
          setMessages(ms => { const i = ms.findIndex(m => m.from === 'you' && m.state === 'queued'); return ms.map((m, k) => k === i ? { ...m, state: 'applied' } : m); });
          if (r.steps.every(s => s.status === 'done')) { say({ text: "The run had finished, so I'll keep this for the next one." }); continue; }
          if (!/blue|accent|library/i.test(text)) { say({ text: "Got it. I'll follow that from the next step." }); continue; }
          // a change of accent: what's still to come uses it, and a card already painted the old way is offered a redo
          r.hue = 'library blue';
          const old = r.steps.filter(x => x.card && x.status === 'done' && x.hue === 'violet' && !r.steps.some(y => y.redo === x.id));
          if (!old.length) { say({ text: 'Got it. From here the accent is color.accent.500 from the library.' }); continue; }
          const names = old.map(x => x.card).join(' and '), many = old.length > 1, id = nid();
          say({ text: `Got it. From here the accent is color.accent.500. The ${names} card${many ? 's are' : ' is'} already violet.`, warn: true, actions: [
            { label: 'Leave it', onClick: () => answer(id, `Kept the ${names} card as it is`) },
            { label: `Redo the ${names} card${many ? 's' : ''}`, onClick: () => { old.forEach(redo); answer(id, `Added a step to redo the ${names} card${many ? 's' : ''}`); } }
          ] }, id);
        }
        paint();
      };
      // a redo goes in before the export if the export hasn't started, otherwise at the end, and picks the run up again if it had finished
      const redo = (x: Step) => {
        const r = run.current, ship = r.steps.find(y => y.id === 'ship')!, ended = r.steps.every(s => s.status === 'done');
        r.steps.splice(ship.status === 'queued' ? r.steps.indexOf(ship) : r.steps.length, 0,
          { id: 'redo-' + x.id, redo: x.id, card: x.card, fromYou: true, title: `Redo the ${x.card} card`, detail: 'Accent to library blue', status: 'queued', ms: 2000 });
        paint();
        if (ended && r.wait) void work(r.wait);
      };
      // the agent works through the steps
      const work = async (wait: Script['wait']) => {
        const r = run.current; r.busy = true;
        for (let x = r.steps.find(y => y.status !== 'done'); x; x = r.steps.find(y => y.status !== 'done')) {
          x.status = 'running'; x.detail = doing(x); paint();
          await wait(x.ms);
          if (x.card) x.hue = r.hue;
          x.status = 'done'; x.detail = x.card ? `Accent set to ${x.hue}` : x.id === 'ship' ? 'Live on designme.studio/pricing' : x.detail;
          apply();
        }
        r.busy = false; paint();
      };
      useScript(async ({ wait }) => { run.current.wait = wait; await work(wait); });
    
      const send = (text: string) => {
        const id = nid(); setMessages(ms => [...ms, { id, from: 'you', text, state: 'queued' }]);
        run.current.queue.push(text);
        if (!run.current.busy) apply();
      };
    
      // the preview's story: tell the agent to use the library's blue, then have it redo the card that was already violet
      // taking over while it types takes the letters back
      useStory(ref, async ({ wait, press, taken }) => {
        await wait(2500);
        taken(() => setDraft(''));
        for (const ch of 'Use our library blue, not violet') { setDraft(d => d + ch); await wait(40); }
        await wait(500); press('.dm-ste-go');
        await wait(5200); press('.dm-ste-acts .dm-primary');
      });
    
      return (
        <div ref={ref} style={{ width: '100%' }}>
          <Steering title="Redesign the pricing page" steps={run.current.steps.map(s => ({ ...s }))} messages={messages} onSend={send} value={draft} onValueChange={setDraft} />
        </div>
      );
    }
    
    04

    Props

    Everything Steering takes.

    PropTypeWhat it does
    titlerequiredstringThe run's goal, like "Redesign the pricing page"
    stepsrequiredSteerStep[]
    messagesSteerMessage[]What you sent and what the agent said back, in order
    onSend(text: string) => voidCalled with the text when a message is sent. Add it to `messages` as `queued`, and mark it `applied` when it's taken up
    valuestringThe message being written
    defaultValuestring
    onValueChange(value: string) => void
    placeholderstringDefaults to "Steer the agent while it works", and "The run has finished" once every step is done
    statusstringThe line under the title. Defaults to "Step 2 of 4", or Done, then the clock
    secondsnumberSeconds to show as m:ss. Leave it out to let the clock count until every step is done
    summarystring | falseShown once every step is done. Defaults to "Done in 0:14 · 1 change from you"; false hides it
    classNamestring
    05

    Code

    The full source, as the CLI installs it.

    Steering.tsxReact · 143 lines
    'use client';
    import { useState, type FormEvent } from 'react';
    import { cx } from '../lib/cx';
    import { useControllable, useSeconds } from '../lib/hooks';
    import { Icon } from '../lib/icons';
    import { enter, Spin, Swap } from '../lib/motion';
    import '../designme.css';
    import './steering.css';
    
    export type SteerStepStatus = 'queued' | 'running' | 'done';
    
    export interface SteerStep {
      id: string;
      /** Like "Update the Pro card" */
      title: string;
      /** What it is doing now, or what it did. It crossfades when it changes */
      detail?: string;
      status: SteerStepStatus;
      /** Marks a step that a message of yours added, with a "From you" tag */
      fromYou?: boolean;
    }
    
    export interface SteerMessage {
      id: string;
      from: 'you' | 'agent';
      text: string;
      /** For yours: `queued` while it waits for the step in progress, `applied` once the agent has taken it up */
      state?: 'queued' | 'applied';
      /** For the agent's: an amber heads-up, for when your message changes work already done */
      warn?: boolean;
      /** Buttons under the agent's message, the last one filled, like Leave it and Redo the Pro card */
      actions?: { label: string; onClick: () => void }[];
      /** Takes the buttons' place once you've answered, like "Added a step to redo the Pro card". The amber goes too */
      answer?: string;
    }
    
    export interface SteeringProps {
      /** The run's goal, like "Redesign the pricing page" */
      title: string;
      steps: SteerStep[];
      /** What you sent and what the agent said back, in order */
      messages?: SteerMessage[];
      /** Called with the text when a message is sent. Add it to `messages` as `queued`, and mark it `applied` when it's taken up */
      onSend?: (text: string) => void;
      /** The message being written */
      value?: string;
      defaultValue?: string;
      onValueChange?: (value: string) => void;
      /** Defaults to "Steer the agent while it works", and "The run has finished" once every step is done */
      placeholder?: string;
      /** The line under the title. Defaults to "Step 2 of 4", or Done, then the clock */
      status?: string;
      /** Seconds to show as m:ss. Leave it out to let the clock count until every step is done */
      seconds?: number;
      /** Shown once every step is done. Defaults to "Done in 0:14 · 1 change from you"; false hides it */
      summary?: string | false;
      className?: string;
    }
    
    const clock = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
    
    /** How many times `value` has changed since the first render, so a glyph can play its entrance on a change but not on mount */
    function useChanges<T>(value: T) {
      const [seen, setSeen] = useState({ value, n: 0 });
      if (Object.is(seen.value, value)) return seen.n;
      setSeen({ value, n: seen.n + 1 });
      return seen.n + 1;
    }
    
    function Glyph({ status }: { status: SteerStepStatus }) {
      const e = useChanges(status) > 0 ? enter() : undefined;
      return (
        <span key={status} className={cx('dm-ste-g', e?.className)} style={e?.style} aria-hidden="true">
          {status === 'running' ? <Spin /> : status === 'done' ? <Icon name="ok" size="xs" /> : <span className="dm-dot" />}
        </span>
      );
    }
    
    /** A run you can steer while it works: its steps, what you told it and what it said back, and a message box.
        A message waits for the step in progress and applies from the next one; you decide what happens to it through `messages`. */
    export function Steering({ title, steps, messages = [], onSend, value, defaultValue = '', onValueChange, placeholder, status, seconds, summary, className }: SteeringProps) {
      const [draft, setDraft] = useControllable(value, defaultValue, onValueChange);
      const finished = steps.length > 0 && steps.every(s => s.status === 'done');
      const counted = useSeconds(seconds === undefined && !finished);
      const secs = seconds ?? counted;
      const at = steps.findIndex(s => s.status === 'running');
      const line = status ?? (finished ? 'Done' : `Step ${(at < 0 ? steps.filter(s => s.status === 'done').length : at) + 1} of ${steps.length}`);
      const mine = messages.filter(m => m.from === 'you').length;
      const done = summary === undefined ? `Done in ${clock(secs)}${mine ? ` · ${mine} change${mine > 1 ? 's' : ''} from you` : ''}` : summary;
      const send = (e: FormEvent) => { e.preventDefault(); const t = draft.trim(); if (!t || finished) return; onSend?.(t); setDraft(''); };
      const sum = finished && done !== false ? enter() : undefined;
    
      return (
        <div data-dm="" className={cx('dm-ste dm-panel', className)}>
          <div className="dm-ste-h"><b>{title}</b><span className="dm-muted dm-ste-st"><span aria-live="polite"><Swap>{line}</Swap></span> · <span className="dm-num">{clock(secs)}</span></span></div>
          <ol className="dm-ste-steps" aria-label="Steps">
            {steps.map(s => {
              const e = s.fromYou ? enter() : undefined;
              return (
                <li key={s.id} className={cx('dm-ste-s', `dm-${s.status === 'running' ? 'run' : s.status}`, e?.className)} style={e?.style}>
                  <Glyph status={s.status} />
                  <span className="dm-ste-t"><b>{s.title}</b>{s.detail && <Swap>{s.detail}</Swap>}</span>
                  {s.fromYou && <span className="dm-tag dm-b">From you</span>}
                </li>
              );
            })}
          </ol>
          <div className="dm-ste-log" aria-live="polite">
            {messages.map(m => {
              const e = enter();
              if (m.from === 'you') return (
                <div key={m.id} className={cx('dm-ste-me', e.className)} style={e.style}>
                  <p>{m.text}</p>
                  <span key={m.state} className={cx('dm-ste-q', m.state === 'applied' && 'dm-enter')}>
                    {m.state === 'applied' ? <><Icon name="check" size="xs" />Applied</> : <><Spin />Applies after this step</>}
                  </span>
                </div>
              );
              const warn = m.warn && !m.answer;
              return (
                <div key={m.id} className={cx('dm-ste-ag', warn && 'dm-warn', e.className)} style={e.style}>
                  <Icon name={warn ? 'alert' : 'spark'} size="xs" />
                  <div className="dm-t">
                    <p>{m.text}</p>
                    {m.answer ? <span className="dm-muted">{m.answer}</span> : m.actions?.length ? (
                      <div className="dm-ste-acts">
                        {m.actions.map((a, i) => <button key={a.label} type="button" className={cx('dm-pill dm-sm', i === m.actions!.length - 1 ? 'dm-primary' : 'dm-ghost')} onClick={a.onClick}>{a.label}</button>)}
                      </div>
                    ) : null}
                  </div>
                </div>
              );
            })}
            {sum && <div className={cx('dm-ste-sum', sum.className)} style={sum.style}><Icon name="ok" size="xs" /><span>{done}</span></div>}
          </div>
          <form className="dm-ste-box" onSubmit={send}>
            <input value={draft} onChange={e => setDraft(e.target.value)} disabled={finished} placeholder={placeholder ?? (finished ? 'The run has finished' : 'Steer the agent while it works')} aria-label="Message the agent" spellCheck={false} autoComplete="off" />
            <button type="submit" className="dm-ste-go" aria-label="Send" disabled={finished || !draft.trim()}><Icon name="up" size="xs" /></button>
          </form>
        </div>
      );
    }
    
    steering.cssReact · 46 lines
    /* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
    /* ---------- Steering ---------- */
    /* a run you can steer while it works: its steps, what you told it and what it said back, and a message box. A message
       waits for the step in progress, then applies from the next one; when it changes work already done, the agent asks */
    .dm-ste{width:100%;max-width:440px;margin:0 auto;padding:6px;display:flex;flex-direction:column}
    .dm-ste-h{display:flex;flex-direction:column;padding:var(--dm-s-2) 10px 10px;border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
    .dm-ste-h b{font-weight:500;font-size:14px}
    .dm-ste-st{font-size:12.5px}
    .dm-ste-steps{list-style:none;margin:0;padding:var(--dm-s-1) 0}
    .dm-ste-s{display:flex;align-items:center;gap:10px;min-height:44px;padding:5px 10px;border-radius:10px;font-size:13px}
    .dm-ste-g{width:16px;height:16px;display:grid;place-items:center;flex:none;color:var(--dm-faint)}
    .dm-ste-g .dm-spin{width:11px;height:11px;color:var(--dm-accent-fg,var(--dm-accent))}
    .dm-ste-s.dm-done .dm-ste-g{color:var(--dm-ok)}
    .dm-ste-t{flex:1;min-width:0;display:flex;flex-direction:column}
    .dm-ste-t b{font-weight:500}
    .dm-ste-t span{font-size:12px;color:var(--dm-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
    .dm-ste-s.dm-queued .dm-ste-t b{color:var(--dm-fg2)}
    .dm-ste-log{display:flex;flex-direction:column;gap:var(--dm-s-2);padding:2px var(--dm-s-1) 10px}
    .dm-ste-log:empty{display:none}
    /* what you sent, on the right, with whether it is waiting or applied */
    .dm-ste-me{align-self:flex-end;max-width:85%;display:flex;flex-direction:column;align-items:flex-end;gap:var(--dm-s-1)}
    .dm-ste-me p{margin:0;padding:7px var(--dm-s-3);border-radius:14px 14px var(--dm-r-xs) 14px;background:var(--dm-chip);font-size:13px;line-height:1.45}
    .dm-ste-q{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--dm-muted)}
    .dm-ste-q .dm-spin{width:9px;height:9px}
    .dm-ste-q svg{color:var(--dm-ok)}
    /* what the agent said back; a heads-up is amber, with white buttons, until you answer it */
    .dm-ste-ag{display:flex;gap:10px;padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-row);font-size:13px;line-height:1.45}
    .dm-ste-ag>svg{flex:none;margin-top:3px;color:var(--dm-accent-fg,var(--dm-accent))}
    .dm-ste-ag.dm-warn{background:var(--dm-warn-bg)}
    .dm-ste-ag.dm-warn>svg{color:var(--dm-warn)}
    .dm-ste-ag .dm-t{flex:1;min-width:0;display:flex;flex-direction:column;gap:var(--dm-s-2)}
    .dm-ste-ag p{margin:0}
    .dm-ste-acts{display:flex;justify-content:flex-end;flex-wrap:wrap;gap:6px}
    .dm-ste-ag .dm-pill.dm-ghost{background:var(--dm-surface);color:var(--dm-fg);box-shadow:var(--dm-shadow)}
    @media (hover:hover) and (pointer:fine){.dm-ste-ag .dm-pill.dm-ghost:hover{box-shadow:var(--dm-shadow-pop)}}
    .dm-ste-ag .dm-muted{font-size:12px}
    .dm-ste-sum{display:flex;align-items:center;gap:var(--dm-s-2);padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-ok-bg);font-size:13px}
    .dm-ste-sum svg{color:var(--dm-ok);flex:none}
    .dm-ste-box{display:flex;align-items:center;gap:6px;height:var(--dm-field-h);margin:0 var(--dm-s-1) var(--dm-s-1);padding:0 5px 0 var(--dm-s-3);border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);transition:box-shadow var(--dm-t-fast)}
    @supports (corner-shape:squircle){.dm-ste-box{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
    .dm-ste-box:has(input:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
    .dm-ste-box input{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:13px;color:var(--dm-fg)}
    .dm-ste-box input::placeholder{color:var(--dm-muted)}
    .dm-ste-go{width:30px;height:30px;border-radius:50%;background:var(--dm-accent);color:var(--dm-on-accent);display:grid;place-items:center;flex:none;transition:background .2s}
    .dm-ste-go[disabled]{opacity:1;cursor:not-allowed;background:var(--dm-dis-hatch),var(--dm-dis-fill);color:var(--dm-dis);box-shadow:var(--dm-soft-ring)}
    
    steering.htmlHTML · 11 lines
    <div class="card" data-c="steering">
      <div class="stage-in">
        <div class="ste panel">
          <div class="ste-h"><b>Redesign the pricing page</b><span class="muted ste-st"><span class="m" aria-live="polite">Step 2 of 4</span> · <span class="num ck">0:00</span></span></div>
          <ol class="ste-steps" aria-label="Steps"></ol>
          <div class="ste-log" aria-live="polite"></div>
          <form class="ste-box"><input placeholder="Steer the agent while it works" aria-label="Message the agent" spellcheck="false" autocomplete="off"><button class="ste-go" aria-label="Send" disabled><svg class="i xs"><use href="#i-up"/></svg></button></form>
        </div>
      </div>
    </div>
    
    steering.cssHTML · 45 lines
    /* ---------- Steering ---------- */
    /* a run you can steer while it works: its steps, what you told it and what it said back, and a message box. A message
       waits for the step in progress, then applies from the next one; when it changes work already done, the agent asks */
    .ste{width:100%;max-width:440px;margin:0 auto;padding:6px;display:flex;flex-direction:column}
    .ste-h{display:flex;flex-direction:column;padding:var(--s-2) 10px 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
    .ste-h b{font-weight:500;font-size:14px}
    .ste-st{font-size:12.5px}
    .ste-steps{list-style:none;margin:0;padding:var(--s-1) 0}
    .ste-s{display:flex;align-items:center;gap:10px;min-height:44px;padding:5px 10px;border-radius:10px;font-size:13px}
    .ste-g{width:16px;height:16px;display:grid;place-items:center;flex:none;color:var(--faint)}
    .ste-g .spin{width:11px;height:11px;color:var(--accent-fg,var(--accent))}
    .ste-s.done .ste-g{color:var(--ok)}
    .ste-t{flex:1;min-width:0;display:flex;flex-direction:column}
    .ste-t b{font-weight:500}
    .ste-t span{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
    .ste-s.queued .ste-t b{color:var(--fg2)}
    .ste-log{display:flex;flex-direction:column;gap:var(--s-2);padding:2px var(--s-1) 10px}
    .ste-log:empty{display:none}
    /* what you sent, on the right, with whether it is waiting or applied */
    .ste-me{align-self:flex-end;max-width:85%;display:flex;flex-direction:column;align-items:flex-end;gap:var(--s-1)}
    .ste-me p{margin:0;padding:7px var(--s-3);border-radius:14px 14px var(--r-xs) 14px;background:var(--chip);font-size:13px;line-height:1.45}
    .ste-q{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--muted)}
    .ste-q .spin{width:9px;height:9px}
    .ste-q svg{color:var(--ok)}
    /* what the agent said back; a heads-up is amber, with white buttons, until you answer it */
    .ste-ag{display:flex;gap:10px;padding:10px var(--s-3);border-radius:var(--r-in);background:var(--row);font-size:13px;line-height:1.45}
    .ste-ag>svg{flex:none;margin-top:3px;color:var(--accent-fg,var(--accent))}
    .ste-ag.warn{background:var(--warn-bg)}
    .ste-ag.warn>svg{color:var(--warn)}
    .ste-ag .t{flex:1;min-width:0;display:flex;flex-direction:column;gap:var(--s-2)}
    .ste-ag p{margin:0}
    .ste-acts{display:flex;justify-content:flex-end;flex-wrap:wrap;gap:6px}
    .ste-ag .pill.ghost{background:var(--surface);color:var(--fg);box-shadow:var(--shadow)}
    @media (hover:hover) and (pointer:fine){.ste-ag .pill.ghost:hover{box-shadow:var(--shadow-pop)}}
    .ste-ag .muted{font-size:12px}
    .ste-sum{display:flex;align-items:center;gap:var(--s-2);padding:10px var(--s-3);border-radius:var(--r-in);background:var(--ok-bg);font-size:13px}
    .ste-sum svg{color:var(--ok);flex:none}
    .ste-box{display:flex;align-items:center;gap:6px;height:var(--field-h);margin:0 var(--s-1) var(--s-1);padding:0 5px 0 var(--s-3);border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);transition:box-shadow var(--t-fast)}
    @supports (corner-shape:squircle){.ste-box{corner-shape:squircle;border-radius:var(--r-field-sq)}}
    .ste-box:has(input:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
    .ste-box input{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:13px;color:var(--fg)}
    .ste-box input::placeholder{color:var(--muted)}
    .ste-go{width:30px;height:30px;border-radius:50%;background:var(--accent);color:var(--on-accent);display:grid;place-items:center;flex:none;transition:background .2s}
    .ste-go[disabled]{opacity:1;cursor:not-allowed;background:var(--dis-hatch),var(--dis-fill);color:var(--dis);box-shadow:var(--soft-ring)}
    
    steering.jsHTML · 107 lines
    DM.def('steering', (r, c) => {
      r.innerHTML = r.innerHTML.replace('{up}', I('up','xs'));
      // the run: four steps on the pricing page. A card step paints its card in the current accent, and remembers which,
      // so a change of mind can find the cards that already have the old one
      const S = [
        { id:'read', t:'Read Pricing v4', d:'3 frames, 14 layers', s:'done' },
        { id:'pro', card:'Pro', t:'Update the Pro card', s:'run', ms:7500 },
        { id:'team', card:'Team', t:'Update the Team card', d:'Pricing v4, frame 3', s:'queued', ms:3500 },
        { id:'ship', t:'Export to staging', d:'designme.studio/pricing', s:'queued', ms:2500 }
      ];
      const steps = $('.ste-steps', r), log = $('.ste-log', r), box = $('.ste-box', r), inp = $('input', box), go = $('.ste-go', r), st = $('.ste-st', r), mode = $('.m', st), ck = $('.ck', st);
      let hue = 'violet', ended = false, running = false;
      const queue = [];
      const G = { run:'<span class="spin"></span>', done:I('ok','xs'), queued:'<span class="dot"></span>' };
      const doing = x => x.card ? `Setting the accent to ${hue}` : x.d;
      function row(x){
        x.el = html(`<li class="ste-s ${x.s}"><span class="ste-g">${G[x.s]}</span><span class="ste-t"><b>${x.t}</b><span>${x.s === 'run' ? doing(x) : x.d || 'Pricing v4, frame 2'}</span></span>${x.mine ? '<span class="tag b">From you</span>' : ''}</li>`);
        return x.el;
      }
      S.forEach(x => steps.appendChild(row(x)));
      function set(x, s, d){ x.s = s; x.el.className = 'ste-s ' + s; const g = $('.ste-g', x.el); g.innerHTML = G[s]; enter(g); if (d) swap($('.ste-t span', x.el), d); }
      const where = () => { const i = S.findIndex(x => x.s === 'run'); swap(mode, ended ? 'Done' : `Step ${i + 1} of ${S.length}`); };
      // the clock runs until the last step is done
      const t0 = performance.now(); let tEnd = 0;
      const clock = () => { const s = Math.floor(((tEnd || performance.now()) - t0) / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
      c.every(250, () => { ck.textContent = clock(); });
      // a message waits for the step in progress, then the agent takes it up before the next one
      const ready = () => { go.disabled = ended || !inp.value.trim(); };
      inp.oninput = ready;
      c.on(box, 'submit', e => {
        e.preventDefault(); const text = inp.value.trim(); if (!text || ended) return;
        inp.value = ''; ready();
        const m = html(`<div class="ste-me"><p></p><span class="ste-q"><span class="spin"></span>Applies after this step</span></div>`);
        $('p', m).textContent = text; log.appendChild(m); enter(m);
        queue.push({ text, el:m });
        if (!running) apply();
      });
      // what the agent says back, with buttons when it needs you to choose
      function say(text, warn, acts){
        const a = html(`<div class="ste-ag${warn ? ' warn' : ''}">${warn ? I('alert','xs') : I('spark','xs')}<div class="t"><p></p>${acts ? '<div class="ste-acts"></div>' : ''}</div></div>`);
        $('p', a).textContent = text; log.appendChild(a); enter(a);
        if (acts) acts.forEach(([label, ink, fn]) => { const b = html(`<button class="pill sm ${ink ? 'primary' : 'ghost'}">${label}</button>`); b.onclick = () => { $('.ste-acts', a).replaceWith(html(`<span class="muted">${fn()}</span>`)); a.classList.remove('warn'); a.firstElementChild.outerHTML = I('spark','xs'); }; $('.ste-acts', a).appendChild(b); });
      }
      function apply(){
        while (queue.length){
          const q = queue.shift(), tag = $('.ste-q', q.el);
          tag.innerHTML = I('check','xs') + (S.some(x => x.s !== 'done') ? 'Applied' : 'Noted'); enter(tag);
          const left = S.some(x => x.s !== 'done');
          if (!left){ say("The run had finished, so I'll keep this for the next one."); continue; }
          if (!/blue|accent|library/i.test(q.text)){ say("Got it. I'll follow that from the next step."); continue; }
          // a change of accent: what's still to come uses it, and a card already painted the old way is offered a redo
          hue = 'library blue';
          const old = S.filter(x => x.card && x.s === 'done' && x.hue === 'violet' && !S.some(y => y.redo === x.id));
          const run = S.find(x => x.s === 'run'); if (run && run.card) swap($('.ste-t span', run.el), doing(run));
          if (!old.length){ say('Got it. From here the accent is color.accent.500 from the library.'); continue; }
          const names = old.map(x => x.card).join(' and ');
          say(`Got it. From here the accent is color.accent.500. The ${names} card${old.length > 1 ? 's are' : ' is'} already violet.`, true, [
            ['Leave it', false, () => `Kept the ${names} card as it is`],
            [`Redo the ${names} card${old.length > 1 ? 's' : ''}`, true, () => { old.forEach(redo); return `Added a step to redo the ${names} card${old.length > 1 ? 's' : ''}`; }]
          ]);
        }
      }
      // a redo goes in before the export if the export hasn't started, otherwise at the end
      function redo(x){
        const n = { id:'redo-' + x.id, redo:x.id, card:x.card, mine:true, t:`Redo the ${x.card} card`, d:'Accent to library blue', s:'queued', ms:2000 };
        const ship = S.find(y => y.id === 'ship'), at = ship.s === 'queued' ? S.indexOf(ship) : S.length;
        S.splice(at, 0, n); steps.insertBefore(row(n), S[at + 1] ? S[at + 1].el : null);
        const li = n.el; li.style.height = '0px'; li.style.minHeight = '0px'; li.style.overflow = 'hidden'; li.style.opacity = '0';
        requestAnimationFrame(() => { li.style.transition = 'height .4s var(--ease),min-height .4s var(--ease),opacity .4s'; li.style.height = '44px'; li.style.minHeight = '44px'; li.style.opacity = '1'; c.later(450, () => { li.removeAttribute('style'); }); });
        if (ended){ ended = false; tEnd = 0; $('.ste-sum', r)?.remove(); inp.disabled = false; inp.placeholder = 'Steer the agent while it works'; ready(); c.run(work); }
        where();
      }
      // the agent works through the steps; between two steps it takes up what you sent
      async function work(){
        running = true;
        for (let x = S.find(y => y.s === 'run' || y.s === 'queued'); x; x = S.find(y => y.s === 'run' || y.s === 'queued')){
          if (x.s !== 'run') set(x, 'run', doing(x));
          where();
          await c.wait(x.ms);
          if (x.card) x.hue = hue;
          set(x, 'done', x.card ? (x.hue === 'violet' ? 'Accent set to violet' : 'Accent set to library blue') : x.id === 'ship' ? 'Live on designme.studio/pricing' : x.d);
          apply();
        }
        running = false; ended = true; tEnd = performance.now(); where();
        inp.disabled = true; inp.placeholder = 'The run has finished'; ready();
        const n = $$('.ste-me', r).length;
        $('.ste-sum', r)?.remove();
        const sum = html(`<div class="ste-sum">${I('ok','xs')}<span>Done in ${clock()}${n ? ` · ${n} change${n > 1 ? 's' : ''} from you` : ''}</span></div>`);
        log.appendChild(sum); enter(sum);
      }
      c.run(work);
      // the preview's story: read what the agent is doing, tell it to use the library's blue, and have it redo the card
      // that was already violet
      c.story(async s => {
        await s.beat(2);
        await s.point($('.ste-t span', S[1].el), false); await s.beat(1);
        await s.tap(inp);
        // typed a few letters at a time, so a busy page (few, longer timers) types it as fast as a quiet one
        const msg = 'Use our library blue, not violet';
        await s.type(inp, msg.match(/.{1,3}/g), { n:.15, each:ready, keep:true });
        await s.beat(1); await s.tap(go); s.away();
        await s.until(() => $('.ste-acts .primary', r)); await s.beat(3);
        await s.tap($('.ste-acts .primary', r)); s.away();
        await s.until(() => ended); await s.beat(2);
      });
    });
    
    06

    Questions

    What people ask about it.

    Does it decide what a message changes?
    No. onSend gives you the text. Your agent decides what to do with it: add it to messages as queued, mark it applied when it's taken up, and add the agent's reply.
    Why does a message wait for the step in progress?
    Changing a step halfway through can leave its work half done. Applying from the next step keeps each step whole, and the agent can still offer to redo one that's finished.
    Can people stop the run instead?
    Not from this component. Put your own stop button next to it if your agent can stop mid-step.
    Can I add steps while it runs?
    Yes. Pass a new steps list with the step where it goes, and mark it fromYou if a message asked for it.
    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.
    Plan reviewCheck the agent's plan, edit steps, then let it run.
    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.
    All 14 in Review and approve →