{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"artifact-workspace","type":"registry:block","title":"Artifact Workspace","description":"Chat on the left, generated output on the right — artifacts open from the answer, stream, version, and fail without breaking the conversation.","author":"Scrim UI (https://scrimui.dev)","categories":["pattern"],"docs":"https://scrimui.dev/patterns/artifact-workspace","dependencies":[],"registryDependencies":["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"],"files":[{"path":"src/showcase/patterns/artifact-workspace/artifact-workspace.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { PromptInput } from \"@/components/ui/prompt-input\";\nimport { StreamingMessage } from \"@/components/ui/streaming-message\";\nimport { ConversationSidebar, type ConversationGroup } from \"@/components/ui/conversation-sidebar\";\nimport { ResponseVersions, type ResponseVersion } from \"@/components/ui/response-versions\";\nimport { ArtifactPreview, type ArtifactStatus } from \"@/components/ui/artifact-preview\";\n\n/**\n * Chat on the left, generated output on the right — the artifact workspace.\n *\n * The flow this pattern exists to demonstrate:\n *\n * 1. **The artifact opens from the answer.** The assistant's message names\n *    what it made and carries an \"Open artifact\" affordance; the panel is a\n *    consequence of the conversation, not a separate app.\n * 2. **Streaming is visible but the chrome never moves.** The panel opens\n *    in its final position, streams its source, then settles — nothing\n *    re-layouts mid-generation.\n * 3. **A new artifact version must not yank the reader.** Revisions append;\n *    the panel follows only while the reader is already on the latest.\n * 4. **A broken artifact does not break the chat.** v3 fails to render, the\n *    panel says so, and the conversation carries on with v2 untouched.\n * 5. **On a narrow screen the artifact is a place you visit.** A floating\n *    button opens it as an overlay; closing it returns to the chat.\n */\n\n/* ------------------------------------------------------------------ */\n/* Script                                                              */\n/* ------------------------------------------------------------------ */\n\ntype Turn = {\n  id: number;\n  role: \"user\" | \"assistant\";\n  text: string;\n  /** This message created or revised the artifact — show its open affordance. */\n  artifactRef?: boolean;\n};\n\nconst CONVERSATIONS: ConversationGroup[] = [\n  {\n    id: \"today\",\n    label: \"Today\",\n    conversations: [\n      { id: \"w1\", title: \"Signup chart artifact\", updatedAt: \"2m\" },\n      { id: \"w2\", title: \"Landing page copy\", updatedAt: \"3h\" },\n    ],\n  },\n  {\n    id: \"week\",\n    label: \"Previous 7 days\",\n    conversations: [{ id: \"w3\", title: \"Quarterly report draft\", updatedAt: \"3d\" }],\n  },\n];\n\nconst CHART_V1 = `export function SignupChart({ data }: { data: number[] }) {\n  const max = Math.max(...data);\n  return (\n    <div className=\"flex items-end gap-2\">\n      {data.map((v, i) => (\n        <div key={i} style={{ height: (v / max) * 160 }} className=\"w-10 rounded-t bg-blue-500\" />\n      ))}\n    </div>\n  );\n}`;\n\nconst CHART_V2 = `export function SignupChart({ data }: { data: number[] }) {\n  const max = Math.max(...data);\n  const total = data.reduce((a, b) => a + b, 0);\n  return (\n    <figure>\n      <div className=\"flex items-end gap-2\">\n        {data.map((v, i) => (\n          <div key={i} style={{ height: (v / max) * 160 }} className=\"w-10 rounded-t bg-blue-500\" />\n        ))}\n      </div>\n      <figcaption>Total signups: {total.toLocaleString()}</figcaption>\n    </figure>\n  );\n}`;\n\nconst CHART_V3_BROKEN = `export function SignupChart({ data }: { data: number[] }) {\n  const max = Math.max(...data);\n  const total = data.reduce((a, b) => a + b, 0);\n  return (\n    <figure>\n      <Trendline points={data.toPairs()} />`;\n\nconst REPLY_V1 =\n  \"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.\";\nconst REPLY_V1_ALT =\n  \"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.\";\nconst REPLY_V2 =\n  \"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.\";\nconst REPLY_ERROR =\n  \"That revision broke the render — the panel shows the failure, and v2 is untouched. The chat is fine; ask me to fix the component.\";\nconst REPLY_GENERIC =\n  \"The artifact stays as it is until you ask for a change. Try “add a totals caption” or ask me to revise the chart.\";\n\n/* ------------------------------------------------------------------ */\n/* Artifact preview mock                                               */\n/* ------------------------------------------------------------------ */\n\nfunction ChartMock({ values, caption }: { values: number[]; caption?: string }) {\n  const max = Math.max(...values);\n  return (\n    <div className=\"flex min-h-[240px] flex-col items-center justify-center gap-3 p-6\">\n      <div className=\"flex h-40 items-end gap-2\">\n        {values.map((v, i) => (\n          <div\n            key={i}\n            style={{ height: `${(v / max) * 100}%` }}\n            className=\"w-10 rounded-t-md bg-blue-500/80 dark:bg-blue-400/80\"\n          />\n        ))}\n      </div>\n      <p className=\"text-xs text-zinc-500 dark:text-zinc-400\">{caption ?? \"Monthly signups\"}</p>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Artifact state                                                      */\n/* ------------------------------------------------------------------ */\n\ntype Artifact = {\n  open: boolean;\n  status: ArtifactStatus;\n  title: string;\n  code: string;\n  values: number[];\n  caption?: string;\n  versions: { id: string }[];\n  currentVersionId: string;\n  errorMessage?: string;\n};\n\nconst CLOSED_ARTIFACT: Artifact = {\n  open: false,\n  status: \"ready\",\n  title: \"signup-chart.tsx\",\n  code: \"\",\n  values: [],\n  versions: [],\n  currentVersionId: \"\",\n};\n\n/** Reveal the source progressively — the panel's own streaming. */\nfunction streamCode(target: string, tick: (code: string) => void, done: () => void) {\n  let i = 0;\n  const step = Math.max(12, Math.floor(target.length / 36));\n  const timer = window.setInterval(() => {\n    i = Math.min(target.length, i + step);\n    tick(target.slice(0, i));\n    if (i >= target.length) {\n      window.clearInterval(timer);\n      done();\n    }\n  }, 50);\n}\n\n/* ------------------------------------------------------------------ */\n/* Pattern                                                             */\n/* ------------------------------------------------------------------ */\n\nexport function ArtifactWorkspacePattern() {\n  const [conversations, setConversations] = React.useState(CONVERSATIONS);\n  const [activeConvo, setActiveConvo] = React.useState(\"w1\");\n  const [turns, setTurns] = React.useState<Turn[]>([]);\n  const [pending, setPending] = React.useState<string | null>(null);\n  const [answerVersions, setAnswerVersions] = React.useState<ResponseVersion[]>([]);\n  const [artifact, setArtifact] = React.useState<Artifact>(CLOSED_ARTIFACT);\n  const [overlay, setOverlay] = React.useState(false);\n  const scrollRef = React.useRef<HTMLDivElement>(null);\n  const idRef = React.useRef(1);\n\n  React.useEffect(() => {\n    const el = scrollRef.current;\n    if (el) el.scrollTop = el.scrollHeight;\n  }, [turns, pending, answerVersions]);\n\n  function assistantSay(text: string, artifactRef?: boolean) {\n    setTurns((t) => [...t, { id: idRef.current++, role: \"assistant\", text, artifactRef }]);\n  }\n\n  /** Stream a new artifact version into the panel. Appends, never replaces,\n   *  and only moves the reader if they were already on the latest version. */\n  function reviseArtifact(fullCode: string, values: number[], caption?: string, breakAt?: number) {\n    setArtifact((a) => {\n      const id = `v${a.versions.length + 1}`;\n      const last = a.versions[a.versions.length - 1]?.id;\n      const wasAtLatest = a.versions.length === 0 || a.currentVersionId === last;\n      return {\n        ...a,\n        open: true,\n        status: \"streaming\",\n        code: \"\",\n        errorMessage: undefined,\n        versions: [...a.versions, { id }],\n        currentVersionId: wasAtLatest ? id : a.currentVersionId,\n      };\n    });\n    const limit = breakAt ?? fullCode.length;\n    streamCode(fullCode.slice(0, limit), (code) => setArtifact((a) => ({ ...a, code })), () => {\n      if (breakAt) {\n        setArtifact((a) => ({\n          ...a,\n          status: \"error\",\n          errorMessage: \"v3 threw while rendering: data.toPairs is not a function.\",\n        }));\n      } else {\n        setArtifact((a) => ({ ...a, status: \"ready\", values, caption }));\n      }\n    });\n  }\n\n  function submit(value: string) {\n    setTurns((t) => [...t, { id: idRef.current++, role: \"user\", text: value }]);\n    const step = turns.filter((t) => t.role === \"assistant\").length;\n\n    window.setTimeout(() => {\n      if (step === 0) {\n        setPending(REPLY_V1);\n      } else if (step === 1) {\n        setPending(REPLY_V2);\n      } else if (step === 2) {\n        setPending(REPLY_ERROR);\n      } else {\n        setPending(REPLY_GENERIC);\n      }\n    }, 450);\n  }\n\n  function onStreamComplete() {\n    if (!pending) return;\n    const text = pending;\n    setPending(null);\n    if (text === REPLY_V1) {\n      /* The first answer is regenerable — the version stack lives on the\n         message, the artifact opens when the answer lands. */\n      setAnswerVersions([{ id: \"a1\", status: \"ready\", content: <StreamingMessage text={text} /> }]);\n      reviseArtifact(CHART_V1, [3, 5, 4]);\n    } else if (text === REPLY_V2) {\n      assistantSay(text, true);\n      reviseArtifact(CHART_V2, [3, 5, 4, 7, 6], \"Total: 25,410\");\n    } else if (text === REPLY_ERROR) {\n      assistantSay(text);\n      reviseArtifact(CHART_V3_BROKEN, [], undefined, Math.floor(CHART_V3_BROKEN.length * 0.9));\n    } else {\n      assistantSay(text);\n    }\n  }\n\n  function regenerateAnswer() {\n    const id = `a${idRef.current++}`;\n    setAnswerVersions((vs) => [\n      ...vs,\n      {\n        id,\n        status: \"generating\",\n        content: (\n          <StreamingMessage\n            key={id}\n            text={REPLY_V1_ALT}\n            isStreaming\n            speed={2}\n            onComplete={() =>\n              setAnswerVersions((vs2) => vs2.map((v) => (v.id === id ? { ...v, status: \"ready\" } : v)))\n            }\n          />\n        ),\n      },\n    ]);\n  }\n\n  function newChat() {\n    const id = `w${idRef.current++}`;\n    setConversations((gs) =>\n      gs.map((g, i) =>\n        i === 0 ? { ...g, conversations: [{ id, title: \"Untitled chat\", updatedAt: \"now\" }, ...g.conversations] } : g,\n      ),\n    );\n    setActiveConvo(id);\n    setTurns([]);\n    setPending(null);\n    setAnswerVersions([]);\n    setArtifact(CLOSED_ARTIFACT);\n    setOverlay(false);\n  }\n\n  function renderArtifactPanel(onClose: () => void) {\n    return (\n      <ArtifactPreview\n        title={artifact.title}\n        type=\"chart\"\n        language=\"tsx\"\n        status={artifact.status}\n        code={artifact.code}\n        errorMessage={artifact.errorMessage}\n        preview={\n          artifact.values.length > 0 ? (\n            <ChartMock values={artifact.values} caption={artifact.caption} />\n          ) : undefined\n        }\n        versions={artifact.versions}\n        currentVersionId={artifact.currentVersionId}\n        onVersionChange={(id) => setArtifact((a) => ({ ...a, currentVersionId: id }))}\n        onClose={onClose}\n        className=\"h-full rounded-none border-0\"\n      />\n    );\n  }\n\n  const openArtifactChip = (\n    <button\n      type=\"button\"\n      onClick={() => setOverlay(true)}\n      className=\"mt-1.5 inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 bg-white px-2.5 py-1 text-xs font-medium text-zinc-600 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700\"\n    >\n      <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"12\" height=\"12\">\n        <rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" />\n        <path d=\"M15 3v18\" />\n      </svg>\n      Open artifact\n    </button>\n  );\n\n  return (\n    <div className=\"relative flex h-[640px] overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900\">\n      {/* Conversation history */}\n      <aside className=\"hidden w-56 shrink-0 border-r border-zinc-200 dark:border-zinc-800 md:block\">\n        <ConversationSidebar\n          groups={conversations}\n          activeId={activeConvo}\n          onNewChat={newChat}\n          onSelect={setActiveConvo}\n        />\n      </aside>\n\n      {/* Chat */}\n      <div className=\"flex min-w-0 flex-1 flex-col\">\n        <div className=\"flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-800\">\n          <div className=\"min-w-0\">\n            <p className=\"text-sm font-semibold text-zinc-900 dark:text-zinc-100\">Artifact Workspace</p>\n            <p className=\"truncate text-xs text-zinc-500 dark:text-zinc-400\">\n              Ask for a chart — it opens in the panel\n            </p>\n          </div>\n          {artifact.open && (\n            <button\n              type=\"button\"\n              onClick={() => setOverlay(true)}\n              className=\"shrink-0 rounded-lg border border-zinc-200 px-2.5 py-1 text-xs font-medium text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800 lg:hidden\"\n            >\n              View artifact\n            </button>\n          )}\n        </div>\n\n        <div ref={scrollRef} className=\"flex-1 space-y-5 overflow-y-auto px-4 py-5\">\n          {turns.length === 0 && answerVersions.length === 0 && !pending && (\n            <p className=\"pt-16 text-center text-[13px] leading-6 text-zinc-400 dark:text-zinc-500\">\n              Ask for a signup chart.\n              <br />\n              The answer builds it in the artifact panel — then revise it, then break it.\n            </p>\n          )}\n\n          {answerVersions.length > 0 && (\n            <div>\n              <ResponseVersions versions={answerVersions} onRegenerate={regenerateAnswer} />\n              {openArtifactChip}\n            </div>\n          )}\n\n          {turns.map((t) =>\n            t.role === \"user\" ? (\n              <div key={t.id} className=\"flex justify-end\">\n                <div className=\"max-w-[85%] whitespace-pre-wrap rounded-2xl rounded-tr-md bg-zinc-900 px-4 py-3 text-[15px] leading-6 text-white dark:bg-zinc-100 dark:text-zinc-900\">\n                  {t.text}\n                </div>\n              </div>\n            ) : (\n              <div key={t.id}>\n                <StreamingMessage text={t.text} />\n                {t.artifactRef && openArtifactChip}\n              </div>\n            ),\n          )}\n\n          {pending && (\n            <StreamingMessage text={pending} isStreaming speed={2} onComplete={onStreamComplete} />\n          )}\n        </div>\n\n        <div className=\"border-t border-zinc-200 px-4 py-3 dark:border-zinc-800\">\n          <PromptInput placeholder=\"Ask for a signup chart…\" onSubmit={submit} />\n        </div>\n      </div>\n\n      {/* Artifact panel — docked on wide screens, an overlay below lg. */}\n      {artifact.open && (\n        <aside className=\"hidden w-[44%] shrink-0 border-l border-zinc-200 dark:border-zinc-800 lg:block\">\n          {renderArtifactPanel(() => setArtifact((a) => ({ ...a, open: false })))}\n        </aside>\n      )}\n      {artifact.open && overlay && (\n        <div className=\"absolute inset-0 z-10 lg:hidden\">\n          {renderArtifactPanel(() => setOverlay(false))}\n        </div>\n      )}\n    </div>\n  );\n}\n","type":"registry:block","target":"components/blocks/artifact-workspace.tsx"}]}