Agent Workspace
SaveA complete agent workspace: streaming conversation, approvals, prompt box, run timeline, active tool call, and artifacts.
- Category
- Block
- Version
- 0.2.0
- Frameworks
- Next.js, Vite + React, Vite + Vue
- Styling
- Tailwind CSS v4
Preview
Start by describing a task
The agent's replies stream in here. Its steps, tool calls, and files appear alongside.
Installation
npx @seamlessui/cli add agent-workspacePre-release@seamlessui/cli is not on npm yet. Install from a local build.
Review the plan first. A dry run lists every file and package without changing anything:
npx @seamlessui/cli add agent-workspace --dry-runUsage
"use client";
import { useState } from "react";
import type { AgentRun } from "@/components/seamless/agent/agent-run";
import { AgentWorkspace } from "@/components/seamless/blocks/agent-workspace";
const initialRun: AgentRun = {
id: "run_1",
title: "Update the rate-limit docs",
status: "waiting-for-approval",
messages: [{ id: "m1", role: "user", status: "complete", content: "Update the rate limits and open a pull request." }],
steps: [
{ id: "s1", name: "search_docs", kind: "tool", status: "succeeded", output: "Found 3 pages.", startedAt: "2026-11-14T10:00:00.600Z", finishedAt: "2026-11-14T10:00:01.400Z" },
{ id: "s2", name: "edit_files", kind: "tool", status: "succeeded", output: "Changed 1 file.", startedAt: "2026-11-14T10:00:01.600Z", finishedAt: "2026-11-14T10:00:02.600Z" },
],
approvals: [{ id: "a1", title: "Open a pull request?", status: "pending", detail: "gh pr create --title \"Update rate limits\"" }],
artifacts: [{ id: "f1", name: "rate-limits.md", type: "text/markdown", status: "ready", content: "Free: 60/min\nPro: 600/min" }],
};
export default function AgentWorkspaceExample() {
// In your app, build the run from your backend's events with applyEvents (see agent-run).
const [run, setRun] = useState(initialRun);
const decide = (status: "approved" | "rejected") => async (id: string) =>
setRun((current) => ({ ...current, status: status === "approved" ? "running" : "cancelled", approvals: current.approvals.map((a) => (a.id === id ? { ...a, status } : a)) }));
return <AgentWorkspace className="w-full" run={run} onSubmit={() => {}} onApprove={decide("approved")} onReject={decide("rejected")} />;
}API
| Prop | Type | Default | Description |
|---|---|---|---|
| run | AgentRun | — | The run to render, normalized with agent-run's applyEvents and fromSnapshot. |
| onSubmit / onStop | (prompt) => void | Promise<void> / () => void | — | Prompt box actions. |
| onApprove / onReject | (approvalId: string) => void | Promise<void> | — | Approval decisions; send them to your backend for verification. |
| composerStatus | "idle" | "submitting" | "running" | — | Defaults from the run status. |
| banner | ReactNode | — | Optional notice above the conversation, e.g. reconnecting. |
Accessibility
- Conversation and run details are labeled regions; the message list is a polite log.
- Every status (run, steps, tool call, approvals, artifacts) is shown as text and a glyph, and announced.
- Works with motion disabled: cards and messages appear instantly and state changes stay visible as text and glyphs.
Files
Installed into src/components/seamless, or components/seamless when the project has no src directory.
src/components/seamless/blocks/agent-workspace.tsxagent-workspace- Also installs agent-run@0.1.1registry dependency
- Also installs approval-card@0.2.0registry dependency
- Also installs artifact-preview@0.2.0registry dependency
- Also installs prompt-composer@0.2.0registry dependency
- Also installs run-timeline@0.2.0registry dependency
- Also installs streaming-message@0.2.0registry dependency
- Also installs tool-call-card@0.2.0registry dependency
- npm packages: noneReact and Tailwind CSS v4 required
Source
The exact source the CLI installs, from /r/items/react/agent-workspace/0.2.0.json.
blocks/agent-workspace.tsxShowHide
"use client";
import type { ReactNode } from "react";
import type { AgentRun, RunStep } from "../agent/agent-run";
import { ApprovalCard } from "../agent/approval-card";
import { ArtifactPreview } from "../agent/artifact-preview";
import { type ComposerStatus, PromptComposer } from "../agent/prompt-composer";
import { RunTimeline } from "../agent/run-timeline";
import { MessageScroller, StreamingMessage } from "../agent/streaming-message";
import { ToolCallCard, type ToolCallStatus } from "../agent/tool-call-card";
export interface AgentWorkspaceProps {
/** The run to show, normalized (see agent-run's applyEvents and fromSnapshot). */
run: AgentRun;
composerStatus?: ComposerStatus;
onSubmit: (prompt: string) => void | Promise<void>;
onStop?: () => void;
onApprove?: (approvalId: string) => void | Promise<void>;
onReject?: (approvalId: string) => void | Promise<void>;
/** Optional content above the conversation, e.g. a connection notice. */
banner?: ReactNode;
className?: string;
}
const toolStatus: Record<RunStep["status"], ToolCallStatus> = {
queued: "queued",
running: "running",
succeeded: "success",
failed: "error",
skipped: "queued",
};
/**
* A complete agent workspace: conversation with streaming replies and approvals,
* a prompt box, the run timeline, the active tool call, and generated artifacts.
* It renders whatever run you pass and reports user actions through callbacks.
*/
export function AgentWorkspace({ run, composerStatus, onSubmit, onStop, onApprove, onReject, banner, className }: AgentWorkspaceProps) {
const active = [...run.steps].reverse().find((step) => step.kind === "tool" && step.status !== "skipped");
const contentKey = run.messages.reduce((total, message) => total + message.content.length, 0) + run.approvals.length;
const status: ComposerStatus =
composerStatus ?? (run.status === "running" || run.status === "waiting-for-approval" ? "running" : "idle");
return (
<div
className={[
"grid grid-cols-[minmax(0,1fr)] items-start gap-x-6 gap-y-5 text-zinc-950 lg:grid-cols-[minmax(0,1fr)_minmax(0,22rem)] dark:text-white",
className,
]
.filter(Boolean)
.join(" ")}
>
<section aria-label="Conversation" className="flex min-h-[28rem] min-w-0 flex-col gap-3">
{banner}
<MessageScroller contentKey={contentKey} className="max-h-[32rem] min-h-64 flex-1 space-y-5 px-1 pt-1 pb-3">
{run.messages.length === 0 ? (
<div className="grid h-full min-h-60 place-items-center px-6 text-center">
<div className="max-w-xs">
<p className="text-sm font-medium text-zinc-950 dark:text-white">Start by describing a task</p>
<p className="mt-1 text-sm text-zinc-500">
The agent's replies stream in here. Its steps, tool calls, and files appear alongside.
</p>
</div>
</div>
) : null}
{run.messages.map((message) => (
<StreamingMessage key={message.id} role={message.role} content={message.content} status={message.status} error={message.error} />
))}
{run.approvals.map((approval) => (
<Enter key={approval.id}>
<ApprovalCard
title={approval.title}
description={approval.description}
detail={approval.detail}
status={approval.status}
onApprove={onApprove ? () => onApprove(approval.id) : undefined}
onReject={onReject ? () => onReject(approval.id) : undefined}
/>
</Enter>
))}
</MessageScroller>
<PromptComposer onSubmit={onSubmit} onStop={onStop} status={status} />
</section>
<aside aria-label="Run details" className="grid min-w-0 content-start gap-3">
<RunTimeline run={run} />
{active ? (
<Enter key={active.id}>
<ToolCallCard
name={active.name}
status={toolStatus[active.status]}
summary={active.summary}
input={active.input}
output={active.output}
error={active.error}
durationMs={active.startedAt && active.finishedAt ? new Date(active.finishedAt).getTime() - new Date(active.startedAt).getTime() : undefined}
/>
</Enter>
) : null}
{run.artifacts.map((artifact) => (
<Enter key={artifact.id}>
<ArtifactPreview
name={artifact.name}
type={artifact.type}
status={artifact.status}
path={artifact.path}
content={artifact.content}
sizeBytes={artifact.sizeBytes}
error={artifact.error}
/>
</Enter>
))}
</aside>
</div>
);
}
/** Plays a short rise-in when a card first appears; instant with reduced motion. */
function Enter({ children }: { children: ReactNode }) {
return (
<div className="min-w-0 transition-[opacity,translate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] starting:translate-y-1 starting:opacity-0 motion-reduce:transition-none">
{children}
</div>
);
}Vue
A native Vue 3 implementation ships in the Vue pilot. Run the same command in a Vite + Vue project; the CLI detects Vue and installs these files instead. The live preview above is the React version; the Vue example below is built in a clean Vite + Vue app by CI.
<script setup lang="ts">
import { ref } from "vue";
import type { AgentRun } from "@/components/seamless/agent/agent-run";
import AgentWorkspace from "@/components/seamless/blocks/AgentWorkspace.vue";
// In your app, build the run from your backend's events with applyEvents (see agent-run).
const run = ref<AgentRun>({
id: "run_1",
title: "Update the rate-limit docs",
status: "waiting-for-approval",
messages: [{ id: "m1", role: "user", status: "complete", content: "Update the rate limits and open a pull request." }],
steps: [{ id: "s1", name: "search_docs", kind: "tool", status: "succeeded", output: "Found 3 pages." }],
approvals: [{ id: "a1", title: "Open a pull request?", status: "pending", detail: 'gh pr create --title "Update rate limits"' }],
artifacts: [],
});
async function decide(id: string, status: "approved" | "rejected") {
await new Promise((resolve) => setTimeout(resolve, 500)); // your API call
run.value = { ...run.value, status: status === "approved" ? "running" : "cancelled", approvals: run.value.approvals.map((a) => (a.id === id ? { ...a, status } : a)) };
}
</script>
<template>
<AgentWorkspace :run="run" :on-approve="(id) => decide(id, 'approved')" :on-reject="(id) => decide(id, 'rejected')" />
</template>src/components/seamless/blocks/AgentWorkspace.vuevue/agent-workspace@0.2.0
blocks/AgentWorkspace.vueShowHide
<script setup lang="ts">
import { computed, nextTick, ref, useId, watch } from "vue";
import type { AgentRun, RunStatus, RunStep } from "../agent/agent-run";
import ToolCallCard, { type ToolCallStatus } from "../agent/ToolCallCard.vue";
import Badge, { type BadgeTone } from "../ui/Badge.vue";
import Button from "../ui/Button.vue";
/**
* A compact agent workspace for Vue: conversation, approvals, a prompt box, and the
* run's tool calls. It renders the run you pass (see agent-run's applyEvents).
*/
const props = defineProps<{
run: AgentRun;
/** Receives the trimmed prompt. */
onSubmit?: (prompt: string) => void | Promise<void>;
/** Called once per decision; buttons ignore clicks until it settles. Verify on your backend. */
onApprove?: (approvalId: string) => void | Promise<void>;
onReject?: (approvalId: string) => void | Promise<void>;
}>();
const runStatus: Record<RunStatus, { label: string; tone: BadgeTone }> = {
queued: { label: "Queued", tone: "neutral" },
running: { label: "Running", tone: "info" },
"waiting-for-approval": { label: "Needs approval", tone: "warning" },
succeeded: { label: "Succeeded", tone: "success" },
failed: { label: "Failed", tone: "danger" },
cancelled: { label: "Cancelled", tone: "neutral" },
};
const toolStatus: Record<RunStep["status"], ToolCallStatus> = { queued: "queued", running: "running", succeeded: "success", failed: "error", skipped: "queued" };
const prompt = ref("");
const deciding = ref<string | null>(null);
const id = useId();
const log = ref<HTMLElement | null>(null);
const busy = computed(() => props.run.status === "running" || props.run.status === "waiting-for-approval");
const tools = computed(() => props.run.steps.filter((step) => step.kind === "tool" && step.status !== "skipped"));
// Follow new messages while the reader is at the bottom.
watch(
() => props.run.messages.map((message) => message.content.length).join(),
async () => {
const element = log.value;
if (!element) return;
const atBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 24;
await nextTick();
if (atBottom) element.scrollTop = element.scrollHeight;
},
);
async function send() {
const text = prompt.value.trim();
if (!text || busy.value) return;
await props.onSubmit?.(text);
prompt.value = "";
}
function onKeydown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
void send();
}
}
async function decide(approvalId: string, approved: boolean) {
if (deciding.value) return;
deciding.value = `${approvalId}:${approved ? "approve" : "reject"}`;
try {
await (approved ? props.onApprove : props.onReject)?.(approvalId);
} finally {
deciding.value = null;
}
}
const ease = "ease-[cubic-bezier(0.23,1,0.32,1)]";
const enter = `transition-[opacity,translate] duration-200 ${ease} starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none`;
const container =
"rounded-xl border bg-white shadow-[0_1px_2px_rgb(0_0_0/0.04)] dark:bg-black dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06)]";
const approvalTone = { pending: "warning", approved: "success", rejected: "danger", expired: "neutral" } as const;
const outcome: Record<AgentRun["approvals"][number]["status"], string> = {
pending: "",
approved: "Approved. The agent continues.",
rejected: "Rejected. Nothing ran.",
expired: "Expired before a decision.",
};
function duration(step: RunStep) {
return step.startedAt && step.finishedAt ? new Date(step.finishedAt).getTime() - new Date(step.startedAt).getTime() : undefined;
}
</script>
<template>
<div class="grid gap-6 text-zinc-950 lg:grid-cols-[minmax(0,1fr)_minmax(0,20rem)] dark:text-white">
<section aria-label="Conversation" class="flex min-w-0 flex-col gap-4">
<div ref="log" role="log" aria-live="polite" class="flex max-h-96 min-h-48 flex-col gap-4 overflow-y-auto">
<p v-if="!props.run.messages.length" class="m-auto max-w-xs py-8 text-center text-sm text-zinc-500">
Describe a task. Messages and approvals appear here; tool calls appear beside them.
</p>
<article
v-for="message in props.run.messages"
:key="message.id"
:data-status="message.status"
:class="['flex', message.role === 'user' ? 'justify-end' : 'justify-start', enter]"
>
<div
:class="[
'text-sm leading-relaxed whitespace-pre-wrap',
message.role === 'user'
? 'max-w-[85%] rounded-2xl rounded-br-md border border-zinc-950/[0.08] bg-zinc-950/[0.04] px-3.5 py-2 dark:border-white/15 dark:bg-black'
: 'max-w-full text-zinc-800 dark:text-zinc-200',
]"
>
<span class="sr-only">{{ message.role === "user" ? "You" : "Assistant" }}:</span>
{{ message.content
}}<span
v-if="message.status === 'streaming'"
aria-hidden="true"
class="ml-0.5 inline-block h-[1.1em] w-[2px] translate-y-[0.2em] rounded-full bg-sky-600 motion-safe:animate-pulse dark:bg-sky-400"
/>
<p
v-if="message.status === 'failed'"
role="alert"
class="mt-2 rounded-lg border border-red-500/35 bg-red-500/[0.08] px-3 py-2 text-red-700 dark:text-red-300"
>
{{ message.error ?? "The response stopped before it finished." }}
</p>
</div>
</article>
<section
v-for="approval in props.run.approvals"
:key="approval.id"
:aria-label="`Approval: ${approval.title}`"
:class="[container, 'overflow-hidden', approval.status === 'pending' ? 'border-amber-500/35' : 'border-zinc-950/10 dark:border-white/10', enter]"
>
<div class="p-4">
<div class="flex flex-wrap items-start justify-between gap-x-3 gap-y-1">
<h3 class="min-w-0 text-sm font-semibold">{{ approval.title }}</h3>
<span role="status" class="shrink-0">
<Badge :tone="approvalTone[approval.status]">{{
approval.status === "pending" ? "Waiting for you" : approval.status[0]!.toUpperCase() + approval.status.slice(1)
}}</Badge>
</span>
</div>
<div v-if="approval.detail" class="mt-3">
<p class="mb-1.5 text-xs font-medium text-zinc-500">What will run</p>
<pre
class="overflow-x-auto rounded-lg border border-zinc-950/[0.07] bg-zinc-950/[0.02] px-3 py-2.5 font-mono text-xs leading-relaxed text-zinc-800 dark:border-white/[0.08] dark:bg-transparent dark:text-zinc-300"
>{{ approval.detail }}</pre>
</div>
</div>
<div
v-if="approval.status === 'pending'"
class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2.5 border-t border-zinc-950/[0.07] bg-amber-500/[0.04] px-4 py-3 dark:border-white/[0.08] dark:bg-amber-500/[0.05]"
>
<p class="text-xs text-zinc-600 dark:text-zinc-400">Nothing runs until you decide.</p>
<div class="flex items-center gap-2">
<Button size="sm" variant="secondary" :loading="deciding === `${approval.id}:reject`" :disabled="deciding === `${approval.id}:approve`" @click="decide(approval.id, false)">Reject</Button>
<Button size="sm" :loading="deciding === `${approval.id}:approve`" :disabled="deciding === `${approval.id}:reject`" @click="decide(approval.id, true)">Approve</Button>
</div>
</div>
<p v-else :class="['border-t border-zinc-950/[0.07] px-4 py-3 text-xs text-zinc-600 dark:border-white/[0.08] dark:text-zinc-400', enter]">
{{ outcome[approval.status] }}
</p>
</section>
</div>
<form
class="rounded-xl border border-zinc-950/10 bg-white shadow-[0_1px_2px_rgb(0_0_0/0.04)] transition-[border-color,box-shadow] duration-150 ease-out hover:border-zinc-950/15 focus-within:border-zinc-950/25 focus-within:shadow-[0_0_0_3px_rgb(14_165_233/0.16)] motion-reduce:transition-none dark:border-white/10 dark:bg-black dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06)] dark:hover:border-white/20 dark:focus-within:border-white/30 dark:focus-within:shadow-[0_0_0_3px_rgb(56_189_248/0.18),inset_0_1px_0_rgb(255_255_255/0.06)]"
@submit.prevent="send"
>
<label :for="id" class="sr-only">Message the agent</label>
<textarea
:id="id"
v-model="prompt"
rows="1"
placeholder="Ask the agent to do something…"
class="block max-h-48 min-h-11 w-full resize-none bg-transparent px-3.5 pt-3 pb-1 text-sm leading-relaxed text-zinc-950 [field-sizing:content] placeholder:text-zinc-500 focus:outline-none dark:text-white"
@keydown="onKeydown"
/>
<div class="flex min-h-11 items-center justify-between gap-3 py-2 pr-2 pl-3.5">
<span v-if="busy" class="flex items-center gap-2 text-xs text-sky-700 dark:text-sky-400">
<svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" class="size-3.5 shrink-0">
<circle cx="8" cy="8" r="6.25" stroke-opacity="0.2" />
<path d="M8 1.75A6.25 6.25 0 1 1 1.75 8" class="origin-center motion-safe:animate-spin" />
</svg>
Agent is working
</span>
<span v-else class="hidden text-xs text-zinc-500 sm:block">Enter to send, Shift+Enter for a new line</span>
<Button type="submit" size="sm" class="ml-auto" :disabled="!prompt.trim() || busy">Send</Button>
</div>
</form>
</section>
<aside aria-label="Run details" class="grid min-w-0 content-start gap-3">
<div :class="[container, 'flex items-center justify-between gap-2 border-zinc-950/10 px-4 py-3 dark:border-white/10']">
<h3 class="min-w-0 truncate text-sm font-semibold">{{ props.run.title }}</h3>
<span role="status" class="shrink-0"><Badge :tone="runStatus[props.run.status].tone">{{ runStatus[props.run.status].label }}</Badge></span>
</div>
<ToolCallCard
v-for="step in tools"
:key="step.id"
:class="enter"
:name="step.name"
:status="toolStatus[step.status]"
:summary="step.summary"
:input="step.input"
:output="step.output"
:error="step.error"
:duration-ms="duration(step)"
/>
</aside>
</div>
</template>