"use client";
import * as React from "react";
import { ArrowPathIcon, CheckIcon, CommandLineIcon, StopIcon, XMarkIcon } from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/pandacoderz-ui/reasoning";
import { Step, StepBody, StepDescription, StepDetails, StepHeader, StepIndicator, StepMeta, StepTitle, Steps, type StepStatus } from "@/components/pandacoderz-ui/steps";
import { Tool, ToolContent, ToolInput, ToolOutput, ToolTrigger } from "@/components/pandacoderz-ui/tool";
import { sleep } from "@/lib/mock-stream";
type ToolCall = { name: string; input: unknown; output?: unknown; errorText?: string; status: "running" | "completed" | "error" };
type RunStep = {
id: string;
title: string;
description?: string;
status: StepStatus;
startedAt?: number;
endedAt?: number;
tools: ToolCall[];
reasoning?: string;
/** Marks a step that needs a human decision before it runs. */
gate?: boolean;
};
const plan: Omit<RunStep, "status" | "tools">[] = [
{ id: "s1", title: "Understand the failing test", description: "Read the test file and the component it exercises.", reasoning: "The CI log points at prompt-input.test.tsx. I'll read the test and the handler before changing anything." },
{ id: "s2", title: "Patch the Enter handler", description: "Read from the event target instead of the stale prop." },
{ id: "s3", title: "Run the test suite", description: "Executes `pnpm vitest run` in the repo.", gate: true },
{ id: "s4", title: "Open a pull request", description: "Push the branch and create a PR with a summary." },
];
const toolScripts: Record<string, ToolCall[]> = {
s1: [
{ name: "read_file", input: { path: "src/components/prompt-input.test.tsx" }, output: { lines: 84, matched: "submits on Enter" }, status: "running" },
{ name: "read_file", input: { path: "src/components/prompt-input.tsx" }, output: { lines: 223 }, status: "running" },
],
s2: [{ name: "edit_file", input: { path: "src/components/prompt-input.tsx", hunks: 1 }, output: { applied: true }, status: "running" }],
s3: [{ name: "run_command", input: { command: "pnpm vitest run" }, output: { passed: 12, failed: 0, durationMs: 2310 }, status: "running" }],
s4: [{ name: "create_pull_request", input: { branch: "fix/enter-key", title: "Fix Enter submit reading stale value" }, output: { url: "https://github.com/pandacoderz/ui/pull/42" }, status: "running" }],
};
function elapsed(step: RunStep) {
if (!step.startedAt) return "";
const end = step.endedAt ?? Date.now();
return `${((end - step.startedAt) / 1000).toFixed(1)}s`;
}
export type AgentRunProps = { className?: string; task?: string; autoStart?: boolean };
export default function AgentRun({ className, task = "Fix the flaky Enter key test and open a PR", autoStart = true }: AgentRunProps) {
const [steps, setSteps] = React.useState<RunStep[]>(() => plan.map((p) => ({ ...p, status: "pending", tools: [] })));
const [logs, setLogs] = React.useState<string[]>([]);
const [runState, setRunState] = React.useState<"idle" | "running" | "waiting" | "done" | "failed" | "stopped">("idle");
const [, tick] = React.useState(0);
const abortRef = React.useRef<AbortController | null>(null);
const gateRef = React.useRef<((ok: boolean) => void) | null>(null);
React.useEffect(() => {
if (runState !== "running") return;
const id = setInterval(() => tick((t) => t + 1), 200);
return () => clearInterval(id);
}, [runState]);
const log = (line: string) => setLogs((prev) => [...prev, `${new Date().toLocaleTimeString([], { hour12: false })} ${line}`]);
const patch = (id: string, fn: (s: RunStep) => RunStep) => setSteps((prev) => prev.map((s) => (s.id === id ? fn(s) : s)));
const run = React.useCallback(async () => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setSteps(plan.map((p) => ({ ...p, status: "pending", tools: [] })));
setLogs([]);
setRunState("running");
log(`task: ${task}`);
try {
for (const p of plan) {
if (p.gate) {
patch(p.id, (s) => ({ ...s, status: "waiting" }));
setRunState("waiting");
log(`waiting for approval: ${p.title}`);
const ok = await new Promise<boolean>((resolve) => {
gateRef.current = resolve;
controller.signal.addEventListener("abort", () => resolve(false), { once: true });
});
gateRef.current = null;
if (controller.signal.aborted) return;
if (!ok) {
patch(p.id, (s) => ({ ...s, status: "error", description: "Denied by user. Run stopped." }));
log("approval denied; stopping");
setRunState("failed");
return;
}
setRunState("running");
log("approved");
}
patch(p.id, (s) => ({ ...s, status: "running", startedAt: Date.now() }));
log(`start: ${p.title}`);
await sleep(400, controller.signal);
for (const tool of toolScripts[p.id] ?? []) {
patch(p.id, (s) => ({ ...s, tools: [...s.tools, { ...tool, status: "running" }] }));
log(`tool ${tool.name} ${JSON.stringify(tool.input)}`);
await sleep(700 + Math.random() * 500, controller.signal);
patch(p.id, (s) => ({ ...s, tools: s.tools.map((t) => (t.name === tool.name && t.status === "running" ? { ...t, status: "completed" } : t)) }));
log(`tool ${tool.name} → ok`);
}
patch(p.id, (s) => ({ ...s, status: "completed", endedAt: Date.now() }));
}
log("done");
setRunState("done");
} catch {
/* aborted */
}
}, [task]);
const stop = () => {
abortRef.current?.abort();
setSteps((prev) => prev.map((s) => (s.status === "running" || s.status === "waiting" ? { ...s, status: "error", endedAt: Date.now(), description: "Stopped." } : s)));
setRunState("stopped");
log("stopped by user");
};
React.useEffect(() => {
if (autoStart) void run();
return () => abortRef.current?.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const completed = steps.filter((s) => s.status === "completed").length;
const badge: Record<typeof runState, { label: string; className: string }> = {
idle: { label: "Idle", className: "bg-muted text-muted-foreground" },
running: { label: "Running", className: "bg-brand-soft text-brand" },
waiting: { label: "Needs approval", className: "bg-amber-500/15 text-amber-600 dark:text-amber-400" },
done: { label: "Completed", className: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400" },
failed: { label: "Failed", className: "bg-red-500/15 text-red-600 dark:text-red-400" },
stopped: { label: "Stopped", className: "bg-muted text-muted-foreground" },
};
return (
<div data-slot="agent-run" 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 shrink-0 flex-wrap items-center gap-3 border-b px-4 py-3">
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{task}</span>
<Badge className={cn("border-transparent", badge[runState].className)}>{badge[runState].label}</Badge>
</div>
<span className="text-xs text-muted-foreground">{completed} of {steps.length} steps · agent: coder-v2</span>
</div>
<div className="flex items-center gap-1">
{runState === "running" || runState === "waiting" ? (
<Button size="sm" variant="secondary" className="rounded-full" onClick={stop}><StopIcon size={14} className="flex" /> Stop</Button>
) : (
<Button size="sm" className="rounded-full" onClick={() => void run()}><ArrowPathIcon size={14} className="flex" /> {runState === "idle" ? "Start" : "Run again"}</Button>
)}
</div>
</header>
<div className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,22rem)]">
<div className="min-h-0 overflow-y-auto p-5">
<Steps>
{steps.map((s, i) => (
<Step key={s.id} status={s.status} isLast={i === steps.length - 1}>
<StepIndicator />
<StepBody>
<StepHeader>
<StepTitle>{s.title}</StepTitle>
<StepMeta>{s.status === "running" ? elapsed(s) : s.endedAt ? elapsed(s) : ""}</StepMeta>
</StepHeader>
{s.description ? <StepDescription>{s.description}</StepDescription> : null}
{s.reasoning && s.status !== "pending" ? (
<Reasoning isStreaming={false} className="mt-1"><ReasoningTrigger /><ReasoningContent>{s.reasoning}</ReasoningContent></Reasoning>
) : null}
{s.status === "waiting" ? (
<div className="mt-1 flex flex-wrap items-center gap-2 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3">
<span className="flex-1 text-xs text-muted-foreground">This step runs a shell command. Allow it?</span>
<Button size="xs" variant="outline" onClick={() => gateRef.current?.(false)}><XMarkIcon size={12} className="flex" /> Deny</Button>
<Button size="xs" onClick={() => gateRef.current?.(true)}><CheckIcon size={12} className="flex" /> Approve</Button>
</div>
) : null}
{s.tools.length ? (
<StepDetails label={`${s.tools.length} tool call${s.tools.length > 1 ? "s" : ""}`} defaultOpen={s.status === "running"}>
{s.tools.map((t, ti) => (
<Tool key={ti} status={t.status}>
<ToolTrigger name={t.name} />
<ToolContent>
<ToolInput payload={t.input} />
<ToolOutput payload={t.output ?? null} showWhen={["completed", "error"]} errorText={t.errorText} />
</ToolContent>
</Tool>
))}
</StepDetails>
) : null}
</StepBody>
</Step>
))}
</Steps>
</div>
<aside className="flex min-h-0 flex-col border-t bg-surface lg:border-t-0 lg:border-l">
<div className="flex h-10 shrink-0 items-center gap-2 border-b px-4 text-xs font-medium text-muted-foreground">
<CommandLineIcon size={14} className="flex" /> Live log
</div>
<pre className="min-h-0 flex-1 overflow-auto p-4 font-mono text-[11px] leading-5 text-muted-foreground">
{logs.length ? logs.join("\n") : "Waiting to start…"}
{runState === "running" ? <span className="ml-1 inline-block h-3 w-1.5 animate-pulse bg-brand align-middle" /> : null}
</pre>
</aside>
</div>
</div>
);
}/**
* Helpers for scripted streaming in demos. Blocks use these to fake a model
* response token by token without touching the network. Swap them for a real
* fetch in production; the UI code does not change.
*/
export function sleep(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const id = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(id);
reject(signal.reason);
},
{ once: true },
);
});
}
/** Split text into word-ish chunks so streaming looks natural. */
export function chunk(text: string): string[] {
return text.match(/\S+\s*|\s+/g) ?? [text];
}
export function jitter(base: number) {
return base + Math.random() * base;
}
/** Yield a string piece by piece with a small random delay between pieces. */
export async function* streamText(
text: string,
signal?: AbortSignal,
delayMs = 14,
): AsyncGenerator<string> {
for (const piece of chunk(text)) {
if (signal?.aborted) return;
yield piece;
await sleep(jitter(delayMs), signal);
}
}
let counter = 0;
export const uid = (prefix = "id") => `${prefix}-${Date.now().toString(36)}-${++counter}`;What’s inside
- Steps shows the plan with pending, running, completed, failed, and waiting states.
- Tool cards nest inside each step’s collapsible details.
- Reasoning shows the agent’s rationale on steps that have one.
- Approval gate pauses the run until you approve or deny, then continues or stops.
Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/agent-run.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/agent-run.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/agent-run.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/agent-run.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, CheckIcon, CommandLineIcon, StopIcon, XMarkIcon } from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/pandacoderz-ui/reasoning";
import { Step, StepBody, StepDescription, StepDetails, StepHeader, StepIndicator, StepMeta, StepTitle, Steps, type StepStatus } from "@/components/pandacoderz-ui/steps";
import { Tool, ToolContent, ToolInput, ToolOutput, ToolTrigger } from "@/components/pandacoderz-ui/tool";
import { sleep } from "@/lib/mock-stream";
type ToolCall = { name: string; input: unknown; output?: unknown; errorText?: string; status: "running" | "completed" | "error" };
type RunStep = {
id: string;
title: string;
description?: string;
status: StepStatus;
startedAt?: number;
endedAt?: number;
tools: ToolCall[];
reasoning?: string;
/** Marks a step that needs a human decision before it runs. */
gate?: boolean;
};
const plan: Omit<RunStep, "status" | "tools">[] = [
{ id: "s1", title: "Understand the failing test", description: "Read the test file and the component it exercises.", reasoning: "The CI log points at prompt-input.test.tsx. I'll read the test and the handler before changing anything." },
{ id: "s2", title: "Patch the Enter handler", description: "Read from the event target instead of the stale prop." },
{ id: "s3", title: "Run the test suite", description: "Executes `pnpm vitest run` in the repo.", gate: true },
{ id: "s4", title: "Open a pull request", description: "Push the branch and create a PR with a summary." },
];
const toolScripts: Record<string, ToolCall[]> = {
s1: [
{ name: "read_file", input: { path: "src/components/prompt-input.test.tsx" }, output: { lines: 84, matched: "submits on Enter" }, status: "running" },
{ name: "read_file", input: { path: "src/components/prompt-input.tsx" }, output: { lines: 223 }, status: "running" },
],
s2: [{ name: "edit_file", input: { path: "src/components/prompt-input.tsx", hunks: 1 }, output: { applied: true }, status: "running" }],
s3: [{ name: "run_command", input: { command: "pnpm vitest run" }, output: { passed: 12, failed: 0, durationMs: 2310 }, status: "running" }],
s4: [{ name: "create_pull_request", input: { branch: "fix/enter-key", title: "Fix Enter submit reading stale value" }, output: { url: "https://github.com/pandacoderz/ui/pull/42" }, status: "running" }],
};
function elapsed(step: RunStep) {
if (!step.startedAt) return "";
const end = step.endedAt ?? Date.now();
return `${((end - step.startedAt) / 1000).toFixed(1)}s`;
}
export type AgentRunProps = { className?: string; task?: string; autoStart?: boolean };
export default function AgentRun({ className, task = "Fix the flaky Enter key test and open a PR", autoStart = true }: AgentRunProps) {
const [steps, setSteps] = React.useState<RunStep[]>(() => plan.map((p) => ({ ...p, status: "pending", tools: [] })));
const [logs, setLogs] = React.useState<string[]>([]);
const [runState, setRunState] = React.useState<"idle" | "running" | "waiting" | "done" | "failed" | "stopped">("idle");
const [, tick] = React.useState(0);
const abortRef = React.useRef<AbortController | null>(null);
const gateRef = React.useRef<((ok: boolean) => void) | null>(null);
React.useEffect(() => {
if (runState !== "running") return;
const id = setInterval(() => tick((t) => t + 1), 200);
return () => clearInterval(id);
}, [runState]);
const log = (line: string) => setLogs((prev) => [...prev, `${new Date().toLocaleTimeString([], { hour12: false })} ${line}`]);
const patch = (id: string, fn: (s: RunStep) => RunStep) => setSteps((prev) => prev.map((s) => (s.id === id ? fn(s) : s)));
const run = React.useCallback(async () => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setSteps(plan.map((p) => ({ ...p, status: "pending", tools: [] })));
setLogs([]);
setRunState("running");
log(`task: ${task}`);
try {
for (const p of plan) {
if (p.gate) {
patch(p.id, (s) => ({ ...s, status: "waiting" }));
setRunState("waiting");
log(`waiting for approval: ${p.title}`);
const ok = await new Promise<boolean>((resolve) => {
gateRef.current = resolve;
controller.signal.addEventListener("abort", () => resolve(false), { once: true });
});
gateRef.current = null;
if (controller.signal.aborted) return;
if (!ok) {
patch(p.id, (s) => ({ ...s, status: "error", description: "Denied by user. Run stopped." }));
log("approval denied; stopping");
setRunState("failed");
return;
}
setRunState("running");
log("approved");
}
patch(p.id, (s) => ({ ...s, status: "running", startedAt: Date.now() }));
log(`start: ${p.title}`);
await sleep(400, controller.signal);
for (const tool of toolScripts[p.id] ?? []) {
patch(p.id, (s) => ({ ...s, tools: [...s.tools, { ...tool, status: "running" }] }));
log(`tool ${tool.name} ${JSON.stringify(tool.input)}`);
await sleep(700 + Math.random() * 500, controller.signal);
patch(p.id, (s) => ({ ...s, tools: s.tools.map((t) => (t.name === tool.name && t.status === "running" ? { ...t, status: "completed" } : t)) }));
log(`tool ${tool.name} → ok`);
}
patch(p.id, (s) => ({ ...s, status: "completed", endedAt: Date.now() }));
}
log("done");
setRunState("done");
} catch {
/* aborted */
}
}, [task]);
const stop = () => {
abortRef.current?.abort();
setSteps((prev) => prev.map((s) => (s.status === "running" || s.status === "waiting" ? { ...s, status: "error", endedAt: Date.now(), description: "Stopped." } : s)));
setRunState("stopped");
log("stopped by user");
};
React.useEffect(() => {
if (autoStart) void run();
return () => abortRef.current?.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const completed = steps.filter((s) => s.status === "completed").length;
const badge: Record<typeof runState, { label: string; className: string }> = {
idle: { label: "Idle", className: "bg-muted text-muted-foreground" },
running: { label: "Running", className: "bg-brand-soft text-brand" },
waiting: { label: "Needs approval", className: "bg-amber-500/15 text-amber-600 dark:text-amber-400" },
done: { label: "Completed", className: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400" },
failed: { label: "Failed", className: "bg-red-500/15 text-red-600 dark:text-red-400" },
stopped: { label: "Stopped", className: "bg-muted text-muted-foreground" },
};
return (
<div data-slot="agent-run" 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 shrink-0 flex-wrap items-center gap-3 border-b px-4 py-3">
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{task}</span>
<Badge className={cn("border-transparent", badge[runState].className)}>{badge[runState].label}</Badge>
</div>
<span className="text-xs text-muted-foreground">{completed} of {steps.length} steps · agent: coder-v2</span>
</div>
<div className="flex items-center gap-1">
{runState === "running" || runState === "waiting" ? (
<Button size="sm" variant="secondary" className="rounded-full" onClick={stop}><StopIcon size={14} className="flex" /> Stop</Button>
) : (
<Button size="sm" className="rounded-full" onClick={() => void run()}><ArrowPathIcon size={14} className="flex" /> {runState === "idle" ? "Start" : "Run again"}</Button>
)}
</div>
</header>
<div className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,22rem)]">
<div className="min-h-0 overflow-y-auto p-5">
<Steps>
{steps.map((s, i) => (
<Step key={s.id} status={s.status} isLast={i === steps.length - 1}>
<StepIndicator />
<StepBody>
<StepHeader>
<StepTitle>{s.title}</StepTitle>
<StepMeta>{s.status === "running" ? elapsed(s) : s.endedAt ? elapsed(s) : ""}</StepMeta>
</StepHeader>
{s.description ? <StepDescription>{s.description}</StepDescription> : null}
{s.reasoning && s.status !== "pending" ? (
<Reasoning isStreaming={false} className="mt-1"><ReasoningTrigger /><ReasoningContent>{s.reasoning}</ReasoningContent></Reasoning>
) : null}
{s.status === "waiting" ? (
<div className="mt-1 flex flex-wrap items-center gap-2 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3">
<span className="flex-1 text-xs text-muted-foreground">This step runs a shell command. Allow it?</span>
<Button size="xs" variant="outline" onClick={() => gateRef.current?.(false)}><XMarkIcon size={12} className="flex" /> Deny</Button>
<Button size="xs" onClick={() => gateRef.current?.(true)}><CheckIcon size={12} className="flex" /> Approve</Button>
</div>
) : null}
{s.tools.length ? (
<StepDetails label={`${s.tools.length} tool call${s.tools.length > 1 ? "s" : ""}`} defaultOpen={s.status === "running"}>
{s.tools.map((t, ti) => (
<Tool key={ti} status={t.status}>
<ToolTrigger name={t.name} />
<ToolContent>
<ToolInput payload={t.input} />
<ToolOutput payload={t.output ?? null} showWhen={["completed", "error"]} errorText={t.errorText} />
</ToolContent>
</Tool>
))}
</StepDetails>
) : null}
</StepBody>
</Step>
))}
</Steps>
</div>
<aside className="flex min-h-0 flex-col border-t bg-surface lg:border-t-0 lg:border-l">
<div className="flex h-10 shrink-0 items-center gap-2 border-b px-4 text-xs font-medium text-muted-foreground">
<CommandLineIcon size={14} className="flex" /> Live log
</div>
<pre className="min-h-0 flex-1 overflow-auto p-4 font-mono text-[11px] leading-5 text-muted-foreground">
{logs.length ? logs.join("\n") : "Waiting to start…"}
{runState === "running" ? <span className="ml-1 inline-block h-3 w-1.5 animate-pulse bg-brand align-middle" /> : null}
</pre>
</aside>
</div>
</div>
);
}/**
* Helpers for scripted streaming in demos. Blocks use these to fake a model
* response token by token without touching the network. Swap them for a real
* fetch in production; the UI code does not change.
*/
export function sleep(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const id = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(id);
reject(signal.reason);
},
{ once: true },
);
});
}
/** Split text into word-ish chunks so streaming looks natural. */
export function chunk(text: string): string[] {
return text.match(/\S+\s*|\s+/g) ?? [text];
}
export function jitter(base: number) {
return base + Math.random() * base;
}
/** Yield a string piece by piece with a small random delay between pieces. */
export async function* streamText(
text: string,
signal?: AbortSignal,
delayMs = 14,
): AsyncGenerator<string> {
for (const piece of chunk(text)) {
if (signal?.aborted) return;
yield piece;
await sleep(jitter(delayMs), signal);
}
}
let counter = 0;
export const uid = (prefix = "id") => `${prefix}-${Date.now().toString(36)}-${++counter}`;The registry item pulls in every component it depends on.
Usage
import AgentRun from "@/components/blocks/agent-run/agent-run";
export default function Page() {
return (
<div className="h-dvh p-4">
<AgentRun />
</div>
);
}Give the parent a bounded height. The block fills it and scrolls internally.
Going live
The run function is a script that mutates step state in order. Replace it with a consumer of your agent’s event stream: map plan events to steps, tool events to the step’s tools, and emit a waiting step when the agent requests permission.