Skip to content
Star on GitHub
Agent statusAgent run timeline
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

Agent run timeline

Every model and tool step of a run on one time axis. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add agent-run-timeline
Run 4821, update the pricing page
Starting
Running
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add agent-run-timeline

React

$npx designme add agent-run-timeline --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.

Agent run timeline draws one agent run as a Gantt chart: every model, tool and app step is a bar on a shared time axis, in its kind's colour and with its own icon or app mark, so people can see what ran in parallel, what took longest and where a step failed and was retried. While the run is live, a playhead with the time on it sweeps across, the bars grow up to it and the step at work carries a passing light.

  • When you need to see why an agent run was slow and which steps overlapped.
  • When a step failed and was retried, and people should see where in the run that happened.
  • When people care about cost per step as well as time.
  • In a run history or debugging view, to replay a finished run or follow one live.
03

Usage

Import it and drive it from your own state.

Import

import { AgentRunTimeline } from '@/components/designme/agent-run-timeline/AgentRunTimeline';

Example

The demo above as a React component, 44 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 { AgentRunTimeline, type TimelineStep } from './AgentRunTimeline';
import { useStory } from '../lib/useScript';

/* ---------- Agent run timeline: the playhead sweeps the run at 1.6x, the tests fail and run again, then the totals settle ---------- */
const STEPS: TimelineStep[] = [
  { label: 'Plan the change', icon: 'brain', kind: 'model', start: 0, duration: 1.6, cost: 0.03 },
  { label: 'Read 6 files', icon: 'file-text', kind: 'tool', start: 1.6, duration: 2.2, cost: 0.02 },
  { label: 'Search Figma', brand: 'figma', kind: 'app', start: 1.9, duration: 1.4, cost: 0.01 },
  { label: 'Edit PricingCard', icon: 'edit', kind: 'model', start: 4, duration: 2.4, cost: 0.06 },
  { label: 'Run tests', icon: 'term', kind: 'tool', start: 6.5, duration: 1.5, cost: 0.01, failed: true, tag: '2 failed', note: '2 tests failed' },
  { label: 'Run tests again', icon: 'refresh', kind: 'tool', start: 8.1, duration: 1.9, cost: 0.01, retry: true },
  { label: 'Open a PR on GitHub', brand: 'github', kind: 'app', start: 10.1, duration: 1, cost: 0.02 },
  { label: 'Post to Slack', brand: 'slack', kind: 'app', start: 11.1, duration: 0.6, cost: 0.01 }
];
const SPAN = 12, END = 11.7, PACE = 1.6;

export default function AgentRunTimelineDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const [time, setTime] = useState<number | undefined>(0);
  const [started, setStarted] = useState(false);
  // the run plays at 1.6 times real speed; while a mouse rests on it the story holds, and the clock waits with it
  useStory(ref, async ({ wait }) => {
    await wait(500);
    setStarted(true);
    let t = 0, last = performance.now();
    while (t < END) {
      await wait(16);
      const now = performance.now();
      // a long gap is a hold ending, and counts as one frame
      t = Math.min(END, t + ((now - last < 250 ? now - last : 16) / 1000) * PACE); last = now;
      setTime(t);
    }
    setTime(undefined);
  });
  return (
    <div ref={ref} style={{ width: '100%' }}>
      <AgentRunTimeline title="Run 4821, update the pricing page" steps={STEPS} span={SPAN} time={time} status={started ? undefined : 'Starting'} />
    </div>
  );
}
04

Props

Everything AgentRunTimeline takes.

PropTypeWhat it does
titlerequiredstringLike "Run 4821, update the pricing page"
stepsrequiredTimelineStep[]
timenumberSeconds into the run. Bars grow up to it and the playhead sits on it. Leave it out once the run has finished
spannumberSeconds the axis covers. Defaults to the end of the last step, rounded up to a multiple of 4
ticksnumber[]Labels on the axis, in seconds. Defaults to quarters of the span
statusstringThe line under the title. Defaults to the steps running now, then "Finished in 11.4s"
statsTimelineStat[]The figures in the footer once the run is done. Defaults to the total time, cost, steps and retries, which count up while it plays
kindLabelsPartial<Record<StepKind, string>>The names of the kinds in the key. Defaults to Model, Tools and Apps
barebooleanSits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own
classNamestring
05

Code

The full source, as the CLI installs it.

AgentRunTimeline.tsxReact · 203 lines
'use client';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { cx } from '../lib/cx';
import { useIsoLayoutEffect, useReducedMotion, useTween } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { Brand, type BrandName } from '../lib/brands';
import { enter, Tip } from '../lib/motion';
import '../designme.css';
import './agent-run-timeline.css';

/** What a step is: the model thinking or writing (purple), a tool like a file read or a test run (teal), or an app the agent
    works in, like Figma or GitHub (orange) */
export type StepKind = 'model' | 'tool' | 'app';

export interface TimelineStep {
  label: string;
  icon?: IconName;
  /** An app's own mark in place of the icon, like 'figma', 'github' or 'slack' */
  brand?: BrandName;
  /** Colours the step's bar, icon and live time, and counts it in the key under the title */
  kind?: StepKind;
  /** Seconds into the run when the step started */
  start: number;
  /** How long it took, in seconds */
  duration: number;
  /** Bar colour, any CSS colour. Defaults to the kind's colour, or the chart blue, var(--dm-c1), for a step with no kind */
  color?: string;
  /** What the step cost, in dollars */
  cost?: number;
  /** Hatches the bar red and puts a red tag after the label, for a step that failed */
  failed?: boolean;
  /** The tag on a failed step, like "2 failed". Defaults to "Failed" */
  tag?: string;
  /** Added to the bar's tooltip, like "2 tests failed" */
  note?: string;
  /** A retry of the failed step just before it: a dashed line joins the end of that step to this one once it starts */
  retry?: boolean;
}

export interface TimelineStat {
  /** Like "11.4s" */
  value: string;
  /** Like "total" */
  label: string;
  icon?: IconName;
}

export interface AgentRunTimelineProps {
  /** Like "Run 4821, update the pricing page" */
  title: string;
  steps: TimelineStep[];
  /** Seconds into the run. Bars grow up to it and the playhead sits on it. Leave it out once the run has finished */
  time?: number;
  /** Seconds the axis covers. Defaults to the end of the last step, rounded up to a multiple of 4 */
  span?: number;
  /** Labels on the axis, in seconds. Defaults to quarters of the span */
  ticks?: number[];
  /** The line under the title. Defaults to the steps running now, then "Finished in 11.4s" */
  status?: string;
  /** The figures in the footer once the run is done. Defaults to the total time, cost, steps and retries, which count up while it plays */
  stats?: TimelineStat[];
  /** The names of the kinds in the key. Defaults to Model, Tools and Apps */
  kindLabels?: Partial<Record<StepKind, string>>;
  /** Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own */
  bare?: boolean;
  className?: string;
}

// each kind's bar colour, a chart colour (teal and orange take a little of their text slot, so the bars keep 3:1 on a light
// page), and palette slot (its icon tint and live time)
const KIND: Record<StepKind, [string, string, string]> = {
  model: ['Model', 'var(--dm-c2)', 'pl'],
  tool: ['Tools', 'color-mix(in srgb,var(--dm-c6) 70%,var(--dm-pt))', 'pt'],
  app: ['Apps', 'color-mix(in srgb,var(--dm-c4) 75%,var(--dm-pp))', 'pp']
};
const KINDS = Object.keys(KIND) as StepKind[];

/** How many times `value` has changed since the first render, so a part 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;
}

/** Like Swap, except that while `instant` is set the text changes straight away: the live status moves too fast to crossfade */
function Status({ text, instant, className }: { text: string; instant: boolean; className?: string }) {
  const [shown, setShown] = useState(text);
  const [out, setOut] = useState(false);
  const reduce = useReducedMotion();
  if (instant && shown !== text) setShown(text);
  useEffect(() => {
    if (instant || text === shown) return;
    setOut(true);
    const t = setTimeout(() => { setShown(text); setOut(false); }, reduce ? 0 : 170);
    return () => clearTimeout(t);
  }, [text, shown, instant, reduce]);
  return <div className={cx(className, !instant && 'dm-sw', out && 'dm-out')}>{instant ? text : shown}</div>;
}

const pct = (n: number) => n * 100 + '%';
const secs = (n: number) => String(+n.toFixed(1));
type State = 'wait' | 'run' | 'done' | 'fail';

/** A run drawn as a Gantt chart: every step as a bar in its kind's colour on a shared time axis, with a playhead while it runs. */
export function AgentRunTimeline({ title, steps, time, span, ticks, status, stats, kindLabels, bare = true, className }: AgentRunTimelineProps) {
  const end = Math.max(0, ...steps.map(s => s.start + s.duration));
  const T = span ?? Math.max(4, Math.ceil(end / 4) * 4);
  const done = time === undefined || time >= T;
  const t = done ? end : Math.max(0, time);
  // the bars ease after the playhead in JS: a CSS width transition restarted on every frame never gets to move
  const eased = useTween(t, 250);
  const changed = useChanges(done) > 0 ? 'dm-enter' : undefined;

  const state = (s: TimelineStep): State => t < s.start ? 'wait' : t < s.start + s.duration && !done ? 'run' : s.failed ? 'fail' : 'done';
  // the line under the title names the steps at work; between two steps it keeps the last one's names
  const now = steps.filter(s => state(s) === 'run').map(s => s.label).join(', ');
  const last = useRef('');
  if (now) last.current = now;
  const retries = steps.filter(s => s.failed && t >= s.start + s.duration).length;
  const line = status ?? (done ? `Finished in ${end.toFixed(1)}s${steps.some(s => s.failed) ? `, ${retries} ${retries === 1 ? 'retry' : 'retries'}` : ''}` : now || last.current || 'Starting');
  // the footer counts up with the playhead, so it is there from the start and never pops in at the end
  const started = steps.filter(s => t >= s.start), hasRetries = steps.some(s => s.failed), n = started.length;
  const ran = (s: TimelineStep) => Math.max(0, Math.min(s.duration, t - s.start));
  const cost = started.reduce((sum, s) => sum + (s.cost ?? 0) * (ran(s) / (s.duration || 1)), 0);
  const figures = (done && stats) || [
    { value: `${Math.min(t, end).toFixed(1)}s`, label: 'total', icon: 'clock' as IconName },
    ...(steps.some(s => s.cost !== undefined) ? [{ value: `$${cost.toFixed(2)}`, label: 'cost', icon: 'dollar' as IconName }] : []),
    { value: String(n), label: n === 1 ? 'step' : 'steps', icon: 'layers' as IconName },
    ...(hasRetries ? [{ value: String(retries), label: retries === 1 ? 'retry' : 'retries', icon: 'refresh' as IconName }] : [])
  ];
  const kinds = KINDS.filter(k => steps.some(s => s.kind === k));
  const per = (k: StepKind) => steps.filter(s => s.kind === k).reduce((sum, s) => sum + ran(s), 0);

  // a step that finishes while you watch gives its icon a small bump
  const seenRunning = useRef(new Set<number>());
  steps.forEach((s, i) => { if (state(s) === 'run') seenRunning.current.add(i); });

  // the time on the playhead covers the axis labels it passes
  const grid = useRef<HTMLDivElement>(null);
  useIsoLayoutEffect(() => {
    const g = grid.current; if (!g) return;
    const b = g.querySelector('.dm-art-now')?.getBoundingClientRect(); if (!b) return;
    g.querySelectorAll('.dm-art-ticks span').forEach(el => { const x = el.getBoundingClientRect(); el.classList.toggle('dm-hid', x.right > b.left - 4 && x.left < b.right + 4); });
  });

  return (
    <div data-dm="" className={cx('dm-art', !bare && 'dm-panel', done ? 'dm-fin' : 'dm-live', className)} style={bare ? { paddingInline: 0 } : undefined}>
      <div className="dm-art-h">
        <div className="dm-art-hd">
          <div className="dm-art-t">{title}</div>
          <Status className={cx('dm-art-s dm-num', done ? 'dm-muted' : 'dm-shim')} text={line} instant={!done} />
        </div>
        {done
          ? <span key="done" className={cx('dm-art-tag dm-tag dm-ok', changed)}><Icon name="check" size="xs" />Done</span>
          : <span key="run" className={cx('dm-art-tag dm-tag', changed)}><span className="dm-spin" aria-hidden="true" />Running</span>}
      </div>
      {kinds.length > 0 && (
        <div className="dm-art-key dm-num" role="list" aria-label="Time by kind of step">
          {kinds.map(k => <span key={k} role="listitem" style={{ '--dm-k': KIND[k][1] } as CSSProperties}><i />{kindLabels?.[k] ?? KIND[k][0]}<b>{per(k).toFixed(1)}s</b></span>)}
        </div>
      )}
      <div className="dm-art-grid" ref={grid} style={{ '--dm-p': (t / T).toFixed(4) } as CSSProperties}>
        <div className="dm-art-axis" aria-hidden="true">
          <span />
          <div className="dm-art-ticks">{(ticks ?? [0, 1, 2, 3, 4].map(k => (T * k) / 4)).map(v => <span key={v} style={{ left: pct(v / T) }}>{secs(v)}s</span>)}</div>
          <span />
        </div>
        <div className="dm-art-past" aria-hidden="true" />
        <div className="dm-art-rows">
          {steps.map((s, i) => {
            const st = state(s), w = Math.max(0, Math.min(s.duration, eased - s.start)), prev = steps[i - 1];
            const k = s.kind ? KIND[s.kind] : null;
            const tip = `${s.label}: ${s.duration.toFixed(1)}s${s.cost !== undefined ? `, $${s.cost.toFixed(2)}` : ''}${s.note ? `, ${s.note}` : ''}`;
            const e = enter(i * 40);
            const hue = { '--dm-k': s.color ?? (k ? k[1] : 'var(--dm-c1)'), '--dm-kt': k ? `var(--dm-${k[2]})` : 'var(--dm-fg2)', '--dm-kb': k ? `var(--dm-${k[2]}-bg)` : 'var(--dm-chip)' } as CSSProperties;
            return (
              <div key={i} className={cx('dm-art-row', e.className, st === 'done' && seenRunning.current.has(i) && 'dm-bump')} style={{ ...e.style, ...hue }} data-k={s.kind} data-s={st}>
                <span className="dm-art-lab">
                  <span className="dm-art-ic" aria-hidden="true">{s.brand ? <Brand name={s.brand} /> : s.icon && <Icon name={s.icon} size="xs" />}</span>
                  <span className="dm-t">{s.label}</span>
                  {st === 'fail' && <span className="dm-tag dm-bad dm-enter">{s.tag ?? 'Failed'}</span>}
                </span>
                <span className="dm-art-lane">
                  {s.retry && prev?.failed && <span className="dm-art-link" aria-hidden="true" style={{ left: pct((prev.start + prev.duration) / T), width: pct((s.start - prev.start - prev.duration) / T) }} />}
                  <Tip label={tip}>
                    <span className="dm-art-bar" style={{ left: pct(s.start / T), width: pct(w / T) }}><i /></span>
                  </Tip>
                </span>
                <span className="dm-art-d dm-num">{st === 'wait' ? '' : `${ran(s).toFixed(1)}s`}</span>
              </div>
            );
          })}
        </div>
        <div className="dm-art-head" aria-hidden="true"><i /><span className="dm-art-now dm-num">{done && <Icon name="check" size="xs" />}{t.toFixed(1)}s</span></div>
      </div>
      <div className="dm-art-f dm-muted dm-num">
        {figures.map((f, i) => <span key={i}>{f.icon && <Icon name={f.icon} size="xs" />}<span><b>{f.value}</b> {f.label}</span></span>)}
      </div>
    </div>
  );
}
agent-run-timeline.cssReact · 93 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Agent run timeline ---------- */
/* each kind of step has a hue: --k paints its bar, --kt its text and icon, --kb the icon's tint. The model is purple, tools
   are teal and apps orange, each app with its own mark. The playhead runs in the accent with the time on it, and the lanes it
   has passed take a faint wash; once the run is done it stops at the finish and turns green */
.dm-art{padding:var(--dm-s-4);--dm-lab:176px;--dm-dur:44px}
.dm-art-h{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--dm-s-3)}
.dm-art-hd{min-width:0}
.dm-art-t{font-weight:500}
.dm-art-s{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-art-s.dm-sw{display:block}
.dm-art-tag{flex:none;transition:background .3s,color .3s}
.dm-art-tag .dm-spin{width:9px;height:9px}
.dm-art.dm-live .dm-art-tag{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-fg))}
/* the key: how long each kind has run so far */
.dm-art-key{display:flex;flex-wrap:wrap;gap:2px var(--dm-s-4);margin-top:10px;font-size:12px;color:var(--dm-muted)}
.dm-art-key span{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.dm-art-key i{width:8px;height:8px;border-radius:2px;background:var(--dm-k);flex:none}
.dm-art-key b{font-weight:500;color:var(--dm-fg2)}
.dm-art-grid{position:relative;margin-top:var(--dm-s-3)}
.dm-art-axis,.dm-art-row{display:grid;grid-template-columns:var(--dm-lab) minmax(0,1fr) var(--dm-dur);align-items:center}
.dm-art-axis{height:20px;font-size:11px;color:var(--dm-muted)}
.dm-art-ticks{position:relative;height:16px}
.dm-art-ticks span{position:absolute;top:0;transform:translateX(-50%);transition:opacity .2s}
.dm-art-ticks span:first-child{transform:none}
.dm-art-ticks span:last-child{transform:translateX(-100%)}
.dm-art-ticks span.dm-hid{opacity:0}
.dm-art-rows{position:relative}
.dm-art-row{height:32px}
.dm-art-lab{display:flex;align-items:center;gap:var(--dm-s-2);min-width:0;padding-right:var(--dm-s-3);font-size:12.5px;color:var(--dm-fg2);white-space:nowrap;transition:color .3s}
.dm-art-lab .dm-t{min-width:0;overflow:hidden;text-overflow:ellipsis}
.dm-art-lab .dm-tag{flex:none}
.dm-art-ic{--dm-ring:0 0 0 0 transparent;width:22px;height:22px;border-radius:7px;display:grid;place-items:center;flex:none;background:var(--dm-kb);color:var(--dm-kt);box-shadow:var(--dm-ring);transition:opacity .3s}
.dm-art-ic .dm-brand{width:13px;height:13px}
/* an app's own mark sits on a plain tile */
.dm-art-row[data-k="app"] .dm-art-ic{--dm-ring:inset 0 0 0 1px color-mix(in srgb,var(--dm-rail) 50%,transparent);background:var(--dm-surface);color:var(--dm-fg)}
/* a step that hasn't started waits quietly; the one at work breathes a ring of its hue round its icon */
.dm-art-row[data-s="wait"] .dm-art-lab{color:var(--dm-muted)}
.dm-art-row[data-s="wait"] .dm-art-ic{opacity:.45}
.dm-art-row[data-s="run"] .dm-art-lab{color:var(--dm-fg)}
.dm-art-row[data-s="run"] .dm-art-ic{animation:dm-artbreathe 1.8s var(--dm-ease-io) infinite}
@keyframes dm-artbreathe{0%,100%{box-shadow:var(--dm-ring),0 0 0 0 color-mix(in srgb,var(--dm-k) 0%,transparent)}50%{box-shadow:var(--dm-ring),0 0 0 3px color-mix(in srgb,var(--dm-k) 24%,transparent)}}
.dm-art-row.dm-bump .dm-art-ic{animation:dm-artpop .45s var(--dm-ease)}
@keyframes dm-artpop{40%{transform:scale(1.14)}}
/* the lanes: quarter lines at half strength, a bar for each step */
.dm-art-lane{position:relative;height:100%;background:linear-gradient(90deg,color-mix(in srgb,var(--dm-line) 50%,transparent) 1px,transparent 1px) 0 0/25% 100%;box-shadow:inset -1px 0 color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-art-bar{position:absolute;top:9px;height:14px;width:0;border-radius:5px;background:linear-gradient(90deg,var(--dm-k),color-mix(in oklab,var(--dm-k) 82%,#fff));cursor:default;transition:filter .2s}
@media (hover:hover) and (pointer:fine){.dm-art-bar:hover{filter:brightness(1.08) saturate(1.1)}}
/* the bar at work carries a passing light and a bright leading edge */
.dm-art-row[data-s="run"] .dm-art-bar{background:linear-gradient(100deg,transparent 30%,rgba(255,255,255,.42) 46%,transparent 62%) 0 0/250% 100% no-repeat,var(--dm-k);animation:dm-artsheen 1.3s linear infinite}
.dm-art-bar i{display:none;position:absolute;right:-1px;top:-3px;bottom:-3px;width:2px;border-radius:1px;background:var(--dm-k);box-shadow:0 0 8px 1px color-mix(in srgb,var(--dm-k) 60%,transparent)}
.dm-art-row[data-s="run"] .dm-art-bar i{display:block}
@keyframes dm-artsheen{from{background-position:120% 0,0 0}to{background-position:-120% 0,0 0}}
.dm-art-row[data-s="fail"] .dm-art-bar{background:repeating-linear-gradient(135deg,var(--dm-c5) 0 3px,color-mix(in srgb,var(--dm-c5) 45%,var(--dm-surface)) 3px 6px)}
/* a retry hangs off the end of the step that failed */
.dm-art-link{position:absolute;top:-9px;height:25px;min-width:6px;border-left:1px dashed var(--dm-warn);border-bottom:1px dashed var(--dm-warn);pointer-events:none;display:none}
.dm-art-row:not([data-s="wait"]) .dm-art-link{display:block;animation:dm-artdraw .5s var(--dm-ease) backwards}
@keyframes dm-artdraw{from{clip-path:inset(0 100% 100% 0)}to{clip-path:inset(0)}}
/* how long each step took, counting while it runs */
.dm-art-d{font-size:12px;color:var(--dm-muted);text-align:right;white-space:nowrap}
.dm-art-row[data-s="run"] .dm-art-d{color:var(--dm-kt)}
.dm-art-row[data-s="done"] .dm-art-d{color:var(--dm-fg2)}
.dm-art-row[data-s="fail"] .dm-art-d{color:var(--dm-bad)}
/* the playhead, with the time on the axis, and the wash over what it has passed */
.dm-art-past{position:absolute;top:20px;bottom:0;left:var(--dm-lab);width:calc((100% - var(--dm-lab) - var(--dm-dur)) * var(--dm-p,0));background:linear-gradient(90deg,transparent,var(--dm-sel));pointer-events:none;opacity:0;transition:opacity .5s}
.dm-art.dm-live .dm-art-past{opacity:1}
.dm-art-head{position:absolute;top:2px;bottom:0;left:calc(var(--dm-lab) + (100% - var(--dm-lab) - var(--dm-dur)) * var(--dm-p,0));width:0;pointer-events:none;opacity:0;transition:opacity .4s}
.dm-art.dm-live .dm-art-head,.dm-art.dm-fin .dm-art-head{opacity:1}
.dm-art-head i{position:absolute;top:16px;bottom:0;left:-.75px;width:1.5px;border-radius:1px;background:var(--dm-accent-mark,var(--dm-accent));transition:background .4s}
.dm-art-now{position:absolute;top:0;left:0;transform:translateX(-50%);display:inline-flex;align-items:center;gap:3px;height:16px;padding:0 5px;border-radius:5px;background:var(--dm-accent);color:var(--dm-on-accent);font-size:11px;font-weight:500;line-height:1;white-space:nowrap;transition:background .4s,color .4s}
.dm-art-now .dm-i{width:10px;height:10px}
.dm-art.dm-fin .dm-art-head i{background:var(--dm-ok)}
.dm-art.dm-fin .dm-art-now{background:var(--dm-ok-bg);color:var(--dm-ok)}
.dm-art-f{display:flex;flex-wrap:wrap;gap:var(--dm-s-2) var(--dm-s-5);margin-top:var(--dm-s-3);padding-top:var(--dm-s-3);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:12.5px}
.dm-art-f>span{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.dm-art-f b{color:var(--dm-fg);font-weight:500}
@media (max-width:600px){.dm-art{--dm-lab:112px;--dm-dur:38px}.dm-art-lab{gap:6px;padding-right:var(--dm-s-2)}.dm-art-lab .dm-tag{display:none}.dm-art-f{gap:var(--dm-s-2) var(--dm-s-4)}}
:root:not([data-style="soft"]) .dm-art-ic:not(:where([data-style="soft"],[data-style="soft"] *)){--dm-ring:inset 0 0 0 1px color-mix(in srgb,var(--dm-k) 35%,transparent)}
:root:not([data-style="soft"]) .dm-art-row[data-k="app"] .dm-art-ic:not(:where([data-style="soft"],[data-style="soft"] *)){--dm-ring:inset 0 0 0 1px color-mix(in srgb,var(--dm-rail) 50%,transparent)}
:root:not([data-style="soft"]) .dm-art-row[data-s="run"] .dm-art-ic:not(:where([data-style="soft"],[data-style="soft"] *)){animation:dm-artbp 1s steps(1,end) infinite}
@keyframes dm-artbp{0%,55%{box-shadow:inset 0 0 0 1px var(--dm-k)}56%,100%{box-shadow:var(--dm-ring)}}
:root:not([data-style="soft"]) .dm-art-row.dm-bump .dm-art-ic:not(:where([data-style="soft"],[data-style="soft"] *)){animation:none}
:root:not([data-style="soft"]) .dm-art-row[data-s="fail"] .dm-art-bar:not(:where([data-style="soft"],[data-style="soft"] *)){background:repeating-linear-gradient(135deg,var(--dm-c5) 0 3px,color-mix(in srgb,var(--dm-c5) 45%,var(--dm-surface)) 3px 6px)}
:root:not([data-style="soft"]) .dm-art-bar:not(:where([data-style="soft"],[data-style="soft"] *)){background:var(--dm-k)}
:root:not([data-style="soft"]) .dm-art-row[data-s="run"] .dm-art-bar:not(:where([data-style="soft"],[data-style="soft"] *)){background:linear-gradient(135deg,var(--dm-k) 25%,color-mix(in srgb,var(--dm-k) 50%,var(--dm-surface)) 25% 50%,var(--dm-k) 50% 75%,color-mix(in srgb,var(--dm-k) 50%,var(--dm-surface)) 75%) 0 0/8px 8px;animation:dm-artmarch .6s steps(4) infinite}
@keyframes dm-artmarch{to{background-position:8px 0}}
:root:not([data-style="soft"]) .dm-art-bar i:not(:where([data-style="soft"],[data-style="soft"] *)){width:1px;right:0;box-shadow:none}
:root:not([data-style="soft"]) .dm-art-link:not(:where([data-style="soft"],[data-style="soft"] *)){border-left-style:solid;border-bottom-style:solid}
:root:not([data-style="soft"]) .dm-art-row:not([data-s="wait"]) .dm-art-link:not(:where([data-style="soft"],[data-style="soft"] *)){animation:dm-artdrawbp .3s steps(3,end) backwards}
@keyframes dm-artdrawbp{from{clip-path:inset(0 100% 100% 0)}to{clip-path:inset(0)}}
:root:not([data-style="soft"]) .dm-art-past:not(:where([data-style="soft"],[data-style="soft"] *)){background:repeating-linear-gradient(135deg,transparent 0 5px,var(--dm-grid) 5px 6px)}
:root:not([data-style="soft"]) .dm-art-head i:not(:where([data-style="soft"],[data-style="soft"] *)){width:1px;left:-.5px}
agent-run-timeline.htmlHTML · 16 lines
<div class="card wide bare" data-c="agent-run-timeline">
  <div class="stage-in">
    <div class="art panel">
      <div class="art-h"><div class="art-hd"><div class="art-t">Run 4821, update the pricing page</div><div class="art-s muted num">Starting</div></div><span class="art-tag tag"><span class="spin" aria-hidden="true"></span>Running</span></div>
      <div class="art-key num" role="list" aria-label="Time by kind of step"></div>
      <div class="art-grid">
        <div class="art-axis" aria-hidden="true"><span></span><div class="art-ticks"></div><span></span></div>
        <div class="art-past" aria-hidden="true"></div>
        <div class="art-rows"></div>
        <div class="art-head" aria-hidden="true"><i></i><span class="art-now num">0.0s</span></div>
      </div>
      <div class="art-f muted num"></div>
    </div>
  </div>
</div>
agent-run-timeline.cssHTML · 92 lines
/* ---------- Agent run timeline ---------- */
/* each kind of step has a hue: --k paints its bar, --kt its text and icon, --kb the icon's tint. The model is purple, tools
   are teal and apps orange, each app with its own mark. The playhead runs in the accent with the time on it, and the lanes it
   has passed take a faint wash; once the run is done it stops at the finish and turns green */
.art{padding:var(--s-4);--lab:176px;--dur:44px}
.art-h{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--s-3)}
.art-hd{min-width:0}
.art-t{font-weight:500}
.art-s{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.art-s.sw{display:block}
.art-tag{flex:none;transition:background .3s,color .3s}
.art-tag .spin{width:9px;height:9px}
.art.live .art-tag{background:var(--accent-bg);color:var(--accent-fg,var(--fg))}
/* the key: how long each kind has run so far */
.art-key{display:flex;flex-wrap:wrap;gap:2px var(--s-4);margin-top:10px;font-size:12px;color:var(--muted)}
.art-key span{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.art-key i{width:8px;height:8px;border-radius:2px;background:var(--k);flex:none}
.art-key b{font-weight:500;color:var(--fg2)}
.art-grid{position:relative;margin-top:var(--s-3)}
.art-axis,.art-row{display:grid;grid-template-columns:var(--lab) minmax(0,1fr) var(--dur);align-items:center}
.art-axis{height:20px;font-size:11px;color:var(--muted)}
.art-ticks{position:relative;height:16px}
.art-ticks span{position:absolute;top:0;transform:translateX(-50%);transition:opacity .2s}
.art-ticks span:first-child{transform:none}
.art-ticks span:last-child{transform:translateX(-100%)}
.art-ticks span.hid{opacity:0}
.art-rows{position:relative}
.art-row{height:32px}
.art-lab{display:flex;align-items:center;gap:var(--s-2);min-width:0;padding-right:var(--s-3);font-size:12.5px;color:var(--fg2);white-space:nowrap;transition:color .3s}
.art-lab .t{min-width:0;overflow:hidden;text-overflow:ellipsis}
.art-lab .tag{flex:none}
.art-ic{--ring:0 0 0 0 transparent;width:22px;height:22px;border-radius:7px;display:grid;place-items:center;flex:none;background:var(--kb);color:var(--kt);box-shadow:var(--ring);transition:opacity .3s}
.art-ic .brand{width:13px;height:13px}
/* an app's own mark sits on a plain tile */
.art-row[data-k="app"] .art-ic{--ring:inset 0 0 0 1px color-mix(in srgb,var(--rail) 50%,transparent);background:var(--surface);color:var(--fg)}
/* a step that hasn't started waits quietly; the one at work breathes a ring of its hue round its icon */
.art-row[data-s="wait"] .art-lab{color:var(--muted)}
.art-row[data-s="wait"] .art-ic{opacity:.45}
.art-row[data-s="run"] .art-lab{color:var(--fg)}
.art-row[data-s="run"] .art-ic{animation:artbreathe 1.8s var(--ease-io) infinite}
@keyframes artbreathe{0%,100%{box-shadow:var(--ring),0 0 0 0 color-mix(in srgb,var(--k) 0%,transparent)}50%{box-shadow:var(--ring),0 0 0 3px color-mix(in srgb,var(--k) 24%,transparent)}}
.art-row.bump .art-ic{animation:artpop .45s var(--ease)}
@keyframes artpop{40%{transform:scale(1.14)}}
/* the lanes: quarter lines at half strength, a bar for each step */
.art-lane{position:relative;height:100%;background:linear-gradient(90deg,color-mix(in srgb,var(--line) 50%,transparent) 1px,transparent 1px) 0 0/25% 100%;box-shadow:inset -1px 0 color-mix(in srgb,var(--line) 50%,transparent)}
.art-bar{position:absolute;top:9px;height:14px;width:0;border-radius:5px;background:linear-gradient(90deg,var(--k),color-mix(in oklab,var(--k) 82%,#fff));cursor:default;transition:filter .2s}
@media (hover:hover) and (pointer:fine){.art-bar:hover{filter:brightness(1.08) saturate(1.1)}}
/* the bar at work carries a passing light and a bright leading edge */
.art-row[data-s="run"] .art-bar{background:linear-gradient(100deg,transparent 30%,rgba(255,255,255,.42) 46%,transparent 62%) 0 0/250% 100% no-repeat,var(--k);animation:artsheen 1.3s linear infinite}
.art-bar i{display:none;position:absolute;right:-1px;top:-3px;bottom:-3px;width:2px;border-radius:1px;background:var(--k);box-shadow:0 0 8px 1px color-mix(in srgb,var(--k) 60%,transparent)}
.art-row[data-s="run"] .art-bar i{display:block}
@keyframes artsheen{from{background-position:120% 0,0 0}to{background-position:-120% 0,0 0}}
.art-row[data-s="fail"] .art-bar{background:repeating-linear-gradient(135deg,var(--c5) 0 3px,color-mix(in srgb,var(--c5) 45%,var(--surface)) 3px 6px)}
/* a retry hangs off the end of the step that failed */
.art-link{position:absolute;top:-9px;height:25px;min-width:6px;border-left:1px dashed var(--warn);border-bottom:1px dashed var(--warn);pointer-events:none;display:none}
.art-row:not([data-s="wait"]) .art-link{display:block;animation:artdraw .5s var(--ease) backwards}
@keyframes artdraw{from{clip-path:inset(0 100% 100% 0)}to{clip-path:inset(0)}}
/* how long each step took, counting while it runs */
.art-d{font-size:12px;color:var(--muted);text-align:right;white-space:nowrap}
.art-row[data-s="run"] .art-d{color:var(--kt)}
.art-row[data-s="done"] .art-d{color:var(--fg2)}
.art-row[data-s="fail"] .art-d{color:var(--bad)}
/* the playhead, with the time on the axis, and the wash over what it has passed */
.art-past{position:absolute;top:20px;bottom:0;left:var(--lab);width:calc((100% - var(--lab) - var(--dur)) * var(--p,0));background:linear-gradient(90deg,transparent,var(--sel));pointer-events:none;opacity:0;transition:opacity .5s}
.art.live .art-past{opacity:1}
.art-head{position:absolute;top:2px;bottom:0;left:calc(var(--lab) + (100% - var(--lab) - var(--dur)) * var(--p,0));width:0;pointer-events:none;opacity:0;transition:opacity .4s}
.art.live .art-head,.art.fin .art-head{opacity:1}
.art-head i{position:absolute;top:16px;bottom:0;left:-.75px;width:1.5px;border-radius:1px;background:var(--accent-mark,var(--accent));transition:background .4s}
.art-now{position:absolute;top:0;left:0;transform:translateX(-50%);display:inline-flex;align-items:center;gap:3px;height:16px;padding:0 5px;border-radius:5px;background:var(--accent);color:var(--on-accent);font-size:11px;font-weight:500;line-height:1;white-space:nowrap;transition:background .4s,color .4s}
.art-now .i{width:10px;height:10px}
.art.fin .art-head i{background:var(--ok)}
.art.fin .art-now{background:var(--ok-bg);color:var(--ok)}
.art-f{display:flex;flex-wrap:wrap;gap:var(--s-2) var(--s-5);margin-top:var(--s-3);padding-top:var(--s-3);border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent);font-size:12.5px}
.art-f>span{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.art-f b{color:var(--fg);font-weight:500}
@media (max-width:600px){.art{--lab:112px;--dur:38px}.art-lab{gap:6px;padding-right:var(--s-2)}.art-lab .tag{display:none}.art-f{gap:var(--s-2) var(--s-4)}}
:root:not([data-style="soft"]) .art-ic:not(:where([data-style="soft"],[data-style="soft"] *)){--ring:inset 0 0 0 1px color-mix(in srgb,var(--k) 35%,transparent)}
:root:not([data-style="soft"]) .art-row[data-k="app"] .art-ic:not(:where([data-style="soft"],[data-style="soft"] *)){--ring:inset 0 0 0 1px color-mix(in srgb,var(--rail) 50%,transparent)}
:root:not([data-style="soft"]) .art-row[data-s="run"] .art-ic:not(:where([data-style="soft"],[data-style="soft"] *)){animation:artbp 1s steps(1,end) infinite}
@keyframes artbp{0%,55%{box-shadow:inset 0 0 0 1px var(--k)}56%,100%{box-shadow:var(--ring)}}
:root:not([data-style="soft"]) .art-row.bump .art-ic:not(:where([data-style="soft"],[data-style="soft"] *)){animation:none}
:root:not([data-style="soft"]) .art-row[data-s="fail"] .art-bar:not(:where([data-style="soft"],[data-style="soft"] *)){background:repeating-linear-gradient(135deg,var(--c5) 0 3px,color-mix(in srgb,var(--c5) 45%,var(--surface)) 3px 6px)}
:root:not([data-style="soft"]) .art-bar:not(:where([data-style="soft"],[data-style="soft"] *)){background:var(--k)}
:root:not([data-style="soft"]) .art-row[data-s="run"] .art-bar:not(:where([data-style="soft"],[data-style="soft"] *)){background:linear-gradient(135deg,var(--k) 25%,color-mix(in srgb,var(--k) 50%,var(--surface)) 25% 50%,var(--k) 50% 75%,color-mix(in srgb,var(--k) 50%,var(--surface)) 75%) 0 0/8px 8px;animation:artmarch .6s steps(4) infinite}
@keyframes artmarch{to{background-position:8px 0}}
:root:not([data-style="soft"]) .art-bar i:not(:where([data-style="soft"],[data-style="soft"] *)){width:1px;right:0;box-shadow:none}
:root:not([data-style="soft"]) .art-link:not(:where([data-style="soft"],[data-style="soft"] *)){border-left-style:solid;border-bottom-style:solid}
:root:not([data-style="soft"]) .art-row:not([data-s="wait"]) .art-link:not(:where([data-style="soft"],[data-style="soft"] *)){animation:artdrawbp .3s steps(3,end) backwards}
@keyframes artdrawbp{from{clip-path:inset(0 100% 100% 0)}to{clip-path:inset(0)}}
:root:not([data-style="soft"]) .art-past:not(:where([data-style="soft"],[data-style="soft"] *)){background:repeating-linear-gradient(135deg,transparent 0 5px,var(--grid) 5px 6px)}
:root:not([data-style="soft"]) .art-head i:not(:where([data-style="soft"],[data-style="soft"] *)){width:1px;left:-.5px}
agent-run-timeline.jsHTML · 75 lines
DM.def('agent-run-timeline', (r, c) => {
  // each kind of step has a hue (a chart colour for its bar, the palette slot for its text and icon tint) and a name in the key.
  // Teal and orange take a little of their text slot, so the bars keep 3:1 on a light stage
  const K = { model:['Model', 'var(--c2)', 'pl'], tool:['Tools', 'color-mix(in srgb,var(--c6) 70%,var(--pt))', 'pt'], app:['Apps', 'color-mix(in srgb,var(--c4) 75%,var(--pp))', 'pp'] };
  // an icon starting with @ is an app's own mark
  const S = [
    { l:'Plan the change', ic:'brain', k:'model', s:0, d:1.6, cost:.03 },
    { l:'Read 6 files', ic:'file-text', k:'tool', s:1.6, d:2.2, cost:.02 },
    { l:'Search Figma', ic:'@figma', k:'app', s:1.9, d:1.4, cost:.01 },
    { l:'Edit PricingCard', ic:'edit', k:'model', s:4, d:2.4, cost:.06 },
    { l:'Run tests', ic:'term', k:'tool', s:6.5, d:1.5, cost:.01, fail:'2 failed' },
    { l:'Run tests again', ic:'refresh', k:'tool', s:8.1, d:1.9, cost:.01, retry:true },
    { l:'Open a PR on GitHub', ic:'@github', k:'app', s:10.1, d:1, cost:.02 },
    { l:'Post to Slack', ic:'@slack', k:'app', s:11.1, d:.6, cost:.01 }
  ];
  const T = 12, END = Math.max(...S.map(s => s.s + s.d)), PACE = 1.6, TK = [0, 3, 6, 9, 12];
  const art = $('.art', r), grid = $('.art-grid', r), rows = $('.art-rows', r), now = $('.art-now', r), st = $('.art-s', r), foot = $('.art-f', r);
  $('.art-ticks', r).innerHTML = TK.map(t => `<span style="left:${t / T * 100}%">${t}s</span>`).join('');
  const ticks = $$('.art-ticks span', r);
  $('.art-key', r).innerHTML = Object.entries(K).map(([id, k]) => `<span role="listitem" style="--k:${k[1]}"><i></i>${k[0]}<b data-k="${id}">0.0s</b></span>`).join('');
  const kb = Object.fromEntries($$('.art-key b', r).map(b => [b.dataset.k, b]));
  const pct = v => v / T * 100 + '%';
  S.forEach((s, i) => {
    const k = K[s.k], prev = S[i - 1];
    const link = s.retry && prev ? `<span class="art-link" aria-hidden="true" style="left:${pct(prev.s + prev.d)};width:${pct(s.s - prev.s - prev.d)}"></span>` : '';
    s.el = html(`<div class="art-row" data-k="${s.k}" data-s="wait" style="--k:${k[1]};--kt:var(--${k[2]});--kb:var(--${k[2]}-bg)"><span class="art-lab"><span class="art-ic" aria-hidden="true">${s.ic[0] === '@' ? BRAND[s.ic.slice(1)] : I(s.ic, 'xs')}</span><span class="t">${s.l}</span></span><span class="art-lane">${link}<span class="art-bar" style="left:${pct(s.s)}" data-tip="${s.l}: ${s.d.toFixed(1)}s, $${s.cost.toFixed(2)}${s.fail ? ', 2 tests failed' : ''}"><i></i></span></span><span class="art-d num"></span></div>`);
    s.bar = $('.art-bar', s.el); s.dur = $('.art-d', s.el); s.st = 'wait';
    rows.appendChild(s.el);
  });
  stagger($$('.art-row', r), 40);
  const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
  let line = '', ft = '';
  // the run as it stands t seconds in: bars, states, times, the key, the footer and the line under the title
  function draw(t){
    grid.style.setProperty('--p', (t / T).toFixed(4));
    let cost = 0, n = 0, re = 0; const per = { model:0, tool:0, app:0 }, live = [];
    S.forEach(s => {
      const w = Math.max(0, Math.min(s.d, t - s.s)), was = s.st;
      s.st = t < s.s ? 'wait' : t < s.s + s.d ? 'run' : s.fail ? 'fail' : 'done';
      s.bar.style.width = pct(w);
      s.dur.textContent = t >= s.s ? w.toFixed(1) + 's' : '';
      if (s.st !== was){
        s.el.dataset.s = s.st;
        if (s.st === 'fail'){ const tg = html(`<span class="tag bad">${s.fail}</span>`); $('.art-lab', s.el).appendChild(tg); enter(tg); }
        if (s.st === 'done'){ s.el.classList.remove('bump'); void s.el.offsetWidth; s.el.classList.add('bump'); }
      }
      if (t >= s.s){ n++; cost += s.cost * w / s.d; per[s.k] += w; }
      if (s.st === 'fail') re++;
      if (s.st === 'run') live.push(s.l);
    });
    for (const k in per) kb[k].textContent = per[k].toFixed(1) + 's';
    const f = `<span>${I('clock','xs')}<span><b>${Math.min(t, END).toFixed(1)}s</b> total</span></span><span>${I('dollar','xs')}<span><b>$${cost.toFixed(2)}</b> cost</span></span><span>${I('layers','xs')}<span><b>${n}</b> ${n === 1 ? 'step' : 'steps'}</span></span><span>${I('refresh','xs')}<span><b>${re}</b> ${re === 1 ? 'retry' : 'retries'}</span></span>`;
    if (f !== ft){ foot.innerHTML = f; ft = f; }
    // between two steps the line keeps the last one's name, so it never flickers
    if (live.length && live.join(', ') !== line){ line = live.join(', '); st.textContent = line; }
    now.textContent = t.toFixed(1) + 's';
    // the time on the playhead covers the axis labels it passes
    const b = now.getBoundingClientRect();
    ticks.forEach(el => { const x = el.getBoundingClientRect(); el.classList.toggle('hid', x.right > b.left - 4 && x.left < b.right + 4); });
  }
  draw(0);
  c.run(async () => {
    await c.wait(500);
    art.classList.add('live'); st.classList.remove('muted'); st.classList.add('shim');
    // time moves on only while the demo isn't held, so a mouse resting on it stops the playhead where it is: a long gap
    // between two frames is a hold (or the card being away) ending, and counts as one frame
    await new Promise(res => { let t = 0, last = 0; c.loop(ts => { const dt = last && ts - last < 250 ? ts - last : 16; last = ts; t = Math.min(END, t + dt / 1000 * PACE); draw(t); if (t >= END){ res(); return false; } }); });
    art.classList.remove('live'); art.classList.add('fin');
    st.classList.remove('shim'); st.classList.add('muted');
    now.innerHTML = I('check','xs') + END.toFixed(1) + 's';
    const tag = $('.art-tag', r); tag.className = 'art-tag tag ok'; tag.innerHTML = I('check','xs') + 'Done'; enter(tag);
    swap(st, `Finished in ${END.toFixed(1)}s, ${plural(S.filter(s => s.fail).length, 'retry', 'retries')}`);
  });
});
06

Questions

What people ask about it.

How do I follow a run live?
Keep the steps you know about in steps and update time with the seconds since the run started, for example on each animation frame. When the run ends, leave time out.
How do I show a failed step and its retry?
Mark the failed step with failed: true, a short tag like "2 failed" and an optional note for its tooltip, then add the retry as its own step after it with retry: true. The footer counts the failed steps as retries.
Which app marks can a step show?
Set brand to one of the marks the components share: google, github, figma, slack, notion, linear, gmail or drive. For anything else, use icon.
Can I replay a finished run?
Yes. Pass the full steps list and move time from 0 to the end of the run. The demo plays it back at 1.6 times real speed, and holds while a mouse rests on it.
07

More in Agent status

Show what the agent is doing, how far along it is, and what it is working with.

ThinkingThe live trace, written as the agent works, then folded away.
Reasoning trailThe agent's plan, notes, sources and edits, after it thinks.
Agent lanesSeveral agents working in parallel, each with its own lane and hand-offs.
Agent treeAn orchestrator and its sub-agents, with cost rolled up per branch.
Agent inboxEvery run that needs you, is running or is done, in one list.
Trace viewOne agent run as a waterfall of spans, with latency and tokens.
Browser sessionThe agent browsing for you, step by step, with Pause and Take over.
Run summaryWhat the agent did, what changed and what needs you.
All 18 in Agent status →