{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-chat-2",
  "title": "AI Chat 2: conversation with artifact panel",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A chat that builds things: conversation list, streaming replies, suggestion chips and a side panel with Preview and Code tabs, version history, copy and download. The panel overlays the chat on narrow screens.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/ai-chat.json",
    "https://ui.ballmac.com/r/ai-message.json",
    "https://ui.ballmac.com/r/artifact-panel.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/prompt-input.json",
    "https://ui.ballmac.com/r/streaming-text.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/ai-chat-2/ai-chat-2.tsx",
      "content": "// Ballmac UI: AI Chat 2. https://ui.ballmac.com/blocks/ai-chat-2\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, FileCode2, MessageSquare, PanelLeft, Plus, Sparkles } from \"lucide-react\"\n\nimport { Chat, ChatFooter, ChatMessages } from \"@/components/ballmac/ai-chat\"\nimport { Message, MessageAvatar, MessageContent, MessageCopyAction, MessageActions } from \"@/components/ballmac/ai-message\"\nimport { ArtifactPanel } from \"@/components/ballmac/artifact-panel\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { PromptInput, PromptInputSubmit, PromptInputTextarea, PromptInputToolbar } from \"@/components/ballmac/prompt-input\"\nimport { StreamingText } from \"@/components/ballmac/streaming-text\"\nimport { cn } from \"@/lib/utils\"\n\ntype AiChat2Turn = {\n  id: string\n  role: \"user\" | \"assistant\"\n  text: string\n  streaming?: boolean\n  /** Which artifact version this reply created or updated. */\n  artifact?: number\n}\n\ntype AiChat2Version = {\n  /** Source code shown in the Code tab and copied or downloaded. */\n  code: string\n  /** The rendered result, shown in the Preview tab. */\n  preview: React.ReactNode\n  /** What changed, shown on the chip in the chat. */\n  note: string\n}\n\ntype AiChat2Props = Omit<React.ComponentProps<\"div\">, \"onSubmit\"> & {\n  /** Conversations in the sidebar. */\n  conversations?: { id: string; title: string }[]\n  /** Name of the generated thing. */\n  artifactTitle?: string\n  /** Kind of artifact, such as \"React component\". */\n  artifactKind?: string\n  /** File name for download. */\n  filename?: string\n  /** Language label for the code. */\n  language?: string\n  /** The versions, oldest first. The last one is shown first. */\n  versions?: AiChat2Version[]\n  /** Messages shown to begin with. */\n  defaultMessages?: AiChat2Turn[]\n  /** Follow-up prompts offered as chips under the conversation. */\n  suggestions?: string[]\n  /**\n   * Called with the prompt. Return the reply text and, if the reply changes the artifact, the 1-based version it moves to.\n   * Without it, a built-in script answers so you can try the whole flow.\n   */\n  onSend?: (prompt: string) => { reply: string; artifact?: number } | Promise<{ reply: string; artifact?: number }>\n  /** Height of the frame. */\n  height?: string\n}\n\nfunction PricingPreview({ yearly = false, compact = false }: { yearly?: boolean; compact?: boolean }) {\n  const [annual, setAnnual] = React.useState(yearly)\n  return (\n    <div className={cn(\"mx-auto flex w-full max-w-md flex-col gap-4 p-4\", compact && \"max-w-xs\")}>\n      {yearly && (\n        <div className=\"bg-muted mx-auto flex rounded-full p-1 text-xs font-medium\" role=\"group\" aria-label=\"Billing interval\">\n          {[\n            [\"Monthly\", false],\n            [\"Yearly\", true],\n          ].map(([label, value]) => (\n            <button key={String(label)} type=\"button\" aria-pressed={annual === value} onClick={() => setAnnual(value as boolean)} className={cn(\"focus-visible:ring-ring/50 rounded-full px-3 py-1 outline-none focus-visible:ring-[3px]\", annual === value ? \"bg-background shadow-sm\" : \"text-muted-foreground\")}>{label as string}</button>\n          ))}\n        </div>\n      )}\n      <div className={cn(\"grid gap-3\", !compact && \"sm:grid-cols-2\")}>\n        {[\n          [\"Starter\", annual ? 9 : 12, [\"3 projects\", \"10 GB storage\"]],\n          [\"Pro\", annual ? 25 : 32, [\"Unlimited projects\", \"1 TB storage\"]],\n        ].map(([name, price, feats], i) => (\n          <div key={String(name)} className={cn(\"bg-card rounded-xl border p-4\", i === 1 && \"border-foreground/30 shadow-md\")}>\n            <p className=\"text-sm font-semibold\">{name as string}</p>\n            <p className=\"mt-2 text-2xl font-semibold tracking-tight tabular-nums\">${price as number}<span className=\"text-muted-foreground text-xs font-normal\"> / seat / mo</span></p>\n            <ul className=\"text-muted-foreground mt-3 space-y-1.5 text-xs\">{(feats as string[]).map((f) => <li key={f} className=\"flex items-center gap-1.5\"><Check className=\"size-3\" aria-hidden=\"true\" />{f}</li>)}</ul>\n          </div>\n        ))}\n      </div>\n    </div>\n  )\n}\n\nconst code1 = `export function PricingTable() {\n  return (\n    <div className=\"grid gap-3 sm:grid-cols-2\">\n      <Plan name=\"Starter\" price={12} features={[\"3 projects\", \"10 GB storage\"]} />\n      <Plan name=\"Pro\" price={32} featured features={[\"Unlimited projects\", \"1 TB storage\"]} />\n    </div>\n  )\n}`\n\nconst code2 = `export function PricingTable() {\n  const [yearly, setYearly] = useState(true)\n  return (\n    <div className=\"grid gap-4\">\n      <BillingToggle yearly={yearly} onChange={setYearly} />\n      <div className=\"grid gap-3 sm:grid-cols-2\">\n        <Plan name=\"Starter\" price={yearly ? 9 : 12} features={[\"3 projects\", \"10 GB storage\"]} />\n        <Plan name=\"Pro\" price={yearly ? 25 : 32} featured features={[\"Unlimited projects\", \"1 TB storage\"]} />\n      </div>\n    </div>\n  )\n}`\n\nconst defaultVersions: AiChat2Version[] = [\n  { code: code1, preview: <PricingPreview />, note: \"Two plans side by side\" },\n  { code: code2, preview: <PricingPreview yearly />, note: \"Added a monthly and yearly toggle\" },\n]\n\nconst defaultMessages: AiChat2Turn[] = [\n  { id: \"u1\", role: \"user\", text: \"Build me a simple pricing table component with a Starter and a Pro plan.\" },\n  { id: \"a1\", role: \"assistant\", artifact: 1, text: \"Here is a pricing table with two plans. The Pro plan is highlighted, and each plan lists what it includes. I put it in a panel on the right so you can see it and copy the code.\" },\n]\n\nconst defaultConversations = [\n  { id: \"c1\", title: \"Pricing table component\" },\n  { id: \"c2\", title: \"Refactor the billing hook\" },\n  { id: \"c3\", title: \"Write release notes for 2.4\" },\n  { id: \"c4\", title: \"Explain this stack trace\" },\n]\n\nconst defaultSuggestions = [\"Add a monthly and yearly toggle\", \"Explain how the code works\"]\n\nfunction AiChat2({\n  conversations = defaultConversations,\n  artifactTitle = \"Pricing table\",\n  artifactKind = \"React component\",\n  filename = \"pricing-table.tsx\",\n  language = \"tsx\",\n  versions = defaultVersions,\n  defaultMessages: initial = defaultMessages,\n  suggestions = defaultSuggestions,\n  onSend,\n  height = \"46rem\",\n  className,\n  style,\n  ...props\n}: AiChat2Props) {\n  const [messages, setMessages] = React.useState(initial)\n  const [version, setVersion] = React.useState(Math.max(1, ...initial.map((m) => m.artifact ?? 1)))\n  // \"auto\" shows the panel beside the chat on wide screens only; \"open\" also lets it cover the chat on narrow ones.\n  const [panel, setPanel] = React.useState<\"auto\" | \"open\" | \"closed\">(\"auto\")\n  const [sidebar, setSidebar] = React.useState(true)\n  const [busy, setBusy] = React.useState(false)\n  const [chips, setChips] = React.useState(suggestions)\n  const [active, setActive] = React.useState<string | undefined>(conversations[0]?.id)\n  const timer = React.useRef<number | null>(null)\n  React.useEffect(() => () => { if (timer.current) window.clearInterval(timer.current) }, [])\n\n  const hasArtifact = initial.some((m) => m.artifact) || messages.some((m) => m.artifact)\n  const current = versions[Math.min(version, versions.length) - 1] ?? versions[0]\n\n  async function send(prompt: string) {\n    if (busy) return\n    const id = messages.length\n    setChips([])\n    setMessages((m) => [...m, { id: `u${id}`, role: \"user\", text: prompt }, { id: `a${id}`, role: \"assistant\", text: \"\", streaming: true }])\n    setBusy(true)\n    const scripted = () => {\n      if (/toggle|yearly|annual/i.test(prompt)) return { reply: \"Done. I added a monthly and yearly toggle above the plans. Switching to yearly drops the prices by about 20%, and the cards keep their layout.\", artifact: 2 }\n      if (/explain|how/i.test(prompt)) return { reply: \"The component is a two-column grid of plan cards. Each plan is a small component that takes a name, a price and a list of features. The Pro plan gets a stronger border to make it stand out.\" }\n      return { reply: \"Got it. I can change the copy, the number of plans or the layout. Tell me what you would like different and I will update the panel.\" }\n    }\n    const result = await Promise.resolve(onSend ? onSend(prompt) : scripted())\n    if (result.artifact) {\n      setVersion(result.artifact)\n      setPanel(\"open\")\n    }\n    const words = result.reply.split(\" \")\n    let n = 0\n    timer.current = window.setInterval(() => {\n      n += 2\n      const done = n >= words.length\n      setMessages((all) => all.map((m) => (m.id === `a${id}` ? { ...m, text: words.slice(0, n).join(\" \"), streaming: !done, artifact: done ? result.artifact : undefined } : m)))\n      if (done) {\n        if (timer.current) window.clearInterval(timer.current)\n        setBusy(false)\n        if (!result.artifact && /toggle|yearly|annual/i.test(prompt) === false) setChips(suggestions.filter((s) => !/explain/i.test(s) || !/explain|how/i.test(prompt)))\n      }\n    }, 45)\n  }\n\n  return (\n    <div data-slot=\"ai-chat-2\" className={cn(\"bg-background relative flex overflow-hidden rounded-2xl border shadow-[0_30px_80px_-50px_rgb(0_0_0/0.4)]\", className)} style={{ height, ...style }} {...props}>\n      <aside hidden={!sidebar} className=\"bg-muted/30 hidden w-60 shrink-0 flex-col border-e lg:flex [&[hidden]]:hidden\">\n        <div className=\"p-3\">\n          <Button variant=\"outline\" className=\"w-full justify-start\" onClick={() => { setMessages([]); setActive(undefined); setChips([]); setPanel(\"closed\") }}><Plus /> New chat</Button>\n        </div>\n        <nav aria-label=\"Conversations\" className=\"min-h-0 flex-1 overflow-y-auto px-2 pb-3\">\n          <p className=\"text-muted-foreground px-2 pt-2 pb-1 text-xs font-medium\">Recent</p>\n          <ul className=\"space-y-0.5\">\n            {conversations.map((c) => (\n              <li key={c.id}>\n                <button type=\"button\" aria-current={c.id === active ? \"page\" : undefined} onClick={() => setActive(c.id)} className={cn(\"focus-visible:ring-ring/50 flex w-full items-center gap-2 rounded-lg px-2 py-2 text-start text-sm outline-none transition-colors focus-visible:ring-[3px]\", c.id === active ? \"bg-accent text-foreground\" : \"text-muted-foreground hover:bg-accent/60 hover:text-foreground\")}>\n                  <MessageSquare className=\"size-4 shrink-0\" aria-hidden=\"true\" /><span className=\"truncate\">{c.title}</span>\n                </button>\n              </li>\n            ))}\n          </ul>\n        </nav>\n      </aside>\n\n      <div className=\"flex min-w-0 flex-1 flex-col\">\n        <div className=\"flex h-14 shrink-0 items-center gap-2 border-b px-3\">\n          <Button variant=\"ghost\" size=\"icon\" className=\"hidden lg:inline-flex\" aria-label={sidebar ? \"Hide conversations\" : \"Show conversations\"} aria-pressed={sidebar} onClick={() => setSidebar((s) => !s)}><PanelLeft  className=\"rtl:-scale-x-100\"/></Button>\n          <p className=\"truncate text-sm font-medium\">{conversations.find((c) => c.id === active)?.title ?? \"New chat\"}</p>\n          {hasArtifact && panel !== \"open\" && <Button variant=\"outline\" size=\"sm\" className={cn(\"ms-auto\", panel === \"auto\" && \"xl:hidden\")} onClick={() => setPanel(\"open\")}><FileCode2 /> Open {artifactTitle}</Button>}\n        </div>\n\n        <Chat className=\"min-h-0 flex-1\">\n          <ChatMessages contentClassName=\"mx-auto w-full max-w-2xl px-4 py-6\">\n            {messages.length === 0 && (\n              <div className=\"text-muted-foreground flex min-h-64 flex-col items-center justify-center text-center\">\n                <Sparkles className=\"size-8\" aria-hidden=\"true\" />\n                <p className=\"text-foreground mt-4 font-medium\">What shall we build?</p>\n                <p className=\"mt-1 text-sm\">Ask for a component, a page or a snippet. The result appears in a panel beside the chat.</p>\n              </div>\n            )}\n            {messages.map((m) =>\n              m.role === \"user\" ? (\n                <Message key={m.id} role=\"user\"><MessageContent>{m.text}</MessageContent></Message>\n              ) : (\n                <Message key={m.id} role=\"assistant\">\n                  <MessageAvatar>AI</MessageAvatar>\n                  <MessageContent>\n                    <StreamingText text={m.text} streaming={m.streaming} />\n                    {m.artifact && !m.streaming && (\n                      <button type=\"button\" onClick={() => { setVersion(m.artifact!); setPanel(\"open\") }} className=\"bg-card hover:bg-accent/50 focus-visible:ring-ring/50 mt-3 flex w-full max-w-sm items-center gap-3 rounded-xl border p-3 text-start outline-none transition-colors focus-visible:ring-[3px]\">\n                        <span aria-hidden=\"true\" className=\"bg-muted flex size-9 items-center justify-center rounded-lg\"><FileCode2 className=\"size-4\" /></span>\n                        <span className=\"min-w-0 flex-1 text-sm\"><span className=\"block font-medium\">{artifactTitle} <span className=\"text-muted-foreground font-normal\">· v{m.artifact}</span></span><span className=\"text-muted-foreground block truncate text-xs\">{versions[m.artifact - 1]?.note}</span></span>\n                      </button>\n                    )}\n                  </MessageContent>\n                  {!m.streaming && m.text && <MessageActions><MessageCopyAction value={m.text} /></MessageActions>}\n                </Message>\n              )\n            )}\n          </ChatMessages>\n          <ChatFooter contentClassName=\"mx-auto w-full max-w-2xl px-4 pb-4\">\n            {chips.length > 0 && (\n              <div className=\"mb-3 flex flex-wrap gap-2\">\n                {chips.map((c) => <button key={c} type=\"button\" onClick={() => send(c)} className=\"hover:bg-accent focus-visible:ring-ring/50 rounded-full border px-3 py-1.5 text-sm outline-none transition-colors focus-visible:ring-[3px]\">{c}</button>)}\n              </div>\n            )}\n            <PromptInput onSubmit={(v) => send(v)} status={busy ? \"streaming\" : \"idle\"}>\n              <PromptInputTextarea placeholder=\"Ask for a change…\" />\n              <PromptInputToolbar>\n                <PromptInputSubmit className=\"ms-auto\" />\n              </PromptInputToolbar>\n            </PromptInput>\n          </ChatFooter>\n        </Chat>\n      </div>\n\n      {hasArtifact && panel !== \"closed\" && current && (\n        <div className={cn(\"bg-background xl:static xl:inset-auto xl:z-auto xl:flex xl:w-[28rem] xl:shrink-0 xl:border-s 2xl:w-[34rem]\", panel === \"open\" ? \"absolute inset-0 z-10 flex\" : \"hidden\")}>\n          <ArtifactPanel\n            title={artifactTitle}\n            kind={artifactKind}\n            icon={<FileCode2 />}\n            code={current.code}\n            filename={filename}\n            language={language}\n            versions={versions.length}\n            version={Math.min(version, versions.length)}\n            onVersionChange={setVersion}\n            streaming={busy && messages.at(-1)?.role === \"assistant\" && !messages.at(-1)?.text}\n            onClose={() => setPanel(\"closed\")}\n            className=\"h-full w-full rounded-none border-0\"\n          >\n            {current.preview}\n          </ArtifactPanel>\n        </div>\n      )}\n    </div>\n  )\n}\n\nexport { AiChat2, PricingPreview, type AiChat2Props, type AiChat2Turn, type AiChat2Version }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/ai-chat-2/ai-chat-2.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai chat",
      "artifact",
      "assistant",
      "streaming",
      "code",
      "side panel",
      "versions"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "An assistant UI that produces artifacts. Pass versions=[{ code, preview, note }], defaultMessages, suggestions and onSend(prompt) returning { reply, artifact? } (the 1-based version the reply moves to). Without onSend a built-in script lets you try the whole flow.",
      "whenToUse": [
        "Assistants that write code, documents or designs",
        "Anywhere results deserve their own panel with versions"
      ],
      "whenNotToUse": [
        "A plain chat pane (use ai-chat-1)"
      ],
      "composesWith": [
        "app-shell-1",
        "ai-chat-1",
        "mail-1"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Chips, the artifact chip in a reply, the panel's tabs and its version arrows are all buttons in reading order"
        },
        {
          "keys": "Streaming",
          "action": "Replies stream into a live region; the panel shows a progress edge while it writes"
        },
        {
          "keys": "Esc / Close",
          "action": "The close button returns to the chat on narrow screens"
        }
      ],
      "customization": [
        "versions, defaultMessages, suggestions, conversations",
        "artifactTitle, artifactKind, filename, language",
        "onSend(prompt) async"
      ]
    },
    "examples": [
      "ai-chat-2-demo",
      "ai-chat-2-blank"
    ],
    "url": "https://ui.ballmac.com/components/ai-chat-2"
  },
  "categories": [
    "blocks",
    "ai-chat"
  ],
  "type": "registry:block"
}