{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"ai-chat","type":"registry:block","title":"AI Chat","description":"The canonical chat interface — sidebar, streaming messages, prompt input with model selector, and sources.","author":"Scrim UI (https://scrimui.dev)","categories":["pattern"],"docs":"https://scrimui.dev/patterns/ai-chat","dependencies":[],"registryDependencies":["https://scrimui.dev/r/prompt-input.json","https://scrimui.dev/r/streaming-message.json","https://scrimui.dev/r/citation-ui.json","https://scrimui.dev/r/conversation-sidebar.json","https://scrimui.dev/r/response-versions.json","https://scrimui.dev/r/context-picker.json"],"files":[{"path":"src/showcase/patterns/ai-chat/ai-chat.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 { CitationList, type Citation } from \"@/components/ui/citation-ui\";\nimport {\n  ConversationSidebar,\n  type ConversationGroup,\n} from \"@/components/ui/conversation-sidebar\";\nimport {\n  ResponseVersions,\n  type ResponseVersion,\n} from \"@/components/ui/response-versions\";\nimport {\n  ContextPicker,\n  type ContextItem,\n} from \"@/components/ui/context-picker\";\n\n/* ------------------------------------------------------------------ */\n/* Types                                                               */\n/* ------------------------------------------------------------------ */\n\ntype Turn = {\n  id: number;\n  role: \"user\" | \"assistant\";\n  text: string;\n};\n\nconst INITIAL_CONVERSATIONS: ConversationGroup[] = [\n  {\n    id: \"today\",\n    label: \"Today\",\n    conversations: [\n      { id: \"t1\", title: \"Streaming UI patterns\", updatedAt: \"2m\" },\n      { id: \"t2\", title: \"Claude model pricing\", updatedAt: \"1h\" },\n    ],\n  },\n  {\n    id: \"week\",\n    label: \"Previous 7 days\",\n    conversations: [\n      { id: \"t3\", title: \"Agent approval UX\", updatedAt: \"2d\" },\n      { id: \"t4\", title: \"Research: RAG citations\", updatedAt: \"4d\" },\n    ],\n  },\n];\n\nconst MODELS = [\n  { id: \"sonnet\", name: \"Claude Sonnet 5\", hint: \"Balanced\", icon: <ClaudeMark /> },\n  { id: \"opus\", name: \"Claude Opus 5\", hint: \"Reasoning\", icon: <ClaudeMark /> },\n  { id: \"haiku\", name: \"Claude Haiku 4.5\", hint: \"Fast\", icon: <ClaudeMark /> },\n];\n\nconst CONTEXT_SOURCES: ContextItem[] = [\n  { id: \"f1\", kind: \"file\", title: \"Q3-planning.md\", detail: \"docs/roadmap\", tokens: 2400, recent: true },\n  { id: \"f2\", kind: \"file\", title: \"metrics.csv\", detail: \"Downloads\", tokens: 9800 },\n  { id: \"w1\", kind: \"web\", title: \"AI SDK — useChat\", detail: \"sdk.vercel.ai/docs\", tokens: 3100, recent: true },\n  { id: \"w2\", kind: \"web\", title: \"Pricing page draft\", detail: \"Notion · shared\", status: \"permission-required\" },\n  { id: \"k1\", kind: \"knowledge\", title: \"Support handbook\", detail: \"142 articles\", tokens: 12400 },\n];\n\nconst SOURCES: Citation[] = [\n  {\n    id: 1,\n    title: \"Claude Fable 5 and Mythos 5\",\n    url: \"https://www.anthropic.com/news/claude-fable-5-mythos-5\",\n    domain: \"anthropic.com\",\n    snippet: \"Fable 5 is the most advanced generally available Claude model.\",\n  },\n  {\n    id: 2,\n    title: \"Designing AI-native interfaces\",\n    url: \"https://example.com/ai-native-ui\",\n    domain: \"example.com\",\n    snippet: \"A field guide to streaming, agent and reasoning states.\",\n  },\n];\n\nconst REPLIES = [\n  \"Streaming answers feel instant because the first token lands in milliseconds. Keep the reveal smooth, offer a stop control, and only add citations after the claim is grounded.\",\n  \"For agent UIs, show tool calls as they happen and gate irreversible actions behind approval. Transparency is what separates a trustworthy assistant from a black box.\",\n  \"The model selector belongs at the point of composition. Let users pick per message, describe the trade-off, and never wipe their draft when they switch.\",\n];\n\n/* Alternate greetings the regenerate loop cycles through. */\nconst GREETINGS = [\n  \"Hi — I'm your AI research assistant. Ask me anything, or attach files and I'll ground answers in them.\",\n  \"Hello — ask me anything. I can search the web, read attachments, and cite what I find.\",\n  \"Welcome back. Pick a model below and ask away — I'll show my sources when I use them.\",\n];\n\n/* ------------------------------------------------------------------ */\n/* Icons                                                               */\n/* ------------------------------------------------------------------ */\n\n/**\n * The provider's mark, inlined so this pattern stays dependency-free. Swap it\n * for whichever providers your own model list uses.\n */\nfunction ClaudeMark() {\n  return (\n    <svg viewBox=\"0 0 24 24\" width=\"13\" height=\"13\" aria-hidden className=\"shrink-0 fill-[#d97757]\">\n      <path d=\"M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z\" />\n    </svg>\n  );\n}\n\nfunction SearchIcon() {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"13\" height=\"13\">\n      <circle cx=\"11\" cy=\"11\" r=\"8\" />\n      <path d=\"m21 21-4.3-4.3\" />\n    </svg>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* AIChatPattern                                                       */\n/* ------------------------------------------------------------------ */\n\nexport function AIChatPattern() {\n  const [turns, setTurns] = React.useState<Turn[]>([]);\n  const [pending, setPending] = React.useState<string | null>(null);\n  const [showSources, setShowSources] = React.useState(false);\n  const [conversations, setConversations] = React.useState(INITIAL_CONVERSATIONS);\n  const [activeConvo, setActiveConvo] = React.useState(\"t1\");\n  const [contextSel, setContextSel] = React.useState<string[]>([]);\n  const [contextGranted, setContextGranted] = React.useState<string[]>([]);\n  /* The greeting doubles as the regenerate demo: it is a version stack, and\n     the regenerate button appends a streaming v2 the reader can page back\n     from. */\n  const [greetingVersions, setGreetingVersions] = React.useState<ResponseVersion[]>([\n    { id: \"g1\", status: \"ready\", content: <StreamingMessage text={GREETINGS[0]} /> },\n  ]);\n  const scrollRef = React.useRef<HTMLDivElement>(null);\n  const idRef = React.useRef(2);\n\n  React.useEffect(() => {\n    const el = scrollRef.current;\n    if (el) el.scrollTop = el.scrollHeight;\n  }, [turns, pending]);\n\n  function submit(value: string) {\n    setTurns((t) => [...t, { id: idRef.current++, role: \"user\", text: value }]);\n    setPending(null);\n    setShowSources(false);\n    const reply = REPLIES[idRef.current % REPLIES.length];\n    window.setTimeout(() => setPending(reply), 500);\n  }\n\n  function onStreamComplete() {\n    if (pending) {\n      setTurns((t) => [...t, { id: idRef.current++, role: \"assistant\", text: pending }]);\n      setPending(null);\n      setShowSources(true);\n    }\n  }\n\n  function regenerateGreeting() {\n    const id = `g${idRef.current++}`;\n    const text = GREETINGS[idRef.current % GREETINGS.length];\n    setGreetingVersions((vs) => [\n      ...vs,\n      {\n        id,\n        status: \"generating\",\n        content: (\n          <StreamingMessage\n            key={id}\n            text={text}\n            isStreaming\n            speed={2}\n            onComplete={() =>\n              setGreetingVersions((vs2) =>\n                vs2.map((v) => (v.id === id ? { ...v, status: \"ready\" } : v)),\n              )\n            }\n          />\n        ),\n      },\n    ]);\n  }\n\n  function newChat() {\n    const id = `t${idRef.current++}`;\n    setConversations((gs) =>\n      gs.map((g, i) =>\n        i === 0\n          ? { ...g, conversations: [{ id, title: \"Untitled chat\", updatedAt: \"now\" }, ...g.conversations] }\n          : g,\n      ),\n    );\n    setActiveConvo(id);\n    setTurns([]);\n    setGreetingVersions([\n      {\n        id: `g${idRef.current++}`,\n        status: \"ready\",\n        content: <StreamingMessage text=\"New conversation started. What are you working on?\" />,\n      },\n    ]);\n    setPending(null);\n    setShowSources(false);\n  }\n\n  const activeTitle =\n    conversations.flatMap((g) => g.conversations).find((c) => c.id === activeConvo)?.title ?? \"AI Chat\";\n\n  return (\n    <div className=\"flex h-[560px] overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900\">\n      {/* Sidebar — hidden below md; on small screens the chat takes over. */}\n      <aside className=\"hidden w-60 shrink-0 flex-col border-r border-zinc-200 dark:border-zinc-800 md:flex\">\n        <div className=\"min-h-0 flex-1\">\n          <ConversationSidebar\n            groups={conversations}\n            activeId={activeConvo}\n            onNewChat={newChat}\n            onSelect={setActiveConvo}\n            onRename={(id, title) =>\n              setConversations((gs) =>\n                gs.map((g) => ({\n                  ...g,\n                  conversations: g.conversations.map((c) => (c.id === id ? { ...c, title } : c)),\n                })),\n              )\n            }\n            onTogglePin={(id) =>\n              setConversations((gs) =>\n                gs.map((g) => ({\n                  ...g,\n                  conversations: g.conversations.map((c) =>\n                    c.id === id ? { ...c, pinned: !c.pinned } : c,\n                  ),\n                })),\n              )\n            }\n            onDelete={(id) =>\n              setConversations((gs) =>\n                gs\n                  .map((g) => ({ ...g, conversations: g.conversations.filter((c) => c.id !== id) }))\n                  .filter((g) => g.conversations.length > 0),\n              )\n            }\n            onRestore={(conv) =>\n              setConversations((gs) =>\n                gs.map((g, i) =>\n                  i === 0 ? { ...g, conversations: [conv, ...g.conversations] } : g,\n                ),\n              )\n            }\n          />\n        </div>\n        <div className=\"border-t border-zinc-200 p-3 text-xs text-zinc-500 dark:text-zinc-400 dark:border-zinc-800\">\n          <div className=\"mb-1 font-medium text-zinc-500 dark:text-zinc-400\">Claude Sonnet 5</div>\n          <div className=\"flex items-center gap-1\">\n            <SearchIcon />\n            Search &amp; web browsing enabled\n          </div>\n        </div>\n      </aside>\n\n      {/* Main */}\n      <div className=\"flex min-w-0 flex-1 flex-col\">\n        {/* Header */}\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\">AI Chat</p>\n            <p className=\"truncate text-xs text-zinc-500 dark:text-zinc-400\">{activeTitle}</p>\n          </div>\n          <span className=\"shrink-0 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400\">\n            Online\n          </span>\n        </div>\n\n        {/* Messages */}\n        <div ref={scrollRef} className=\"flex-1 space-y-5 overflow-y-auto px-4 py-5 sm:px-6\">\n          <ResponseVersions versions={greetingVersions} onRegenerate={regenerateGreeting} />\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              </div>\n            ),\n          )}\n\n          {pending && (\n            <StreamingMessage\n              text={pending}\n              isStreaming\n              speed={2}\n              onStop={() => setPending(null)}\n              onComplete={onStreamComplete}\n            />\n          )}\n\n          {showSources && !pending && (\n            <div className=\"pl-11\">\n              <CitationList citations={SOURCES} />\n            </div>\n          )}\n        </div>\n\n        {/* Composer */}\n        <div className=\"border-t border-zinc-200 px-4 py-3 dark:border-zinc-800\">\n          <ContextPicker\n            className=\"mb-2\"\n            items={CONTEXT_SOURCES.map((it) =>\n              contextGranted.includes(it.id) && it.status === \"permission-required\"\n                ? { ...it, status: \"available\" as const }\n                : it,\n            )}\n            selectedIds={contextSel}\n            onSelectionChange={setContextSel}\n            onRequestAccess={(item) => setContextGranted((g) => [...g, item.id])}\n          />\n          <PromptInput\n            models={MODELS}\n            defaultModel=\"sonnet\"\n            placeholder=\"Ask anything…\"\n            showWebSearch\n            onSubmit={submit}\n          />\n          <p className=\"mt-1.5 text-center text-[11px] text-zinc-500 dark:text-zinc-400\">\n            AI can make mistakes. Verify important information.\n          </p>\n        </div>\n      </div>\n    </div>\n  );\n}\n","type":"registry:block","target":"components/blocks/ai-chat.tsx"}]}