"use client";
import * as React from "react";
import { ArrowPathIcon, CheckCircleIcon, ClockIcon, CpuChipIcon, HandRaisedIcon, PlusIcon, XCircleIcon } from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { MessageMarkdown } from "@/components/pandacoderz-ui/message";
import { Step, StepBody, StepHeader, StepIndicator, StepTitle, Steps, type StepStatus } from "@/components/pandacoderz-ui/steps";
type RunStatus = "queued" | "running" | "review" | "done" | "failed";
type Run = {
id: string;
title: string;
agent: string;
status: RunStatus;
startedAt: number;
steps: { title: string; status: StepStatus }[];
output: string;
};
const now = Date.now();
const seed: Run[] = [
{ id: "r1", title: "Migrate auth middleware to Edge runtime", agent: "coder", status: "running", startedAt: now - 4 * 60_000, steps: [{ title: "Read middleware", status: "completed" }, { title: "Rewrite for Edge APIs", status: "running" }, { title: "Run integration tests", status: "pending" }], output: "Rewriting `middleware.ts`. The `jsonwebtoken` import is Node-only; swapping to `jose`…" },
{ id: "r2", title: "Weekly changelog from merged PRs", agent: "writer", status: "review", startedAt: now - 11 * 60_000, steps: [{ title: "Fetch merged PRs", status: "completed" }, { title: "Group by area", status: "completed" }, { title: "Draft changelog", status: "waiting" }], output: "## Week 36\n\n**Chat**\n- Streaming markdown now handles partial tables\n- Stop button cancels in-flight tool calls\n\n**Docs**\n- New Blocks section\n\n_Ready for review._" },
{ id: "r3", title: "Triage 14 new GitHub issues", agent: "triage", status: "done", startedAt: now - 38 * 60_000, steps: [{ title: "Read issues", status: "completed" }, { title: "Label and assign", status: "completed" }], output: "Labelled 14 issues: 6 bug, 5 enhancement, 3 question. Assigned 4 to @spencer. Two look like duplicates of #118." },
{ id: "r4", title: "Regenerate pricing page copy", agent: "writer", status: "failed", startedAt: now - 52 * 60_000, steps: [{ title: "Fetch pricing tiers", status: "error" }], output: "`GET /api/pricing` returned 401. The service token may have expired." },
{ id: "r5", title: "Backfill embeddings for docs", agent: "indexer", status: "queued", startedAt: now, steps: [{ title: "Chunk 212 pages", status: "pending" }, { title: "Embed", status: "pending" }, { title: "Upsert", status: "pending" }], output: "" },
{ id: "r6", title: "Summarize customer calls (Sep 5)", agent: "analyst", status: "running", startedAt: now - 90_000, steps: [{ title: "Transcribe 3 calls", status: "completed" }, { title: "Extract themes", status: "running" }, { title: "Write summary", status: "pending" }], output: "Themes so far: onboarding friction, pricing clarity…" },
];
const meta: Record<RunStatus, { label: string; icon: React.ComponentType<{ size?: number; className?: string }>; className: string; iconClass?: string }> = {
queued: { label: "Queued", icon: ClockIcon, className: "bg-muted text-muted-foreground" },
running: { label: "Running", icon: ArrowPathIcon, className: "bg-brand-soft text-brand", iconClass: "animate-spin" },
review: { label: "Needs review", icon: HandRaisedIcon, className: "bg-amber-500/15 text-amber-600 dark:text-amber-400" },
done: { label: "Done", icon: CheckCircleIcon, className: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400" },
failed: { label: "Failed", icon: XCircleIcon, className: "bg-red-500/15 text-red-600 dark:text-red-400" },
};
const filters: { id: "all" | RunStatus; label: string }[] = [
{ id: "all", label: "All" },
{ id: "running", label: "Running" },
{ id: "review", label: "Needs review" },
{ id: "done", label: "Done" },
{ id: "failed", label: "Failed" },
];
/** `now` is null during SSR so relative times never mismatch on hydration. */
function ago(ts: number, now: number | null) {
if (now === null) return "…";
const s = Math.max(0, Math.round((now - ts) / 1000));
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.round(s / 60)}m`;
return `${Math.round(s / 3600)}h`;
}
export type AgentInboxProps = { className?: string; runs?: Run[] };
export default function AgentInbox({ className, runs: initial = seed }: AgentInboxProps) {
const [runs, setRuns] = React.useState(initial);
const [filter, setFilter] = React.useState<(typeof filters)[number]["id"]>("all");
const [selectedId, setSelectedId] = React.useState<string | null>(initial[1]?.id ?? null);
const [now, setNow] = React.useState<number | null>(null);
// Simulate the running agents making progress.
React.useEffect(() => {
setNow(Date.now());
const id = setInterval(() => {
setNow(Date.now());
setRuns((prev) =>
prev.map((r) => {
if (r.status !== "running" || Math.random() > 0.25) return r;
const idx = r.steps.findIndex((s) => s.status === "running");
if (idx === -1) return r;
const steps = r.steps.map((s, i) => (i === idx ? { ...s, status: "completed" as StepStatus } : i === idx + 1 ? { ...s, status: "running" as StepStatus } : s));
const finished = steps.every((s) => s.status === "completed");
return { ...r, steps, status: finished ? "review" : r.status, output: finished ? `${r.output}\n\n_All steps completed. Waiting for your review._` : r.output };
}),
);
}, 1500);
return () => clearInterval(id);
}, []);
const visible = runs.filter((r) => filter === "all" || r.status === filter);
const selected = runs.find((r) => r.id === selectedId) ?? null;
const counts = runs.reduce<Record<string, number>>((acc, r) => ({ ...acc, [r.status]: (acc[r.status] ?? 0) + 1 }), {});
const setStatus = (id: string, status: RunStatus) => setRuns((prev) => prev.map((r) => (r.id === id ? { ...r, status } : r)));
return (
<div data-slot="agent-inbox" className={cn("flex h-full min-h-0 w-full flex-col overflow-hidden rounded-3xl border bg-background shadow-xs", className)}>
<header className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-md bg-brand text-primary-foreground"><CpuChipIcon size={14} className="flex" /></span>
Agent runs
<span className="text-xs font-normal text-muted-foreground">{counts.running ?? 0} running · {counts.review ?? 0} to review</span>
</div>
<Button size="sm" className="rounded-full"><PlusIcon size={14} className="flex" /> New run</Button>
</header>
<div className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,26rem)]">
<div className="flex min-h-0 flex-col">
<div className="flex shrink-0 gap-1 overflow-x-auto border-b px-3 py-2 no-scrollbar">
{filters.map((f) => (
<button key={f.id} type="button" onClick={() => setFilter(f.id)} className={cn("h-7 shrink-0 rounded-full px-3 text-xs font-medium transition-colors", filter === f.id ? "bg-foreground text-background" : "text-muted-foreground hover:bg-accent hover:text-foreground")}>
{f.label}{f.id !== "all" && counts[f.id] ? <span className="ml-1 opacity-70">{counts[f.id]}</span> : null}
</button>
))}
</div>
<ul className="min-h-0 flex-1 overflow-y-auto p-3 grid content-start gap-2 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
{visible.map((r) => {
const m = meta[r.status];
const Icon = m.icon;
const done = r.steps.filter((s) => s.status === "completed").length;
return (
<li key={r.id}>
<button type="button" onClick={() => setSelectedId(r.id)} className={cn("flex w-full flex-col gap-2 rounded-2xl border bg-card p-3 text-left transition-colors hover:border-brand/40", selectedId === r.id && "border-brand/60 bg-brand-soft/20")}>
<div className="flex items-start justify-between gap-2">
<span className="line-clamp-2 text-sm font-medium leading-5">{r.title}</span>
<Badge className={cn("shrink-0 gap-1 border-transparent", m.className)}><Icon size={12} className={cn("flex", m.iconClass)} />{m.label}</Badge>
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="rounded bg-muted px-1.5 py-0.5 font-mono">{r.agent}</span>
<span>{r.status === "queued" ? "queued" : `${ago(r.startedAt, now)} ago`}</span>
<span className="ml-auto tabular-nums">{done}/{r.steps.length} steps</span>
</div>
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full transition-[width] duration-500", r.status === "failed" ? "bg-red-500" : "bg-brand")} style={{ width: `${(done / r.steps.length) * 100}%` }} />
</div>
{r.output ? <p className="line-clamp-1 text-xs text-muted-foreground">{r.output.split("\n")[0].replace(/[#*_`]/g, "")}</p> : null}
</button>
</li>
);
})}
{visible.length === 0 ? <li className="col-span-full py-10 text-center text-sm text-muted-foreground">Nothing here.</li> : null}
</ul>
</div>
<aside className="flex min-h-0 flex-col border-t bg-surface lg:border-t-0 lg:border-l">
{selected ? (
<>
<div className="flex shrink-0 flex-col gap-2 border-b p-4">
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-medium leading-5">{selected.title}</h3>
<Badge className={cn("shrink-0 border-transparent", meta[selected.status].className)}>{meta[selected.status].label}</Badge>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="rounded bg-muted px-1.5 py-0.5 font-mono">{selected.agent}</span>
<span>started {ago(selected.startedAt, now)} ago</span>
</div>
{selected.status === "review" ? (
<div className="flex gap-2 pt-1">
<Button size="xs" onClick={() => setStatus(selected.id, "done")}>Approve</Button>
<Button size="xs" variant="outline" onClick={() => setStatus(selected.id, "running")}>Request changes</Button>
</div>
) : selected.status === "failed" ? (
<div className="flex gap-2 pt-1"><Button size="xs" onClick={() => setStatus(selected.id, "running")}><ArrowPathIcon size={12} className="flex" /> Retry</Button></div>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<Steps className="mb-5">
{selected.steps.map((s, i) => (
<Step key={i} status={s.status} isLast={i === selected.steps.length - 1} className="pb-3">
<StepIndicator className="size-6" />
<StepBody className="pt-0"><StepHeader className="min-h-6"><StepTitle>{s.title}</StepTitle></StepHeader></StepBody>
</Step>
))}
</Steps>
<div className="rounded-xl border bg-background p-3">
<span className="mb-2 block text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Output</span>
{selected.output ? <MessageMarkdown className="text-[13px]">{selected.output}</MessageMarkdown> : <p className="text-sm text-muted-foreground">No output yet.</p>}
</div>
</div>
</>
) : (
<p className="p-6 text-center text-sm text-muted-foreground">Select a run to see its steps and output.</p>
)}
</aside>
</div>
</div>
);
}What’s inside
- Run cards show status, agent, elapsed time, step progress, and the latest output line.
- Filters by status with live counts.
- Detail panel renders the run’s steps and markdown output, with approve, request changes, and retry actions.
Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/agent-inbox.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/agent-inbox.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/agent-inbox.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/agent-inbox.jsonInstall the dependencies:
npm install @heroicons-animated/react motionAdd the shadcn primitives it builds on:
npx shadcn@latest add badge buttonCopy the source into your project:
"use client";
import * as React from "react";
import { ArrowPathIcon, CheckCircleIcon, ClockIcon, CpuChipIcon, HandRaisedIcon, PlusIcon, XCircleIcon } from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { MessageMarkdown } from "@/components/pandacoderz-ui/message";
import { Step, StepBody, StepHeader, StepIndicator, StepTitle, Steps, type StepStatus } from "@/components/pandacoderz-ui/steps";
type RunStatus = "queued" | "running" | "review" | "done" | "failed";
type Run = {
id: string;
title: string;
agent: string;
status: RunStatus;
startedAt: number;
steps: { title: string; status: StepStatus }[];
output: string;
};
const now = Date.now();
const seed: Run[] = [
{ id: "r1", title: "Migrate auth middleware to Edge runtime", agent: "coder", status: "running", startedAt: now - 4 * 60_000, steps: [{ title: "Read middleware", status: "completed" }, { title: "Rewrite for Edge APIs", status: "running" }, { title: "Run integration tests", status: "pending" }], output: "Rewriting `middleware.ts`. The `jsonwebtoken` import is Node-only; swapping to `jose`…" },
{ id: "r2", title: "Weekly changelog from merged PRs", agent: "writer", status: "review", startedAt: now - 11 * 60_000, steps: [{ title: "Fetch merged PRs", status: "completed" }, { title: "Group by area", status: "completed" }, { title: "Draft changelog", status: "waiting" }], output: "## Week 36\n\n**Chat**\n- Streaming markdown now handles partial tables\n- Stop button cancels in-flight tool calls\n\n**Docs**\n- New Blocks section\n\n_Ready for review._" },
{ id: "r3", title: "Triage 14 new GitHub issues", agent: "triage", status: "done", startedAt: now - 38 * 60_000, steps: [{ title: "Read issues", status: "completed" }, { title: "Label and assign", status: "completed" }], output: "Labelled 14 issues: 6 bug, 5 enhancement, 3 question. Assigned 4 to @spencer. Two look like duplicates of #118." },
{ id: "r4", title: "Regenerate pricing page copy", agent: "writer", status: "failed", startedAt: now - 52 * 60_000, steps: [{ title: "Fetch pricing tiers", status: "error" }], output: "`GET /api/pricing` returned 401. The service token may have expired." },
{ id: "r5", title: "Backfill embeddings for docs", agent: "indexer", status: "queued", startedAt: now, steps: [{ title: "Chunk 212 pages", status: "pending" }, { title: "Embed", status: "pending" }, { title: "Upsert", status: "pending" }], output: "" },
{ id: "r6", title: "Summarize customer calls (Sep 5)", agent: "analyst", status: "running", startedAt: now - 90_000, steps: [{ title: "Transcribe 3 calls", status: "completed" }, { title: "Extract themes", status: "running" }, { title: "Write summary", status: "pending" }], output: "Themes so far: onboarding friction, pricing clarity…" },
];
const meta: Record<RunStatus, { label: string; icon: React.ComponentType<{ size?: number; className?: string }>; className: string; iconClass?: string }> = {
queued: { label: "Queued", icon: ClockIcon, className: "bg-muted text-muted-foreground" },
running: { label: "Running", icon: ArrowPathIcon, className: "bg-brand-soft text-brand", iconClass: "animate-spin" },
review: { label: "Needs review", icon: HandRaisedIcon, className: "bg-amber-500/15 text-amber-600 dark:text-amber-400" },
done: { label: "Done", icon: CheckCircleIcon, className: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400" },
failed: { label: "Failed", icon: XCircleIcon, className: "bg-red-500/15 text-red-600 dark:text-red-400" },
};
const filters: { id: "all" | RunStatus; label: string }[] = [
{ id: "all", label: "All" },
{ id: "running", label: "Running" },
{ id: "review", label: "Needs review" },
{ id: "done", label: "Done" },
{ id: "failed", label: "Failed" },
];
/** `now` is null during SSR so relative times never mismatch on hydration. */
function ago(ts: number, now: number | null) {
if (now === null) return "…";
const s = Math.max(0, Math.round((now - ts) / 1000));
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.round(s / 60)}m`;
return `${Math.round(s / 3600)}h`;
}
export type AgentInboxProps = { className?: string; runs?: Run[] };
export default function AgentInbox({ className, runs: initial = seed }: AgentInboxProps) {
const [runs, setRuns] = React.useState(initial);
const [filter, setFilter] = React.useState<(typeof filters)[number]["id"]>("all");
const [selectedId, setSelectedId] = React.useState<string | null>(initial[1]?.id ?? null);
const [now, setNow] = React.useState<number | null>(null);
// Simulate the running agents making progress.
React.useEffect(() => {
setNow(Date.now());
const id = setInterval(() => {
setNow(Date.now());
setRuns((prev) =>
prev.map((r) => {
if (r.status !== "running" || Math.random() > 0.25) return r;
const idx = r.steps.findIndex((s) => s.status === "running");
if (idx === -1) return r;
const steps = r.steps.map((s, i) => (i === idx ? { ...s, status: "completed" as StepStatus } : i === idx + 1 ? { ...s, status: "running" as StepStatus } : s));
const finished = steps.every((s) => s.status === "completed");
return { ...r, steps, status: finished ? "review" : r.status, output: finished ? `${r.output}\n\n_All steps completed. Waiting for your review._` : r.output };
}),
);
}, 1500);
return () => clearInterval(id);
}, []);
const visible = runs.filter((r) => filter === "all" || r.status === filter);
const selected = runs.find((r) => r.id === selectedId) ?? null;
const counts = runs.reduce<Record<string, number>>((acc, r) => ({ ...acc, [r.status]: (acc[r.status] ?? 0) + 1 }), {});
const setStatus = (id: string, status: RunStatus) => setRuns((prev) => prev.map((r) => (r.id === id ? { ...r, status } : r)));
return (
<div data-slot="agent-inbox" className={cn("flex h-full min-h-0 w-full flex-col overflow-hidden rounded-3xl border bg-background shadow-xs", className)}>
<header className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-md bg-brand text-primary-foreground"><CpuChipIcon size={14} className="flex" /></span>
Agent runs
<span className="text-xs font-normal text-muted-foreground">{counts.running ?? 0} running · {counts.review ?? 0} to review</span>
</div>
<Button size="sm" className="rounded-full"><PlusIcon size={14} className="flex" /> New run</Button>
</header>
<div className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,26rem)]">
<div className="flex min-h-0 flex-col">
<div className="flex shrink-0 gap-1 overflow-x-auto border-b px-3 py-2 no-scrollbar">
{filters.map((f) => (
<button key={f.id} type="button" onClick={() => setFilter(f.id)} className={cn("h-7 shrink-0 rounded-full px-3 text-xs font-medium transition-colors", filter === f.id ? "bg-foreground text-background" : "text-muted-foreground hover:bg-accent hover:text-foreground")}>
{f.label}{f.id !== "all" && counts[f.id] ? <span className="ml-1 opacity-70">{counts[f.id]}</span> : null}
</button>
))}
</div>
<ul className="min-h-0 flex-1 overflow-y-auto p-3 grid content-start gap-2 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
{visible.map((r) => {
const m = meta[r.status];
const Icon = m.icon;
const done = r.steps.filter((s) => s.status === "completed").length;
return (
<li key={r.id}>
<button type="button" onClick={() => setSelectedId(r.id)} className={cn("flex w-full flex-col gap-2 rounded-2xl border bg-card p-3 text-left transition-colors hover:border-brand/40", selectedId === r.id && "border-brand/60 bg-brand-soft/20")}>
<div className="flex items-start justify-between gap-2">
<span className="line-clamp-2 text-sm font-medium leading-5">{r.title}</span>
<Badge className={cn("shrink-0 gap-1 border-transparent", m.className)}><Icon size={12} className={cn("flex", m.iconClass)} />{m.label}</Badge>
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="rounded bg-muted px-1.5 py-0.5 font-mono">{r.agent}</span>
<span>{r.status === "queued" ? "queued" : `${ago(r.startedAt, now)} ago`}</span>
<span className="ml-auto tabular-nums">{done}/{r.steps.length} steps</span>
</div>
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full transition-[width] duration-500", r.status === "failed" ? "bg-red-500" : "bg-brand")} style={{ width: `${(done / r.steps.length) * 100}%` }} />
</div>
{r.output ? <p className="line-clamp-1 text-xs text-muted-foreground">{r.output.split("\n")[0].replace(/[#*_`]/g, "")}</p> : null}
</button>
</li>
);
})}
{visible.length === 0 ? <li className="col-span-full py-10 text-center text-sm text-muted-foreground">Nothing here.</li> : null}
</ul>
</div>
<aside className="flex min-h-0 flex-col border-t bg-surface lg:border-t-0 lg:border-l">
{selected ? (
<>
<div className="flex shrink-0 flex-col gap-2 border-b p-4">
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-medium leading-5">{selected.title}</h3>
<Badge className={cn("shrink-0 border-transparent", meta[selected.status].className)}>{meta[selected.status].label}</Badge>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="rounded bg-muted px-1.5 py-0.5 font-mono">{selected.agent}</span>
<span>started {ago(selected.startedAt, now)} ago</span>
</div>
{selected.status === "review" ? (
<div className="flex gap-2 pt-1">
<Button size="xs" onClick={() => setStatus(selected.id, "done")}>Approve</Button>
<Button size="xs" variant="outline" onClick={() => setStatus(selected.id, "running")}>Request changes</Button>
</div>
) : selected.status === "failed" ? (
<div className="flex gap-2 pt-1"><Button size="xs" onClick={() => setStatus(selected.id, "running")}><ArrowPathIcon size={12} className="flex" /> Retry</Button></div>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<Steps className="mb-5">
{selected.steps.map((s, i) => (
<Step key={i} status={s.status} isLast={i === selected.steps.length - 1} className="pb-3">
<StepIndicator className="size-6" />
<StepBody className="pt-0"><StepHeader className="min-h-6"><StepTitle>{s.title}</StepTitle></StepHeader></StepBody>
</Step>
))}
</Steps>
<div className="rounded-xl border bg-background p-3">
<span className="mb-2 block text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Output</span>
{selected.output ? <MessageMarkdown className="text-[13px]">{selected.output}</MessageMarkdown> : <p className="text-sm text-muted-foreground">No output yet.</p>}
</div>
</div>
</>
) : (
<p className="p-6 text-center text-sm text-muted-foreground">Select a run to see its steps and output.</p>
)}
</aside>
</div>
</div>
);
}The registry item pulls in every component it depends on.
Usage
import AgentInbox from "@/components/blocks/agent-inbox/agent-inbox";
export default function Page() {
return (
<div className="h-dvh p-4">
<AgentInbox />
</div>
);
}Give the parent a bounded height. The block fills it and scrolls internally.
Going live
Pass your own runs and subscribe to updates from your orchestrator. The simulated interval that advances running steps is only there so the demo moves.