Zum Inhalt springen
UI Kit
Accent

KI-Agenten Dashboard

Viele KI-Agenten auf einem Bildschirm. Ein Überblick mit Status, laufenden Aufgaben, Freigaben, Verbrauch und Wiederholen, eine sortierbare Tabelle, das Detail einzelner Läufe mit Schritten, Fehler und Freigabe und ein eigener Telefonbildschirm. Einen Lauf im Detail zeigt agent-run.

4 VariantsEine von 5 Components in AI und Chat

Viele Agenten, ein ruhiger Überblick

Arbeiten mehrere KI-Agenten zugleich, muss jemand sehen, welche laufen, welche auf eine Entscheidung warten und was sie kosten. Dieses Dashboard gibt diesen Überblick und setzt an jeden Agenten den passenden nächsten Schritt, vom neuen Versuch bis zur Freigabe.

Der „Überblick“ zeigt vier Zahlen oben und je Agent eine Karte mit Status, laufender Aufgabe, Verbrauch gegen das Budget und dem nächsten Schritt, neu versuchen, prüfen, pausieren oder fortsetzen. Die „Tabelle aller Agenten“ filtert nach Status mit Anzahl und sortiert nach Name, Status, Erfolg oder Verbrauch, ab Tablet als Tabelle, am Telefon als Karten mit denselben Angaben. „Lauf im Detail“ listet die letzten Läufe aller Agenten, daneben den gewählten mit Zeit, Kosten, Tokens und Schritten, ein Fehler sagt, was zu tun ist, und wiederholt ab dem fehlerhaften Schritt, eine Freigabe wartet auf eine Entscheidung. Die „Telefon-App“ ist ein eigener Bildschirm mit dem Verbrauch oben und drei Reitern am Daumen für Agenten, Freigaben und den Verlauf des Tages.

Zeig die Kosten neben jedem Agenten, nicht erst im Monatsbericht. Wiederholen ab dem fehlerhaften Schritt bewahrt die Arbeit, die schon gelungen ist. Für einen einzelnen Lauf in voller Tiefe hat das Kit den Agenten-Lauf, verlinke ihn aus dem Detail.

Variants

ÜberblickNeu in 1.2

Vier Zahlen oben, je Agent eine Karte mit Status, laufender Aufgabe, Verbrauch gegen das Budget und dem passenden nächsten Schritt: neu versuchen, prüfen, pausieren oder fortsetzen.

Tabelle aller AgentenNeu in 1.2

Nach Status filtern mit Anzahl, sortieren nach Name, Status, Erfolg oder Verbrauch. Ab Tablet eine Tabelle, am Telefon Karten mit denselben Angaben, darunter die Summe.

Lauf im DetailNeu in 1.2

Links die letzten Läufe aller Agenten, rechts der gewählte mit Zeit, Kosten, Tokens und Schritten. Ein Fehler sagt, was zu tun ist, und wiederholt ab dem fehlerhaften Schritt, eine Freigabe wartet auf eine Entscheidung. Am Telefon öffnet die Liste das Detail mit einem Weg zurück.

Telefon-AppNeu in 1.2

Ein eigener Telefonbildschirm mit dem Verbrauch oben und drei Reitern am Daumen: Agenten mit Wiederholen, Freigaben für unterwegs und der Verlauf des Tages. Am Telefon über die volle Breite, ab Tablet im Gerät.

Details

Was Agenten-Dashboard mitbringt, aus Registry und Quellcode gelesen.

Kategorie
AI und Chat
Bereich
App
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/agent-dashboard.tsx
Bringt mit
utilskiticonrevealmotionchart
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 64 eigene Texte
Studio
Im Code anpassen
In Sammlungen
KI-Produkt

Props

Die Schnittstelle AgentDashboardProps aus components/ui/agent-dashboard.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • 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

Eingebaute Texte

Diese Texte schreibt Agenten-Dashboard selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 64 Texte anzeigen
runningEnglisch RunningDeutsch Läuft
idleEnglisch ReadyDeutsch Bereit
waitingEnglisch Needs youDeutsch Wartet auf dich
failedEnglisch ErrorDeutsch Fehler
pausedEnglisch PausedDeutsch Pausiert
successEnglisch DoneDeutsch Fertig
activeEnglisch Active agentsDeutsch Aktive Agenten
activeOfEnglisch {n} of {total}Deutsch {n} von {total}
runsTodayEnglisch Runs todayDeutsch Läufe heute
needsYouEnglisch Waiting for youDeutsch Wartet auf dich
approvalsNEnglisch {n} approvalsDeutsch {n} Freigaben
approval1Englisch 1 approvalDeutsch 1 Freigabe
spendEnglisch SpendDeutsch Verbrauch
spendOfEnglisch {v} of {b}Deutsch {v} von {b}
budgetLeftEnglisch {v} leftDeutsch noch {v}
pauseEnglisch PauseDeutsch Pausieren
resumeEnglisch ResumeDeutsch Fortsetzen
pauseNameEnglisch Pause {name}Deutsch {name} pausieren
resumeNameEnglisch Resume {name}Deutsch {name} fortsetzen
retryEnglisch Try againDeutsch Neu versuchen
retryNameEnglisch Try {name} againDeutsch {name} neu versuchen
reviewEnglisch ReviewDeutsch Prüfen
reviewNameEnglisch Review the approval of {name}Deutsch Freigabe von {name} prüfen
lastRunEnglisch Last run {v}Deutsch Letzter Lauf {v}
justNowEnglisch just nowDeutsch gerade eben
pausedNoteEnglisch Paused. Scheduled runs wait until you resume.Deutsch Pausiert. Geplante Läufe warten, bis du fortsetzt.
trendEnglisch Spend over the last {n} daysDeutsch Verbrauch der letzten {n} Tage
filterEnglisch Filter by statusDeutsch Nach Status filtern
allEnglisch AllDeutsch Alle
agentEnglisch AgentDeutsch Agent
statusEnglisch StatusDeutsch Status
taskEnglisch TaskDeutsch Aufgabe
rateEnglisch SuccessDeutsch Erfolg
actionsEnglisch ActionsDeutsch Aktionen
sortByEnglisch Sort by {name}Deutsch Nach {name} sortieren
totalEnglisch TotalDeutsch Summe
noneEnglisch No agent with this status.Deutsch Kein Agent mit diesem Status.
runsEnglisch Recent runsDeutsch Letzte Läufe
runEnglisch RunDeutsch Lauf
startedEnglisch StartedDeutsch Gestartet
durationEnglisch DurationDeutsch Dauer
costEnglisch CostDeutsch Kosten
tokensEnglisch TokensDeutsch Tokens
stepsEnglisch StepsDeutsch Schritte
backEnglisch All runsDeutsch Alle Läufe
openRunEnglisch Open run {name}Deutsch Lauf {name} öffnen
retryFromEnglisch Try again from this stepDeutsch Ab diesem Schritt neu versuchen
skipEnglisch Skip stepDeutsch Schritt überspringen
skippedEnglisch Skipped. The run goes on without this step.Deutsch Übersprungen. Der Lauf geht ohne diesen Schritt weiter.
retryingEnglisch Running again from the failed step.Deutsch Läuft ab dem fehlerhaften Schritt neu.
approveEnglisch ApproveDeutsch Freigeben
declineEnglisch DeclineDeutsch Ablehnen
approvedEnglisch Approved. The agent goes on.Deutsch Freigegeben. Der Agent macht weiter.
declinedEnglisch Declined. The agent stops here and keeps what it did so far.Deutsch Abgelehnt. Der Agent hält hier an und behält, was er bis jetzt getan hat.
asksEnglisch The agent asks toDeutsch Der Agent möchte
whyEnglisch WhyDeutsch Warum
errorTitleEnglisch What went wrongDeutsch Was schiefging
agentsEnglisch AgentsDeutsch Agenten
approvalsEnglisch ApprovalsDeutsch Freigaben
activityEnglisch ActivityDeutsch Verlauf
tabsEnglisch SectionsDeutsch Bereiche
nothingEnglisch Nothing waits for you.Deutsch Nichts wartet auf dich.
honestEnglisch Agents act on their own within the rules you set. Anything with risk waits for a person.Deutsch Agenten handeln selbst, innerhalb der Regeln, die du festlegst. Alles mit Risiko wartet auf einen Menschen.
estimateEnglisch Spend of running agents is an estimate until the run ends.Deutsch Der Verbrauch laufender Agenten ist geschätzt, bis der Lauf endet.

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Agenten-Dashboard reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
  • Setzt aria-controls, aria-current, aria-label, aria-labelledby, aria-live, aria-pressed, aria-selected, aria-sort, aria-valuemax, aria-valuemin und aria-valuenow, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen group, img, label, region, status, tab, tablist und tabpanel.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 1.164 Zeilen
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} />;
  }
}

Häufige Fragen

Wie installiere ich Agenten-Dashboard?

Mit aiw add @ai-website/agent-dashboard. Der Befehl legt die Datei components/ui/agent-dashboard.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, reveal, motion und chart. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Agenten-Dashboard?

4 Varianten, Überblick, Tabelle aller Agenten, Lauf im Detail und Telefon-App. Du wählst sie mit der Prop variant, zum Beispiel variant="fleet-table".

Kann ich die Texte von Agenten-Dashboard übersetzen?

Ja. Agenten-Dashboard bringt 64 eigene Texte auf Deutsch und Englisch mit, etwa „Läuft“, „Bereit“ und „Wartet auf dich“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Agenten-Dashboard einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Agenten-Dashboard mit aiw_components und installieren sie mit aiw_add @ai-website/agent-dashboard. Nenn im Prompt die Variante, etwa variant="overview", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert