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.
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
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.
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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle AgentDashboardProps aus components/ui/agent-dashboard.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalAgentDashboardVariantoverviewfleet-tablerun-detailmobile
titleoptionalstringdescriptionoptionalstringagentsoptionalDashboardAgent[]runsoptionalDashboardRun[]eventsoptionalDashboardEvent[]mobile: the activity feed
currencyoptionalstringISO currency code for spend, e.g. EUR. Without it spend is shown with `unit`.
unitoptionalstringe.g. "credits", used when no currency is set
budgetoptionalnumberBudget of the whole workspace this period
periodoptionalstringe.g. "October"
onActionoptional(action: 'pause' | 'resume' | 'retry' | 'approve' | 'decline' | 'skip' | 'open', id: string) => voidPause, 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| running | Englisch Running | Deutsch Läuft |
| idle | Englisch Ready | Deutsch Bereit |
| waiting | Englisch Needs you | Deutsch Wartet auf dich |
| failed | Englisch Error | Deutsch Fehler |
| paused | Englisch Paused | Deutsch Pausiert |
| success | Englisch Done | Deutsch Fertig |
| active | Englisch Active agents | Deutsch Aktive Agenten |
| activeOf | Englisch {n} of {total} | Deutsch {n} von {total} |
| runsToday | Englisch Runs today | Deutsch Läufe heute |
| needsYou | Englisch Waiting for you | Deutsch Wartet auf dich |
| approvalsN | Englisch {n} approvals | Deutsch {n} Freigaben |
| approval1 | Englisch 1 approval | Deutsch 1 Freigabe |
| spend | Englisch Spend | Deutsch Verbrauch |
| spendOf | Englisch {v} of {b} | Deutsch {v} von {b} |
| budgetLeft | Englisch {v} left | Deutsch noch {v} |
| pause | Englisch Pause | Deutsch Pausieren |
| resume | Englisch Resume | Deutsch Fortsetzen |
| pauseName | Englisch Pause {name} | Deutsch {name} pausieren |
| resumeName | Englisch Resume {name} | Deutsch {name} fortsetzen |
| retry | Englisch Try again | Deutsch Neu versuchen |
| retryName | Englisch Try {name} again | Deutsch {name} neu versuchen |
| review | Englisch Review | Deutsch Prüfen |
| reviewName | Englisch Review the approval of {name} | Deutsch Freigabe von {name} prüfen |
| lastRun | Englisch Last run {v} | Deutsch Letzter Lauf {v} |
| justNow | Englisch just now | Deutsch gerade eben |
| pausedNote | Englisch Paused. Scheduled runs wait until you resume. | Deutsch Pausiert. Geplante Läufe warten, bis du fortsetzt. |
| trend | Englisch Spend over the last {n} days | Deutsch Verbrauch der letzten {n} Tage |
| filter | Englisch Filter by status | Deutsch Nach Status filtern |
| all | Englisch All | Deutsch Alle |
| agent | Englisch Agent | Deutsch Agent |
| status | Englisch Status | Deutsch Status |
| task | Englisch Task | Deutsch Aufgabe |
| rate | Englisch Success | Deutsch Erfolg |
| actions | Englisch Actions | Deutsch Aktionen |
| sortBy | Englisch Sort by {name} | Deutsch Nach {name} sortieren |
| total | Englisch Total | Deutsch Summe |
| none | Englisch No agent with this status. | Deutsch Kein Agent mit diesem Status. |
| runs | Englisch Recent runs | Deutsch Letzte Läufe |
| run | Englisch Run | Deutsch Lauf |
| started | Englisch Started | Deutsch Gestartet |
| duration | Englisch Duration | Deutsch Dauer |
| cost | Englisch Cost | Deutsch Kosten |
| tokens | Englisch Tokens | Deutsch Tokens |
| steps | Englisch Steps | Deutsch Schritte |
| back | Englisch All runs | Deutsch Alle Läufe |
| openRun | Englisch Open run {name} | Deutsch Lauf {name} öffnen |
| retryFrom | Englisch Try again from this step | Deutsch Ab diesem Schritt neu versuchen |
| skip | Englisch Skip step | Deutsch Schritt überspringen |
| skipped | Englisch Skipped. The run goes on without this step. | Deutsch Übersprungen. Der Lauf geht ohne diesen Schritt weiter. |
| retrying | Englisch Running again from the failed step. | Deutsch Läuft ab dem fehlerhaften Schritt neu. |
| approve | Englisch Approve | Deutsch Freigeben |
| decline | Englisch Decline | Deutsch Ablehnen |
| approved | Englisch Approved. The agent goes on. | Deutsch Freigegeben. Der Agent macht weiter. |
| declined | Englisch 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. |
| asks | Englisch The agent asks to | Deutsch Der Agent möchte |
| why | Englisch Why | Deutsch Warum |
| errorTitle | Englisch What went wrong | Deutsch Was schiefging |
| agents | Englisch Agents | Deutsch Agenten |
| approvals | Englisch Approvals | Deutsch Freigaben |
| activity | Englisch Activity | Deutsch Verlauf |
| tabs | Englisch Sections | Deutsch Bereiche |
| nothing | Englisch Nothing waits for you. | Deutsch Nichts wartet auf dich. |
| honest | Englisch 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. |
| estimate | Englisch 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
'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.