WorkspaceAgent settings
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callAgent settings
How much the agent may do alone, with a save bar for changes. 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 agent-settingsReact
npx designme add agent-settings --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.
A settings panel for how much an agent may do on its own: an autonomy scale, on/off switches, a spend cap per run and free-text instructions. Any change brings up an "Unsaved changes" bar with Save and Reset, so people can review before the agent's behaviour changes.
- When people need to decide whether the agent asks first or works alone.
- When you want to cap what one agent run may spend.
- When standing instructions for the agent should live next to its other settings.
- When several settings changes should be reviewed and saved together.
- An autonomy scale built from
levels(Ask, Balanced and Autopilot by default), with the picked level's description shown under the heading. - One switch per entry in
toggles, each with a label and an optional detail line. - A spend cap stepped up and down through the amounts in
caps, shown withformatCap. - An instructions text box that is part of the same value.
- A save bar that appears whenever the value differs from the last saved one. Save calls
onSave, and if it returns a promise the button spins until it settles; Reset goes back to the last saved settings and callsonReset. - Works controlled through
valueandonValueChange, or holds its own state withdefaultValue.
- The autonomy scale is a role="radiogroup" labelled "Autonomy", and each step is role="radio" with aria-checked.
- Each toggle is role="switch" with aria-checked and its label as aria-label.
- The cap buttons are labelled "Lower cap" and "Raise cap", and the text box is labelled "Instructions".
- The save bar is inert and aria-hidden while there is nothing to save. If it hides with the focus in it, after Save or Reset, the focus moves to the autonomy scale.
Usage
Import it and drive it from your own state.
Import
import { AgentSettings } from '@/components/designme/agent-settings/AgentSettings';Example
The demo above as a React component, 26 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 { useRef, useState } from 'react';
import { AgentSettings, type AgentSettingsValue } from './AgentSettings';
import { useStory } from '../lib/useScript';
/* ---------- Agent settings: Slack summaries get switched on, and the save bar comes up ---------- */
export default function AgentSettingsDemo() {
const [value, setValue] = useState<AgentSettingsValue>({
autonomy: 1, toggles: { memory: true, slack: false }, cap: 5, instructions: 'Use our tokens only. Keep copy short and friendly.'
});
const ref = useRef<HTMLDivElement>(null);
// the preview flips one switch on its own, unless you take over first
useStory(ref, async ({ wait }) => {
await wait(1400);
setValue(v => ({ ...v, toggles: { ...v.toggles, slack: !v.toggles.slack } }));
});
return (
<div ref={ref} style={{ width: '100%' }}>
<AgentSettings value={value} onValueChange={setValue}
toggles={[{ key: 'memory', label: 'Use team memory', detail: 'Brand rules and past decisions' }, { key: 'slack', label: 'Post a summary in Slack', detail: '#design, after each run' }]}
onSave={() => new Promise<void>(res => setTimeout(res, 600))} />
</div>
);
}
Props
Everything AgentSettings takes.
| Prop | Type | What it does |
|---|---|---|
levels | AgentSettingsLevel[] | The autonomy scale. Defaults to Ask, Balanced and Autopilot |
toggles | AgentSettingsToggle[] | |
caps | number[] | The amounts the spend cap steps through with − and + |
formatCap | (cap: number) => string | How the cap reads. Defaults to "$5" |
capLabel | string | |
capDetail | string | |
value | AgentSettingsValue | The settings. Leave it out to let the panel hold its own |
defaultValue | AgentSettingsValue | |
onValueChange | (value: AgentSettingsValue) => void | |
onSave | (value: AgentSettingsValue) => void | Promise<void> | Called by Save. Return a promise and the button spins until it settles. Until then, the save bar stays up |
onReset | () => void | Called by Reset, after the settings go back to the last saved ones |
className | string |
Code
The full source, as the CLI installs it.
AgentSettings.tsxReact · 133 lines
'use client';
import { useEffect, useRef, useState } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Spin, Swap } from '../lib/motion';
import '../designme.css';
import './agent-settings.css';
/** A step on the autonomy scale */
export interface AgentSettingsLevel {
/** On the segment, like "Balanced" */
label: string;
/** Under "Autonomy" while it is picked, like "Asks before edits to shared files" */
description: string;
}
/** An on/off setting */
export interface AgentSettingsToggle {
key: string;
/** Like "Use team memory" */
label: string;
/** The quiet line under it, like "Brand rules and past decisions" */
detail?: string;
}
/** Everything the panel edits */
export interface AgentSettingsValue {
/** Index into `levels` */
autonomy: number;
/** On or off, by toggle key */
toggles: Record<string, boolean>;
/** The spend cap, one of `caps` */
cap: number;
instructions: string;
}
export interface AgentSettingsProps {
/** The autonomy scale. Defaults to Ask, Balanced and Autopilot */
levels?: AgentSettingsLevel[];
toggles?: AgentSettingsToggle[];
/** The amounts the spend cap steps through with − and + */
caps?: number[];
/** How the cap reads. Defaults to "$5" */
formatCap?: (cap: number) => string;
capLabel?: string;
capDetail?: string;
/** The settings. Leave it out to let the panel hold its own */
value?: AgentSettingsValue;
defaultValue?: AgentSettingsValue;
onValueChange?: (value: AgentSettingsValue) => void;
/** Called by Save. Return a promise and the button spins until it settles. Until then, the save bar stays up */
onSave?: (value: AgentSettingsValue) => void | Promise<void>;
/** Called by Reset, after the settings go back to the last saved ones */
onReset?: () => void;
className?: string;
}
const LEVELS: AgentSettingsLevel[] = [
{ label: 'Ask', description: 'Asks before every change' },
{ label: 'Balanced', description: 'Asks before edits to shared files' },
{ label: 'Autopilot', description: 'Works alone, asks only before publishing' }
];
const CAPS = [1, 2, 5, 10, 20, 50];
const same = (a: AgentSettingsValue, b: AgentSettingsValue) => JSON.stringify(a) === JSON.stringify(b);
/** How much the agent may do alone. Any change brings up a save bar, which saves or resets them. */
export function AgentSettings({
levels = LEVELS, toggles = [], caps = CAPS, formatCap = n => '$' + n, capLabel = 'Spend cap per run', capDetail = 'Pauses and asks when reached',
value, defaultValue, onValueChange, onSave, onReset, className
}: AgentSettingsProps) {
const [v, setV] = useControllable(value, defaultValue ?? { autonomy: 1, toggles: {}, cap: caps[Math.min(2, caps.length - 1)], instructions: '' }, onValueChange);
// what the save bar compares against: the settings as they were last saved
const [saved, setSaved] = useState(v);
const [phase, setPhase] = useState<'idle' | 'saving' | 'saved'>('idle');
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const dirty = !same(v, saved) || phase !== 'idle';
// when the bar of unsaved changes hides with the focus in it, the focus goes to the top of the form instead of the page
const root = useRef<HTMLDivElement>(null), bar = useRef<HTMLDivElement>(null);
useIsoLayoutEffect(() => { if (!dirty && bar.current?.contains(document.activeElement)) root.current?.querySelector<HTMLElement>('[aria-checked="true"], button')?.focus({ preventScroll: true }); }, [dirty]);
const n = levels.length, a = Math.max(0, Math.min(n - 1, v.autonomy));
const ci = Math.max(0, caps.indexOf(v.cap));
const set = (patch: Partial<AgentSettingsValue>) => setV({ ...v, ...patch });
const save = async () => {
if (phase !== 'idle') return;
setPhase('saving');
try { await onSave?.(v); } catch { setPhase('idle'); return; }
setSaved(v); setPhase('saved');
timer.current = setTimeout(() => setPhase('idle'), 700);
};
const reset = () => { setV(saved); onReset?.(); };
return (
<div data-dm="" ref={root} className={cx('dm-asx dm-panel', dirty && 'dm-dirty', className)}>
<div className="dm-asx-r dm-asx-col">
<div className="dm-asx-l"><b>Autonomy</b><Swap className="dm-muted dm-asx-ad">{levels[a]?.description ?? ''}</Swap></div>
<div className="dm-asx-steps" role="radiogroup" aria-label="Autonomy" style={n === 3 ? undefined : { gridTemplateColumns: `repeat(${n},1fr)` }}>
<span className="dm-asx-knob" style={{ left: `calc(3px + ${a} * (100% - 6px) / ${n})`, ...(n === 3 ? {} : { width: `calc((100% - 6px) / ${n})` }) }} />
{levels.map((l, i) => <button key={l.label} type="button" role="radio" aria-checked={i === a} onClick={() => set({ autonomy: i })}>{l.label}</button>)}
</div>
</div>
{toggles.map(t => (
<div key={t.key} className="dm-asx-r">
<div className="dm-asx-l"><b>{t.label}</b>{t.detail && <span className="dm-muted">{t.detail}</span>}</div>
<button type="button" className="dm-toggle" role="switch" aria-checked={!!v.toggles[t.key]} aria-label={t.label}
onClick={() => set({ toggles: { ...v.toggles, [t.key]: !v.toggles[t.key] } })} />
</div>
))}
<div className="dm-asx-r">
<div className="dm-asx-l"><b>{capLabel}</b>{capDetail && <span className="dm-muted">{capDetail}</span>}</div>
<div className="dm-asx-cap">
<button type="button" className="dm-ib dm-fill dm-asx-m" aria-label="Lower cap" onClick={() => set({ cap: caps[Math.max(0, ci - 1)] })}><Icon name="minus" size="xs" /></button>
<Swap className="dm-num dm-asx-v">{formatCap(v.cap)}</Swap>
<button type="button" className="dm-ib dm-fill dm-asx-p" aria-label="Raise cap" onClick={() => set({ cap: caps[Math.min(caps.length - 1, ci + 1)] })}><Icon name="plus" size="xs" /></button>
</div>
</div>
<div className="dm-asx-r dm-asx-col">
<div className="dm-asx-l"><b>Instructions</b></div>
<textarea className="dm-asx-ta" rows={2} spellCheck={false} aria-label="Instructions" value={v.instructions} onChange={e => set({ instructions: e.target.value })} />
</div>
<div ref={bar} className="dm-asx-bar" inert={!dirty} aria-hidden={!dirty}>
<span>Unsaved changes</span>
<button type="button" className="dm-pill dm-ghost dm-sm dm-asx-rs" onClick={reset}>Reset</button>
<button type="button" className="dm-pill dm-accent dm-sm dm-asx-sv" onClick={save}>
{phase === 'saving' ? <Spin /> : phase === 'saved' ? <><Icon name="check" size="xs" />Saved</> : 'Save'}
</button>
</div>
</div>
);
}
agent-settings.cssReact · 25 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Agent settings ---------- */
.dm-asx{padding:6px 14px 14px;display:flex;flex-direction:column;position:relative;overflow:hidden}
.dm-asx-r{display:flex;align-items:center;justify-content:space-between;gap:var(--dm-s-3);padding:11px 0;border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-asx-r:nth-last-child(2){border-bottom:0}
.dm-asx-col{flex-direction:column;align-items:stretch;gap:9px}
.dm-asx-l{display:flex;flex-direction:column;min-width:0;font-size:13px}
.dm-asx-l b{font-weight:500}
.dm-asx-l .dm-muted{font-size:12px}
.dm-asx-steps{position:relative;display:grid;grid-template-columns:repeat(3,1fr);padding:3px;border-radius:var(--dm-r-pill);background:var(--dm-chip)}
.dm-asx-steps button{position:relative;z-index:1;height:30px;font-size:12.5px;color:var(--dm-fg2);transition:color .25s}
.dm-asx-steps button[aria-checked="true"]{color:var(--dm-fg)}
.dm-asx-knob{position:absolute;top:3px;bottom:3px;width:calc((100% - 6px)/3);border-radius:var(--dm-r-pill);background:var(--dm-surface);box-shadow:0 1px 2px rgba(0,0,0,.08);transition:left .45s var(--dm-ease)}
.dm-asx-cap{display:flex;align-items:center;gap:6px}
.dm-asx-cap .dm-ib{width:26px;height:26px}
.dm-asx-v{min-width:38px;text-align:center;font-size:13px}
.dm-asx-ta{display:block;width:100%;resize:none;border:0;outline:0;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);padding:10px var(--dm-s-3);font:inherit;font-size:13.5px;line-height:1.5;color:var(--dm-fg);transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-asx-ta{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-asx-ta:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-asx-bar{display:flex;align-items:center;gap:6px;padding:6px 6px 6px 14px;border-radius:var(--dm-r-pill);background:var(--dm-ink);color:var(--dm-on-ink);font-size:12.5px;margin-top:10px;transform:translateY(30px);opacity:0;filter:blur(4px);pointer-events:none;transition:transform .45s var(--dm-ease),opacity .3s,filter .3s,height .45s var(--dm-ease),margin-bottom .45s var(--dm-ease);height:0;margin-bottom:-10px}
/* on an ink-filled bar the ring takes the bar's text colour, so it stands out against the fill in every theme */
.dm-asx-bar span{flex:1}
.dm-asx-bar .dm-pill.dm-ghost{color:inherit;opacity:.8}@media (hover:hover) and (pointer:fine){.dm-asx-bar .dm-pill.dm-ghost:hover{background:color-mix(in srgb,var(--dm-on-ink) 14%,transparent)}}
.dm-asx.dm-dirty .dm-asx-bar{transform:none;opacity:1;filter:none;pointer-events:auto;height:40px;margin-bottom:0}
agent-settings.htmlHTML · 14 lines
<div class="card" data-c="agent-settings">
<div class="stage-in">
<div class="asx panel">
<div class="asx-r asx-col"><div class="asx-l"><b>Autonomy</b><span class="muted asx-ad">Asks before edits to shared files</span></div>
<div class="asx-steps" role="radiogroup" aria-label="Autonomy"><span class="asx-knob"></span><button role="radio" data-v="0">Ask</button><button role="radio" data-v="1" aria-checked="true">Balanced</button><button role="radio" data-v="2">Autopilot</button></div></div>
<div class="asx-r"><div class="asx-l"><b>Use team memory</b><span class="muted">Brand rules and past decisions</span></div><button class="toggle" role="switch" aria-checked="true" aria-label="Use team memory"></button></div>
<div class="asx-r"><div class="asx-l"><b>Post a summary in Slack</b><span class="muted">#design, after each run</span></div><button class="toggle" role="switch" aria-checked="false" aria-label="Post a summary in Slack"></button></div>
<div class="asx-r"><div class="asx-l"><b>Spend cap per run</b><span class="muted">Pauses and asks when reached</span></div><div class="asx-cap"><button class="ib fill asx-m" aria-label="Lower cap"><svg class="i xs"><use href="#i-minus"/></svg></button><span class="num asx-v">$5</span><button class="ib fill asx-p" aria-label="Raise cap"><svg class="i xs"><use href="#i-plus"/></svg></button></div></div>
<div class="asx-r asx-col"><div class="asx-l"><b>Instructions</b></div><textarea class="asx-ta" rows="2" spellcheck="false">Use our tokens only. Keep copy short and friendly.</textarea></div>
<div class="asx-bar"><span>Unsaved changes</span><button class="pill ghost sm asx-rs">Reset</button><button class="pill accent sm asx-sv">Save</button></div>
</div>
</div>
</div>
agent-settings.cssHTML · 24 lines
/* ---------- Agent settings ---------- */
.asx{padding:6px 14px 14px;display:flex;flex-direction:column;position:relative;overflow:hidden}
.asx-r{display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);padding:11px 0;border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.asx-r:nth-last-child(2){border-bottom:0}
.asx-col{flex-direction:column;align-items:stretch;gap:9px}
.asx-l{display:flex;flex-direction:column;min-width:0;font-size:13px}
.asx-l b{font-weight:500}
.asx-l .muted{font-size:12px}
.asx-steps{position:relative;display:grid;grid-template-columns:repeat(3,1fr);padding:3px;border-radius:var(--r-pill);background:var(--chip)}
.asx-steps button{position:relative;z-index:1;height:30px;font-size:12.5px;color:var(--fg2);transition:color .25s}
.asx-steps button[aria-checked="true"]{color:var(--fg)}
.asx-knob{position:absolute;top:3px;bottom:3px;width:calc((100% - 6px)/3);border-radius:var(--r-pill);background:var(--surface);box-shadow:0 1px 2px rgba(0,0,0,.08);transition:left .45s var(--ease)}
.asx-cap{display:flex;align-items:center;gap:6px}
.asx-cap .ib{width:26px;height:26px}
.asx-v{min-width:38px;text-align:center;font-size:13px}
.asx-ta{display:block;width:100%;resize:none;border:0;outline:0;border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);padding:10px var(--s-3);font:inherit;font-size:13.5px;line-height:1.5;color:var(--fg);transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.asx-ta{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.asx-ta:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.asx-bar{display:flex;align-items:center;gap:6px;padding:6px 6px 6px 14px;border-radius:var(--r-pill);background:var(--ink);color:var(--on-ink);font-size:12.5px;margin-top:10px;transform:translateY(30px);opacity:0;filter:blur(4px);pointer-events:none;transition:transform .45s var(--ease),opacity .3s,filter .3s,height .45s var(--ease),margin-bottom .45s var(--ease);height:0;margin-bottom:-10px}
/* on an ink-filled bar the ring takes the bar's text colour, so it stands out against the fill in every theme */
.asx-bar span{flex:1}
.asx-bar .pill.ghost{color:inherit;opacity:.8}@media (hover:hover) and (pointer:fine){.asx-bar .pill.ghost:hover{background:color-mix(in srgb,var(--on-ink) 14%,transparent)}}
.asx.dirty .asx-bar{transform:none;opacity:1;filter:none;pointer-events:auto;height:40px;margin-bottom:0}
agent-settings.jsHTML · 24 lines
DM.def('agent-settings', (r, c) => {
r.innerHTML = r.innerHTML.replace('{minus}', I('minus','xs')).replace('{plus}', I('plus','xs'));
const root = $('.asx', r), steps = $$('.asx-steps button', r), knob = $('.asx-knob', r), ad = $('.asx-ad', r), tg = $$('.toggle', r), v = $('.asx-v', r), ta = $('.asx-ta', r);
const AD = ['Asks before every change', 'Asks before edits to shared files', 'Works alone, asks only before publishing'];
const CAPS = [1, 2, 5, 10, 20, 50];
let s = { a:1, t:[true,false], cap:2, i:ta.value }, saved = JSON.stringify(s);
// the bar of unsaved changes is out of reach while it's hidden; when it hides with the focus in it, the focus goes to the
// top of the form instead of the page
const dirty = () => { const bar = $('.asx-bar', r), was = root.classList.contains('dirty'), on = root.classList.toggle('dirty', JSON.stringify(s) !== saved); bar.inert = !on;
if (was && !on && bar.contains(document.activeElement)){ const to = $('[aria-checked="true"]', r) || $('button', r); if (to) to.focus({ preventScroll:true }); } };
function setA(i, init){ s.a = i; steps.forEach((b, j) => b.setAttribute('aria-checked', j === i)); knob.style.left = `calc(3px + ${i} * (100% - 6px) / 3)`; if (!init) swap(ad, AD[i]); dirty(); }
steps.forEach((b, i) => b.onclick = () => setA(i));
tg.forEach((t, i) => t.onclick = () => { s.t[i] = !s.t[i]; t.setAttribute('aria-checked', s.t[i]); dirty(); });
const cap = d => { s.cap = Math.max(0, Math.min(CAPS.length - 1, s.cap + d)); swap(v, '$' + CAPS[s.cap]); dirty(); };
$('.asx-m', r).onclick = () => cap(-1); $('.asx-p', r).onclick = () => cap(1);
ta.oninput = () => { s.i = ta.value; dirty(); };
function apply(o){ s = JSON.parse(o); setA(s.a, true); swap(ad, AD[s.a]); tg.forEach((t, i) => t.setAttribute('aria-checked', s.t[i])); v.textContent = '$' + CAPS[s.cap]; ta.value = s.i; dirty(); }
$('.asx-rs', r).onclick = () => apply(saved);
$('.asx-sv', r).onclick = () => c.run(async () => { const b = $('.asx-sv', r); b.innerHTML = '<span class="spin"></span>'; await c.wait(600); saved = JSON.stringify(s); b.innerHTML = I('check','xs') + 'Saved'; await c.wait(700); dirty(); b.textContent = 'Save'; });
setA(1, true);
// the preview flips one switch on its own, unless you take over first
c.auto(1400, () => tg[1].click());
});
Questions
What people ask about it.
- What happens if saving fails?
- If the promise returned by
onSaverejects, the button goes back to Save and the save bar stays up, because the settings still differ from the last saved ones. - Can I change the autonomy levels?
- Yes. Pass
levelswith a label and description for each step. The scale lays out as many steps as you give it, andautonomyin the value is the index of the picked one. - How do I add my own on/off settings?
- Pass
toggleswith akey,labeland optionaldetailfor each. Their state lives invalue.toggles, keyed bykey. - Can the spend cap use other amounts or a currency?
- Yes.
capssets the amounts the minus and plus buttons step through, andformatCapsets how the amount reads.capLabelandcapDetailchange the text beside it.
More in Workspace
Navigation, search, settings, models and automations around the agent.