Skip to content
UI Kit
Accent color

Agent run

Screens for AI agents that act on their own. A live run with stop, every tool call with input and output, a queue of actions waiting for a person, a plan to check before the start and the history of runs.

One of 3 components in AI and chat

5 variants

Variants

Live run

The run plays as it scrolls into view. Logs write themselves, steps tick off, spend counts up and the agent stops for approval. Stop ends it at once.

Open in Studio

Tool calls

Every call of a run with status, duration and retries. Desktop shows input and output beside the list, phones open each call in place.

Open in Studio

Approval queue

Actions from several agents that wait for a person, each with reason, risk and the exact changes. Decisions slide out and can be undone.

Open in Studio

Plan review

The plan before the start. Steps reorder with a glide, can be removed, added or marked to ask first, and switches decide what the agent may do alone.

Open in Studio

Run history

Success rate with its trend, totals and every run with trigger, steps, duration and spend. Filters reorder with a glide, a row opens its summary.

Open in Studio

Details

What Agent run brings along, read from the registry and the source.

Category
AI and chat
Area
App
Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/agent-run.tsx
Brings along
utilskiticonrevealmotionswitch
npm packages
None
Languages
English and German, 67 texts of its own
In collections
AI product

Props

The AgentRunProps interface from components/ui/agent-run.tsx, with the comments from the code.

  • variantoptional
    AgentRunVariant

    live-runtool-callsapproval-queueplan-reviewrun-history

  • titleoptional
    string
  • goaloptional
    string

    What the agent is asked to do

  • agentoptional
    { name: string }
  • elapsedoptional
    number

    live-run: seconds since the start

  • budgetoptional
    { used: number; limit: number; unit: string }

    live-run: spend so far against a limit, e.g. { used: 3.4, limit: 10, unit: "credits" }

  • stepsoptional
    AgentRunStep[]
  • approvalsoptional
    AgentApproval[]
  • planoptional
    AgentPlanStep[]
  • permissionsoptional
    AgentPermission[]
  • runsoptional
    AgentRunRecord[]
  • trendoptional
    number[]

    run-history: success rate per week, 0 to 1, oldest first

  • statsoptional
    { period: string; duration: string; spend: string }

    run-history: totals shown next to the rate, e.g. { period: "Last 7 days", duration: "3 min 40 s", spend: "41.6 credits" }

Built-in texts

Agent run writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 67 texts
goalEnglish GoalGerman Ziel
elapsedEnglish Running forGerman Läuft seit
budgetEnglish SpendGerman Verbrauch
stopEnglish Stop agentGerman Agent stoppen
stoppedEnglish Stopped. Everything before this step is kept, nothing after it ran.German Gestoppt. Alles vor diesem Schritt bleibt, danach lief nichts mehr.
haltedEnglish StoppedGerman Gestoppt
finishedEnglish Finished. The result is ready for review.German Fertig. Das Ergebnis liegt zur Durchsicht bereit.
waitingNoteEnglish The agent waits for your approval before it continues.German Der Agent wartet auf deine Freigabe, bevor er weitermacht.
doneEnglish DoneGerman Fertig
runningEnglish RunningGerman Läuft
queuedEnglish QueuedGerman Wartet
failedEnglish FailedGerman Fehlgeschlagen
skippedEnglish SkippedGerman Übersprungen
waitingEnglish Needs approvalGerman Braucht Freigabe
progressEnglish {done} of {total} stepsGerman {done} von {total} Schritten
estimateEnglish Spend is an estimate until the run ends.German Der Verbrauch ist geschätzt, bis der Lauf endet.
callsEnglish Tool callsGerman Werkzeugaufrufe
inputEnglish InputGerman Eingabe
outputEnglish OutputGerman Ausgabe
retriesEnglish Retried {n} timesGerman {n} Mal wiederholt
retry1English Retried onceGerman Einmal wiederholt
usageEnglish UsageGerman Verbrauch
durationEnglish DurationGerman Dauer
noOutputEnglish No output, the call failed.German Keine Ausgabe, der Aufruf schlug fehl.
pendingEnglish {n} waitingGerman {n} offen
approveEnglish ApproveGerman Freigeben
declineEnglish DeclineGerman Ablehnen
approveAllEnglish Approve all low riskGerman Alle mit geringem Risiko freigeben
approvedEnglish Approved, the agent continues.German Freigegeben, der Agent macht weiter.
declinedEnglish Declined, the agent skips this step.German Abgelehnt, der Agent überspringt den Schritt.
allClearEnglish Nothing waits for you.German Nichts wartet auf dich.
allClearNoteEnglish New requests appear here and in your notifications.German Neue Anfragen erscheinen hier und in deinen Benachrichtigungen.
whyEnglish WhyGerman Warum
changesEnglish What changesGerman Was sich ändert
beforeEnglish BeforeGerman Vorher
afterEnglish AfterGerman Nachher
risk_lowEnglish Low riskGerman Geringes Risiko
risk_mediumEnglish Medium riskGerman Mittleres Risiko
risk_highEnglish High riskGerman Hohes Risiko
undoEnglish UndoGerman Rückgängig
planEnglish PlanGerman Plan
planNoteEnglish Check the plan before the agent starts. Change the order, remove steps or ask to be asked first.German Prüf den Plan, bevor der Agent startet. Reihenfolge ändern, Schritte streichen oder vorher gefragt werden.
moveUpEnglish Move {name} upGerman {name} nach oben
moveDownEnglish Move {name} downGerman {name} nach unten
removeEnglish Remove {name}German {name} entfernen
askFirstEnglish Ask me firstGerman Vorher fragen
addStepEnglish Add a stepGerman Schritt hinzufügen
addEnglish AddGerman Hinzufügen
rulesEnglish What the agent may do on its ownGerman Was der Agent allein darf
startEnglish Start with this planGerman Mit diesem Plan starten
startingEnglish Plan approved. The agent starts with step 1.German Plan freigegeben. Der Agent beginnt mit Schritt 1.
stopAnytimeEnglish You can stop the agent at any time.German Du kannst den Agenten jederzeit stoppen.
editEnglish Edit planGerman Plan bearbeiten
stepsEnglish {n} stepsGerman {n} Schritte
asksEnglish {n} with approvalGerman {n} mit Freigabe
historyEnglish RunsGerman Läufe
allEnglish AllGerman Alle
successEnglish SucceededGerman Erfolgreich
rateEnglish Success rateGerman Erfolgsquote
weeksEnglish Per weekGerman Je Woche
avgDurationEnglish Typical durationGerman Übliche Dauer
spendEnglish SpendGerman Verbrauch
noRunsEnglish No run matches this filter.German Kein Lauf passt zu diesem Filter.
openEnglish Show summaryGerman Zusammenfassung zeigen
closeEnglish Hide summaryGerman Zusammenfassung verbergen
stepsCountEnglish {n} stepsGerman {n} Schritte
stepOneEnglish 1 stepGerman 1 Schritt

Accessibility

Read from the source of the component.

  • Agent run handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
  • Sets aria-current, aria-expanded, aria-label, aria-live, aria-pressed and aria-selected, so screen readers announce purpose and state.
  • Uses the roles status, tab, tablist and tabpanel.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,158 lines
components/ui/agent-run.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger, useScrollFade } from '@/components/ui/motion';
import { Switch } from '@/components/ui/switch';

export type AgentRunVariant = 'live-run' | 'tool-calls' | 'approval-queue' | 'plan-review' | 'run-history';

export type AgentStepStatus = 'done' | 'running' | 'queued' | 'failed' | 'skipped' | 'waiting';

export interface AgentField {
  label: string;
  value: string;
}

export interface AgentRunStep {
  id: string;
  title: string;
  /** The tool the agent calls, e.g. "Read spreadsheet" */
  tool?: string;
  icon?: IconName;
  status: AgentStepStatus;
  /** e.g. "4.2 s" */
  duration?: string;
  /** One line under the title */
  detail?: string;
  /** Lines the agent writes while it works */
  log?: string[];
  /** tool-calls: what went into the call and what came back */
  input?: AgentField[];
  output?: AgentField[];
  /** tool-calls: the call was repeated this often */
  retries?: number;
  /** tool-calls: e.g. "1,240 tokens" */
  usage?: string;
}

export interface AgentChange {
  field: string;
  before?: string;
  after: string;
}

export interface AgentApproval {
  id: string;
  agent: string;
  /** The run it belongs to */
  run: string;
  /** What will happen, as a sentence */
  action: string;
  icon?: IconName;
  risk: 'low' | 'medium' | 'high';
  /** Why the agent wants to do it */
  reason: string;
  changes?: AgentChange[];
  /** e.g. "4 min ago" */
  requested: string;
}

export interface AgentPlanStep {
  id: string;
  title: string;
  tool?: string;
  /** Ask before this step runs */
  ask?: boolean;
}

export interface AgentPermission {
  id: string;
  label: string;
  description: string;
  on?: boolean;
}

export interface AgentRunRecord {
  id: string;
  title: string;
  /** What started it, e.g. "Every Monday 8:00" */
  trigger: string;
  status: 'success' | 'failed' | 'stopped';
  started: string;
  duration: string;
  cost: string;
  steps: number;
  summary?: string;
}

export interface AgentRunProps {
  variant?: AgentRunVariant;
  title?: string;
  /** What the agent is asked to do */
  goal?: string;
  agent?: { name: string };
  /** live-run: seconds since the start */
  elapsed?: number;
  /** live-run: spend so far against a limit, e.g. { used: 3.4, limit: 10, unit: "credits" } */
  budget?: { used: number; limit: number; unit: string };
  steps?: AgentRunStep[];
  approvals?: AgentApproval[];
  plan?: AgentPlanStep[];
  permissions?: AgentPermission[];
  runs?: AgentRunRecord[];
  /** run-history: success rate per week, 0 to 1, oldest first */
  trend?: number[];
  /** run-history: totals shown next to the rate, e.g. { period: "Last 7 days", duration: "3 min 40 s", spend: "41.6 credits" } */
  stats?: { period: string; duration: string; spend: string };
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    goal: 'Goal',
    elapsed: 'Running for',
    budget: 'Spend',
    stop: 'Stop agent',
    stopped: 'Stopped. Everything before this step is kept, nothing after it ran.',
    halted: 'Stopped',
    finished: 'Finished. The result is ready for review.',
    waitingNote: 'The agent waits for your approval before it continues.',
    
    done: 'Done',
    running: 'Running',
    queued: 'Queued',
    failed: 'Failed',
    skipped: 'Skipped',
    waiting: 'Needs approval',
    progress: '{done} of {total} steps',
    estimate: 'Spend is an estimate until the run ends.',
    calls: 'Tool calls',
    input: 'Input',
    output: 'Output',
    retries: 'Retried {n} times',
    retry1: 'Retried once',
    usage: 'Usage',
    duration: 'Duration',
    noOutput: 'No output, the call failed.',
    
    pending: '{n} waiting',
    approve: 'Approve',
    decline: 'Decline',
    approveAll: 'Approve all low risk',
    approved: 'Approved, the agent continues.',
    declined: 'Declined, the agent skips this step.',
    allClear: 'Nothing waits for you.',
    allClearNote: 'New requests appear here and in your notifications.',
    why: 'Why',
    changes: 'What changes',
    before: 'Before',
    after: 'After',
    risk_low: 'Low risk',
    risk_medium: 'Medium risk',
    risk_high: 'High risk',
    undo: 'Undo',
    plan: 'Plan',
    planNote: 'Check the plan before the agent starts. Change the order, remove steps or ask to be asked first.',
    moveUp: 'Move {name} up',
    moveDown: 'Move {name} down',
    remove: 'Remove {name}',
    askFirst: 'Ask me first',
    addStep: 'Add a step',
    add: 'Add',
    rules: 'What the agent may do on its own',
    start: 'Start with this plan',
    starting: 'Plan approved. The agent starts with step 1.',
    stopAnytime: 'You can stop the agent at any time.',
    edit: 'Edit plan',
    steps: '{n} steps',
    asks: '{n} with approval',
    history: 'Runs',
    all: 'All',
    success: 'Succeeded',
    rate: 'Success rate',
    weeks: 'Per week',
    avgDuration: 'Typical duration',
    spend: 'Spend',
    
    noRuns: 'No run matches this filter.',
    open: 'Show summary',
    close: 'Hide summary',
    stepsCount: '{n} steps',
    stepOne: '1 step',
  },
  de: {
    goal: 'Ziel',
    elapsed: 'Läuft seit',
    budget: 'Verbrauch',
    stop: 'Agent stoppen',
    stopped: 'Gestoppt. Alles vor diesem Schritt bleibt, danach lief nichts mehr.',
    halted: 'Gestoppt',
    finished: 'Fertig. Das Ergebnis liegt zur Durchsicht bereit.',
    waitingNote: 'Der Agent wartet auf deine Freigabe, bevor er weitermacht.',
    
    done: 'Fertig',
    running: 'Läuft',
    queued: 'Wartet',
    failed: 'Fehlgeschlagen',
    skipped: 'Übersprungen',
    waiting: 'Braucht Freigabe',
    progress: '{done} von {total} Schritten',
    estimate: 'Der Verbrauch ist geschätzt, bis der Lauf endet.',
    calls: 'Werkzeugaufrufe',
    input: 'Eingabe',
    output: 'Ausgabe',
    retries: '{n} Mal wiederholt',
    retry1: 'Einmal wiederholt',
    usage: 'Verbrauch',
    duration: 'Dauer',
    noOutput: 'Keine Ausgabe, der Aufruf schlug fehl.',
    
    pending: '{n} offen',
    approve: 'Freigeben',
    decline: 'Ablehnen',
    approveAll: 'Alle mit geringem Risiko freigeben',
    approved: 'Freigegeben, der Agent macht weiter.',
    declined: 'Abgelehnt, der Agent überspringt den Schritt.',
    allClear: 'Nichts wartet auf dich.',
    allClearNote: 'Neue Anfragen erscheinen hier und in deinen Benachrichtigungen.',
    why: 'Warum',
    changes: 'Was sich ändert',
    before: 'Vorher',
    after: 'Nachher',
    risk_low: 'Geringes Risiko',
    risk_medium: 'Mittleres Risiko',
    risk_high: 'Hohes Risiko',
    undo: 'Rückgängig',
    plan: 'Plan',
    planNote: 'Prüf den Plan, bevor der Agent startet. Reihenfolge ändern, Schritte streichen oder vorher gefragt werden.',
    moveUp: '{name} nach oben',
    moveDown: '{name} nach unten',
    remove: '{name} entfernen',
    askFirst: 'Vorher fragen',
    addStep: 'Schritt hinzufügen',
    add: 'Hinzufügen',
    rules: 'Was der Agent allein darf',
    start: 'Mit diesem Plan starten',
    starting: 'Plan freigegeben. Der Agent beginnt mit Schritt 1.',
    stopAnytime: 'Du kannst den Agenten jederzeit stoppen.',
    edit: 'Plan bearbeiten',
    steps: '{n} Schritte',
    asks: '{n} mit Freigabe',
    history: 'Läufe',
    all: 'Alle',
    success: 'Erfolgreich',
    rate: 'Erfolgsquote',
    weeks: 'Je Woche',
    avgDuration: 'Übliche Dauer',
    spend: 'Verbrauch',
    
    noRuns: 'Kein Lauf passt zu diesem Filter.',
    open: 'Zusammenfassung zeigen',
    close: 'Zusammenfassung verbergen',
    stepsCount: '{n} Schritte',
    stepOne: '1 Schritt',
  },
};

type Texts = (typeof TEXTS)['en'];
type P = AgentRunProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;

/* ------------------------------------------------------------------ */
/* Motion, kept in the block so it ships on its own                    */
/* ------------------------------------------------------------------ */

/** State seeded from a prop that starts over when the prop changes (the Studio edits props live) */
function useSynced<T>(value: T) {
  const [state, setState] = useState(value);
  const key = JSON.stringify(value);
  const seen = useRef(key);
  if (seen.current !== key) {
    seen.current = key;
    setState(value);
  }
  return [state, setState] as const;
}

const CSS =
  '@keyframes arn-up{from{opacity:0;transform:translateY(8px)}}@keyframes arn-pop{from{opacity:0;transform:scale(.94)}}@keyframes arn-out{to{opacity:0;transform:translateX(24px)}}@keyframes arn-spin{to{transform:rotate(360deg)}}@keyframes arn-line{from{transform:scaleY(0)}}@keyframes arn-sheen{to{transform:translateX(130%)}}@keyframes arn-draw{from{stroke-dashoffset:1}}' +
  '@media (prefers-reduced-motion:no-preference){.arn-up{animation:arn-up .45s var(--ease-kit) var(--arn-d,0ms) both}.arn-pop{animation:arn-pop .35s var(--ease-kit) both}.arn-out{animation:arn-out .32s var(--ease-kit) both}.arn-spin{animation:arn-spin 1s linear infinite}.arn-line{transform-origin:top;animation:arn-line .6s var(--ease-kit) both}.arn-sheen{animation:arn-sheen 2.2s ease-in-out infinite}}.arn-sheen{transform:translateX(-130%)}';

function Style() {
  return (
    <style href="kit-agent-run" precedence="default">
      {CSS}
    </style>
  );
}

/** Rows marked data-flip glide to their new place after a reorder */
function useFlip<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null);
  const before = useRef<Map<string, DOMRect>>(new Map());
  if (typeof window !== 'undefined' && ref.current) before.current = new Map(Array.from(ref.current.querySelectorAll<HTMLElement>('[data-flip]'), (el) => [el.dataset.flip!, el.getBoundingClientRect()]));
  useIso(() => {
    if (!ref.current || prefersReducedMotion()) return;
    ref.current.querySelectorAll<HTMLElement>('[data-flip]').forEach((el) => {
      const a = before.current.get(el.dataset.flip!);
      if (!a) return;
      const b = el.getBoundingClientRect();
      if (Math.abs(a.top - b.top) < 1 && Math.abs(a.left - b.left) < 1) return;
      el.animate([{ transform: `translate(${a.left - b.left}px, ${a.top - b.top}px)` }, { transform: 'none' }], { duration: 420, easing: 'cubic-bezier(0.2, 0.7, 0.2, 1)' });
    });
  }, [key]);
  return ref;
}

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

const STATUS: Record<AgentStepStatus, { icon: IconName; cls: string }> = {
  done: { icon: 'check', cls: 'bg-[color-mix(in_oklab,var(--success)_16%,var(--card))] text-success-foreground' },
  running: { icon: 'loader', cls: 'bg-[color-mix(in_oklab,var(--primary)_16%,var(--card))] text-primary' },
  queued: { icon: 'circle', cls: 'bg-card text-subtle-foreground border border-border' },
  failed: { icon: 'x', cls: 'bg-[color-mix(in_oklab,var(--destructive)_14%,var(--card))] text-destructive' },
  skipped: { icon: 'minus', cls: 'bg-card text-subtle-foreground border border-border' },
  waiting: { icon: 'hourglass', cls: 'bg-[color-mix(in_oklab,var(--warning)_22%,var(--card))] text-warning-foreground' },
};

function StatusDot({ status, size = 28 }: { status: AgentStepStatus; size?: number }) {
  const s = STATUS[status];
  return (
    <span aria-hidden className={cn('relative grid shrink-0 place-items-center rounded-full', s.cls)} style={{ width: size, height: size }}>
      <Icon name={s.icon} size={Math.round(size * 0.5)} className={cn(status === 'running' && 'arn-spin', status === 'queued' && 'scale-50')} strokeWidth={status === 'done' ? 2.2 : 1.8} />
    </span>
  );
}

function StatusText({ status, t, className }: { status: AgentStepStatus; t: Texts; className?: string }) {
  const tone = status === 'done' ? 'text-success-foreground' : status === 'failed' ? 'text-destructive' : status === 'waiting' ? 'text-warning-foreground' : status === 'running' ? 'text-primary' : 'text-muted-foreground';
  return <span className={cn('text-[12px] font-medium', tone, className)}>{t[status]}</span>;
}

function Stage({ children, className, wide }: { children: React.ReactNode; className?: string; wide?: boolean }) {
  return (
    <section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
      <div className="kit-container">
        <div className={cn('mx-auto w-full', wide ? 'max-w-[68rem]' : 'max-w-[52rem]')}>{children}</div>
      </div>
    </section>
  );
}

function Panel({ children, className }: { children: React.ReactNode; className?: string }) {
  return <div className={cn('overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]', className)}>{children}</div>;
}

function Head({ title, eyebrow, description, children }: { title?: string; eyebrow?: React.ReactNode; description?: string; children?: React.ReactNode }) {
  return (
    <Reveal className="mb-5 flex flex-col gap-3 sm:mb-6 sm:flex-row sm:items-end sm:justify-between">
      <div className="min-w-0">
        {eyebrow && <p className="mb-1.5 flex items-center gap-1.5 text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">{eyebrow}</p>}
        {title && <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>}
        {description && <p className="mt-2 max-w-[40rem] text-pretty text-[14px] leading-relaxed text-muted-foreground">{description}</p>}
      </div>
      {children}
    </Reveal>
  );
}

const fmtClock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;

/* ------------------------------------------------------------------ */
/* live-run                                                            */
/* ------------------------------------------------------------------ */

function LiveRun({ t, title, goal, agent, elapsed = 0, budget, steps: initial = [], className }: P) {
  const locale = useLocale();
  const [steps, setSteps] = useSynced(initial);
  const [lines, setLines] = useState<Record<string, number>>(() => Object.fromEntries(initial.map((s) => [s.id, s.status === 'running' ? Math.min(1, s.log?.length ?? 0) : s.log?.length ?? 0])));
  const [secs, setSecs] = useState(elapsed);
  const [spent, setSpent] = useState(budget?.used ?? 0);
  const [state, setState] = useState<'run' | 'stopped' | 'waiting' | 'done'>(initial.some((s) => s.status === 'running') ? 'run' : initial.some((s) => s.status === 'waiting') ? 'waiting' : 'done');
  const [ref, inView] = useInView<HTMLDivElement>('0px 0px -15% 0px');

  // The running step writes its log, then the next one starts, until a step needs approval
  const stepsRef = useRef(steps);
  const linesRef = useRef(lines);
  stepsRef.current = steps;
  linesRef.current = lines;
  useEffect(() => {
    if (state !== 'run' || !inView || prefersReducedMotion()) return;
    const tick = window.setInterval(() => setSecs((v) => v + 1), 1000);
    const work = window.setInterval(() => {
      const cur = stepsRef.current;
      const i = cur.findIndex((x) => x.status === 'running');
      if (i < 0) return;
      const s = cur[i];
      const shown = linesRef.current[s.id] ?? 0;
      if (shown < (s.log?.length ?? 0)) {
        setLines((l) => ({ ...l, [s.id]: shown + 1 }));
        setSpent((v) => round(v + 0.2, 1));
        return;
      }
      const next = cur.map((x, j) => (j === i ? { ...x, status: 'done' as const, duration: x.duration ?? `${2 + (i % 3)} s` } : j === i + 1 && x.status === 'queued' ? { ...x, status: 'running' as const } : x));
      setSteps(next);
      const after = next[i + 1];
      if (!after) setState('done');
      else if (after.status === 'waiting') setState('waiting');
      else setLines((l) => ({ ...l, [after.id]: 0 }));
    }, 900);
    return () => {
      window.clearInterval(tick);
      window.clearInterval(work);
    };
  }, [state, inView]);

  const stop = () => {
    // A step the person stopped did not fail, it and everything after it is skipped
    setSteps((cur) => cur.map((s) => (s.status === 'running' || s.status === 'queued' || s.status === 'waiting' ? { ...s, status: 'skipped' as const } : s)));
    setState('stopped');
  };
  const done = steps.filter((s) => s.status === 'done').length;
  const share = steps.length ? done / steps.length : 0;
  const nf = (n: number) => n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
  const live = state === 'run';

  return (
    <Stage className={className}>
      <Head
        title={title}
        eyebrow={
          <>
            <Icon name="bot" size={15} />
            {agent?.name}
          </>
        }
      />
      <Reveal effect="scale" delay={80}>
        <div ref={ref}>
          <Panel>
            <div className="border-b border-border p-4 sm:p-5">
              {goal && (
                <p className="text-pretty text-[14.5px] leading-relaxed text-foreground">
                  <span className="mr-1.5 text-muted-foreground">{t.goal}</span>
                  {goal}
                </p>
              )}
              <div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-[repeat(3,minmax(0,1fr))_auto] sm:items-end">
                <div className="order-1">
                  <p className="text-[12px] text-muted-foreground">{t.elapsed}</p>
                  <p className="mt-0.5 text-[17px] font-semibold tabular-nums text-foreground">{fmtClock(secs)}</p>
                </div>
                <div className="order-3 max-sm:col-span-2 sm:order-2">
                  <p className="text-[12px] text-muted-foreground">{fill(t.progress, { done, total: steps.length })}</p>
                  <div className="mt-2.5 h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_9%,transparent)]">
                    <div className="relative h-full origin-left overflow-hidden rounded-full bg-primary transition-transform duration-700 ease-[var(--ease-kit)]" style={{ transform: `scaleX(${round(share, 3)})` }}>
                      {live && <span aria-hidden className="arn-sheen absolute inset-0 bg-[linear-gradient(90deg,transparent,rgb(255_255_255/0.45),transparent)]" />}
                    </div>
                  </div>
                </div>
                {budget && (
                  <div className="order-2 sm:order-3">
                    <p className="text-[12px] text-muted-foreground">{t.budget}</p>
                    <p className="mt-0.5 text-[15px] font-semibold tabular-nums text-foreground">
                      {nf(spent)} <span className="text-[13px] font-normal text-muted-foreground">/ {nf(budget.limit)} {budget.unit}</span>
                    </p>
                  </div>
                )}
                <div className="order-4 max-sm:col-span-2">
                  {live || state === 'waiting' ? (
                    <button type="button" onClick={stop} className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-[color-mix(in_oklab,var(--destructive)_40%,var(--border))] px-4 text-[13.5px] font-medium text-destructive transition-colors hover:bg-[color-mix(in_oklab,var(--destructive)_10%,transparent)] sm:h-9 sm:w-auto">
                      <Icon name="stop" size={15} />
                      {t.stop}
                    </button>
                  ) : (
                    <span className={cn('inline-flex h-9 items-center gap-1.5 text-[13px] font-medium', state === 'done' ? 'text-success-foreground' : 'text-muted-foreground')}>
                      <Icon name={state === 'done' ? 'check-circle' : 'stop'} size={15} />
                      {state === 'done' ? t.done : t.halted}
                    </span>
                  )}
                </div>
              </div>
            </div>
            <ol className="relative p-2 sm:p-3">
              {steps.map((s, i) => {
                const count = lines[s.id] ?? 0;
                const last = i === steps.length - 1;
                return (
                  <li key={s.id} className="relative grid grid-cols-[28px_minmax(0,1fr)] gap-x-3 rounded-xl px-2 py-2.5 sm:px-3" aria-current={s.status === 'running' ? 'step' : undefined}>
                    {!last && <span aria-hidden className={cn('absolute left-[calc(0.5rem+13.5px)] top-[2.6rem] bottom-0 w-px sm:left-[calc(0.75rem+13.5px)]', s.status === 'done' ? 'arn-line bg-[color-mix(in_oklab,var(--success)_45%,var(--border))]' : 'bg-border')} />}
                    <StatusDot status={s.status} />
                    <div className="min-w-0">
                      <div className="flex min-h-7 items-center gap-3">
                        <p className={cn('min-w-0 flex-1 text-[14.5px] font-medium leading-snug', s.status === 'queued' || s.status === 'skipped' ? 'text-muted-foreground' : 'text-foreground')}>{s.title}</p>
                        <span className="shrink-0 text-[12px] tabular-nums text-subtle-foreground">
                          {s.duration && s.status === 'done' && s.duration}
                          {s.status !== 'done' && s.status !== 'queued' && <StatusText status={s.status} t={t} />}
                        </span>
                      </div>
                      {s.tool && (
                        <span className="mt-1 inline-flex items-center gap-1 rounded-md bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] px-1.5 py-0.5 text-[11.5px] font-medium text-muted-foreground">
                          <Icon name={s.icon ?? 'wrench'} size={12} />
                          {s.tool}
                        </span>
                      )}
                      {s.detail && <p className="mt-0.5 text-pretty text-[13px] leading-relaxed text-muted-foreground">{s.detail}</p>}
                      {!!s.log?.length && (s.status === 'running' || s.status === 'done') && (
                        // Every line is in place from the start, so the list never changes height
                        <ul className="mt-2 space-y-1 rounded-lg border border-border bg-background/60 px-3 py-2" aria-live={s.status === 'running' ? 'polite' : undefined}>
                          {s.log.map((line, j) => (
                            <li key={j} aria-hidden={j >= count || undefined} className={cn('flex gap-2 text-[12.5px] leading-relaxed text-muted-foreground transition-[opacity,transform] duration-500 ease-[var(--ease-kit)]', j >= count && 'translate-y-1 opacity-0')}>
                              <span aria-hidden className="mt-[0.55em] size-1 shrink-0 rounded-full bg-[color-mix(in_oklab,var(--foreground)_30%,transparent)]" />
                              {line}
                            </li>
                          ))}
                        </ul>
                      )}
                      {s.status === 'waiting' && state === 'waiting' && (
                        <div className="arn-up mt-2.5 flex flex-col gap-2 rounded-lg border border-[color-mix(in_oklab,var(--warning)_45%,var(--border))] bg-[color-mix(in_oklab,var(--warning)_9%,var(--card))] p-3 sm:flex-row sm:items-center sm:justify-between">
                          <p className="text-[13px] text-foreground">{t.waitingNote}</p>
                          <div className="flex gap-2">
                            <button
                              type="button"
                              onClick={() => (setSteps((c) => c.map((x) => (x.id === s.id ? { ...x, status: 'running' } : x))), setLines((l) => ({ ...l, [s.id]: 0 })), setState('run'))}
                              className="inline-flex h-11 flex-1 items-center justify-center gap-1.5 rounded-lg bg-primary px-3.5 text-[13px] font-medium text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] sm:h-8"
                            >
                              <Icon name="check" size={14} />
                              {t.approve}
                            </button>
                            <button
                              type="button"
                              onClick={() => {
                                setSteps((c) => {
                                  const k = c.findIndex((x) => x.id === s.id);
                                  return c.map((x, j) => (j === k ? { ...x, status: 'skipped' } : j === k + 1 && x.status === 'queued' ? { ...x, status: 'running' } : x));
                                });
                                setState(steps[i + 1] ? 'run' : 'done');
                              }}
                              className="inline-flex h-11 flex-1 items-center justify-center rounded-lg border border-border bg-card px-3.5 text-[13px] font-medium text-foreground hover:bg-accent sm:h-8"
                            >
                              {t.decline}
                            </button>
                          </div>
                        </div>
                      )}
                    </div>
                  </li>
                );
              })}
            </ol>
            <p className="border-t border-border bg-surface/60 px-4 py-3 text-[12.5px] text-muted-foreground sm:px-5" aria-live="polite">
              {state === 'stopped' ? t.stopped : state === 'done' ? t.finished : t.estimate}
            </p>
          </Panel>
        </div>
      </Reveal>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* tool-calls                                                          */
/* ------------------------------------------------------------------ */

function Fields({ items, empty }: { items?: AgentField[]; empty?: string }) {
  if (!items?.length) return <p className="text-[13px] text-muted-foreground">{empty}</p>;
  return (
    <dl className="divide-y divide-border rounded-lg border border-border bg-background/60">
      {items.map((f) => (
        <div key={f.label} className="grid grid-cols-1 gap-0.5 px-3 py-2 sm:grid-cols-[9rem_minmax(0,1fr)] sm:gap-3">
          <dt className="text-[12px] text-muted-foreground">{f.label}</dt>
          <dd className="break-words text-[13px] tabular-nums text-foreground">{f.value}</dd>
        </div>
      ))}
    </dl>
  );
}

function CallDetail({ s, t }: { s: AgentRunStep; t: Texts }) {
  return (
    <div className="space-y-4">
      <div className="flex flex-wrap gap-x-5 gap-y-1 text-[12.5px] text-muted-foreground">
        {s.duration && (
          <span className="inline-flex items-center gap-1.5">
            <Icon name="timer" size={14} />
            {t.duration} <span className="tabular-nums text-foreground">{s.duration}</span>
          </span>
        )}
        {s.usage && (
          <span className="inline-flex items-center gap-1.5">
            <Icon name="gauge" size={14} />
            {t.usage} <span className="tabular-nums text-foreground">{s.usage}</span>
          </span>
        )}
        {!!s.retries && (
          <span className="inline-flex items-center gap-1.5 text-warning-foreground">
            <Icon name="repeat" size={14} />
            {s.retries === 1 ? t.retry1 : fill(t.retries, { n: s.retries })}
          </span>
        )}
      </div>
      <div>
        <p className="mb-1.5 text-[12px] font-medium text-subtle-foreground">{t.input}</p>
        <Fields items={s.input} />
      </div>
      <div>
        <p className="mb-1.5 text-[12px] font-medium text-subtle-foreground">{t.output}</p>
        <Fields items={s.output} empty={t.noOutput} />
      </div>
    </div>
  );
}

function ToolCalls({ t, title, agent, goal, steps = [], className }: P) {
  const [sel, setSel] = useState(steps.find((s) => s.status === 'failed')?.id ?? steps[0]?.id);
  const [openId, setOpenId] = useState<string | null>(sel ?? null);
  const current = steps.find((s) => s.id === sel);
  const items = useRef<(HTMLButtonElement | null)[]>([]);

  return (
    <Stage wide className={className}>
      <Head
        title={title}
        eyebrow={
          <>
            <Icon name="bot" size={15} />
            {agent?.name}
          </>
        }
        description={goal}
      />
      <Reveal effect="scale" delay={80}>
        <Panel className="grid grid-cols-1 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)]">
          <div className="border-border lg:border-r">
            <p className="border-b border-border px-4 py-3 text-[12.5px] font-medium text-muted-foreground">
              {t.calls} · {steps.length}
            </p>
            <Stagger as="ul" step={50} className="p-2" aria-label={t.calls}>
              {steps.map((s, i) => {
                const on = s.id === sel;
                const opened = openId === s.id;
                return (
                  <li key={s.id}>
                    <button
                      ref={(el) => {
                        items.current[i] = el;
                      }}
                      type="button"
                      aria-current={on || undefined}
                      aria-expanded={opened}
                      onClick={() => (setSel(s.id), setOpenId(opened ? null : s.id))}
                      onKeyDown={(e) => {
                        const d = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
                        if (!d) return;
                        e.preventDefault();
                        const n = (i + d + steps.length) % steps.length;
                        setSel(steps[n].id);
                        items.current[n]?.focus();
                      }}
                      className={cn('relative flex min-h-12 w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left transition-colors duration-200', on ? 'bg-accent' : 'hover:bg-accent/60')}
                    >
                      {on && <span aria-hidden className="absolute inset-y-2 left-0 w-0.5 rounded-full bg-primary max-lg:hidden" />}
                      <StatusDot status={s.status} size={26} />
                      <span className="min-w-0 flex-1">
                        <span className="block truncate text-[13.5px] font-medium text-foreground">{s.tool ?? s.title}</span>
                        <span className="block truncate text-[12px] text-muted-foreground">{s.title}</span>
                      </span>
                      <span className="shrink-0 text-right text-[12px] tabular-nums text-subtle-foreground">
                        {s.duration}
                        {!!s.retries && (
                          <span className="mt-0.5 flex items-center justify-end gap-0.5 text-warning-foreground">
                            <Icon name="repeat" size={11} />
                            {s.retries}
                          </span>
                        )}
                      </span>
                      <Icon name="chevron-down" size={16} className={cn('shrink-0 text-muted-foreground transition-transform duration-300 lg:hidden', opened && 'rotate-180')} />
                    </button>
                    {opened && (
                      <div className="arn-up px-2.5 pb-3 pt-1 lg:hidden">
                        {s.detail && <p className="mb-3 text-pretty text-[13px] leading-relaxed text-muted-foreground">{s.detail}</p>}
                        <CallDetail s={s} t={t} />
                      </div>
                    )}
                  </li>
                );
              })}
            </Stagger>
          </div>
          {current && (
            <div key={current.id} className="arn-up hidden min-w-0 p-5 lg:block" aria-live="polite">
              <div className="mb-4 flex items-start gap-3">
                <StatusDot status={current.status} size={32} />
                <div className="min-w-0">
                  <p className="text-[16px] font-semibold text-foreground">{current.tool ?? current.title}</p>
                  <p className="text-[13px] text-muted-foreground">
                    {current.title} · <StatusText status={current.status} t={t} />
                  </p>
                </div>
              </div>
              {current.detail && <p className="mb-4 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{current.detail}</p>}
              <CallDetail s={current} t={t} />
            </div>
          )}
        </Panel>
      </Reveal>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* approval-queue                                                      */
/* ------------------------------------------------------------------ */

const RISK = {
  low: 'bg-[color-mix(in_oklab,var(--success)_14%,var(--card))] text-success-foreground',
  medium: 'bg-[color-mix(in_oklab,var(--warning)_20%,var(--card))] text-warning-foreground',
  high: 'bg-[color-mix(in_oklab,var(--destructive)_13%,var(--card))] text-destructive',
};

function ApprovalQueue({ t, title, approvals = [], className }: P) {
  const [open, setOpen] = useSynced(approvals);
  const [leaving, setLeaving] = useState<Record<string, 'approved' | 'declined'>>({});
  const [last, setLast] = useState<{ item: AgentApproval; index: number; how: 'approved' | 'declined' } | null>(null);

  const decide = (a: AgentApproval, how: 'approved' | 'declined') => {
    setLeaving((l) => ({ ...l, [a.id]: how }));
    const index = open.findIndex((x) => x.id === a.id);
    window.setTimeout(
      () => {
        setOpen((o) => o.filter((x) => x.id !== a.id));
        setLeaving(({ [a.id]: _, ...rest }) => rest);
        setLast({ item: a, index, how });
      },
      prefersReducedMotion() ? 0 : 320,
    );
  };
  const low = open.filter((a) => a.risk === 'low');

  return (
    <Stage className={className}>
      <Head title={title} eyebrow={<span className="tabular-nums">{fill(t.pending, { n: open.length })}</span>}>
        {low.length > 1 && (
          <button type="button" onClick={() => low.forEach((a) => decide(a, 'approved'))} className="inline-flex h-11 items-center gap-1.5 self-start rounded-lg border border-border bg-card px-3.5 text-[13px] font-medium text-foreground shadow-[var(--shadow-sm)] hover:bg-accent sm:h-9 sm:self-auto">
            <Icon name="check-square" size={15} />
            {t.approveAll}
          </button>
        )}
      </Head>
      {last && (
        <p role="status" className="arn-up mb-3 flex items-center justify-between gap-3 rounded-xl border border-border bg-card px-4 py-2.5 text-[13px] text-muted-foreground">
          <span className="flex min-w-0 items-center gap-2">
            <Icon name={last.how === 'approved' ? 'check-circle' : 'x-circle'} size={16} className={last.how === 'approved' ? 'text-success-foreground' : 'text-muted-foreground'} />
            <span className="truncate">{last.how === 'approved' ? t.approved : t.declined}</span>
          </span>
          <button
            type="button"
            onClick={() => {
              setOpen((o) => [...o.slice(0, last.index), last.item, ...o.slice(last.index)]);
              setLast(null);
            }}
            className="-my-2 inline-flex h-11 shrink-0 items-center rounded-md px-2 font-medium text-foreground hover:bg-accent sm:h-8"
          >
            {t.undo}
          </button>
        </p>
      )}
      {open.length === 0 ? (
        <Panel className="arn-pop grid place-items-center px-6 py-14 text-center">
          <span className="grid size-12 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_15%,var(--card))] text-success-foreground">
            <Icon name="check" size={22} />
          </span>
          <p className="mt-4 text-[16px] font-semibold text-foreground">{t.allClear}</p>
          <p className="mt-1 text-[13.5px] text-muted-foreground">{t.allClearNote}</p>
        </Panel>
      ) : (
        <Stagger as="ul" step={80} className="space-y-3">
          {open.map((a) => (
            <li key={a.id} className={cn(leaving[a.id] && 'arn-out pointer-events-none')}>
              <article className="overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-shadow duration-300 hover:shadow-[var(--highlight),var(--shadow-md)]">
                <div className="flex gap-3 p-4 sm:gap-4 sm:p-5">
                  <span aria-hidden className="grid size-10 shrink-0 place-items-center rounded-xl border border-border bg-background text-muted-foreground">
                    <Icon name={a.icon ?? 'bot'} size={19} />
                  </span>
                  <div className="min-w-0 flex-1">
                    <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px] text-muted-foreground">
                      <span className="font-medium text-foreground">{a.agent}</span>
                      <span aria-hidden className="max-sm:hidden">·</span>
                      <span className="truncate max-sm:hidden">{a.run}</span>
                      <span aria-hidden>·</span>
                      <span>{a.requested}</span>
                    </div>
                    <h3 className="mt-1 text-pretty text-[15.5px] font-semibold leading-snug text-foreground">{a.action}</h3>
                    <p className="mt-1.5 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">
                      <span className="font-medium text-foreground">{t.why}. </span>
                      {a.reason}
                    </p>
                  </div>
                </div>
                {!!a.changes?.length && (
                  <div className="border-t border-border px-4 py-3 sm:px-5">
                    <p className="mb-2 text-[12px] font-medium text-subtle-foreground">{t.changes}</p>
                    <dl className="grid grid-cols-1 gap-2">
                      {a.changes.map((c) => (
                        <div key={c.field} className="grid grid-cols-1 gap-1 text-[13px] sm:grid-cols-[8.5rem_minmax(0,1fr)] sm:gap-3">
                          <dt className="text-muted-foreground">{c.field}</dt>
                          <dd className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
                            {c.before && (
                              <>
                                <span className="sr-only">{t.before}</span>
                                <del className="rounded bg-[color-mix(in_oklab,var(--destructive)_10%,transparent)] px-1.5 py-0.5 text-muted-foreground decoration-[color-mix(in_oklab,var(--destructive)_60%,transparent)]">{c.before}</del>
                                <Icon name="arrow-right" size={13} className="text-subtle-foreground" />
                              </>
                            )}
                            <span className="sr-only">{t.after}</span>
                            <ins className="rounded bg-[color-mix(in_oklab,var(--success)_12%,transparent)] px-1.5 py-0.5 text-foreground no-underline">{c.after}</ins>
                          </dd>
                        </div>
                      ))}
                    </dl>
                  </div>
                )}
                <div className="flex items-center gap-2 border-t border-border bg-surface/60 px-4 py-3 sm:px-5">
                  <span className={cn('rounded-md px-2 py-1 text-[11.5px] font-medium', RISK[a.risk])}>{t[`risk_${a.risk}`]}</span>
                  <span className="flex-1" />
                  <button type="button" onClick={() => decide(a, 'declined')} className="inline-flex h-11 items-center rounded-lg border border-border bg-card px-3.5 text-[13px] font-medium text-foreground hover:bg-accent sm:h-9">
                    {t.decline}
                  </button>
                  <button type="button" onClick={() => decide(a, 'approved')} className="inline-flex h-11 items-center gap-1.5 rounded-lg bg-primary px-3.5 text-[13px] font-medium text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] sm:h-9">
                    <Icon name="check" size={15} />
                    {t.approve}
                  </button>
                </div>
              </article>
            </li>
          ))}
        </Stagger>
      )}
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* plan-review                                                         */
/* ------------------------------------------------------------------ */

function PlanReview({ t, title, goal, agent, plan: initial = [], permissions = [], className }: P) {
  const [plan, setPlan] = useSynced(initial);
  const [draft, setDraft] = useState('');
  const [started, setStarted] = useState(false);
  const seq = useRef(0);
  const uid = useId();
  const list = useFlip<HTMLOListElement>(plan.map((p) => p.id).join());
  const move = (i: number, d: number) => {
    const j = i + d;
    if (j < 0 || j >= plan.length) return;
    const next = [...plan];
    [next[i], next[j]] = [next[j], next[i]];
    setPlan(next);
    requestAnimationFrame(() => document.getElementById(`${uid}-${next[j].id}-${d < 0 ? 'up' : 'down'}`)?.focus());
  };
  const asks = plan.filter((p) => p.ask).length;

  return (
    <Stage className={className}>
      <Head
        title={title}
        eyebrow={
          <>
            <Icon name="bot" size={15} />
            {agent?.name}
          </>
        }
      />
      <Reveal effect="scale" delay={80}>
        <Panel>
          {goal && (
            <div className="border-b border-border p-4 sm:p-5">
              <p className="text-pretty text-[14.5px] leading-relaxed text-foreground">
                <span className="mr-1.5 text-muted-foreground">{t.goal}</span>
                {goal}
              </p>
              <p className="mt-2 text-pretty text-[13px] leading-relaxed text-muted-foreground">{t.planNote}</p>
            </div>
          )}
          <div className="flex items-center justify-between px-4 pt-4 text-[12.5px] sm:px-5">
            <span className="font-medium text-foreground">{t.plan}</span>
            <span className="tabular-nums text-muted-foreground">
              {fill(t.steps, { n: plan.length })} · {fill(t.asks, { n: asks })}
            </span>
          </div>
          <ol ref={list} className="space-y-2 p-3 sm:p-4" aria-label={t.plan}>
            {plan.map((p, i) => (
              <li key={p.id} data-flip={p.id} className={cn('group flex flex-wrap items-center gap-x-3 gap-y-1 rounded-xl border bg-background py-1.5 pl-3 pr-1.5 transition-colors duration-200 sm:flex-nowrap', p.ask ? 'border-[color-mix(in_oklab,var(--warning)_45%,var(--border))]' : 'border-border')}>
                <span className="grid size-7 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[12.5px] font-semibold tabular-nums text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">{i + 1}</span>
                <span className="min-w-0 flex-1 basis-[calc(100%-2.75rem)] py-1 sm:basis-auto">
                  <span className="block text-[14px] font-medium leading-snug text-foreground">{p.title}</span>
                  {p.tool && <span className="block truncate text-[12px] text-muted-foreground">{p.tool}</span>}
                </span>
                <span className="flex w-full items-center justify-between pl-10 sm:w-auto sm:gap-1 sm:pl-0">
                <button
                  type="button"
                  aria-pressed={!!p.ask}
                  disabled={started}
                  onClick={() => setPlan((c) => c.map((x) => (x.id === p.id ? { ...x, ask: !x.ask } : x)))}
                  className={cn('inline-flex h-11 shrink-0 items-center gap-1.5 rounded-lg px-2.5 text-[12.5px] font-medium transition-colors sm:h-8', p.ask ? 'bg-[color-mix(in_oklab,var(--warning)_18%,var(--card))] text-warning-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground')}
                >
                  <Icon name="hourglass" size={14} />
                  {t.askFirst}
                </button>
                <span className="flex shrink-0">
                  <button type="button" id={`${uid}-${p.id}-up`} aria-label={fill(t.moveUp, { name: p.title })} disabled={i === 0 || started} onClick={() => move(i, -1)} className="grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 sm:size-8">
                    <Icon name="arrow-up" size={15} />
                  </button>
                  <button type="button" id={`${uid}-${p.id}-down`} aria-label={fill(t.moveDown, { name: p.title })} disabled={i === plan.length - 1 || started} onClick={() => move(i, 1)} className="grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 sm:size-8">
                    <Icon name="arrow-down" size={15} />
                  </button>
                  <button type="button" aria-label={fill(t.remove, { name: p.title })} disabled={started} onClick={() => setPlan((c) => c.filter((x) => x.id !== p.id))} className="grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-destructive disabled:opacity-30 sm:size-8">
                    <Icon name="trash" size={15} />
                  </button>
                </span>
                </span>
              </li>
            ))}
          </ol>
          {!started && (
            <form
              className="flex gap-2 px-3 pb-4 sm:px-4"
              onSubmit={(e) => {
                e.preventDefault();
                if (!draft.trim()) return;
                setPlan((c) => [...c, { id: `n${++seq.current}`, title: draft.trim() }]);
                setDraft('');
              }}
            >
              <label className="flex h-11 min-w-0 flex-1 items-center gap-2 rounded-xl border border-dashed border-border-strong px-3 focus-within:border-primary focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-10">
                <Icon name="plus" size={16} className="shrink-0 text-muted-foreground" />
                <span className="sr-only">{t.addStep}</span>
                <input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder={t.addStep} className="w-0 min-w-0 flex-1 bg-transparent text-[14px] text-foreground outline-none placeholder:text-subtle-foreground focus-visible:outline-none" />
              </label>
              <button type="submit" disabled={!draft.trim()} className="inline-flex h-11 shrink-0 items-center rounded-xl border border-border bg-card px-4 text-[13px] font-medium text-foreground hover:bg-accent disabled:opacity-40 sm:h-10">
                {t.add}
              </button>
            </form>
          )}
          {permissions.length > 0 && (
            <div className="border-t border-border px-4 py-4 sm:px-5">
              <p className="mb-2 text-[12.5px] font-medium text-foreground">{t.rules}</p>
              <div className="divide-y divide-border">
                {permissions.map((r) => (
                  <Switch key={r.id} size="sm" label={<span className="text-[13.5px] font-medium">{r.label}</span>} description={<span className="text-[12.5px]">{r.description}</span>} defaultChecked={r.on} disabled={started} className="py-2.5" />
                ))}
              </div>
            </div>
          )}
          <div className="flex flex-col gap-3 border-t border-border bg-surface/60 px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between sm:px-5">
            <p className="text-[13px] text-muted-foreground" aria-live="polite">
              {started ? (
                <span className="arn-up inline-flex items-center gap-1.5 text-success-foreground">
                  <Icon name="check-circle" size={15} />
                  {t.starting}
                </span>
              ) : (
                t.stopAnytime
              )}
            </p>
            <button
              type="button"
              onClick={() => setStarted((s) => !s)}
              disabled={!plan.length}
              className={cn(
                'inline-flex h-11 items-center justify-center gap-1.5 rounded-lg px-4 text-[13.5px] font-medium sm:h-9',
                started ? 'border border-border bg-card text-foreground hover:bg-accent' : 'bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]',
              )}
            >
              <Icon name={started ? 'edit' : 'play'} size={15} />
              {started ? t.edit : t.start}
            </button>
          </div>
        </Panel>
      </Reveal>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* run-history                                                         */
/* ------------------------------------------------------------------ */

const RUN: Record<AgentRunRecord['status'], AgentStepStatus> = { success: 'done', failed: 'failed', stopped: 'skipped' };

function Trend({ values }: { values: number[] }) {
  if (values.length < 2) return null;
  const w = 120;
  const h = 32;
  const pts = values.map((v, i) => [round((i / (values.length - 1)) * w), round(h - 3 - v * (h - 6))]);
  const d = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x} ${y}`).join(' ');
  return (
    <svg aria-hidden viewBox={`0 0 ${w} ${h}`} className="h-8 w-[7.5rem] overflow-visible">
      <path d={`${d} L${w} ${h} L0 ${h} Z`} style={{ fill: 'color-mix(in oklab, var(--primary) 14%, transparent)' }} />
      <path d={d} fill="none" stroke="var(--primary)" strokeWidth="1.6" strokeLinejoin="round" pathLength={1} className="[stroke-dasharray:1] motion-safe:animate-[arn-draw_1.2s_var(--ease-kit)_both]" />
      <circle cx={pts[pts.length - 1][0]} cy={pts[pts.length - 1][1]} r="2.6" fill="var(--primary)" />
    </svg>
  );
}

function RunHistory({ t, title, agent, runs = [], trend = [], stats, className }: P) {
  const locale = useLocale();
  const [filter, setFilter] = useState<'all' | AgentRunRecord['status']>('all');
  const [openId, setOpenId] = useState<string | null>(null);
  const shown = runs.filter((r) => filter === 'all' || r.status === filter);
  const list = useFlip<HTMLUListElement>(filter);
  const ok = runs.filter((r) => r.status === 'success').length;
  const rate = runs.length ? Math.round((ok / runs.length) * 100) : 0;
  const filters: { id: typeof filter; label: string; n: number }[] = [
    { id: 'all', label: t.all, n: runs.length },
    { id: 'success', label: t.success, n: ok },
    { id: 'failed', label: t.failed, n: runs.filter((r) => r.status === 'failed').length },
    { id: 'stopped', label: t.halted, n: runs.filter((r) => r.status === 'stopped').length },
  ];
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const [row, fade] = useScrollFade<HTMLDivElement>();

  return (
    <Stage wide className={className}>
      <Head
        title={title}
        eyebrow={
          <>
            <Icon name="bot" size={15} />
            {agent?.name}
          </>
        }
      />
      <Reveal effect="scale" delay={80}>
        <Panel>
          <div className="grid grid-cols-2 gap-px border-b border-border bg-border lg:grid-cols-4">
            <div className="bg-card p-4 sm:p-5">
              <p className="text-[12px] text-muted-foreground">{t.rate}</p>
              <div className="mt-1 flex items-end justify-between gap-2">
                <p className="text-[21px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground sm:text-[26px]">{locale === 'de' ? `${rate} %` : `${rate}%`}</p>
                <span className="max-sm:hidden" title={t.weeks}>
                  <Trend values={trend} />
                </span>
              </div>
            </div>
            <div className="bg-card p-4 sm:p-5">
              <p className="text-[12px] text-muted-foreground">{t.history}</p>
              <p className="mt-1 text-[21px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground sm:text-[26px]">{runs.length}</p>
              <p className="mt-1.5 text-[12px] text-subtle-foreground">{stats?.period}</p>
            </div>
            <div className="bg-card p-4 sm:p-5">
              <p className="text-[12px] text-muted-foreground">{t.avgDuration}</p>
              <p className="mt-1 text-[21px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground sm:text-[26px]">{stats?.duration}</p>
            </div>
            <div className="bg-card p-4 sm:p-5">
              <p className="text-[12px] text-muted-foreground">{t.spend}</p>
              <p className="mt-1 text-[21px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground sm:text-[26px]">{stats?.spend}</p>
            </div>
          </div>
          <div className="border-b border-border p-2 sm:px-4">
            <div ref={row} role="tablist" aria-label={t.history} className={cn('kit-no-scrollbar flex gap-1 overflow-x-auto', fade)}>
              {filters.map((f, i) => (
                <button
                  key={f.id}
                  ref={(el) => {
                    tabs.current[i] = el;
                  }}
                  type="button"
                  role="tab"
                  aria-selected={filter === f.id}
                  tabIndex={filter === f.id ? 0 : -1}
                  onClick={() => setFilter(f.id)}
                  onKeyDown={(e) => {
                    const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
                    if (!d) return;
                    e.preventDefault();
                    const n = (i + d + filters.length) % filters.length;
                    setFilter(filters[n].id);
                    tabs.current[n]?.focus();
                  }}
                  className={cn('relative inline-flex h-11 shrink-0 items-center gap-1.5 rounded-lg px-3 text-[13px] font-medium transition-colors focus-visible:-outline-offset-2 sm:h-9', filter === f.id ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground')}
                >
                  {f.label}
                  <span className="tabular-nums text-subtle-foreground">{f.n}</span>
                </button>
              ))}
            </div>
          </div>
          <ul ref={list} className="divide-y divide-border" role="tabpanel">
            {shown.length === 0 && <li className="px-5 py-10 text-center text-[13.5px] text-muted-foreground">{t.noRuns}</li>}
            {shown.map((r) => {
              const opened = openId === r.id;
              return (
                <li key={r.id} data-flip={r.id} className="bg-card">
                  <button type="button" aria-expanded={opened} onClick={() => setOpenId(opened ? null : r.id)} className="grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 px-4 py-3 text-left transition-colors focus-visible:-outline-offset-2 hover:bg-accent/50 sm:px-5 lg:grid-cols-[auto_minmax(0,1fr)_10rem_6rem_6rem_auto]">
                    <StatusDot status={RUN[r.status]} size={26} />
                    <span className="min-w-0">
                      <span className="block truncate text-[14px] font-medium text-foreground">{r.title}</span>
                      <span className="block truncate text-[12px] text-muted-foreground">
                        {r.started} · {r.trigger}
                      </span>
                    </span>
                    <span className="hidden truncate text-[12.5px] text-muted-foreground lg:block">{r.steps === 1 ? t.stepOne : fill(t.stepsCount, { n: r.steps })}</span>
                    <span className="hidden text-right text-[12.5px] tabular-nums text-muted-foreground lg:block">{r.duration}</span>
                    <span className="hidden text-right text-[12.5px] tabular-nums text-foreground lg:block">{r.cost}</span>
                    <span className="flex items-center gap-2">
                      <span className="text-right text-[12px] tabular-nums text-muted-foreground lg:hidden">{r.duration}</span>
                      <Icon name="chevron-down" size={16} className={cn('text-muted-foreground transition-transform duration-300', opened && 'rotate-180')} />
                      <span className="sr-only">{opened ? t.close : t.open}</span>
                    </span>
                  </button>
                  {opened && (
                    <div className="arn-up px-4 pb-4 sm:px-5 lg:pl-[3.75rem]">
                      <p className="text-pretty rounded-lg border border-border bg-background/60 px-3.5 py-3 text-[13.5px] leading-relaxed text-muted-foreground">{r.summary}</p>
                      <p className="mt-2 flex gap-4 text-[12px] tabular-nums text-subtle-foreground lg:hidden">
                        <span>{r.steps === 1 ? t.stepOne : fill(t.stepsCount, { n: r.steps })}</span>
                        <span>{r.cost}</span>
                      </p>
                    </div>
                  )}
                </li>
              );
            })}
          </ul>
        </Panel>
      </Reveal>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */

/**
 * Screens for AI agents that act on their own: a live run with stop, every
 * tool call with input and output, a queue of actions that wait for a person,
 * a plan to check before the start and the history of past runs.
 */
export function AgentRun({ variant = 'live-run', labels, ...props }: AgentRunProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  const body = (() => {
    switch (variant) {
      case 'tool-calls':
        return <ToolCalls {...p} />;
      case 'approval-queue':
        return <ApprovalQueue {...p} />;
      case 'plan-review':
        return <PlanReview {...p} />;
      case 'run-history':
        return <RunHistory {...p} />;
      default:
        return <LiveRun {...p} />;
    }
  })();
  return (
    <>
      <Style />
      {body}
    </>
  );
}

Frequent questions

How do I install Agent run?

With aiw add @ai-website/agent-run. The command puts components/ui/agent-run.tsx into your project, the code is yours from then on. It brings along icon, reveal, motion and switch. It needs no further npm packages.

Which variants does Agent run have?

5 variants, Live run, Tool calls, Approval queue, Plan review and Run history. You pick one with the variant prop, for example variant="tool-calls".

Can I translate the texts of Agent run?

Yes. Agent run carries 67 texts of its own in English and German, such as “Goal”, “Running for” and “Spend”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Agent run?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Agent run with aiw_components and install it with aiw_add @ai-website/agent-run. Name the variant in your prompt, for example variant="live-run", and the assistant writes your content straight into the props.

Can I adjust Agent run without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching AgentRun code to copy.

Often searched

Jump to

Copied to the clipboard