Skip to content
UI Kit
Accent color

AI agent dashboard

Many AI agents on one screen. An overview with state, live tasks, approvals, spend and retry, a sortable fleet table, the detail of single runs with steps, error and approval, and a phone screen of its own. One run in depth is agent-run.

4 variantsOne of 5 components in AI and chat

Many agents, one calm overview

When several AI agents work at the same time, someone needs to see which ones run, which ones wait for a decision and what they cost. This dashboard gives that overview and puts the right next step on every agent, from retry to approval.

Overview shows four numbers on top and one card per agent with its state, the live task, the spend against its budget and the next step, retry, review, pause or resume. Fleet table filters by status with counts and sorts by name, status, success or spend, a table from tablets on and cards with the same facts on phones. Run detail lists recent runs of all agents with the chosen run beside it, with time, cost, tokens and steps, an error says what to do and retries from the failed step, and an approval waits for a decision. Phone app is a screen of its own with spend on top and three tabs at the thumb for agents, approvals and the activity of the day.

Show cost next to every agent, not only in a report at the end of the month. Retry from the failed step saves the work that already went well. For one run in full depth the kit has Agent run, link to it from the run detail.

Variants

OverviewNew in 1.2

Four numbers on top, one card per agent with its state, the live task, the spend against its budget and the right next step: retry, review, pause or resume.

Fleet tableNew in 1.2

Filter by status with counts, sort by name, status, success or spend. A table from tablets on, cards with the same facts on phones, the total below.

Run detailNew in 1.2

Recent runs of all agents on the left, the chosen run on the right with time, cost, tokens and steps. An error says what to do and retries from the failed step, an approval waits for a decision. On phones the list opens the detail with a way back.

Phone appNew in 1.2

Its own phone screen with spend on top and three tabs at the thumb: agents with retry, approvals to decide on the go and the activity of the day. Full width on phones, in a device frame from tablets on.

Details

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

Category
AI and chat
Area
App
Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/agent-dashboard.tsx
Brings along
utilskiticonrevealmotionchart
npm packages
None
Languages
English and German, 64 texts of its own
Studio
Adjust in code
In collections
AI product

Props

The AgentDashboardProps interface from components/ui/agent-dashboard.tsx, with the comments from the code.

  • variantoptional
    AgentDashboardVariant

    overviewfleet-tablerun-detailmobile

  • titleoptional
    string
  • descriptionoptional
    string
  • agentsoptional
    DashboardAgent[]
  • runsoptional
    DashboardRun[]
  • eventsoptional
    DashboardEvent[]

    mobile: the activity feed

  • currencyoptional
    string

    ISO currency code for spend, e.g. EUR. Without it spend is shown with `unit`.

  • unitoptional
    string

    e.g. "credits", used when no currency is set

  • budgetoptional
    number

    Budget of the whole workspace this period

  • periodoptional
    string

    e.g. "October"

  • onActionoptional
    (action: 'pause' | 'resume' | 'retry' | 'approve' | 'decline' | 'skip' | 'open', id: string) => void

    Pause, resume, retry, approve, decline, skip, with the id of the agent or run

Built-in texts

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

Show all 64 texts
runningEnglish RunningGerman Läuft
idleEnglish ReadyGerman Bereit
waitingEnglish Needs youGerman Wartet auf dich
failedEnglish ErrorGerman Fehler
pausedEnglish PausedGerman Pausiert
successEnglish DoneGerman Fertig
activeEnglish Active agentsGerman Aktive Agenten
activeOfEnglish {n} of {total}German {n} von {total}
runsTodayEnglish Runs todayGerman Läufe heute
needsYouEnglish Waiting for youGerman Wartet auf dich
approvalsNEnglish {n} approvalsGerman {n} Freigaben
approval1English 1 approvalGerman 1 Freigabe
spendEnglish SpendGerman Verbrauch
spendOfEnglish {v} of {b}German {v} von {b}
budgetLeftEnglish {v} leftGerman noch {v}
pauseEnglish PauseGerman Pausieren
resumeEnglish ResumeGerman Fortsetzen
pauseNameEnglish Pause {name}German {name} pausieren
resumeNameEnglish Resume {name}German {name} fortsetzen
retryEnglish Try againGerman Neu versuchen
retryNameEnglish Try {name} againGerman {name} neu versuchen
reviewEnglish ReviewGerman Prüfen
reviewNameEnglish Review the approval of {name}German Freigabe von {name} prüfen
lastRunEnglish Last run {v}German Letzter Lauf {v}
justNowEnglish just nowGerman gerade eben
pausedNoteEnglish Paused. Scheduled runs wait until you resume.German Pausiert. Geplante Läufe warten, bis du fortsetzt.
trendEnglish Spend over the last {n} daysGerman Verbrauch der letzten {n} Tage
filterEnglish Filter by statusGerman Nach Status filtern
allEnglish AllGerman Alle
agentEnglish AgentGerman Agent
statusEnglish StatusGerman Status
taskEnglish TaskGerman Aufgabe
rateEnglish SuccessGerman Erfolg
actionsEnglish ActionsGerman Aktionen
sortByEnglish Sort by {name}German Nach {name} sortieren
totalEnglish TotalGerman Summe
noneEnglish No agent with this status.German Kein Agent mit diesem Status.
runsEnglish Recent runsGerman Letzte Läufe
runEnglish RunGerman Lauf
startedEnglish StartedGerman Gestartet
durationEnglish DurationGerman Dauer
costEnglish CostGerman Kosten
tokensEnglish TokensGerman Tokens
stepsEnglish StepsGerman Schritte
backEnglish All runsGerman Alle Läufe
openRunEnglish Open run {name}German Lauf {name} öffnen
retryFromEnglish Try again from this stepGerman Ab diesem Schritt neu versuchen
skipEnglish Skip stepGerman Schritt überspringen
skippedEnglish Skipped. The run goes on without this step.German Übersprungen. Der Lauf geht ohne diesen Schritt weiter.
retryingEnglish Running again from the failed step.German Läuft ab dem fehlerhaften Schritt neu.
approveEnglish ApproveGerman Freigeben
declineEnglish DeclineGerman Ablehnen
approvedEnglish Approved. The agent goes on.German Freigegeben. Der Agent macht weiter.
declinedEnglish Declined. The agent stops here and keeps what it did so far.German Abgelehnt. Der Agent hält hier an und behält, was er bis jetzt getan hat.
asksEnglish The agent asks toGerman Der Agent möchte
whyEnglish WhyGerman Warum
errorTitleEnglish What went wrongGerman Was schiefging
agentsEnglish AgentsGerman Agenten
approvalsEnglish ApprovalsGerman Freigaben
activityEnglish ActivityGerman Verlauf
tabsEnglish SectionsGerman Bereiche
nothingEnglish Nothing waits for you.German Nichts wartet auf dich.
honestEnglish Agents act on their own within the rules you set. Anything with risk waits for a person.German Agenten handeln selbst, innerhalb der Regeln, die du festlegst. Alles mit Risiko wartet auf einen Menschen.
estimateEnglish Spend of running agents is an estimate until the run ends.German Der Verbrauch laufender Agenten ist geschätzt, bis der Lauf endet.

Accessibility

Read from the source of the component.

  • Agent dashboard handles the keys Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
  • Sets aria-controls, aria-current, aria-label, aria-labelledby, aria-live, aria-pressed, aria-selected, aria-sort, aria-valuemax, aria-valuemin and aria-valuenow, so screen readers announce purpose and state.
  • Uses the roles group, img, label, region, status, tab, tablist and tabpanel.
  • Announces changes through a live region without moving focus.
  • 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. 2 places in the code switch motion off then.

Source

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

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

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

export type AgentDashboardVariant = 'overview' | 'fleet-table' | 'run-detail' | 'mobile';

export type AgentState = 'running' | 'idle' | 'waiting' | 'failed' | 'paused';

export interface DashboardAgent {
  id: string;
  name: string;
  /** One line on what it does, e.g. "Answers support mail" */
  role?: string;
  icon?: IconName;
  state: AgentState;
  /** The task it works on or waits with */
  task?: string;
  /** running: 0 to 100 */
  progress?: number;
  runsToday?: number;
  /** Share of successful runs in percent */
  successRate?: number;
  /** Spend this period */
  spend: number;
  /** Budget this period */
  budget?: number;
  /** Spend per day, oldest first, for the small chart */
  trend?: number[];
  /** e.g. "4 min ago" */
  lastRun?: string;
  /** failed: what went wrong, in plain words */
  error?: string;
  /** waiting: approvals that wait for a person */
  approvals?: number;
}

export type RunState = 'success' | 'failed' | 'running' | 'waiting';

export interface DashboardRunStep {
  id: string;
  title: string;
  state: 'done' | 'running' | 'failed' | 'waiting' | 'queued' | 'skipped';
  detail?: string;
  duration?: string;
}

export interface DashboardRun {
  id: string;
  /** Agent id */
  agent: string;
  title: string;
  state: RunState;
  /** e.g. "10:42" */
  started: string;
  duration?: string;
  cost: number;
  tokens?: number;
  steps?: DashboardRunStep[];
  error?: string;
  /** waiting: what the agent asks to do */
  approval?: { action: string; reason: string };
}

export interface DashboardEvent {
  id: string;
  /** Agent id */
  agent: string;
  text: string;
  time: string;
  tone?: 'default' | 'success' | 'warning' | 'error';
}

export interface AgentDashboardProps {
  variant?: AgentDashboardVariant;
  title?: string;
  description?: string;
  agents?: DashboardAgent[];
  runs?: DashboardRun[];
  /** mobile: the activity feed */
  events?: DashboardEvent[];
  /** ISO currency code for spend, e.g. EUR. Without it spend is shown with `unit`. */
  currency?: string;
  /** e.g. "credits", used when no currency is set */
  unit?: string;
  /** Budget of the whole workspace this period */
  budget?: number;
  /** e.g. "October" */
  period?: string;
  /** Pause, resume, retry, approve, decline, skip, with the id of the agent or run */
  onAction?: (action: 'pause' | 'resume' | 'retry' | 'approve' | 'decline' | 'skip' | 'open', id: string) => void;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    running: 'Running',
    idle: 'Ready',
    waiting: 'Needs you',
    failed: 'Error',
    paused: 'Paused',
    success: 'Done',
    active: 'Active agents',
    activeOf: '{n} of {total}',
    runsToday: 'Runs today',
    needsYou: 'Waiting for you',
    approvalsN: '{n} approvals',
    approval1: '1 approval',
    spend: 'Spend',
    spendOf: '{v} of {b}',
    budgetLeft: '{v} left',
    pause: 'Pause',
    resume: 'Resume',
    pauseName: 'Pause {name}',
    resumeName: 'Resume {name}',
    retry: 'Try again',
    retryName: 'Try {name} again',
    review: 'Review',
    reviewName: 'Review the approval of {name}',
    lastRun: 'Last run {v}',
    justNow: 'just now',
    pausedNote: 'Paused. Scheduled runs wait until you resume.',
    trend: 'Spend over the last {n} days',
    filter: 'Filter by status',
    all: 'All',
    agent: 'Agent',
    status: 'Status',
    task: 'Task',
    rate: 'Success',
    actions: 'Actions',
    sortBy: 'Sort by {name}',
    total: 'Total',
    none: 'No agent with this status.',
    runs: 'Recent runs',
    run: 'Run',
    started: 'Started',
    duration: 'Duration',
    cost: 'Cost',
    tokens: 'Tokens',
    steps: 'Steps',
    back: 'All runs',
    openRun: 'Open run {name}',
    retryFrom: 'Try again from this step',
    skip: 'Skip step',
    skipped: 'Skipped. The run goes on without this step.',
    retrying: 'Running again from the failed step.',
    approve: 'Approve',
    decline: 'Decline',
    approved: 'Approved. The agent goes on.',
    declined: 'Declined. The agent stops here and keeps what it did so far.',
    asks: 'The agent asks to',
    why: 'Why',
    errorTitle: 'What went wrong',
    agents: 'Agents',
    approvals: 'Approvals',
    activity: 'Activity',
    tabs: 'Sections',
    nothing: 'Nothing waits for you.',
    honest: 'Agents act on their own within the rules you set. Anything with risk waits for a person.',
    estimate: 'Spend of running agents is an estimate until the run ends.',
  },
  de: {
    running: 'Läuft',
    idle: 'Bereit',
    waiting: 'Wartet auf dich',
    failed: 'Fehler',
    paused: 'Pausiert',
    success: 'Fertig',
    active: 'Aktive Agenten',
    activeOf: '{n} von {total}',
    runsToday: 'Läufe heute',
    needsYou: 'Wartet auf dich',
    approvalsN: '{n} Freigaben',
    approval1: '1 Freigabe',
    spend: 'Verbrauch',
    spendOf: '{v} von {b}',
    budgetLeft: 'noch {v}',
    pause: 'Pausieren',
    resume: 'Fortsetzen',
    pauseName: '{name} pausieren',
    resumeName: '{name} fortsetzen',
    retry: 'Neu versuchen',
    retryName: '{name} neu versuchen',
    review: 'Prüfen',
    reviewName: 'Freigabe von {name} prüfen',
    lastRun: 'Letzter Lauf {v}',
    justNow: 'gerade eben',
    pausedNote: 'Pausiert. Geplante Läufe warten, bis du fortsetzt.',
    trend: 'Verbrauch der letzten {n} Tage',
    filter: 'Nach Status filtern',
    all: 'Alle',
    agent: 'Agent',
    status: 'Status',
    task: 'Aufgabe',
    rate: 'Erfolg',
    actions: 'Aktionen',
    sortBy: 'Nach {name} sortieren',
    total: 'Summe',
    none: 'Kein Agent mit diesem Status.',
    runs: 'Letzte Läufe',
    run: 'Lauf',
    started: 'Gestartet',
    duration: 'Dauer',
    cost: 'Kosten',
    tokens: 'Tokens',
    steps: 'Schritte',
    back: 'Alle Läufe',
    openRun: 'Lauf {name} öffnen',
    retryFrom: 'Ab diesem Schritt neu versuchen',
    skip: 'Schritt überspringen',
    skipped: 'Übersprungen. Der Lauf geht ohne diesen Schritt weiter.',
    retrying: 'Läuft ab dem fehlerhaften Schritt neu.',
    approve: 'Freigeben',
    decline: 'Ablehnen',
    approved: 'Freigegeben. Der Agent macht weiter.',
    declined: 'Abgelehnt. Der Agent hält hier an und behält, was er bis jetzt getan hat.',
    asks: 'Der Agent möchte',
    why: 'Warum',
    errorTitle: 'Was schiefging',
    agents: 'Agenten',
    approvals: 'Freigaben',
    activity: 'Verlauf',
    tabs: 'Bereiche',
    nothing: 'Nichts wartet auf dich.',
    honest: 'Agenten handeln selbst, innerhalb der Regeln, die du festlegst. Alles mit Risiko wartet auf einen Menschen.',
    estimate: 'Der Verbrauch laufender Agenten ist geschätzt, bis der Lauf endet.',
  },
};

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

/** 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 agd-up{from{opacity:0;transform:translateY(6px)}}@keyframes agd-ping{0%{transform:scale(1);opacity:.6}100%{transform:scale(2.4);opacity:0}}' +
  '@media (prefers-reduced-motion:no-preference){.agd-up{animation:agd-up .4s var(--ease-kit) var(--agd-d,0ms) both}.agd-ping{animation:agd-ping 1.6s ease-out infinite}}';

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

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

const card = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const btn = 'inline-flex h-11 shrink-0 items-center whitespace-nowrap justify-center gap-1.5 rounded-lg px-3 text-[13px] font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 sm:h-9';
const primaryBtn = cn(btn, '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))]');
const quietBtn = cn(btn, 'border border-border bg-card text-foreground hover:bg-accent');

const TONE: Record<AgentState | RunState, { dot: string; text: string; bg: string }> = {
  running: { dot: 'bg-primary', text: 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]', bg: 'bg-[color-mix(in_oklab,var(--primary)_13%,transparent)]' },
  idle: { dot: 'bg-success', text: 'text-success-foreground', bg: 'bg-[color-mix(in_oklab,var(--success)_13%,transparent)]' },
  success: { dot: 'bg-success', text: 'text-success-foreground', bg: 'bg-[color-mix(in_oklab,var(--success)_13%,transparent)]' },
  waiting: { dot: 'bg-warning', text: 'text-warning-foreground', bg: 'bg-[color-mix(in_oklab,var(--warning)_16%,transparent)]' },
  failed: { dot: 'bg-destructive', text: 'text-destructive', bg: 'bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)]' },
  paused: { dot: 'bg-muted-foreground', text: 'text-muted-foreground', bg: 'bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)]' },
};

function StatePill({ state, t, className }: { state: AgentState | RunState; t: Texts; className?: string }) {
  const tone = TONE[state];
  return (
    <span className={cn('inline-flex h-6 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md px-2 text-[11.5px] font-medium', tone.bg, tone.text, className)}>
      <span className="relative grid size-1.5 place-items-center">
        {state === 'running' && <span aria-hidden className={cn('agd-ping absolute inset-0 rounded-full', tone.dot)} />}
        <span className={cn('size-1.5 rounded-full', tone.dot)} />
      </span>
      {t[state]}
    </span>
  );
}

function useMoney(currency?: string, unit?: string) {
  const locale = useLocale();
  const intl = locale === 'de' ? 'de-DE' : 'en-US';
  if (currency) {
    const nf = new Intl.NumberFormat(intl, { style: 'currency', currency, maximumFractionDigits: 2, minimumFractionDigits: 2 });
    return (n: number) => nf.format(n);
  }
  const nf = new Intl.NumberFormat(intl, { maximumFractionDigits: 1 });
  return (n: number) => (unit ? `${nf.format(n)} ${unit}` : nf.format(n));
}

function useNumber() {
  const nf = new Intl.NumberFormat(useLocale() === 'de' ? 'de-DE' : 'en-US');
  return (n: number) => nf.format(n);
}

/** A small spend chart */
function Spark({ values, label, className }: { values: number[]; label: string; className?: string }) {
  const w = 96;
  const h = 28;
  if (values.length < 2) return null;
  const max = Math.max(...values, 1);
  const pts = values.map((v, i) => [round((i / (values.length - 1)) * (w - 4) + 2), round(h - 3 - (v / max) * (h - 6))] as [number, number]);
  const path = linePath(pts);
  return (
    <svg viewBox={`0 0 ${w} ${h}`} width={w} height={h} role="img" aria-label={label} className={cn('shrink-0 overflow-visible', className)}>
      <path d={`${path} L${pts[pts.length - 1][0]} ${h} L${pts[0][0]} ${h}Z`} fill="color-mix(in oklab, var(--primary) 14%, transparent)" />
      <path d={path} fill="none" stroke="var(--primary)" strokeWidth="1.6" strokeLinecap="round" />
      <circle cx={pts[pts.length - 1][0]} cy={pts[pts.length - 1][1]} r="2.4" fill="var(--primary)" />
    </svg>
  );
}

function Bar({ value, tone = 'primary', className, label }: { value: number; tone?: 'primary' | 'warning' | 'destructive' | 'success'; className?: string; label?: string }) {
  const color = tone === 'warning' ? 'bg-warning' : tone === 'destructive' ? 'bg-destructive' : tone === 'success' ? 'bg-success' : 'bg-primary';
  return (
    <span role={label ? 'meter' : undefined} aria-label={label} aria-valuemin={label ? 0 : undefined} aria-valuemax={label ? 100 : undefined} aria-valuenow={label ? Math.round(value) : undefined} className={cn('block h-1.5 overflow-hidden rounded-full bg-track', className)}>
      <span className={cn('block h-full origin-left rounded-full transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', color)} style={{ transform: `scaleX(${round(Math.max(0, Math.min(100, value)) / 100, 3)})` }} />
    </span>
  );
}

const budgetTone = (share: number) => (share >= 100 ? 'destructive' : share >= 85 ? 'warning' : 'primary');

function Stage({ title, description, children, className, aside }: { title?: string; description?: string; children: React.ReactNode; className?: string; aside?: React.ReactNode }) {
  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="mx-auto w-full max-w-[72rem]">
          {(title || description || aside) && (
            <Reveal className="mb-6 flex flex-wrap items-end justify-between gap-4 sm:mb-8">
              <div className="min-w-0 max-w-[40rem]">
                {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 text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
              </div>
              {aside}
            </Reveal>
          )}
          {children}
        </div>
      </div>
    </section>
  );
}

function Honest({ t, children }: { t: Texts; children?: React.ReactNode }) {
  return (
    <p className="mt-5 flex items-start gap-2 px-1 text-[12.5px] leading-snug text-muted-foreground">
      <Icon name="shield" size={14} className="mt-px shrink-0" />
      <span>{children ?? t.honest}</span>
    </p>
  );
}

/**
 * The agents as live state: pause, resume and retry change them, a retried
 * agent runs to the end, running agents creep on while in view.
 */
function useFleet(initial: DashboardAgent[], t: Texts, inView: boolean) {
  const [agents, setAgents] = useSynced(initial);
  const [retried, setRetried] = useState<string[]>([]);
  useEffect(() => {
    if (!inView || prefersReducedMotion()) return;
    const id = window.setInterval(() => {
      setAgents((cur) =>
        cur.map((a) => {
          if (a.state !== 'running') return a;
          const fast = retried.includes(a.id);
          const next = Math.min(fast ? 100 : 96, (a.progress ?? 0) + (fast ? 4 : 0.08));
          if (fast && next >= 100) return { ...a, state: 'idle' as const, progress: undefined, error: undefined, lastRun: t.justNow, runsToday: (a.runsToday ?? 0) + 1 };
          return { ...a, progress: round(next, 1) };
        }),
      );
    }, 160);
    return () => window.clearInterval(id);
  }, [inView, retried, setAgents, t.justNow]);
  const act = (id: string, action: 'pause' | 'resume' | 'retry') =>
    setAgents((cur) =>
      cur.map((a) => {
        if (a.id !== id) return a;
        if (action === 'pause') return { ...a, state: 'paused' as const };
        if (action === 'resume') return { ...a, state: a.task && a.progress !== undefined ? ('running' as const) : ('idle' as const) };
        setRetried((r) => [...r, id]);
        if (prefersReducedMotion()) return { ...a, state: 'idle' as const, error: undefined, lastRun: t.justNow, runsToday: (a.runsToday ?? 0) + 1 };
        return { ...a, state: 'running' as const, progress: 0, error: undefined };
      }),
    );
  return [agents, act] as const;
}

/* ------------------------------------------------------------------ */
/* overview                                                            */
/* ------------------------------------------------------------------ */

function Overview({ t, title, description, agents: initial = [], currency, unit, budget, period, onAction, className }: P) {
  const locale = useLocale();
  const money = useMoney(currency, unit);
  const num = useNumber();
  const [ref, inView] = useInView<HTMLDivElement>();
  const [agents, act] = useFleet(initial, t, inView);
  const running = agents.filter((a) => a.state === 'running').length;
  const spent = agents.reduce((s, a) => s + a.spend, 0);
  const waiting = agents.reduce((s, a) => s + (a.state === 'waiting' ? a.approvals ?? 1 : 0), 0);
  const runsToday = agents.reduce((s, a) => s + (a.runsToday ?? 0), 0);
  const share = budget ? (spent / budget) * 100 : 0;

  const kpis = [
    { label: t.active, value: fill(t.activeOf, { n: running, total: agents.length }), icon: 'bot' as IconName },
    { label: t.runsToday, value: num(runsToday), icon: 'activity' as IconName },
    { label: t.needsYou, value: waiting === 1 ? t.approval1 : fill(t.approvalsN, { n: waiting }), icon: 'bell' as IconName, warn: waiting > 0 },
  ];

  return (
    <Stage title={title} description={description} className={className}>
      <Style />
      <div ref={ref}>
        <Reveal className={cn(card, 'grid grid-cols-2 gap-px overflow-hidden bg-border p-0 lg:grid-cols-4')}>
          {kpis.map((k) => (
            <div key={k.label} className="bg-card p-4 sm:p-5">
              <p className="flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
                <Icon name={k.icon} size={14} className={k.warn ? 'text-warning-foreground' : undefined} />
                {k.label}
              </p>
              <p className={cn('mt-1.5 text-[20px] font-semibold tabular-nums tracking-[-0.02em] sm:text-[22px]', k.warn ? 'text-warning-foreground' : 'text-foreground')}>{k.value}</p>
            </div>
          ))}
          <div className="bg-card p-4 sm:p-5">
            <p className="flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
              <Icon name="wallet" size={14} />
              {period ? `${t.spend} · ${period}` : t.spend}
            </p>
            <p className="mt-1.5 text-[20px] font-semibold tabular-nums tracking-[-0.02em] text-foreground sm:text-[22px]">{money(spent)}</p>
            {budget ? (
              <>
                <Bar value={share} tone={budgetTone(share)} className="mt-2" label={fill(t.spendOf, { v: money(spent), b: money(budget) })} />
                <p className="mt-1.5 text-[12px] tabular-nums text-muted-foreground">{fill(t.spendOf, { v: formatPercent(Math.round(share), locale), b: money(budget) })}</p>
              </>
            ) : null}
          </div>
        </Reveal>

        <Stagger as="ul" step={70} className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
          {agents.map((a) => {
            const share = a.budget ? (a.spend / a.budget) * 100 : null;
            return (
              <li key={a.id} className={cn(card, 'flex flex-col p-4 transition-shadow duration-300 hover:shadow-[var(--highlight),var(--shadow-md)]')}>
                <div className="flex items-start gap-3">
                  <span aria-hidden className={cn('grid size-10 shrink-0 place-items-center rounded-xl border border-border', a.state === 'paused' ? 'text-muted-foreground' : 'text-foreground')}>
                    <Icon name={a.icon ?? 'bot'} size={18} />
                  </span>
                  <div className="min-w-0 flex-1">
                    <h3 className="truncate text-[14.5px] font-semibold text-foreground">{a.name}</h3>
                    {a.role && <p className="text-[12.5px] leading-snug text-muted-foreground sm:truncate">{a.role}</p>}
                  </div>
                  <StatePill state={a.state} t={t} />
                </div>

                <div className="mt-4 flex-1 md:min-h-[4.5rem]" aria-live="polite">
                  {a.state === 'running' && (
                    <>
                      <p className="line-clamp-2 text-[13px] leading-snug text-foreground">{a.task}</p>
                      <div className="mt-2.5 flex items-center gap-2.5">
                        <Bar value={a.progress ?? 0} className="flex-1" label={a.task} />
                        <span className="w-10 text-right text-[12px] tabular-nums text-muted-foreground">{formatPercent(Math.round(a.progress ?? 0), locale)}</span>
                      </div>
                    </>
                  )}
                  {a.state === 'failed' && (
                    <div className="rounded-lg bg-[color-mix(in_oklab,var(--destructive)_8%,var(--card))] p-2.5">
                      <p className="flex items-start gap-1.5 text-[12.5px] leading-snug text-destructive">
                        <Icon name="alert-circle" size={14} className="mt-px shrink-0" />
                        <span className="line-clamp-3">{a.error}</span>
                      </p>
                    </div>
                  )}
                  {a.state === 'waiting' && (
                    <div className="rounded-lg bg-[color-mix(in_oklab,var(--warning)_12%,var(--card))] p-2.5">
                      <p className="flex items-start gap-1.5 text-[12.5px] leading-snug text-warning-foreground">
                        <Icon name="hourglass" size={14} className="mt-px shrink-0" />
                        <span className="line-clamp-3">{a.task}</span>
                      </p>
                    </div>
                  )}
                  {a.state === 'idle' && <p className="text-[13px] leading-snug text-muted-foreground">{a.lastRun && fill(t.lastRun, { v: a.lastRun })}</p>}
                  {a.state === 'paused' && <p className="text-[13px] leading-snug text-muted-foreground">{t.pausedNote}</p>}
                </div>

                <div className="mt-4 flex items-end gap-3 border-t border-border pt-3">
                  <div className="min-w-0 flex-1">
                    <p className="text-[11.5px] text-muted-foreground">{t.spend}</p>
                    <p className="truncate text-[13px] font-medium tabular-nums text-foreground">
                      {money(a.spend)}
                      {a.budget ? <span className="font-normal text-muted-foreground"> / {money(a.budget)}</span> : null}
                    </p>
                    {share !== null && <Bar value={share} tone={budgetTone(share)} className="mt-1.5" />}
                  </div>
                  {a.trend && <Spark values={a.trend} label={fill(t.trend, { n: a.trend.length })} className="max-[380px]:hidden" />}
                </div>
                <div className="mt-3 flex gap-2">
                  {a.state === 'failed' ? (
                    <button type="button" aria-label={fill(t.retryName, { name: a.name })} onClick={() => (act(a.id, 'retry'), onAction?.('retry', a.id))} className={cn(primaryBtn, 'flex-1')}>
                      <Icon name="refresh" size={15} />
                      {t.retry}
                    </button>
                  ) : a.state === 'waiting' ? (
                    <button type="button" aria-label={fill(t.reviewName, { name: a.name })} onClick={() => onAction?.('open', a.id)} className={cn(primaryBtn, 'flex-1')}>
                      <Icon name="check-square" size={15} />
                      {t.review}
                    </button>
                  ) : null}
                  {a.state !== 'failed' && (
                    <button
                      type="button"
                      aria-label={fill(a.state === 'paused' ? t.resumeName : t.pauseName, { name: a.name })}
                      onClick={() => {
                        const action = a.state === 'paused' ? 'resume' : 'pause';
                        act(a.id, action);
                        onAction?.(action, a.id);
                      }}
                      className={cn(quietBtn, a.state !== 'waiting' && 'flex-1')}
                    >
                      <Icon name={a.state === 'paused' ? 'play' : 'pause'} size={15} />
                      {a.state === 'paused' ? t.resume : t.pause}
                    </button>
                  )}
                </div>
              </li>
            );
          })}
        </Stagger>
      </div>
      <Honest t={t} />
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* fleet-table                                                         */
/* ------------------------------------------------------------------ */

type SortKey = 'name' | 'state' | 'successRate' | 'spend';
const ORDER: AgentState[] = ['failed', 'waiting', 'running', 'paused', 'idle'];

function FleetTable({ t, title, description, agents: initial = [], currency, unit, onAction, className }: P) {
  const locale = useLocale();
  const money = useMoney(currency, unit);
  const [ref, inView] = useInView<HTMLDivElement>();
  const [agents, act] = useFleet(initial, t, inView);
  const [filter, setFilter] = useState<'all' | AgentState>('all');
  const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: 'state', dir: 1 });
  const filters: ('all' | AgentState)[] = ['all', ...ORDER.filter((s) => agents.some((a) => a.state === s))];
  const count = (f: 'all' | AgentState) => (f === 'all' ? agents.length : agents.filter((a) => a.state === f).length);
  const shown = agents
    .filter((a) => filter === 'all' || a.state === filter)
    .sort((a, b) => {
      const k = sort.key;
      const v = k === 'name' ? a.name.localeCompare(b.name, locale) : k === 'state' ? ORDER.indexOf(a.state) - ORDER.indexOf(b.state) : (a[k] ?? 0) - (b[k] ?? 0);
      return v * sort.dir;
    });
  const total = shown.reduce((s, a) => s + a.spend, 0);
  const head = (key: SortKey | null, label: string, cls?: string) => {
    const on = key && sort.key === key;
    return (
      <th scope="col" aria-sort={on ? (sort.dir === 1 ? 'ascending' : 'descending') : undefined} className={cn('px-3 py-2.5 text-left text-[12px] font-medium text-muted-foreground first:pl-5 last:pr-5', cls)}>
        {key ? (
          <button type="button" aria-label={fill(t.sortBy, { name: label })} onClick={() => setSort((s) => ({ key, dir: s.key === key ? ((s.dir * -1) as 1 | -1) : key === 'name' || key === 'state' ? 1 : -1 }))} className="-mx-1.5 inline-flex h-8 items-center gap-1 rounded-md px-1.5 hover:bg-accent hover:text-foreground">
            {label}
            <Icon name={on ? (sort.dir === 1 ? 'chevron-up' : 'chevron-down') : 'chevrons-up-down'} size={13} className={on ? 'text-foreground' : 'opacity-60'} />
          </button>
        ) : (
          label
        )}
      </th>
    );
  };
  const action = (a: DashboardAgent, full?: boolean) =>
    a.state === 'failed' ? (
      <button type="button" aria-label={fill(t.retryName, { name: a.name })} onClick={() => (act(a.id, 'retry'), onAction?.('retry', a.id))} className={cn(quietBtn, full && 'flex-1')}>
        <Icon name="refresh" size={14} />
        {t.retry}
      </button>
    ) : (
      <button
        type="button"
        aria-label={fill(a.state === 'paused' ? t.resumeName : t.pauseName, { name: a.name })}
        onClick={() => {
          const x = a.state === 'paused' ? 'resume' : 'pause';
          act(a.id, x);
          onAction?.(x, a.id);
        }}
        className={cn(quietBtn, full && 'flex-1')}
      >
        <Icon name={a.state === 'paused' ? 'play' : 'pause'} size={14} />
        {a.state === 'paused' ? t.resume : t.pause}
      </button>
    );
  const taskLine = (a: DashboardAgent) => (a.state === 'failed' ? a.error : a.state === 'idle' ? a.lastRun && fill(t.lastRun, { v: a.lastRun }) : a.state === 'paused' ? t.pausedNote : a.task);

  return (
    <Stage title={title} description={description} className={className}>
      <Style />
      <div ref={ref}>
        <Reveal>
          <div role="group" aria-label={t.filter} className="kit-no-scrollbar -mx-1 flex gap-1.5 overflow-x-auto px-1 pb-1">
            {filters.map((f) => (
              <button key={f} type="button" aria-pressed={filter === f} onClick={() => setFilter(f)} className={cn('inline-flex h-11 shrink-0 items-center gap-2 rounded-lg border px-3 text-[13px] font-medium transition-colors sm:h-9', filter === f ? 'border-border-strong bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'border-transparent text-muted-foreground hover:bg-accent hover:text-foreground')}>
                {f !== 'all' && <span aria-hidden className={cn('size-1.5 rounded-full', TONE[f].dot)} />}
                {f === 'all' ? t.all : t[f]}
                <span className="tabular-nums text-subtle-foreground">{count(f)}</span>
              </button>
            ))}
          </div>
        </Reveal>

        {/* Table from tablets on, cards on phones */}
        <Reveal delay={80} className={cn(card, 'mt-3 hidden overflow-hidden lg:block')}>
          <table className="w-full border-collapse text-[13px]">
            <thead className="border-b border-border bg-surface">
              <tr>
                {head('name', t.agent)}
                {head('state', t.status)}
                {head(null, t.task, 'w-[34%]')}
                {head('successRate', t.rate)}
                {head('spend', t.spend, 'text-right')}
                <th scope="col" className="px-3 py-2.5 pr-5 text-right text-[12px] font-medium text-muted-foreground">
                  <span className="sr-only">{t.actions}</span>
                </th>
              </tr>
            </thead>
            <tbody>
              {shown.map((a, i) => (
                <tr key={a.id} className="agd-up border-b border-border last:border-b-0 hover:bg-[color-mix(in_oklab,var(--foreground)_3%,transparent)]" style={{ '--agd-d': `${i * 40}ms` } as React.CSSProperties}>
                  <th scope="row" className="py-3 pl-5 pr-3 text-left font-normal">
                    <span className="flex items-center gap-2.5">
                      <span aria-hidden className="grid size-8 shrink-0 place-items-center rounded-lg border border-border text-foreground">
                        <Icon name={a.icon ?? 'bot'} size={15} />
                      </span>
                      <span className="min-w-0">
                        <span className="block truncate font-medium text-foreground">{a.name}</span>
                        {a.role && <span className="block truncate text-[12px] text-muted-foreground">{a.role}</span>}
                      </span>
                    </span>
                  </th>
                  <td className="px-3 py-3">
                    <StatePill state={a.state} t={t} />
                  </td>
                  <td className="px-3 py-3">
                    <p className={cn('line-clamp-2 leading-snug', a.state === 'failed' ? 'text-destructive' : a.state === 'running' || a.state === 'waiting' ? 'text-foreground' : 'text-muted-foreground')}>{taskLine(a)}</p>
                    {a.state === 'running' && <Bar value={a.progress ?? 0} className="mt-1.5 max-w-[14rem]" />}
                  </td>
                  <td className="px-3 py-3">
                    {a.successRate !== undefined && (
                      <span className="flex items-center gap-2">
                        <Bar value={a.successRate} tone={a.successRate < 85 ? 'warning' : 'success'} className="w-16" />
                        <span className="tabular-nums text-foreground">{formatPercent(a.successRate, locale)}</span>
                      </span>
                    )}
                  </td>
                  <td className="whitespace-nowrap px-3 py-3 text-right tabular-nums text-foreground">
                    {money(a.spend)}
                    {a.budget ? <span className="block text-[12px] text-muted-foreground">/ {money(a.budget)}</span> : null}
                  </td>
                  <td className="py-3 pl-3 pr-5 text-right">{action(a)}</td>
                </tr>
              ))}
              {!shown.length && (
                <tr>
                  <td colSpan={6} className="px-5 py-10 text-center text-muted-foreground">
                    {t.none}
                  </td>
                </tr>
              )}
            </tbody>
            <tfoot className="border-t border-border bg-surface">
              <tr>
                <th scope="row" colSpan={4} className="py-3 pl-5 pr-3 text-left text-[12.5px] font-medium text-muted-foreground">
                  {t.total}
                </th>
                <td className="whitespace-nowrap px-3 py-3 text-right font-semibold tabular-nums text-foreground">{money(total)}</td>
                <td />
              </tr>
            </tfoot>
          </table>
        </Reveal>

        <ul className="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2 lg:hidden">
          {shown.map((a, i) => (
            <li key={a.id} className={cn(card, 'agd-up p-4')} style={{ '--agd-d': `${i * 50}ms` } as React.CSSProperties}>
              <div className="flex items-start gap-3">
                <div className="min-w-0 flex-1">
                  <h3 className="truncate text-[14px] font-semibold text-foreground">{a.name}</h3>
                  {a.role && <p className="truncate text-[12.5px] text-muted-foreground">{a.role}</p>}
                </div>
                <StatePill state={a.state} t={t} />
              </div>
              <p className={cn('mt-2.5 line-clamp-2 text-[13px] leading-snug', a.state === 'failed' ? 'text-destructive' : 'text-muted-foreground')}>{taskLine(a)}</p>
              {a.state === 'running' && <Bar value={a.progress ?? 0} className="mt-2" />}
              <dl className="mt-3 grid grid-cols-2 gap-2 border-t border-border pt-3 text-[12.5px]">
                <div>
                  <dt className="text-muted-foreground">{t.rate}</dt>
                  <dd className="font-medium tabular-nums text-foreground">{a.successRate !== undefined ? formatPercent(a.successRate, locale) : ''}</dd>
                </div>
                <div>
                  <dt className="text-muted-foreground">{t.spend}</dt>
                  <dd className="font-medium tabular-nums text-foreground">{money(a.spend)}</dd>
                </div>
              </dl>
              <div className="mt-3 flex">{action(a, true)}</div>
            </li>
          ))}
          {!shown.length && <li className="rounded-2xl border border-dashed md:col-span-2 border-border-strong px-4 py-8 text-center text-[13px] text-muted-foreground">{t.none}</li>}
          <li className="flex items-center justify-between px-1 pt-1 text-[13px] md:col-span-2">
            <span className="text-muted-foreground">{t.total}</span>
            <span className="font-semibold tabular-nums text-foreground">{money(total)}</span>
          </li>
        </ul>
      </div>
      <Honest t={t}>{t.estimate}</Honest>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* run-detail                                                          */
/* ------------------------------------------------------------------ */

const STEP_ICON: Record<DashboardRunStep['state'], { icon: IconName; cls: string }> = {
  done: { icon: 'check', cls: 'border-[color-mix(in_oklab,var(--success)_40%,var(--border))] text-success-foreground' },
  running: { icon: 'loader', cls: 'border-primary text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' },
  failed: { icon: 'x', cls: 'border-[color-mix(in_oklab,var(--destructive)_50%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_10%,var(--card))] text-destructive' },
  waiting: { icon: 'hourglass', cls: 'border-[color-mix(in_oklab,var(--warning)_55%,var(--border))] bg-[color-mix(in_oklab,var(--warning)_12%,var(--card))] text-warning-foreground' },
  queued: { icon: 'circle', cls: 'border-dashed border-border-strong text-subtle-foreground' },
  skipped: { icon: 'arrow-right', cls: 'border-border text-subtle-foreground' },
};

function RunDetail({ t, title, description, agents = [], runs: initial = [], currency, unit, onAction, className }: P) {
  const money = useMoney(currency, unit);
  const num = useNumber();
  const uid = useId();
  const [runs, setRuns] = useSynced(initial);
  const [sel, setSel] = useState(Math.max(0, initial.findIndex((r) => r.state === 'failed')));
  const [phoneDetail, setPhoneDetail] = useState(false);
  const [msg, setMsg] = useState<Record<string, string>>({});
  const listRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const detail = useRef<HTMLDivElement>(null);
  const run = runs[Math.min(sel, runs.length - 1)];
  const agentOf = (id: string) => agents.find((a) => a.id === id);
  const open = (i: number) => {
    setSel(i);
    setPhoneDetail(true);
    onAction?.('open', runs[i].id);
    requestAnimationFrame(() => {
      if (window.matchMedia('(max-width: 1023px)').matches) detail.current?.focus({ preventScroll: false });
    });
  };
  const update = (id: string, patch: (r: DashboardRun) => DashboardRun, note: string) => {
    setRuns((rs) => rs.map((r) => (r.id === id ? patch(r) : r)));
    setMsg((m) => ({ ...m, [id]: note }));
  };
  const retry = (r: DashboardRun) => {
    onAction?.('retry', r.id);
    update(r.id, (x) => ({ ...x, state: 'running', error: undefined, steps: x.steps?.map((s) => (s.state === 'failed' ? { ...s, state: 'running' } : s)) }), t.retrying);
  };
  const skip = (r: DashboardRun) => {
    onAction?.('skip', r.id);
    update(r.id, (x) => ({ ...x, state: 'running', error: undefined, steps: x.steps?.map((s, i, all) => (s.state === 'failed' ? { ...s, state: 'skipped' } : all[i - 1]?.state === 'failed' && s.state === 'queued' ? { ...s, state: 'running' } : s)) }), t.skipped);
  };
  const decide = (r: DashboardRun, ok: boolean) => {
    onAction?.(ok ? 'approve' : 'decline', r.id);
    update(r.id, (x) => ({ ...x, state: ok ? 'running' : 'success', approval: undefined, steps: x.steps?.map((s) => (s.state === 'waiting' ? { ...s, state: ok ? 'running' : 'skipped' } : s)) }), ok ? t.approved : t.declined);
  };

  return (
    <Stage title={title} description={description} className={className}>
      <Style />
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[22rem_minmax(0,1fr)] lg:items-start lg:gap-5">
        <Reveal className={cn(card, 'overflow-hidden', phoneDetail && 'max-lg:hidden')}>
          <p className="border-b border-border px-4 py-3 text-[13px] font-medium text-foreground">{t.runs}</p>
          <ul aria-label={t.runs}>
            {runs.map((r, i) => {
              const on = i === sel;
              return (
                <li key={r.id} className="border-b border-border last:border-b-0">
                  <button
                    ref={(el) => {
                      listRefs.current[i] = el;
                    }}
                    type="button"
                    aria-current={on ? 'true' : undefined}
                    aria-label={fill(t.openRun, { name: r.title })}
                    onClick={() => open(i)}
                    onKeyDown={(e) => {
                      const d = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
                      if (!d) return;
                      e.preventDefault();
                      listRefs.current[(i + d + runs.length) % runs.length]?.focus();
                    }}
                    className={cn('relative flex w-full items-start gap-3 px-4 py-3 text-left transition-colors', on ? 'bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]' : 'hover:bg-accent')}
                  >
                    {on && <span aria-hidden className="absolute inset-y-2 left-0 w-0.5 rounded-full bg-primary max-lg:hidden" />}
                    <span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE[r.state].dot)} />
                    <span className="min-w-0 flex-1">
                      <span className="block truncate text-[13.5px] font-medium text-foreground">{r.title}</span>
                      <span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
                        {agentOf(r.agent)?.name ?? r.agent} · {r.started}
                      </span>
                    </span>
                    <span className="flex shrink-0 flex-col items-end gap-1">
                      <span className="text-[12px] tabular-nums text-muted-foreground">{money(r.cost)}</span>
                      <Icon name="chevron-right" size={15} className="text-subtle-foreground lg:hidden" />
                    </span>
                  </button>
                </li>
              );
            })}
          </ul>
        </Reveal>

        {run && (
          <Reveal delay={100} className={cn('min-w-0', !phoneDetail && 'max-lg:hidden')}>
            <div ref={detail} tabIndex={-1} aria-labelledby={`${uid}-t`} role="region" className={cn(card, 'overflow-hidden focus-visible:outline-offset-4')}>
              <div className="border-b border-border p-4 sm:p-5">
                <button type="button" onClick={() => (setPhoneDetail(false), requestAnimationFrame(() => listRefs.current[sel]?.focus()))} className="-ml-2 mb-2 inline-flex h-11 items-center gap-1 rounded-lg px-2 text-[13px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground lg:hidden">
                  <Icon name="chevron-left" size={16} />
                  {t.back}
                </button>
                <div className="flex flex-wrap items-start justify-between gap-3">
                  <div className="min-w-0">
                    <p className="flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
                      <Icon name={agentOf(run.agent)?.icon ?? 'bot'} size={14} />
                      {agentOf(run.agent)?.name ?? run.agent}
                    </p>
                    <h3 id={`${uid}-t`} className="mt-1 text-pretty text-[17px] font-semibold leading-snug tracking-[-0.01em] text-foreground">
                      {run.title}
                    </h3>
                  </div>
                  <StatePill state={run.state} t={t} />
                </div>
                <dl className="mt-4 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-4">
                  {[
                    [t.started, run.started],
                    [t.duration, run.duration ?? ''],
                    [t.cost, money(run.cost)],
                    [t.tokens, run.tokens ? num(run.tokens) : ''],
                  ].map(([k, v]) => (
                    <div key={k} className="bg-card px-3 py-2.5">
                      <dt className="text-[11.5px] text-muted-foreground">{k}</dt>
                      <dd className="mt-0.5 text-[13.5px] font-medium tabular-nums text-foreground">{v}</dd>
                    </div>
                  ))}
                </dl>
              </div>

              {run.error && (
                <div className="agd-up border-b border-border bg-[color-mix(in_oklab,var(--destructive)_6%,var(--card))] p-4 sm:p-5">
                  <p className="flex items-center gap-1.5 text-[13px] font-medium text-destructive">
                    <Icon name="alert-circle" size={15} />
                    {t.errorTitle}
                  </p>
                  <p className="mt-1.5 text-pretty text-[13.5px] leading-relaxed text-foreground">{run.error}</p>
                  <div className="mt-3 flex flex-wrap gap-2">
                    <button type="button" onClick={() => retry(run)} className={cn(primaryBtn, 'max-sm:flex-1')}>
                      <Icon name="refresh" size={15} />
                      {t.retryFrom}
                    </button>
                    <button type="button" onClick={() => skip(run)} className={cn(quietBtn, 'max-sm:flex-1')}>
                      <Icon name="skip-forward" size={15} />
                      {t.skip}
                    </button>
                  </div>
                </div>
              )}
              {run.approval && (
                <div className="agd-up border-b border-border bg-[color-mix(in_oklab,var(--warning)_9%,var(--card))] p-4 sm:p-5">
                  <p className="text-[12.5px] font-medium text-warning-foreground">{t.asks}</p>
                  <p className="mt-1 text-pretty text-[14.5px] font-medium leading-snug text-foreground">{run.approval.action}</p>
                  <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
                    <span className="font-medium text-foreground">{t.why}. </span>
                    {run.approval.reason}
                  </p>
                  <div className="mt-3 flex flex-wrap gap-2">
                    <button type="button" onClick={() => decide(run, false)} className={cn(quietBtn, 'max-sm:flex-1')}>
                      {t.decline}
                    </button>
                    <button type="button" onClick={() => decide(run, true)} className={cn(primaryBtn, 'max-sm:flex-1')}>
                      <Icon name="check" size={15} />
                      {t.approve}
                    </button>
                  </div>
                </div>
              )}
              {msg[run.id] && (
                <p role="status" className="agd-up flex items-start gap-2 border-b border-border px-4 py-3 text-[13px] text-foreground sm:px-5">
                  <Icon name="info" size={15} className="mt-px shrink-0 text-muted-foreground" />
                  {msg[run.id]}
                </p>
              )}

              {run.steps && run.steps.length > 0 && (
                <div className="p-4 sm:p-5">
                  <p className="text-[13px] font-medium text-foreground">{t.steps}</p>
                  <ol className="mt-3">
                    {run.steps.map((s, i) => {
                      const ic = STEP_ICON[s.state];
                      return (
                        <li key={s.id} className="relative flex gap-3 pb-4 last:pb-0">
                          {i < run.steps!.length - 1 && <span aria-hidden className="absolute bottom-0 left-[13px] top-7 w-px bg-border" />}
                          <span aria-hidden className={cn('relative z-[1] grid size-7 shrink-0 place-items-center rounded-full border bg-card', ic.cls)}>
                            <Icon name={ic.icon} size={13} strokeWidth={2.2} className={s.state === 'running' ? 'animate-spin motion-reduce:animate-none' : undefined} />
                          </span>
                          <div className="min-w-0 flex-1 pt-0.5">
                            <p className="flex flex-wrap items-baseline justify-between gap-x-3">
                              <span className={cn('text-[13.5px] font-medium', s.state === 'queued' || s.state === 'skipped' ? 'text-muted-foreground' : 'text-foreground', s.state === 'skipped' && 'line-through')}>
                                {s.title}
                                <span className="sr-only">, {t[s.state === 'done' ? 'success' : s.state === 'queued' || s.state === 'skipped' ? 'paused' : s.state]}</span>
                              </span>
                              {s.duration && <span className="text-[12px] tabular-nums text-muted-foreground">{s.duration}</span>}
                            </p>
                            {s.detail && <p className="mt-0.5 text-[12.5px] leading-snug text-muted-foreground">{s.detail}</p>}
                          </div>
                        </li>
                      );
                    })}
                  </ol>
                </div>
              )}
            </div>
          </Reveal>
        )}
      </div>
      <Honest t={t} />
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* mobile                                                              */
/* ------------------------------------------------------------------ */

function Mobile({ t, title, description, agents: initial = [], runs = [], events = [], currency, unit, budget, period, onAction, className }: P) {
  const money = useMoney(currency, unit);
  const locale = useLocale();
  const uid = useId();
  const [ref, inView] = useInView<HTMLDivElement>();
  const [agents, act] = useFleet(initial, t, inView);
  const [tab, setTab] = useState<'agents' | 'approvals' | 'activity'>('agents');
  const [decided, setDecided] = useState<Record<string, boolean>>({});
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const pending = runs.filter((r) => r.approval && decided[r.id] === undefined);
  const spent = agents.reduce((s, a) => s + a.spend, 0);
  const share = budget ? (spent / budget) * 100 : 0;
  const tabs = [
    { id: 'agents' as const, label: t.agents, icon: 'bot' as IconName, badge: 0 },
    { id: 'approvals' as const, label: t.approvals, icon: 'check-square' as IconName, badge: pending.length },
    { id: 'activity' as const, label: t.activity, icon: 'activity' as IconName, badge: 0 },
  ];
  const agentName = (id: string) => agents.find((a) => a.id === id)?.name ?? id;

  return (
    <section className={cn('kit-glow relative overflow-clip bg-surface py-0 sm:py-16 lg:py-20', className)}>
      <Style />
      <div className="sm:kit-container">
        <div ref={ref} className="mx-auto grid w-full max-w-[64rem] grid-cols-1 items-center gap-6 sm:gap-10 lg:grid-cols-[minmax(0,1fr)_auto] lg:gap-16">
          {(title || description) && (
            <Reveal className="max-w-[30rem] max-sm:px-4 max-sm:pt-12">
              {title && <h2 className="text-balance text-[26px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[32px]">{title}</h2>}
              {description && <p className="mt-3 text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
              <ul className="mt-6 space-y-3 max-sm:hidden">
                {tabs.map((x) => (
                  <li key={x.id} className="flex items-center gap-3 text-[14px] text-foreground">
                    <span aria-hidden className="grid size-8 place-items-center rounded-lg border border-border bg-card text-muted-foreground">
                      <Icon name={x.icon} size={15} />
                    </span>
                    {x.label}
                    {x.badge > 0 && <span className="rounded-md bg-[color-mix(in_oklab,var(--warning)_16%,transparent)] px-1.5 text-[12px] font-medium tabular-nums text-warning-foreground">{x.badge}</span>}
                  </li>
                ))}
              </ul>
            </Reveal>
          )}

          {/* A real phone layout on phones, the same screen in a device frame from tablets on */}
          <Reveal effect="scale" delay={80} className="mx-auto w-full sm:w-[340px]">
            <div className="sm:rounded-[46px] sm:border sm:border-border-strong sm:bg-bezel sm:p-[10px] sm:shadow-[var(--shadow-lg)]">
              <div className="relative flex flex-col overflow-hidden bg-background sm:h-[680px] sm:rounded-[37px]">
                <div aria-hidden className="relative hidden h-10 shrink-0 items-center justify-between px-7 text-[11.5px] font-semibold text-foreground sm:flex">
                  <span>9:41</span>
                  <span className="absolute left-1/2 top-2.5 h-[24px] w-[90px] -translate-x-1/2 rounded-full bg-bezel" />
                  <span className="h-2.5 w-4 rounded-[3px] border border-current p-px">
                    <span className="block h-full w-2/3 rounded-[1px] bg-current" />
                  </span>
                </div>
                <header className="shrink-0 px-4 pb-3 pt-6 sm:pt-2">
                  <p className="text-[12px] text-muted-foreground">{period ? `${t.spend} · ${period}` : t.spend}</p>
                  <div className="mt-0.5 flex items-baseline justify-between gap-3">
                    <p className="text-[24px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">{money(spent)}</p>
                    {budget ? <p className="text-[12px] tabular-nums text-muted-foreground">{formatPercent(Math.round(share), locale)}</p> : null}
                  </div>
                  {budget ? <Bar value={share} tone={budgetTone(share)} className="mt-2" label={fill(t.spendOf, { v: money(spent), b: money(budget) })} /> : null}
                </header>

                <div className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto px-4 pb-4 sm:pb-3">
                  <div role="tabpanel" id={`${uid}-${tab}`} aria-labelledby={`${uid}-tab-${tab}`} tabIndex={0} key={tab} className="agd-up rounded-xl">
                    {tab === 'agents' && (
                      <ul className="space-y-2">
                        {agents.map((a) => (
                          <li key={a.id} className="rounded-xl border border-border bg-card p-3">
                            <div className="flex items-center gap-2.5">
                              <span aria-hidden className={cn('size-2 shrink-0 rounded-full', TONE[a.state].dot)} />
                              <p className="min-w-0 flex-1 truncate text-[13.5px] font-medium text-foreground">{a.name}</p>
                              <span className={cn('text-[11.5px] font-medium', TONE[a.state].text)}>{t[a.state]}</span>
                            </div>
                            <p className={cn('mt-1 line-clamp-2 pl-[18px] text-[12.5px] leading-snug', a.state === 'failed' ? 'text-destructive' : 'text-muted-foreground')}>
                              {a.state === 'failed' ? a.error : a.state === 'idle' ? a.lastRun && fill(t.lastRun, { v: a.lastRun }) : a.state === 'paused' ? t.pausedNote : a.task}
                            </p>
                            {a.state === 'running' && <Bar value={a.progress ?? 0} className="ml-[18px] mt-2" />}
                            {(a.state === 'failed' || a.state === 'paused') && (
                              <div className="mt-2 pl-[18px]">
                                <button
                                  type="button"
                                  aria-label={fill(a.state === 'failed' ? t.retryName : t.resumeName, { name: a.name })}
                                  onClick={() => {
                                    const x = a.state === 'failed' ? 'retry' : 'resume';
                                    act(a.id, x);
                                    onAction?.(x, a.id);
                                  }}
                                  className={cn(quietBtn, 'h-11 w-full sm:h-11')}
                                >
                                  <Icon name={a.state === 'failed' ? 'refresh' : 'play'} size={14} />
                                  {a.state === 'failed' ? t.retry : t.resume}
                                </button>
                              </div>
                            )}
                          </li>
                        ))}
                      </ul>
                    )}
                    {tab === 'approvals' && (
                      <ul className="space-y-2">
                        {runs
                          .filter((r) => r.approval)
                          .map((r) => (
                            <li key={r.id} className="rounded-xl border border-border bg-card p-3">
                              <p className="text-[12px] text-muted-foreground">
                                {agentName(r.agent)} · {r.started}
                              </p>
                              <p className="mt-1 text-[13.5px] font-medium leading-snug text-foreground">{r.approval!.action}</p>
                              <p className="mt-1 text-[12.5px] leading-snug text-muted-foreground">{r.approval!.reason}</p>
                              {decided[r.id] === undefined ? (
                                <div className="mt-2.5 grid grid-cols-2 gap-2">
                                  <button type="button" onClick={() => (setDecided((d) => ({ ...d, [r.id]: false })), onAction?.('decline', r.id))} className={cn(quietBtn, 'h-11 sm:h-11')}>
                                    {t.decline}
                                  </button>
                                  <button type="button" onClick={() => (setDecided((d) => ({ ...d, [r.id]: true })), onAction?.('approve', r.id))} className={cn(primaryBtn, 'h-11 sm:h-11')}>
                                    {t.approve}
                                  </button>
                                </div>
                              ) : (
                                <p role="status" className={cn('agd-up mt-2.5 flex items-start gap-1.5 text-[12.5px] leading-snug', decided[r.id] ? 'text-success-foreground' : 'text-muted-foreground')}>
                                  <Icon name={decided[r.id] ? 'check-circle' : 'x-circle'} size={14} className="mt-px shrink-0" />
                                  {decided[r.id] ? t.approved : t.declined}
                                </p>
                              )}
                            </li>
                          ))}
                        {!runs.some((r) => r.approval) && <li className="py-10 text-center text-[13px] text-muted-foreground">{t.nothing}</li>}
                      </ul>
                    )}
                    {tab === 'activity' && (
                      <ol className="relative space-y-3.5 pl-4">
                        <span aria-hidden className="absolute bottom-1 left-[3px] top-1 w-px bg-border" />
                        {events.map((e) => (
                          <li key={e.id} className="relative">
                            <span aria-hidden className={cn('absolute -left-4 top-1.5 size-[7px] rounded-full ring-2 ring-background', e.tone === 'success' ? 'bg-success' : e.tone === 'warning' ? 'bg-warning' : e.tone === 'error' ? 'bg-destructive' : 'bg-muted-foreground')} />
                            <p className="text-[12px] text-muted-foreground">
                              {agentName(e.agent)} · {e.time}
                            </p>
                            <p className="mt-0.5 text-[13px] leading-snug text-foreground">{e.text}</p>
                          </li>
                        ))}
                      </ol>
                    )}
                  </div>
                </div>

                <div role="tablist" aria-label={t.tabs} className="z-10 grid shrink-0 grid-cols-3 border-t border-border bg-card px-2 pb-2 pt-1 max-sm:sticky max-sm:bottom-0 sm:pb-4">
                  {tabs.map((x, i) => {
                    const on = tab === x.id;
                    return (
                      <button
                        key={x.id}
                        ref={(el) => {
                          tabRefs.current[i] = el;
                        }}
                        type="button"
                        role="tab"
                        id={`${uid}-tab-${x.id}`}
                        aria-selected={on}
                        aria-controls={`${uid}-${x.id}`}
                        tabIndex={on ? 0 : -1}
                        onClick={() => setTab(x.id)}
                        onKeyDown={(e) => {
                          const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
                          const n = e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : d ? (i + d + tabs.length) % tabs.length : -1;
                          if (n < 0) return;
                          e.preventDefault();
                          setTab(tabs[n].id);
                          tabRefs.current[n]?.focus();
                        }}
                        className={cn('relative flex min-h-12 flex-col items-center justify-center gap-0.5 rounded-lg text-[11px] font-medium transition-colors', on ? 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'text-muted-foreground hover:text-foreground')}
                      >
                        <span className="relative">
                          <Icon name={x.icon} size={19} />
                          {x.badge > 0 && <span className="absolute -right-2.5 -top-1.5 grid h-4 min-w-4 place-items-center rounded-full bg-warning px-1 text-[10px] font-semibold tabular-nums text-[#1a1406]">{x.badge}</span>}
                        </span>
                        {x.label}
                      </button>
                    );
                  })}
                </div>
              </div>
            </div>
          </Reveal>
        </div>
      </div>
    </section>
  );
}

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

/**
 * A dashboard for many AI agents at once: an overview with state, live
 * tasks, approvals, spend and retry for errors, a sortable fleet table, the
 * detail of single runs with steps, error and approval, and a phone screen
 * of its own with tabs. One agent run in depth is agent-run.
 */
export function AgentDashboard({ variant = 'overview', labels, ...props }: AgentDashboardProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  switch (variant) {
    case 'fleet-table':
      return <FleetTable {...p} />;
    case 'run-detail':
      return <RunDetail {...p} />;
    case 'mobile':
      return <Mobile {...p} />;
    default:
      return <Overview {...p} />;
  }
}

Frequent questions

How do I install Agent dashboard?

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

Which variants does Agent dashboard have?

4 variants, Overview, Fleet table, Run detail and Phone app. You pick one with the variant prop, for example variant="fleet-table".

Can I translate the texts of Agent dashboard?

Yes. Agent dashboard carries 64 texts of its own in English and German, such as “Running”, “Ready” and “Needs you”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Agent dashboard?

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

Often searched

Jump to

Copied to the clipboard