Agent statusAgent tree
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callAgent tree
An orchestrator and its sub-agents, with cost rolled up per branch. 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-treeReact
npx designme add agent-tree --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.
Agent tree shows an orchestrator and the sub-agents it hands work to, as a tree a person can fold. Every row has its task, a status and what it has spent, and each branch adds up its own cost and everything under it, so you can see where the money went. When a step fails, its row turns red and offers the fix in place.
- When one agent splits a job across sub-agents and people need to see who is doing what, and what is still queued.
- When a run spends money as it goes and people should see the cost per agent and per step, not just the total.
- When a step can fail and the run should wait for a person to take the fix it offers.
- When the tree gets long and finished branches should fold away.
rootis the orchestrator; each node has aname, atask, astatusofqueued,running,doneorfailed, its owncostin dollars, andchildren.- A branch shows its own cost plus everything under it. Hover its cost to see the split, like "Its own $0.02, 3 steps $0.26". The total sits beside the title.
- A running node spins, a finished one turns to a green check, and a failed one to a red alert on a red-tinted row. A changed
taskcrossfades. fixadds a white button under a failed step's task, like Use color.accent.500.- Click a branch, or its chevron, to fold it.
collapsed,defaultCollapsedandonCollapsedChangehold which branches are folded. - The line under the title reads Running, "1 step failed" or Done, with a clock that counts until the root is done, or shows
seconds. Once the root is done a green line sums it up, like "Done in 0:14 · 3 agents · $0.66";summarychanges it or, set tofalse, hides it.
- The rows form a tree with
role="tree". Each row is atreeitemwitharia-level, and a branch hasaria-expanded. - Only one row is in the tab order. Up and Down move through the rows you can see, Right opens a branch or moves into it, Left closes it or moves to its parent, Home and End jump to the first and last row, and Enter or Space folds a branch.
- A folded branch is inert, so its rows can't be reached until it opens.
- The status line under the title is announced politely when it changes.
Usage
Import it and drive it from your own state.
Import
import { AgentTree } from '@/components/designme/agent-tree/AgentTree';Example
The demo above as a React component, 64 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 { AgentTree, type AgentNode, type AgentNodeStatus } from './AgentTree';
import { useScript, useStory } from '../lib/useScript';
/* ---------- Agent tree: the design agent works through its steps, one fails on a missing token, and the fix it offers waits for you;
the finished research starts folded ---------- */
type Row = { name: string; task: string; status: AgentNodeStatus; cost: number; kids?: string[] };
// the run: an orchestrator, its three agents and their steps. cost is what each spends on its own, in dollars
const START: Record<string, Row> = {
root: { name: 'Orchestrator', task: 'Plans the work and hands it out', status: 'running', cost: .04, kids: ['res', 'des', 'cop'] },
res: { name: 'Research agent', task: 'Compare 3 competitor pricing pages', status: 'done', cost: .02, kids: ['res1', 'res2'] },
res1: { name: 'Read the 3 pricing pages', task: '3 pages, 9.4k words', status: 'done', cost: .06 },
res2: { name: 'Summarise their plans', task: 'Notes added to Pricing v4', status: 'done', cost: .04 },
des: { name: 'Design agent', task: 'Rework the plan cards in Figma', status: 'running', cost: .02, kids: ['des1', 'des2', 'des3'] },
des1: { name: 'Update the Pro card', task: 'Pricing v4, frame 2', status: 'running', cost: .03 },
des2: { name: 'Update the Team card', task: 'Pricing v4, frame 3', status: 'queued', cost: 0 },
des3: { name: 'Apply library tokens', task: 'Colour and spacing', status: 'queued', cost: 0 },
cop: { name: 'Copy agent', task: 'Rewrite the plan descriptions', status: 'queued', cost: 0 }
};
export default function AgentTreeDemo() {
const ref = useRef<HTMLDivElement>(null);
const [rows, setRows] = useState(START);
const [fixing, setFixing] = useState(false);
const fixed = useRef<() => void>(undefined);
const set = (id: string, patch: Partial<Row>) => setRows(r => ({ ...r, [id]: { ...r[id], ...patch } }));
// whatever is running spends as it goes, so its cost and every total above it count up
useEffect(() => {
const id = setInterval(() => setRows(r => Object.fromEntries(Object.entries(r).map(([k, x]) => [k, x.status === 'running' ? { ...x, cost: x.cost + (k === 'root' ? .0006 : .0016) } : x]))), 100);
return () => clearInterval(id);
}, []);
useScript(async ({ wait }) => {
await wait(2000); set('des1', { status: 'done' }); set('des2', { status: 'running' });
await wait(2500); set('des2', { status: 'done' }); set('des3', { status: 'running' });
await wait(2000); set('des3', { status: 'failed', task: "color.accent.600 isn't in the library" }); setFixing(true);
await new Promise<void>(res => { fixed.current = res; });
setFixing(false); set('des3', { status: 'running', task: 'Using color.accent.500' });
await wait(2000); set('des3', { status: 'done', task: 'Colour and spacing, from the library' }); set('des', { status: 'done' }); set('cop', { status: 'running' });
await wait(3000); set('cop', { status: 'done' }); set('root', { status: 'done' });
});
// the preview's story: take the fix when a step fails, then fold the design branch once it's done
useStory(ref, async ({ wait, press }) => {
await wait(8500); press('.dm-atr-fix');
await wait(6000); press('[data-id="des"]');
});
const node = (id: string): AgentNode => {
const x = rows[id];
return { id, name: x.name, task: x.task, status: x.status, cost: x.cost, children: x.kids?.map(node),
...(id === 'des3' && fixing ? { fix: { label: 'Use color.accent.500', onClick: () => fixed.current?.() } } : {}) };
};
return (
<div ref={ref} style={{ width: '100%' }}>
<AgentTree root={node('root')} title="Redesign the pricing page" defaultCollapsed={['res']} />
</div>
);
}
Props
Everything AgentTree takes.
| Prop | Type | What it does |
|---|---|---|
rootrequired | AgentNode | The orchestrator, with its agents and their steps as children |
titlerequired | string | The run's goal, like "Redesign the pricing page" |
status | string | The line under the title. Defaults to "Running", "1 step failed" or "Done", then the clock |
seconds | number | Seconds to show as m:ss. Leave it out to let the clock count until the root is done |
summary | string | false | Shown under the tree once the root is done. Defaults to "Done in 0:14 · 3 agents · $0.66"; false hides it |
collapsed | string[] | Ids of the folded branches. Leave it out to let each branch fold itself |
defaultCollapsed | string[] | |
onCollapsedChange | (ids: string[]) => void | |
formatCost | (dollars: number) => string | Writes a cost. Defaults to dollars and cents, like "$0.12" |
className | string |
Code
The full source, as the CLI installs it.
AgentTree.tsxReact · 144 lines
'use client';
import { useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useSeconds } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter, Fold, Spin, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './agent-tree.css';
export type AgentNodeStatus = 'queued' | 'running' | 'done' | 'failed';
export interface AgentNode {
id: string;
/** Like "Design agent", or a step's name */
name: string;
/** Its task, or what it is doing now. It crossfades when it changes */
task: string;
status: AgentNodeStatus;
/** What this node spent on its own, in dollars. A branch shows this plus everything under it */
cost?: number;
/** A way out of a failed step, shown as a button under its task */
fix?: { label: string; onClick: () => void };
children?: AgentNode[];
}
export interface AgentTreeProps {
/** The orchestrator, with its agents and their steps as children */
root: AgentNode;
/** The run's goal, like "Redesign the pricing page" */
title: string;
/** The line under the title. Defaults to "Running", "1 step failed" or "Done", then the clock */
status?: string;
/** Seconds to show as m:ss. Leave it out to let the clock count until the root is done */
seconds?: number;
/** Shown under the tree once the root is done. Defaults to "Done in 0:14 · 3 agents · $0.66"; false hides it */
summary?: string | false;
/** Ids of the folded branches. Leave it out to let each branch fold itself */
collapsed?: string[];
defaultCollapsed?: string[];
onCollapsedChange?: (ids: string[]) => void;
/** Writes a cost. Defaults to dollars and cents, like "$0.12" */
formatCost?: (dollars: number) => string;
className?: string;
}
const CLS: Record<AgentNodeStatus, string> = { queued: 'dm-queued', running: 'dm-run', done: 'dm-done', failed: 'dm-fail' };
const clock = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
const total = (n: AgentNode): number => (n.cost ?? 0) + (n.children ?? []).reduce((a, k) => a + total(k), 0);
const branch = (n: AgentNode) => !!n.children?.length;
/** 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: AgentNodeStatus }) {
const moved = useChanges(status) > 0;
const e = moved ? enter() : undefined;
return (
<span key={status} className={cx('dm-atr-g', e?.className)} style={e?.style} aria-hidden="true">
{status === 'running' ? <Spin /> : status === 'done' ? <Icon name="ok" size="xs" /> : status === 'failed' ? <Icon name="alert" size="xs" /> : <span className="dm-dot" />}
</span>
);
}
/** An orchestrator and its sub-agents as a tree. Each row shows its task, status and cost; a branch's cost rolls up
everything under it, and branches fold. The arrow keys move through it as in any tree view. */
export function AgentTree({ root, title, status, seconds, summary, collapsed, defaultCollapsed = [], onCollapsedChange, formatCost, className }: AgentTreeProps) {
const [shut, setShut] = useControllable(collapsed, defaultCollapsed, onCollapsedChange);
const [focus, setFocus] = useState(root.id);
const treeRef = useRef<HTMLDivElement>(null);
const finished = root.status === 'done';
const counted = useSeconds(seconds === undefined && !finished);
const secs = seconds ?? counted;
const money = formatCost ?? ((v: number) => '$' + v.toFixed(2));
const all: { n: AgentNode; lv: number; parent?: AgentNode }[] = [];
(function walk(n: AgentNode, lv: number, parent?: AgentNode) { all.push({ n, lv, parent }); n.children?.forEach(k => walk(k, lv + 1, n)); })(root, 0);
const failed = all.filter(x => x.n.status === 'failed').length;
const line = status ?? (finished ? 'Done' : failed ? `${failed} step${failed > 1 ? 's' : ''} failed` : 'Running');
const agents = root.children?.length ?? 0;
const done = summary === undefined ? `Done in ${clock(secs)} · ${agents} agent${agents === 1 ? '' : 's'} · ${money(total(root))}` : summary;
const isOpen = (n: AgentNode) => !shut.includes(n.id);
const toggle = (n: AgentNode, open = !isOpen(n)) => { if (branch(n)) setShut(open ? shut.filter(id => id !== n.id) : [...shut, n.id]); };
// the rows a person can reach: everything not inside a folded branch
const visible = all.filter(x => { for (let p = x.parent; p; p = all.find(y => y.n === p)?.parent) if (!isOpen(p)) return false; return true; });
const go = (n?: AgentNode) => { if (!n) return; setFocus(n.id); treeRef.current?.querySelector<HTMLElement>(`[data-id="${CSS.escape(n.id)}"]`)?.focus({ preventScroll: true }); };
const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
const i = visible.findIndex(x => x.n.id === focus); if (i < 0) return;
const { n, parent } = visible[i], open = branch(n) && isOpen(n);
if (e.key === 'ArrowDown') go(visible[i + 1]?.n);
else if (e.key === 'ArrowUp') go(visible[i - 1]?.n);
else if (e.key === 'ArrowRight') { if (branch(n) && !open) toggle(n, true); else if (open) go(n.children![0]); }
else if (e.key === 'ArrowLeft') { if (open) toggle(n, false); else go(parent); }
else if (e.key === 'Enter' || e.key === ' ') toggle(n);
else if (e.key === 'Home') go(visible[0].n);
else if (e.key === 'End') go(visible[visible.length - 1].n);
else return;
e.preventDefault();
};
const node = (n: AgentNode, lv: number): ReactNode => {
const sum = total(n), own = n.cost ?? 0, k = n.children?.length ?? 0;
const cost = <span className="dm-atr-c dm-num">{n.status === 'queued' && !sum ? '–' : money(branch(n) ? sum : own)}</span>;
const fx = n.status === 'failed' && n.fix ? enter() : undefined;
return (
<div key={n.id} role="none" style={{ '--dm-lv': lv } as CSSProperties}>
<div data-id={n.id} className={cx('dm-atr-n', CLS[n.status])} role="treeitem" aria-level={lv + 1} aria-expanded={branch(n) ? isOpen(n) : undefined}
tabIndex={focus === n.id ? 0 : -1}
onClick={e => { if ((e.target as Element).closest('.dm-atr-fix')) return; setFocus(n.id); toggle(n); }}>
{branch(n) ? <span className="dm-atr-tg" aria-hidden="true"><Icon name="chev-d" size="xs" /></span> : <span className="dm-atr-sp" />}
<Glyph status={n.status} />
<span className="dm-atr-t">
<b>{n.name}</b><Swap>{n.task}</Swap>
{fx && <button type="button" className={cx('dm-pill dm-sm dm-atr-fix', fx.className)} style={fx.style} onClick={n.fix!.onClick}>{n.fix!.label}</button>}
</span>
{branch(n) ? <Tip label={`Its own ${money(own)}, ${k} ${lv ? (k > 1 ? 'steps' : 'step') : (k > 1 ? 'agents' : 'agent')} ${money(sum - own)}`}>{cost}</Tip> : cost}
</div>
{branch(n) && (
<Fold open={isOpen(n)} className="dm-atr-kids">
<div role="group">{n.children!.map(c => node(c, lv + 1))}</div>
</Fold>
)}
</div>
);
};
const sum = finished && done !== false ? enter() : undefined;
return (
<div data-dm="" className={cx('dm-atr dm-panel', className)}>
<div className="dm-atr-h">
<div className="dm-atr-ht"><b>{title}</b><span className="dm-muted dm-atr-st"><span aria-live="polite"><Swap>{line}</Swap></span> · <span className="dm-num">{clock(secs)}</span></span></div>
<span className="dm-atr-tot dm-num">{money(total(root))}</span>
</div>
<div ref={treeRef} className="dm-atr-tree" role="tree" aria-label="Agents" onKeyDown={onKey}>{node(root, 0)}</div>
{sum && <div className={cx('dm-atr-sum', sum.className)} style={sum.style}><Icon name="ok" size="xs" /><span>{done}</span></div>}
</div>
);
}
agent-tree.cssReact · 40 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Agent tree ---------- */
/* an orchestrator and its sub-agents as a tree: a chevron folds a branch, each row shows its task, status and cost, and a
branch's cost is its own plus everything under it */
.dm-atr{width:100%;max-width:440px;margin:0 auto;padding:6px}
.dm-atr-h{display:flex;align-items:center;justify-content:space-between;gap:var(--dm-s-3);padding:var(--dm-s-2) 10px 10px}
.dm-atr-ht{display:flex;flex-direction:column;min-width:0}
.dm-atr-ht b{font-weight:500;font-size:14px}
.dm-atr-st{font-size:12.5px}
.dm-atr-tot{font-size:14px;font-weight:500}
.dm-atr-tree{display:flex;flex-direction:column;padding-top:var(--dm-s-1);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-atr-n{display:flex;align-items:center;gap:var(--dm-s-2);min-height:44px;padding:5px 10px 5px calc(4px + var(--dm-lv,0) * 20px);border-radius:10px;font-size:13px;outline:0;transition:background .16s}
@media (hover:hover) and (pointer:fine){.dm-atr-n:hover{background:var(--dm-row)}}
.dm-atr-n:focus-visible{box-shadow:inset 0 0 0 2px var(--dm-focus)}
.dm-atr-tg{width:20px;height:20px;display:grid;place-items:center;flex:none;border-radius:6px;color:var(--dm-muted);transition:background .16s,color .16s}
@media (hover:hover) and (pointer:fine){.dm-atr-tg:hover{background:var(--dm-row);color:var(--dm-fg)}}
.dm-atr-tg svg{transition:transform .32s var(--dm-ease)}
.dm-atr-n[aria-expanded="false"] .dm-atr-tg svg{transform:rotate(-90deg)}
.dm-atr-sp{width:20px;flex:none}
.dm-atr-g{width:16px;height:16px;display:grid;place-items:center;flex:none;color:var(--dm-faint)}
.dm-atr-g .dm-spin{width:11px;height:11px;color:var(--dm-accent-fg,var(--dm-accent))}
.dm-atr-n.dm-done .dm-atr-g{color:var(--dm-ok)}
.dm-atr-n.dm-fail .dm-atr-g{color:var(--dm-bad)}
.dm-atr-t{flex:1;min-width:0;display:flex;flex-direction:column}
.dm-atr-t b{font-weight:500}
.dm-atr-t span{font-size:12px;color:var(--dm-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-atr-n.dm-fail .dm-atr-t span{color:var(--dm-bad)}
.dm-atr-n.dm-queued .dm-atr-t b{color:var(--dm-fg2)}
.dm-atr-c{font-size:12.5px;color:var(--dm-fg2);min-width:44px;text-align:right}
.dm-atr-n.dm-queued .dm-atr-c{color:var(--dm-muted)}
/* the way out of a failed step: a white button on its red-tinted row */
.dm-atr-n.dm-fail{background:var(--dm-bad-bg)}
.dm-atr-fix{flex:none;align-self:flex-start;margin-top:var(--dm-s-1);background:var(--dm-surface);color:var(--dm-fg);box-shadow:var(--dm-shadow)}
@media (hover:hover) and (pointer:fine){.dm-atr-fix:hover{background:var(--dm-surface);box-shadow:var(--dm-shadow-pop)}}
/* a folded branch keeps its children's space shut; the guide line marks the level */
.dm-atr-kids{position:relative}
.dm-atr-kids::before{content:"";position:absolute;top:2px;bottom:6px;left:calc(14px + var(--dm-lv,0) * 20px);width:1px;background:var(--dm-line)}
.dm-atr-sum{display:flex;align-items:center;gap:var(--dm-s-2);margin:var(--dm-s-2) var(--dm-s-1) 2px;padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-ok-bg);font-size:13px}
.dm-atr-sum svg{color:var(--dm-ok);flex:none}
agent-tree.htmlHTML · 9 lines
<div class="card" data-c="agent-tree">
<div class="stage-in">
<div class="atr panel">
<div class="atr-h"><div class="atr-ht"><b>Redesign the pricing page</b><span class="muted atr-st">3 agents working</span></div><span class="atr-tot num">$0.00</span></div>
<div class="atr-tree" role="tree" aria-label="Agents"></div>
</div>
</div>
</div>
agent-tree.cssHTML · 39 lines
/* ---------- Agent tree ---------- */
/* an orchestrator and its sub-agents as a tree: a chevron folds a branch, each row shows its task, status and cost, and a
branch's cost is its own plus everything under it */
.atr{width:100%;max-width:440px;margin:0 auto;padding:6px}
.atr-h{display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);padding:var(--s-2) 10px 10px}
.atr-ht{display:flex;flex-direction:column;min-width:0}
.atr-ht b{font-weight:500;font-size:14px}
.atr-st{font-size:12.5px}
.atr-tot{font-size:14px;font-weight:500}
.atr-tree{display:flex;flex-direction:column;padding-top:var(--s-1);border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.atr-n{display:flex;align-items:center;gap:var(--s-2);min-height:44px;padding:5px 10px 5px calc(4px + var(--lv,0) * 20px);border-radius:10px;font-size:13px;outline:0;transition:background .16s}
@media (hover:hover) and (pointer:fine){.atr-n:hover{background:var(--row)}}
.atr-n:focus-visible{box-shadow:inset 0 0 0 2px var(--focus)}
.atr-tg{width:20px;height:20px;display:grid;place-items:center;flex:none;border-radius:6px;color:var(--muted);transition:background .16s,color .16s}
@media (hover:hover) and (pointer:fine){.atr-tg:hover{background:var(--row);color:var(--fg)}}
.atr-tg svg{transition:transform .32s var(--ease)}
.atr-n[aria-expanded="false"] .atr-tg svg{transform:rotate(-90deg)}
.atr-sp{width:20px;flex:none}
.atr-g{width:16px;height:16px;display:grid;place-items:center;flex:none;color:var(--faint)}
.atr-g .spin{width:11px;height:11px;color:var(--accent-fg,var(--accent))}
.atr-n.done .atr-g{color:var(--ok)}
.atr-n.fail .atr-g{color:var(--bad)}
.atr-t{flex:1;min-width:0;display:flex;flex-direction:column}
.atr-t b{font-weight:500}
.atr-t span{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.atr-n.fail .atr-t span{color:var(--bad)}
.atr-n.queued .atr-t b{color:var(--fg2)}
.atr-c{font-size:12.5px;color:var(--fg2);min-width:44px;text-align:right}
.atr-n.queued .atr-c{color:var(--muted)}
/* the way out of a failed step: a white button on its red-tinted row */
.atr-n.fail{background:var(--bad-bg)}
.atr-fix{flex:none;align-self:flex-start;margin-top:var(--s-1);background:var(--surface);color:var(--fg);box-shadow:var(--shadow)}
@media (hover:hover) and (pointer:fine){.atr-fix:hover{background:var(--surface);box-shadow:var(--shadow-pop)}}
/* a folded branch keeps its children's space shut; the guide line marks the level */
.atr-kids{position:relative}
.atr-kids::before{content:"";position:absolute;top:2px;bottom:6px;left:calc(14px + var(--lv,0) * 20px);width:1px;background:var(--line)}
.atr-sum{display:flex;align-items:center;gap:var(--s-2);margin:var(--s-2) var(--s-1) 2px;padding:10px var(--s-3);border-radius:var(--r-in);background:var(--ok-bg);font-size:13px}
.atr-sum svg{color:var(--ok);flex:none}
agent-tree.jsHTML · 86 lines
DM.def('agent-tree', (r, c) => {
// the run: an orchestrator, its three agents and their steps. cost is what each spends on its own, in dollars; a branch
// shows its own cost plus everything under it
const T = { id:'root', n:'Orchestrator', t:'Plans the work and hands it out', cost:.04, s:'run', kids:[
{ id:'res', n:'Research agent', t:'Compare 3 competitor pricing pages', cost:.02, s:'done', kids:[
{ id:'res1', n:'Read the 3 pricing pages', t:'3 pages, 9.4k words', cost:.06, s:'done' },
{ id:'res2', n:'Summarise their plans', t:'Notes added to Pricing v4', cost:.04, s:'done' } ] },
{ id:'des', n:'Design agent', t:'Rework the plan cards in Figma', cost:.02, s:'run', kids:[
{ id:'des1', n:'Update the Pro card', t:'Pricing v4, frame 2', cost:.03, s:'run' },
{ id:'des2', n:'Update the Team card', t:'Pricing v4, frame 3', cost:0, s:'queued' },
{ id:'des3', n:'Apply library tokens', t:'Colour and spacing', cost:0, s:'queued' } ] },
{ id:'cop', n:'Copy agent', t:'Rewrite the plan descriptions', cost:0, s:'queued' } ] };
const tree = $('.atr-tree', r), stEl = $('.atr-st', r), tot = $('.atr-tot', r), root = $('.atr', r);
const all = []; (function walk(n, lv, p){ n.lv = lv; n.parent = p; all.push(n); (n.kids || []).forEach(k => walk(k, lv + 1, n)); })(T, 0, null);
const by = id => all.find(n => n.id === id), kids = n => !!(n.kids && n.kids.length);
const sum = n => n.cost + (n.kids || []).reduce((a, k) => a + sum(k), 0), money = v => '$' + v.toFixed(2);
const G = { run:'<span class="spin"></span>', done:I('ok','xs'), fail:I('alert','xs'), queued:'<span class="dot"></span>' };
function build(n){
const row = html(`<div class="atr-n ${n.s}" role="treeitem" aria-level="${n.lv + 1}"${kids(n) ? ' aria-expanded="true"' : ''} tabindex="-1" style="--lv:${n.lv}">${kids(n) ? `<span class="atr-tg" aria-hidden="true">${I('chev-d','xs')}</span>` : '<span class="atr-sp"></span>'}<span class="atr-g">${G[n.s]}</span><span class="atr-t"><b>${n.n}</b><span>${n.t}</span></span><span class="atr-c num"></span></div>`);
n.el = row; const out = [row];
if (kids(n)){ const f = html(`<div class="fold atr-kids" role="group" style="--lv:${n.lv}"><div></div></div>`); n.kids.forEach(k => build(k).forEach(e => f.firstElementChild.appendChild(e))); n.fold = f; out.push(f); }
return out;
}
build(T).forEach(e => tree.appendChild(e)); T.el.tabIndex = 0;
// each branch folds by its row (or its chevron); folded, it keeps its total
function toggle(n, open){
if (!kids(n)) return; const o = open ?? n.el.getAttribute('aria-expanded') !== 'true';
n.el.setAttribute('aria-expanded', o); n.fold.classList.toggle('shut', !o); n.fold.inert = !o;
}
function paint(){
all.forEach(n => { const ce = $('.atr-c', n.el), v = kids(n) ? sum(n) : n.cost;
ce.textContent = n.s === 'queued' && !v ? '–' : money(v);
if (kids(n)){ const k = n.kids.length, what = n.lv ? (k > 1 ? ' steps ' : ' step ') : (k > 1 ? ' agents ' : ' agent ');
ce.dataset.tip = `Its own ${money(n.cost)}, ${k}${what}${money(sum(n) - n.cost)}`; } });
tot.textContent = money(sum(T));
}
// the line under the title: what the run is doing, and a clock
const t0 = performance.now(); let ended = 0;
const clock = () => { const s = Math.floor(((ended || performance.now()) - t0) / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
stEl.innerHTML = '<span class="m" aria-live="polite">Running</span> · <span class="num ck">0:00</span>';
const setMode = m => swap($('.m', stEl), m);
// whatever is running spends as it goes, so its cost and every total above it count up
c.every(100, () => { all.forEach(n => { if (n.s === 'run') n.cost += n.lv ? .0016 : .0006; }); paint(); $('.ck', stEl).textContent = clock(); });
paint();
function step(n, s, t){ n.s = s; n.el.className = 'atr-n ' + s; const g = $('.atr-g', n.el); g.innerHTML = G[s]; enter(g); if (t !== undefined) swap($('.atr-t span', n.el), t); paint(); }
// clicking a branch folds it; the arrow keys move through the tree as in any tree view
c.on(tree, 'click', e => { const row = e.target.closest('.atr-n'); if (!row || e.target.closest('.atr-fix')) return; const n = all.find(x => x.el === row); all.forEach(x => x.el.tabIndex = x === n ? 0 : -1); toggle(n); });
const visible = () => all.filter(n => { for (let p = n.parent; p; p = p.parent) if (p.el.getAttribute('aria-expanded') === 'false') return false; return true; });
c.on(tree, 'keydown', e => {
const v = visible(), i = v.findIndex(n => n.el === document.activeElement); if (i < 0) return;
const n = v[i], open = n.el.getAttribute('aria-expanded') === 'true'; let go = null;
if (e.key === 'ArrowDown') go = v[i + 1]; else if (e.key === 'ArrowUp') go = v[i - 1];
else if (e.key === 'ArrowRight'){ if (kids(n) && !open) toggle(n, true); else if (kids(n)) go = n.kids[0]; }
else if (e.key === 'ArrowLeft'){ if (kids(n) && open) toggle(n, false); else go = n.parent; }
else if (e.key === 'Enter' || e.key === ' ') toggle(n);
else if (e.key === 'Home') go = v[0]; else if (e.key === 'End') go = v[v.length - 1];
else return;
e.preventDefault(); if (go){ all.forEach(x => x.el.tabIndex = -1); go.el.tabIndex = 0; go.el.focus({ preventScroll:true }); }
});
// the run: the design agent works through its steps, one fails on a token the library doesn't have, and the fix it offers
// waits for you
c.run(async () => {
await c.wait(2000); step(by('des1'), 'done'); step(by('des2'), 'run');
await c.wait(2500); step(by('des2'), 'done'); step(by('des3'), 'run');
await c.wait(2000);
const tk = by('des3'); step(tk, 'fail', "color.accent.600 isn't in the library"); setMode('1 step failed');
const fix = html(`<button class="pill sm atr-fix">Use color.accent.500</button>`); $('.atr-t', tk.el).appendChild(fix); enter(fix);
await new Promise(res => { fix.onclick = res; });
fix.remove(); step(tk, 'run', 'Using color.accent.500'); setMode('Running');
await c.wait(2000); step(tk, 'done', 'Colour and spacing, from the library'); step(by('des'), 'done'); step(by('cop'), 'run');
await c.wait(3000); step(by('cop'), 'done'); step(T, 'done'); ended = performance.now(); setMode('Done');
const sm = html(`<div class="atr-sum">${I('ok','xs')}<span>Done in ${clock()} · 3 agents · ${money(sum(T))}</span></div>`); root.appendChild(sm); enter(sm);
});
// the finished research starts folded, as a done branch would
toggle(by('res'), false);
// the preview's story: read a branch's cost, take the fix when a step fails, and fold the design branch once it's done
c.story(async s => {
await s.beat(3);
await s.point($('.atr-c', by('des').el)); await s.beat(4); s.away();
await s.until(() => $('.atr-fix', r)); await s.beat(4);
await s.tap($('.atr-fix', r)); s.away();
await s.until(() => T.s === 'done'); await s.beat(2);
await s.tap(by('des').el); s.away(); await s.beat(1);
});
});
Questions
What people ask about it.
- Does it keep track of costs?
- No. Give each node the
costit spent on its own and the tree adds up the branches. Update the costs as your run reports them and the totals follow. - Can it show more than three levels?
- Yes. Nodes can nest as deep as your run does; each level indents a step further, with a guide line down the side.
- What does the fix button do?
- Whatever you pass as its
onClick. In the demo it lets the failed step run again with the token the library has. - Can costs be in another currency?
- Yes. Pass
formatCost, which gets each amount and returns the text to show.
More in Agent status
Show what the agent is doing, how far along it is, and what it is working with.