# Artifact Workspace

> Chat on the left, generated output on the right — artifacts open from the answer, stream, version, and fail without breaking the conversation.

A shadcn/ui block from the [@scrimui](https://scrimui.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/scrimui/artifact-workspace).

- Page, with a live preview: https://shadcn.exchange/r/scrimui/artifact-workspace
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/scrimui/artifact-workspace-light-7944954d35077654.png
- Registry item: https://scrimui.dev/r/artifact-workspace.json
- Categories: pattern
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://scrimui.dev/r/artifact-workspace.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://scrimui.dev/r/prompt-input.json`, `https://scrimui.dev/r/streaming-message.json`, `https://scrimui.dev/r/conversation-sidebar.json`, `https://scrimui.dev/r/response-versions.json`, `https://scrimui.dev/r/artifact-preview.json`, `button`, `card`

## Notes from the registry

https://scrimui.dev/patterns/artifact-workspace

## Files

### `src/showcase/patterns/artifact-workspace/artifact-workspace.tsx`

```tsx
"use client";

import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import * as React from "react";
import { PromptInput } from "@/components/ui/prompt-input";
import { StreamingMessage } from "@/components/ui/streaming-message";
import { ConversationSidebar, type ConversationGroup } from "@/components/ui/conversation-sidebar";
import { ResponseVersions, type ResponseVersion } from "@/components/ui/response-versions";
import { ArtifactPreview, type ArtifactStatus } from "@/components/ui/artifact-preview";

/**
 * Chat on the left, generated output on the right — the artifact workspace.
 *
 * The flow this pattern exists to demonstrate:
 *
 * 1. **The artifact opens from the answer.** The assistant's message names
 *    what it made and carries an "Open artifact" affordance; the panel is a
 *    consequence of the conversation, not a separate app.
 * 2. **Streaming is visible but the chrome never moves.** The panel opens
 *    in its final position, streams its source, then settles — nothing
 *    re-layouts mid-generation.
 * 3. **A new artifact version must not yank the reader.** Revisions append;
 *    the panel follows only while the reader is already on the latest.
 * 4. **A broken artifact does not break the chat.** v3 fails to render, the
 *    panel says so, and the conversation carries on with v2 untouched.
 * 5. **On a narrow screen the artifact is a place you visit.** A floating
 *    button opens it as an overlay; closing it returns to the chat.
 */

/* ------------------------------------------------------------------ */
/* Script                                                              */
/* ------------------------------------------------------------------ */

type Turn = {
  id: number;
  role: "user" | "assistant";
  text: string;
  /** This message created or revised the artifact — show its open affordance. */
  artifactRef?: boolean;
};

const CONVERSATIONS: ConversationGroup[] = [
  {
    id: "today",
    label: "Today",
    conversations: [
      { id: "w1", title: "Signup chart artifact", updatedAt: "2m" },
      { id: "w2", title: "Landing page copy", updatedAt: "3h" },
    ],
  },
  {
    id: "week",
    label: "Previous 7 days",
    conversations: [{ id: "w3", title: "Quarterly report draft", updatedAt: "3d" }],
  },
];

const CHART_V1 = `export function SignupChart({ data }: { data: number[] }) {
 const max = Math.max(...data);
 return (
 <div className="flex items-end gap-2">
 {data.map((v, i) => (
 <div key={i} style={{ height: (v / max) * 160 }} className="w-10 rounded-t bg-primary" />
 ))}
 </div>
 );
}`;

const CHART_V2 = `export function SignupChart({ data }: { data: number[] }) {
 const max = Math.max(...data);
 const total = data.reduce((a, b) => a + b, 0);
 return (
 <figure>
 <div className="flex items-end gap-2">
 {data.map((v, i) => (
 <div key={i} style={{ height: (v / max) * 160 }} className="w-10 rounded-t bg-primary" />
 ))}
 </div>
 <figcaption>Total signups: {total.toLocaleString()}</figcaption>
 </figure>
 );
}`;

const CHART_V3_BROKEN = `export function SignupChart({ data }: { data: number[] }) {
  const max = Math.max(...data);
  const total = data.reduce((a, b) => a + b, 0);
  return (
    <figure>
      <Trendline points={data.toPairs()} />`;

const REPLY_V1 =
  "Here's the signup chart as a small React component — it's streaming into the artifact panel. Open it to watch the source settle, then page between Preview and Code.";
const REPLY_V1_ALT =
  "Done — the chart component is in the artifact panel on the right. The source is still streaming in; the panel's chrome stays put while it does.";
const REPLY_V2 =
  "Added the totals caption and bumped the artifact to v2. If you were reading v1, the panel won't move you — the pager shows the new version landed.";
const REPLY_ERROR =
  "That revision broke the render — the panel shows the failure, and v2 is untouched. The chat is fine; ask me to fix the component.";
const REPLY_GENERIC =
  "The artifact stays as it is until you ask for a change. Try “add a totals caption” or ask me to revise the chart.";

/* ------------------------------------------------------------------ */
/* Artifact preview mock                                               */
/* ------------------------------------------------------------------ */

function ChartMock({ values, caption }: { values: number[]; caption?: string }) {
  const max = Math.max(...values);
  return (
    <div className="flex min-h-[240px] flex-col items-center justify-center gap-3 p-6">
      <div className="flex h-40 items-end gap-2">
        {values.map((v, i) => (
          <div
            key={i}
            style={{ height: `${(v / max) * 100}%` }}
            className="w-10 rounded-t-md bg-primary/80"
          />
        ))}
      </div>
      <p className="text-xs text-muted-foreground">{caption ?? "Monthly signups"}</p>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Artifact state                                                      */
/* ------------------------------------------------------------------ */

type Artifact = {
  open: boolean;
  status: ArtifactStatus;
  title: string;
  code: string;
  values: number[];
  caption?: string;
  versions: { id: string }[];
  currentVersionId: string;
  errorMessage?: string;
};

const CLOSED_ARTIFACT: Artifact = {
  open: false,
  status: "ready",
  title: "signup-chart.tsx",
  code: "",
  values: [],
  versions: [],
  currentVersionId: "",
};

/** Reveal the source progressively — the panel's own streaming. */
function streamCode(target: string, tick: (code: string) => void, done: () => void) {
  let i = 0;
  const step = Math.max(12, Math.floor(target.length / 36));
  const timer = window.setInterval(() => {
    i = Math.min(target.length, i + step);
    tick(target.slice(0, i));
    if (i >= target.length) {
      window.clearInterval(timer);
      done();
    }
  }, 50);
}

/* ------------------------------------------------------------------ */
/* Pattern                                                             */
/* ------------------------------------------------------------------ */

export function ArtifactWorkspacePattern() {
  const [conversations, setConversations] = React.useState(CONVERSATIONS);
  const [activeConvo, setActiveConvo] = React.useState("w1");
  const [turns, setTurns] = React.useState<Turn[]>([]);
  const [pending, setPending] = React.useState<string | null>(null);
  const [answerVersions, setAnswerVersions] = React.useState<ResponseVersion[]>([]);
  const [artifact, setArtifact] = React.useState<Artifact>(CLOSED_ARTIFACT);
  const [overlay, setOverlay] = React.useState(false);
  const scrollRef = React.useRef<HTMLDivElement>(null);
  const idRef = React.useRef(1);

  React.useEffect(() => {
    const el = scrollRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  }, [turns, pending, answerVersions]);

  function assistantSay(text: string, artifactRef?: boolean) {
    setTurns((t) => [...t, { id: idRef.current++, role: "assistant", text, artifactRef }]);
  }

  /** Stream a new artifact version into the panel. Appends, never replaces,
   *  and only moves the reader if they were already on the latest version. */
  function reviseArtifact(fullCode: string, values: number[], caption?: string, breakAt?: number) {
    setArtifact((a) => {
      const id = `v${a.versions.length + 1}`;
      const last = a.versions[a.versions.length - 1]?.id;
      const wasAtLatest = a.versions.length === 0 || a.currentVersionId === last;
      return {
        ...a,
        open: true,
        status: "streaming",
        code: "",
        errorMessage: undefined,
        versions: [...a.versions, { id }],
        currentVersionId: wasAtLatest ? id : a.currentVersionId,
      };
    });
    const limit = breakAt ?? fullCode.length;
    streamCode(fullCode.slice(0, limit), (code) => setArtifact((a) => ({ ...a, code })), () => {
      if (breakAt) {
        setArtifact((a) => ({
          ...a,
          status: "error",
          errorMessage: "v3 threw while rendering: data.toPairs is not a function.",
        }));
      } else {
        setArtifact((a) => ({ ...a, status: "ready", values, caption }));
      }
    });
  }

  function submit(value: string) {
    setTurns((t) => [...t, { id: idRef.current++, role: "user", text: value }]);
    const step = turns.filter((t) => t.role === "assistant").length;

    window.setTimeout(() => {
      if (step === 0) {
        setPending(REPLY_V1);
      } else if (step === 1) {
        setPending(REPLY_V2);
      } else if (step === 2) {
        setPending(REPLY_ERROR);
      } else {
        setPending(REPLY_GENERIC);
      }
    }, 450);
  }

  function onStreamComplete() {
    if (!pending) return;
    const text = pending;
    setPending(null);
    if (text === REPLY_V1) {
      /* The first answer is regenerable — the version stack lives on the
         message, the artifact opens when the answer lands. */
      setAnswerVersions([{ id: "a1", status: "ready", content: <StreamingMessage text={text} /> }]);
      reviseArtifact(CHART_V1, [3, 5, 4]);
    } else if (text === REPLY_V2) {
      assistantSay(text, true);
      reviseArtifact(CHART_V2, [3, 5, 4, 7, 6], "Total: 25,410");
    } else if (text === REPLY_ERROR) {
      assistantSay(text);
      reviseArtifact(CHART_V3_BROKEN, [], undefined, Math.floor(CHART_V3_BROKEN.length * 0.9));
    } else {
      assistantSay(text);
    }
  }

  function regenerateAnswer() {
    const id = `a${idRef.current++}`;
    setAnswerVersions((vs) => [
      ...vs,
      {
        id,
        status: "generating",
        content: (
          <StreamingMessage
            key={id}
            text={REPLY_V1_ALT}
            isStreaming
            speed={2}
            onComplete={() =>
              setAnswerVersions((vs2) => vs2.map((v) => (v.id === id ? { ...v, status: "ready" } : v)))
            }
          />
        ),
      },
    ]);
  }

  function newChat() {
    const id = `w${idRef.current++}`;
    setConversations((gs) =>
      gs.map((g, i) =>
        i === 0 ? { ...g, conversations: [{ id, title: "Untitled chat", updatedAt: "now" }, ...g.conversations] } : g,
      ),
    );
    setActiveConvo(id);
    setTurns([]);
    setPending(null);
    setAnswerVersions([]);
    setArtifact(CLOSED_ARTIFACT);
    setOverlay(false);
  }

  function renderArtifactPanel(onClose: () => void) {
    return (
      <ArtifactPreview
        title={artifact.title}
        type="chart"
        language="tsx"
        status={artifact.status}
        code={artifact.code}
        errorMessage={artifact.errorMessage}
        preview={
          artifact.values.length > 0 ? (
            <ChartMock values={artifact.values} caption={artifact.caption} />
          ) : undefined
        }
        versions={artifact.versions}
        currentVersionId={artifact.currentVersionId}
        onVersionChange={(id) => setArtifact((a) => ({ ...a, currentVersionId: id }))}
        onClose={onClose}
        className="h-full rounded-none border-0"
      />
    );
  }

  const openArtifactChip = (
    <Button variant="outline" size="sm"
      type="button"
      onClick={() => setOverlay(true)}
      className="mt-1.5 inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted"
    >
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width="12" height="12">
        <rect width="18" height="18" x="3" y="3" rx="2" />
        <path d="M15 3v18" />
      </svg>
      Open artifact
    </Button>
  );

  return (
    <Card className="flex-row gap-0 py-0 relative flex h-[640px] overflow-hidden rounded-xl border border-border bg-card">
      {/* Conversation history */}
      <aside className="hidden w-56 shrink-0 border-r border-border md:block">
        <ConversationSidebar
          groups={conversations}
          activeId={activeConvo}
          onNewChat={newChat}
          onSelect={setActiveConvo}
        />
      </aside>

      {/* Chat */}
      <div className="flex min-w-0 flex-1 flex-col">
        <div className="flex items-center justify-between border-b border-border px-4 py-3">
          <div className="min-w-0">
            <p className="text-sm font-semibold text-foreground">Artifact Workspace</p>
            <p className="truncate text-xs text-muted-foreground">
              Ask for a chart — it opens in the panel
            </p>
          </div>
          {artifact.open && (
            <Button variant="outline" size="sm"
              type="button"
              onClick={() => setOverlay(true)}
              className="shrink-0 rounded-md border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground hover:bg-muted lg:hidden"
            >
              View artifact
            </Button>
          )}
        </div>

        <div ref={scrollRef} className="flex-1 space-y-5 overflow-y-auto px-4 py-5">
          {turns.length === 0 && answerVersions.length === 0 && !pending && (
            <p className="pt-16 text-center text-sm leading-6 text-muted-foreground">
              Ask for a signup chart.
              <br />
              The answer builds it in the artifact panel — then revise it, then break it.
            </p>
          )}

          {answerVersions.length > 0 && (
            <div>
              <ResponseVersions versions={answerVersions} onRegenerate={regenerateAnswer} />
              {openArtifactChip}
            </div>
          )}

          {turns.map((t) =>
            t.role === "user" ? (
              <div key={t.id} className="flex justify-end">
                <div className="max-w-[85%] whitespace-pre-wrap rounded-xl bg-muted px-4 py-3 text-sm leading-6 text-foreground">
                  {t.text}
                </div>
              </div>
            ) : (
              <div key={t.id}>
                <StreamingMessage text={t.text} />
                {t.artifactRef && openArtifactChip}
              </div>
            ),
          )}

          {pending && (
            <StreamingMessage text={pending} isStreaming speed={2} onComplete={onStreamComplete} />
          )}
        </div>

        <div className="border-t border-border px-4 py-3">
          <PromptInput placeholder="Ask for a signup chart…" onSubmit={submit} />
        </div>
      </div>

      {/* Artifact panel — docked on wide screens, an overlay below lg. */}
      {artifact.open && (
        <aside className="hidden w-[44%] shrink-0 border-l border-border lg:block">
          {renderArtifactPanel(() => setArtifact((a) => ({ ...a, open: false })))}
        </aside>
      )}
      {artifact.open && overlay && (
        <div className="absolute inset-0 z-10 lg:hidden">
          {renderArtifactPanel(() => setOverlay(false))}
        </div>
      )}
    </Card>
  );
}
```
