Screen
Agent run
BetaBeta — the props contract is close to final, but small breaking changes can still land.One autonomous agent run: its plan step by step, the tool calls behind each step, approvals it waits on, and what it produced.
Composition
Run waiting on an approval
- <AgentRunBlock>
- <SidebarProvider>
- <Sidebar>
- runs list
- <SidebarInset>
- <AgentRunHeader>
- <AgentRunElapsed />
- <AgentRunBody>
- <AgentRunPlan>
- <Timeline>
- <AgentRunStep>
- <TimelineItem>
- children
- <AgentRunToolCall>
- <Activity />
- <ToolApproval />
- <AgentRunToolCall>
- children
- <TimelineItem>
- <AgentRunStep>
- <Timeline>
- <AgentRunAside>
- <AgentRunSection>
- draft
- <CodeDiff />
- draft
- <AgentRunDetails />
- <AgentRunSection>
- <AgentRunPlan>
- <AgentRunHeader>
- <Sidebar>
- <SidebarProvider>
Installation
npx shadcn@latest add https://control-ui.dev/r/agent-run-block.jsonUsage
"use client";import { MastraReactProvider, type TaskItem, type ToolInvocationPart, useChat } from "@mastra/react";import type { ActivityState } from "@/components/control-ui/activity";import type { AgentRunStatus } from "@/components/control-ui/blocks/agent-run";import { AgentRunAside, AgentRunBlock, AgentRunBody, AgentRunDetails, AgentRunHeader, AgentRunPlan, AgentRunStep, AgentRunToolCall,} from "@/components/control-ui/blocks/agent-run";import type { ToolApprovalState } from "@/components/control-ui/tool-approval";import { ToolApproval, ToolApprovalApprove, ToolApprovalBody, ToolApprovalDeny, ToolApprovalField, ToolApprovalFooter, ToolApprovalHeader, ToolApprovalIcon, ToolApprovalPreview, ToolApprovalRisk, ToolApprovalStatus, ToolApprovalTitle,} from "@/components/control-ui/tool-approval";import { Button } from "@/components/control-ui/ui/button";import type { TimelineState } from "@/components/control-ui/ui/timeline";type RunDetails = { id: string; title: string; startedAt: number; endedAt?: number };type TaskProgress = Pick<TaskItem, "id" | "status">;type ToolCallApprovals = { decisions: Record<string, { status: "approved" | "declined" }>; pending: ReadonlySet<string>; approve: (toolCallId: string) => Promise<void>; decline: (toolCallId: string) => Promise<void>;};const TASK_TOOL_NAMES: readonly string[] = ["task_write", "task_update", "task_complete", "task_check"];const BEFORE_PLAN = "before-plan";function isTaskList(value: unknown): value is { tasks: TaskProgress[] } { if (typeof value !== "object" || value === null || !("tasks" in value) || !Array.isArray(value.tasks)) return false; return value.tasks.every( (task: unknown) => typeof task === "object" && task !== null && "id" in task && typeof task.id === "string" && "status" in task && typeof task.status === "string", );}function parseTaskList(result: unknown) { if (typeof result !== "string") return isTaskList(result) ? result.tasks : undefined; try { const parsed: unknown = JSON.parse(result); return isTaskList(parsed) ? parsed.tasks : undefined; } catch { return undefined; }}function pendingApprovalIds(metadata: Record<string, unknown> | undefined) { const requests = metadata?.requireApprovalMetadata; if (typeof requests !== "object" || requests === null) return []; return Object.values(requests).flatMap((request: unknown) => typeof request === "object" && request !== null && "toolCallId" in request && typeof request.toolCallId === "string" ? [request.toolCallId] : [], );}function activityState(state: string): ActivityState { if (state === "result" || state === "output-available") return "success"; if (state === "output-error" || state === "output-denied") return "error"; if (state === "approval-requested") return "waiting"; if (state === "partial-call" || state === "input-streaming" || state === "call" || state === "approval-responded") return "running"; return "pending";}function approvalState(decision: "approved" | "declined" | undefined): ToolApprovalState { if (decision === "approved") return "approved"; if (decision === "declined") return "denied"; return "pending";}function awaitsApproval(part: ToolInvocationPart, approvals: ToolCallApprovals) { const invocation = part.toolInvocation; if (approvals.decisions[invocation.toolCallId] !== undefined) return false; return invocation.state === "approval-requested" || approvals.pending.has(invocation.toolCallId);}function toolState(part: ToolInvocationPart, approvals: ToolCallApprovals): ActivityState { return awaitsApproval(part, approvals) ? "waiting" : activityState(part.toolInvocation.state);}function groupToolsByTask(parts: readonly ToolInvocationPart[]) { let currentStepId: string | undefined; const toolsByStep = new Map<string, ToolInvocationPart[]>(); for (const part of parts) { const invocation = part.toolInvocation; if (TASK_TOOL_NAMES.includes(invocation.toolName)) { currentStepId = parseTaskList(invocation.result)?.find((task) => task.status === "in_progress")?.id ?? currentStepId; continue; } const stepId = currentStepId ?? BEFORE_PLAN; const bucket = toolsByStep.get(stepId) ?? []; bucket.push(part); toolsByStep.set(stepId, bucket); } return toolsByStep;}function gettingStartedState(tools: readonly ToolInvocationPart[], approvals: ToolCallApprovals): TimelineState { const states = tools.map((part) => toolState(part, approvals)); if (states.includes("waiting")) return "waiting"; if (states.includes("running")) return "running"; if (states.includes("error")) return "error"; return "success";}const taskStepStates = { completed: "success", in_progress: "running", pending: "pending" } satisfies Record< TaskItem["status"], TimelineState>;function renderTool(part: ToolInvocationPart, approvals: ToolCallApprovals) { const invocation = part.toolInvocation; const decision = approvals.decisions[invocation.toolCallId]?.status; const needsApproval = awaitsApproval(part, approvals) || decision !== undefined; return [ <AgentRunToolCall key={invocation.toolCallId} name={invocation.toolName} state={toolState(part, approvals)} input={invocation.args} output={invocation.result} errorText={invocation.errorText} />, needsApproval ? ( <ToolApproval key={`${invocation.toolCallId}-approval`} state={approvalState(decision)} onApprove={() => approvals.approve(invocation.toolCallId)} onDeny={() => approvals.decline(invocation.toolCallId)} > <ToolApprovalBody> <ToolApprovalHeader> <ToolApprovalIcon /> <ToolApprovalTitle>{`Run ${invocation.toolName}?`}</ToolApprovalTitle> <ToolApprovalRisk /> <ToolApprovalStatus /> </ToolApprovalHeader> <ToolApprovalPreview> <ToolApprovalField label="Input"> <div className="whitespace-pre-wrap wrap-anywhere">{JSON.stringify(invocation.args, null, 2)}</div> </ToolApprovalField> </ToolApprovalPreview> </ToolApprovalBody> <ToolApprovalFooter> <ToolApprovalDeny /> <ToolApprovalApprove /> </ToolApprovalFooter> </ToolApproval> ) : null, ];}function MastraAgentRun({ agentId, run }: { agentId: string; run: RunDetails }) { const { messages, tasks, isRunning, isAwaitingToolApproval, cancelRun, approveToolCall, declineToolCall, toolCallApprovals } = useChat({ agentId, }); const approvals = { decisions: toolCallApprovals, pending: new Set(messages.flatMap((message) => pendingApprovalIds(message.content.metadata))), approve: approveToolCall, decline: declineToolCall, }; const toolParts = messages .flatMap((message) => (message.role === "assistant" ? message.content.parts : [])) .filter((part): part is ToolInvocationPart => part.type === "tool-invocation"); const toolsByStep = groupToolsByTask(toolParts); const startedTools = toolsByStep.get(BEFORE_PLAN) ?? []; const toolCount = [...toolsByStep.values()].reduce((total, tools) => total + tools.length, 0); let status: AgentRunStatus = "pending"; if (isAwaitingToolApproval) status = "waiting"; else if (isRunning) status = "running"; else if (messages.length > 0 && tasks.every((task) => task.status === "completed")) status = "success"; const aside = ( <AgentRunAside> <AgentRunDetails items={[{ label: "Tool calls", value: String(toolCount) }]} /> </AgentRunAside> ); return ( <AgentRunBlock runs={[{ id: run.id, title: run.title, status }]} activeRunId={run.id}> <AgentRunHeader title={run.title} status={status} startedAt={run.startedAt} endedAt={run.endedAt} actions={ status === "running" ? ( <Button size="xs" variant="quiet" onClick={cancelRun}> Stop </Button> ) : null } /> <AgentRunBody aside={aside}> <AgentRunPlan description={tasks.length === 0 && startedTools.length === 0 ? "Waiting for the agent to plan…" : undefined}> {startedTools.length > 0 ? ( <AgentRunStep title="Getting started" state={gettingStartedState(startedTools, approvals)}> {startedTools.flatMap((part) => renderTool(part, approvals))} </AgentRunStep> ) : null} {tasks.map((task) => { const tools = toolsByStep.get(task.id) ?? []; const isWaiting = tools.some((part) => awaitsApproval(part, approvals)); return ( <AgentRunStep key={task.id} title={task.content} detail={task.status === "in_progress" ? task.activeForm : undefined} state={isWaiting ? "waiting" : taskStepStates[task.status]} > {tools.length > 0 ? tools.flatMap((part) => renderTool(part, approvals)) : null} </AgentRunStep> ); })} </AgentRunPlan> </AgentRunBody> </AgentRunBlock> );}export function AgentRunSurface({ agentId, baseUrl, run }: { agentId: string; baseUrl: string; run: RunDetails }) { return ( <MastraReactProvider baseUrl={baseUrl}> <MastraAgentRun agentId={agentId} run={run} /> </MastraReactProvider> );}Library dependencies
Owned source
This template’s recipe and private support files. Public dependencies stay linked above.
Block recipe
src/registry/blocks/control-ui/agent-run.tsxAgent run clock
src/registry/blocks/control-ui/agent-run-clock.tsAgent run sidebar
src/registry/blocks/control-ui/agent-run-sidebar.tsxAgent run status
src/registry/blocks/control-ui/agent-run-status.ts"use client";import { Check, Circle, CircleAlert, CircleDashed, CirclePause, LoaderCircle } from "lucide-react";import type { ComponentProps, ReactNode } from "react";import { createContext, useContext, useId } from "react";import type { ActivityProps } from "@/components/control-ui/activity";import { Activity, ActivityContent, ActivityDetail, ActivityDetailContent, ActivityDetailLabel, ActivityIcon, ActivityRow, ActivityStatus, ActivityTitle, ActivityTrigger,} from "@/components/control-ui/activity";import { cn } from "@/components/control-ui/lib/cn";import { Badge } from "@/components/control-ui/ui/badge";import { Card, CardAction, CardContent, CardHeader } from "@/components/control-ui/ui/card";import { LiveStatus } from "@/components/control-ui/ui/live-status";import { SidebarInset, SidebarProvider, type SidebarProviderProps, type SidebarStyle, SidebarTrigger,} from "@/components/control-ui/ui/sidebar";import type { TimelineItemProps, TimelineState } from "@/components/control-ui/ui/timeline";import { Timeline, TimelineContent, TimelineDescription, TimelineIndicator, TimelineItem, TimelineMeta, TimelineSeparator, TimelineTitle,} from "@/components/control-ui/ui/timeline";import { Heading, Text } from "@/components/control-ui/ui/typography";import { elapsedIsoDuration, formatElapsed, useRunClock } from "./agent-run-clock";import { AgentRunSidebar } from "./agent-run-sidebar";import { type AgentRunStatus, type AgentRunSummary, agentRunStatusColors, agentRunStatusLabels, agentRunStepLabels,} from "./agent-run-status";export type { AgentRunStatus, AgentRunSummary } from "./agent-run-status";type AgentRunLayout = "viewport" | "contained";const AgentRunLayoutContext = createContext<AgentRunLayout>("viewport");function useHeadingLevels() { const layout = useContext(AgentRunLayoutContext); return layout === "contained" ? ({ title: 2, section: 3 } as const) : ({ title: 1, section: 2 } as const);}export type AgentRunBlockProps = Omit<ComponentProps<"div">, "children"> & Pick<SidebarProviderProps, "persistOpen" | "keyboardShortcut"> & { appName?: string; brand?: ReactNode; runs: readonly AgentRunSummary[]; activeRunId?: string; onRunSelect?: (run: AgentRunSummary) => void; onNewRun?: () => void; sidebarFooter?: ReactNode; children: ReactNode; layout?: AgentRunLayout; };export function AgentRunBlock({ appName = "Agents", brand, runs, activeRunId, onRunSelect, onNewRun, sidebarFooter, children, layout = "viewport", className, style, ...props}: AgentRunBlockProps) { const providerStyle: SidebarStyle = { "--sidebar-width": "16rem", ...style }; return ( <SidebarProvider layout={layout} className={cn("bg-background text-foreground", layout === "viewport" && "h-svh overflow-hidden", className)} style={providerStyle} {...props} > <AgentRunSidebar appName={appName} brand={brand} runs={runs} activeRunId={activeRunId} sidebarFooter={sidebarFooter} onRunSelect={onRunSelect} onNewRun={onNewRun} /> <SidebarInset className="@container/agent-run flex h-full min-h-0 min-w-0 flex-col overflow-hidden"> <AgentRunLayoutContext.Provider value={layout}>{children}</AgentRunLayoutContext.Provider> </SidebarInset> </SidebarProvider> );}export type AgentRunElapsedProps = Omit<ComponentProps<"time">, "dateTime" | "children"> & { startedAt: number; endedAt?: number;};export function AgentRunElapsed({ startedAt, endedAt, className, ...props }: AgentRunElapsedProps) { const now = useRunClock(endedAt === undefined); const end = endedAt ?? now; const ms = end === null ? null : Math.max(0, end - startedAt); return ( <time dateTime={ms === null ? undefined : elapsedIsoDuration(ms)} className={cn("text-label tabular-nums text-muted-foreground", className)} {...props} > {ms === null ? "" : formatElapsed(ms)} </time> );}export type AgentRunHeaderProps = Omit<ComponentProps<"header">, "title"> & { title: string; status: AgentRunStatus; startedAt?: number; endedAt?: number; actions?: ReactNode;};export function AgentRunHeader({ title, status, startedAt, endedAt, actions, className, ...props }: AgentRunHeaderProps) { const levels = useHeadingLevels(); return ( <header className={cn("flex h-12 shrink-0 items-center gap-2 border-b border-border/70 px-3", className)} {...props}> <SidebarTrigger size="xs" className="lg:hidden" /> <Heading level={levels.title} size="label" weight="medium" className="min-w-0 truncate" title={title}> {title} </Heading> <Badge size="sm" color={agentRunStatusColors[status]}> {agentRunStatusLabels[status]} </Badge> {startedAt === undefined ? null : <AgentRunElapsed startedAt={startedAt} endedAt={endedAt} className="text-body" />} <div className="ms-auto flex shrink-0 items-center gap-1">{actions}</div> <LiveStatus message={`${title}: ${agentRunStatusLabels[status]}`} /> </header> );}export type AgentRunBodyProps = ComponentProps<"div"> & { aside?: ReactNode };export function AgentRunBody({ aside, className, children, ...props }: AgentRunBodyProps) { return ( <div className={cn( "grid min-h-0 flex-1 content-start overflow-y-auto @3xl/agent-run:grid-cols-[minmax(0,1fr)_minmax(18rem,22rem)] @3xl/agent-run:content-stretch @3xl/agent-run:overflow-hidden", className, )} {...props} > <div className="min-w-0 px-4 py-5 @lg/agent-run:px-6 @3xl/agent-run:overflow-y-auto">{children}</div> {aside} </div> );}export type AgentRunPlanProps = Omit<ComponentProps<"section">, "title"> & { title?: ReactNode; description?: ReactNode };export function AgentRunPlan({ title = "Plan", description, className, children, ...props }: AgentRunPlanProps) { const levels = useHeadingLevels(); const headingId = useId(); return ( <section aria-labelledby={headingId} className={cn("min-w-0", className)} {...props}> <Heading id={headingId} level={levels.section} size="label" weight="medium"> {title} </Heading> {description ? ( <Text size="caption" tone="muted"> {description} </Text> ) : null} <Timeline className="mt-3">{children}</Timeline> </section> );}const stepIcons = { neutral: <Circle aria-hidden="true" />, pending: <CircleDashed aria-hidden="true" />, running: <LoaderCircle className="motion-safe:animate-spin" aria-hidden="true" />, waiting: <CirclePause aria-hidden="true" />, success: <Check aria-hidden="true" />, error: <CircleAlert aria-hidden="true" />,} satisfies Record<TimelineState, ReactNode>;export type AgentRunStepProps = Omit<TimelineItemProps, "title"> & { title: ReactNode; detail?: ReactNode; startedAt?: number; endedAt?: number; actions?: ReactNode;};export function AgentRunStep({ title, detail, startedAt, endedAt, actions, state = "pending", children, ...props }: AgentRunStepProps) { const hasMeta = startedAt !== undefined || actions !== undefined; return ( <TimelineItem state={state} {...props}> <TimelineIndicator aria-label={agentRunStepLabels[state]}>{stepIcons[state]}</TimelineIndicator> <TimelineSeparator /> <TimelineContent> <TimelineTitle>{title}</TimelineTitle> {detail ? <TimelineDescription>{detail}</TimelineDescription> : null} {hasMeta ? ( <TimelineMeta> {startedAt === undefined ? null : <AgentRunElapsed startedAt={startedAt} endedAt={endedAt} />} {actions} </TimelineMeta> ) : null} {children ? <div className="mt-2 grid gap-2">{children}</div> : null} </TimelineContent> </TimelineItem> );}function formatToolValue(value: unknown): string { return typeof value === "string" ? value : JSON.stringify(value, null, 2);}export type AgentRunToolCallProps = Omit<ActivityProps, "kind" | "name" | "children"> & { name: string; summary?: ReactNode; icon?: ReactNode; input?: unknown; output?: unknown; errorText?: string; startedAt?: number; endedAt?: number;};export function AgentRunToolCall({ name, summary, icon, input, output, errorText, startedAt, endedAt, ...props }: AgentRunToolCallProps) { const hasDetails = input !== undefined || output !== undefined || errorText !== undefined; const Row = hasDetails ? ActivityTrigger : ActivityRow; return ( <Activity kind="tool" name={name} {...props}> <Row> {icon ? <ActivityIcon>{icon}</ActivityIcon> : <ActivityIcon />} <ActivityTitle> {name} {summary ? <span className="text-muted-foreground"> · {summary}</span> : null} </ActivityTitle> {startedAt === undefined ? null : <AgentRunElapsed startedAt={startedAt} endedAt={endedAt} className="ms-2" />} <ActivityStatus className="sr-only" /> </Row> {hasDetails ? ( <ActivityContent> {input === undefined ? null : ( <ActivityDetail> <ActivityDetailLabel>Input</ActivityDetailLabel> <ActivityDetailContent format="code">{formatToolValue(input)}</ActivityDetailContent> </ActivityDetail> )} {output === undefined ? null : ( <ActivityDetail> <ActivityDetailLabel>Output</ActivityDetailLabel> <ActivityDetailContent format="code">{formatToolValue(output)}</ActivityDetailContent> </ActivityDetail> )} {errorText === undefined ? null : ( <ActivityDetail> <ActivityDetailLabel>Error</ActivityDetailLabel> <ActivityDetailContent className="text-destructive-text">{errorText}</ActivityDetailContent> </ActivityDetail> )} </ActivityContent> ) : null} </Activity> );}export type AgentRunAsideProps = ComponentProps<"aside">;export function AgentRunAside({ className, ...props }: AgentRunAsideProps) { return ( <aside aria-label="Run output" className={cn( "grid content-start gap-4 border-t border-border/70 px-4 py-5 @lg/agent-run:px-6 @3xl/agent-run:overflow-y-auto @3xl/agent-run:border-t-0 @3xl/agent-run:border-s", className, )} {...props} /> );}export type AgentRunSectionProps = Omit<ComponentProps<typeof Card>, "title"> & { title: ReactNode; actions?: ReactNode };export function AgentRunSection({ title, actions, children, ...props }: AgentRunSectionProps) { const levels = useHeadingLevels(); return ( <Card {...props}> <CardHeader> <Heading level={levels.section} size="label" weight="medium"> {title} </Heading> {actions ? <CardAction>{actions}</CardAction> : null} </CardHeader> <CardContent>{children}</CardContent> </Card> );}export type AgentRunDetail = { label: string; value: ReactNode };export type AgentRunDetailsProps = Omit<AgentRunSectionProps, "title" | "children"> & { title?: ReactNode; items: readonly AgentRunDetail[];};export function AgentRunDetails({ title = "Details", items, ...props }: AgentRunDetailsProps) { if (items.length === 0) return null; return ( <AgentRunSection title={title} {...props}> <dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-body"> {items.map((item) => ( <div key={item.label} className="contents"> <dt className="text-muted-foreground">{item.label}</dt> <dd className="min-w-0 break-words">{item.value}</dd> </div> ))} </dl> </AgentRunSection> );}