Review and approveSteering
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callSteering
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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add steeringReact
npx designme add steering --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.
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.
stepslists the run, each with atitle, adetailthat crossfades when it changes, and astatusofqueued,runningordone. A step that a message added hasfromYou, which shows a "From you" tag.- Your messages sit on the right. A
queuedone says "Applies after this step" with a small spinner, and anappliedone shows a check. - The agent's replies sit on the left.
warnmakes one amber, for a heads-up like "The Pro card is already violet", andactionsadds white and filled buttons under it. Once you answer,answertakes the buttons' place and the amber goes. - The line under the title reads "Step 2 of 4", or Done, with a clock that counts until every step is done, or shows
seconds. - Once every step is done a green line sums it up, like "Done in 0:14 · 1 change from you";
summarychanges it or, set tofalse, hides it. The message box then says the run has finished. - Enter or the send button sends; the button stays off while the box is empty.
- The steps are an ordered list labelled "Steps".
- The message box is a form with a labelled input, so Enter sends, and the send button is named Send.
- New messages, the agent's replies and the step line under the title are announced politely.
- The answer buttons are real buttons, named by their labels.
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>
);
}
Props
Everything Steering takes.
| Prop | Type | What it does |
|---|---|---|
titlerequired | string | The run's goal, like "Redesign the pricing page" |
stepsrequired | SteerStep[] | |
messages | SteerMessage[] | What you sent and what the agent said back, in order |
onSend | (text: string) => void | Called with the text when a message is sent. Add it to `messages` as `queued`, and mark it `applied` when it's taken up |
value | string | The message being written |
defaultValue | string | |
onValueChange | (value: string) => void | |
placeholder | string | Defaults to "Steer the agent while it works", and "The run has finished" once every step is done |
status | string | The line under the title. Defaults to "Step 2 of 4", or Done, then the clock |
seconds | number | Seconds to show as m:ss. Leave it out to let the clock count until every step is done |
summary | string | false | Shown once every step is done. Defaults to "Done in 0:14 · 1 change from you"; false hides it |
className | string |
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);
});
});
Questions
What people ask about it.
- Does it decide what a message changes?
- No.
onSendgives you the text. Your agent decides what to do with it: add it tomessagesasqueued, mark itappliedwhen 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
stepslist with the step where it goes, and mark itfromYouif a message asked for it.
More in Review and approve
Moments where a person checks, steers or signs off on the agent's work.