Skip to content
Seamless UI

Agent Workspace

Save

A 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

Agent Workspace preview
Scenario
Applied through event 0

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-workspace

Pre-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-run

Usage

agent-workspace-example.tsx
"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

PropTypeDefaultDescription
runAgentRun—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.
bannerReactNode—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.

Source

The exact source the CLI installs, from /r/items/react/agent-workspace/0.2.0.json.

blocks/agent-workspace.tsxShow
blocks/agent-workspace.tsx
"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.

agent-workspace-example.vue
<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.vueShow
blocks/AgentWorkspace.vue
<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>