{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-llm-chat",
  "title": "Ai Llm Chat",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-react/blocks/ai-llm-chat/AiLlmChat.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport {\n  ArrowUp,\n  Check,\n  ChevronDown,\n  Copy,\n  MessageSquarePlus,\n  MoreHorizontal,\n  Paperclip,\n  PanelLeftClose,\n  RefreshCw,\n  Search,\n  Sparkles,\n  Square,\n  ThumbsDown,\n  ThumbsUp,\n  Code,\n  Lightbulb,\n  PenLine,\n  BookOpen,\n} from 'lucide-react'\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar'\nimport { Button } from '@/components/ui/button'\n\ntype Role = 'user' | 'assistant'\n\ninterface Turn {\n  id: string\n  role: Role\n  body: string\n  code?: { lang: string; content: string }\n  createdAt: Date\n}\n\ninterface Thread {\n  id: string\n  title: string\n  updatedAt: Date\n  turns: Turn[]\n}\n\nconst now = new Date()\nconst todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())\nconst yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)\nconst sevenDays = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)\n\nconst suggestions = [\n  { icon: Code, label: 'Explain this code', body: 'Walk me through what `useRegistry()` does line by line.' },\n  {\n    icon: PenLine,\n    label: 'Draft a release note',\n    body: 'Write a release note for shipping the new inbox + chat blocks.',\n  },\n  {\n    icon: Lightbulb,\n    label: 'Brainstorm',\n    body: 'Give me five ideas for verticals to build on top of the uipkge registry.',\n  },\n  {\n    icon: BookOpen,\n    label: 'Summarise',\n    body: 'Summarise the CLAUDE.md \"Primitive vs Block\" boundary in three bullets.',\n  },\n]\n\nconst models = [\n  { id: 'opus-4-7', label: 'Opus 4.7', hint: 'Most capable' },\n  { id: 'sonnet-4-6', label: 'Sonnet 4.6', hint: 'Balanced' },\n  { id: 'haiku-4-5', label: 'Haiku 4.5', hint: 'Fastest' },\n]\n\nconst initialThreads: Thread[] = [\n  {\n    id: 't1',\n    title: 'Designing the registry block boundary',\n    updatedAt: new Date(now.getTime() - 12 * 60 * 1000),\n    turns: [\n      {\n        id: 'u1',\n        role: 'user',\n        body: 'When should I extract a repeating tile pattern across blocks into a primitive?',\n        createdAt: new Date(now.getTime() - 22 * 60 * 1000),\n      },\n      {\n        id: 'a1',\n        role: 'assistant',\n        body: 'Short answer: almost never until you have 5+ distinct blocks calling for the *exact same* shape, and the extraction is slot-only with no layout opinion.\\n\\nThe trap is that StatCard-style primitives — ones that bake an \"icon top-right, value below title, trend pill bottom-right\" layout — look reusable but lock every block that consumes them. The user installing the block can\\'t swap one tile into a chart-led tile without un-abstracting the primitive first.\\n\\nThe heuristic the CLAUDE.md spells out:',\n        createdAt: new Date(now.getTime() - 21 * 60 * 1000),\n      },\n      {\n        id: 'a1b',\n        role: 'assistant',\n        body: 'Here is the minimal check you can run on any candidate primitive:',\n        code: {\n          lang: 'ts',\n          content:\n            '// If the prop changes BEHAVIOR -> primitive (variant, disabled, multiple)\\n// If the prop changes LAYOUT or WHICH FIELDS RENDER -> block territory\\n//\\n// Red flag: a primitive that accepts an `items` array of structured records\\n// and renders them with a fixed shape. That is layout opinion in a prop.\\ninterface KpiItem { title: string; value: string; icon: any; trend: number }\\n//                ^ if you see this on a `registry:ui` item, stop.',\n        },\n        createdAt: new Date(now.getTime() - 20 * 60 * 1000),\n      },\n      {\n        id: 'u2',\n        role: 'user',\n        body: 'And if the same 3 tiles repeat inside one block file?',\n        createdAt: new Date(now.getTime() - 14 * 60 * 1000),\n      },\n      {\n        id: 'a2',\n        role: 'assistant',\n        body: 'Leave them inline. The registry rule is \"three similar lines is better than a premature abstraction.\" Blocks are supposed to read top-to-bottom at the call site — that is the whole reason a user installs a block instead of a primitive. The cost of writing the tile out three times is small; the cost of pre-abstracting and getting the shape slightly wrong is high (every consumer downstream pays for it).',\n        createdAt: new Date(now.getTime() - 13 * 60 * 1000),\n      },\n    ],\n  },\n  {\n    id: 't2',\n    title: 'Tailwind v4 OKLCH dark mode tokens',\n    updatedAt: new Date(now.getTime() - 3 * 60 * 60 * 1000),\n    turns: [],\n  },\n  {\n    id: 't3',\n    title: 'Zero-downtime migration plan',\n    updatedAt: yesterday,\n    turns: [],\n  },\n  {\n    id: 't4',\n    title: 'Composing kanban + calendar in one view',\n    updatedAt: new Date(now.getTime() - 30 * 60 * 60 * 1000),\n    turns: [],\n  },\n  {\n    id: 't5',\n    title: 'shadcn-vue resolver circular warning',\n    updatedAt: new Date(now.getTime() - 4 * 24 * 60 * 60 * 1000),\n    turns: [],\n  },\n  {\n    id: 't6',\n    title: 'Cloudflare Pages preview env wiring',\n    updatedAt: new Date(now.getTime() - 9 * 24 * 60 * 60 * 1000),\n    turns: [],\n  },\n]\n\nfunction formatTime(d: Date): string {\n  if (d >= todayStart) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })\n  if (d.toDateString() === yesterday.toDateString()) return 'Yesterday'\n  return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })\n}\n\nexport interface AiLlmChatProps {\n  onSend?: (body: string) => void\n  onRegenerate?: (id: string) => void\n  onStop?: () => void\n}\n\nexport function AiLlmChat({ onSend, onRegenerate, onStop }: AiLlmChatProps = {}) {\n  const [threads, setThreads] = React.useState<Thread[]>(initialThreads)\n  const [activeId, setActiveId] = React.useState<string>('t1')\n  const [draft, setDraft] = React.useState('')\n  const [sidebarOpen, setSidebarOpen] = React.useState(true)\n  const [sidebarSearch, setSidebarSearch] = React.useState('')\n  const [modelOpen, setModelOpen] = React.useState(false)\n  const [activeModel, setActiveModel] = React.useState(models[0])\n  const [isStreaming, setIsStreaming] = React.useState(false)\n  const [copiedId, setCopiedId] = React.useState<string | null>(null)\n  const scrollRoot = React.useRef<HTMLDivElement | null>(null)\n  const streamTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n\n  const activeThread = threads.find((t) => t.id === activeId)!\n  const turns = activeThread?.turns ?? []\n  const isEmpty = turns.length === 0\n\n  const filteredThreads = React.useMemo(() => {\n    const q = sidebarSearch.trim().toLowerCase()\n    return q ? threads.filter((t) => t.title.toLowerCase().includes(q)) : threads\n  }, [threads, sidebarSearch])\n\n  const groupedThreads = React.useMemo(() => {\n    const today: Thread[] = []\n    const ydy: Thread[] = []\n    const week: Thread[] = []\n    const earlier: Thread[] = []\n    for (const t of [...filteredThreads].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())) {\n      if (t.updatedAt >= todayStart) today.push(t)\n      else if (t.updatedAt.toDateString() === yesterday.toDateString()) ydy.push(t)\n      else if (t.updatedAt >= sevenDays) week.push(t)\n      else earlier.push(t)\n    }\n    return { today, yesterday: ydy, week, earlier }\n  }, [filteredThreads])\n\n  function scrollToBottom() {\n    if (!scrollRoot.current) return\n    scrollRoot.current.scrollTop = scrollRoot.current.scrollHeight\n  }\n\n  React.useEffect(() => {\n    scrollToBottom()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [activeId, turns.length, isStreaming])\n\n  function selectThread(id: string) {\n    setActiveId(id)\n  }\n\n  function newChat() {\n    const t: Thread = {\n      id: `t${Date.now()}`,\n      title: 'New chat',\n      updatedAt: new Date(),\n      turns: [],\n    }\n    setThreads((prev) => [t, ...prev])\n    setActiveId(t.id)\n    setDraft('')\n  }\n\n  function send(prefill?: string) {\n    const body = (prefill ?? draft).trim()\n    if (!body || isStreaming) return\n    const userTurn: Turn = {\n      id: `u${Date.now()}`,\n      role: 'user',\n      body,\n      createdAt: new Date(),\n    }\n    setThreads((prev) =>\n      prev.map((t) =>\n        t.id === activeId\n          ? {\n              ...t,\n              updatedAt: new Date(),\n              title:\n                t.title === 'New chat' ? body.slice(0, 48) + (body.length > 48 ? '…' : '') : t.title,\n              turns: [...t.turns, userTurn],\n            }\n          : t,\n      ),\n    )\n    setDraft('')\n    onSend?.(body)\n    setIsStreaming(true)\n    streamTimer.current = setTimeout(() => {\n      const assistantTurn: Turn = {\n        id: `a${Date.now()}`,\n        role: 'assistant',\n        body: 'Here is a placeholder response. Wire `send()` up to your streaming endpoint and push assistant tokens onto the last turn as they arrive.',\n        createdAt: new Date(),\n      }\n      setThreads((prev) =>\n        prev.map((t) => (t.id === activeId ? { ...t, turns: [...t.turns, assistantTurn] } : t)),\n      )\n      setIsStreaming(false)\n    }, 1400)\n  }\n\n  function stop() {\n    if (streamTimer.current) clearTimeout(streamTimer.current)\n    setIsStreaming(false)\n    onStop?.()\n  }\n\n  function copyTurn(id: string, body: string) {\n    if (typeof navigator !== 'undefined' && navigator.clipboard) {\n      navigator.clipboard.writeText(body).catch(() => {})\n    }\n    setCopiedId(id)\n    setTimeout(() => {\n      setCopiedId((curr) => (curr === id ? null : curr))\n    }, 1500)\n  }\n\n  function onComposerKey(e: React.KeyboardEvent<HTMLTextAreaElement>) {\n    if (e.key === 'Enter' && !e.shiftKey) {\n      e.preventDefault()\n      send()\n    }\n  }\n\n  function selectModel(id: string) {\n    const m = models.find((m) => m.id === id)\n    if (m) setActiveModel(m)\n    setModelOpen(false)\n  }\n\n  return (\n    <div\n      className=\"bg-background text-foreground grid h-[680px] w-full overflow-hidden rounded-xl border shadow-sm transition-[grid-template-columns]\"\n      style={{ gridTemplateColumns: sidebarOpen ? '260px 1fr' : '0px 1fr' }}\n    >\n      <aside className=\"bg-muted/30 flex min-w-0 flex-col overflow-hidden border-r\">\n        <div className=\"flex h-14 shrink-0 items-center gap-2 border-b px-3\">\n          <Button className=\"h-9 flex-1 justify-start gap-2 rounded-lg\" variant=\"outline\" onClick={newChat}>\n            <MessageSquarePlus className=\"size-4\" />\n            New chat\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"size-8 shrink-0\"\n            aria-label=\"Collapse sidebar\"\n            onClick={() => setSidebarOpen(false)}\n          >\n            <PanelLeftClose className=\"size-4\" />\n          </Button>\n        </div>\n        <div className=\"px-3 pt-3 pb-2\">\n          <div className=\"relative\">\n            <Search className=\"text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2\" />\n            <input\n              value={sidebarSearch}\n              onChange={(e) => setSidebarSearch(e.target.value)}\n              placeholder=\"Search chats\"\n              className=\"border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 h-8 w-full rounded-lg border pl-8 text-xs shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px]\"\n            />\n          </div>\n        </div>\n        <div className=\"thread-scroll flex-1 overflow-y-auto pb-3\">\n          {(\n            [\n              ['Today', groupedThreads.today],\n              ['Yesterday', groupedThreads.yesterday],\n              ['Previous 7 days', groupedThreads.week],\n              ['Earlier', groupedThreads.earlier],\n            ] as const\n          ).map(([key, group]) =>\n            group.length > 0 ? (\n              <React.Fragment key={key}>\n                <p className=\"text-muted-foreground px-3 pt-3 pb-1 text-[10px] font-semibold tracking-widest uppercase\">\n                  {key}\n                </p>\n                {group.map((t) => (\n                  <button\n                    key={t.id}\n                    className={[\n                      'group flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-[13px] transition-colors',\n                      t.id === activeId\n                        ? 'bg-primary/10 text-foreground font-medium'\n                        : 'text-foreground/80 hover:bg-muted/60',\n                    ].join(' ')}\n                    onClick={() => selectThread(t.id)}\n                  >\n                    <span className=\"truncate\">{t.title}</span>\n                    <MoreHorizontal className=\"text-muted-foreground size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100\" />\n                  </button>\n                ))}\n              </React.Fragment>\n            ) : null,\n          )}\n        </div>\n        <div className=\"border-t px-3 py-3\">\n          <div className=\"flex items-center gap-2\">\n            <Avatar className=\"size-8\">\n              <AvatarFallback className=\"text-[11px] font-medium\">U</AvatarFallback>\n            </Avatar>\n            <div className=\"min-w-0 flex-1\">\n              <p className=\"truncate text-xs font-medium\">You</p>\n              <p className=\"text-muted-foreground truncate text-[10px]\">Free plan</p>\n            </div>\n            <Button variant=\"ghost\" size=\"icon\" className=\"size-8\" aria-label=\"Account menu\">\n              <MoreHorizontal className=\"size-4\" />\n            </Button>\n          </div>\n        </div>\n      </aside>\n\n      <section className=\"flex min-w-0 flex-col\">\n        <header className=\"flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4\">\n          <div className=\"flex items-center gap-2\">\n            {!sidebarOpen && (\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-8\"\n                aria-label=\"Open sidebar\"\n                onClick={() => setSidebarOpen(true)}\n              >\n                <MessageSquarePlus className=\"size-4\" />\n              </Button>\n            )}\n            <div className=\"relative\">\n              <button\n                className=\"hover:bg-muted/60 flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium transition-colors\"\n                onClick={() => setModelOpen((v) => !v)}\n              >\n                <Sparkles className=\"text-primary size-3.5\" />\n                {activeModel.label}\n                <ChevronDown className=\"text-muted-foreground size-3.5\" />\n              </button>\n              {modelOpen && (\n                <div className=\"bg-popover absolute top-full left-0 z-10 mt-1 w-56 overflow-hidden rounded-lg border p-1 shadow-lg\">\n                  {models.map((m) => (\n                    <button\n                      key={m.id}\n                      className=\"hover:bg-muted/60 flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors\"\n                      onClick={() => selectModel(m.id)}\n                    >\n                      <div className=\"min-w-0\">\n                        <p className=\"font-medium\">{m.label}</p>\n                        <p className=\"text-muted-foreground text-[10px]\">{m.hint}</p>\n                      </div>\n                      {m.id === activeModel.id && <Check className=\"text-primary size-3.5 shrink-0\" />}\n                    </button>\n                  ))}\n                </div>\n              )}\n            </div>\n          </div>\n          <Button variant=\"ghost\" size=\"icon\" className=\"size-8\" aria-label=\"More options\">\n            <MoreHorizontal className=\"size-4\" />\n          </Button>\n        </header>\n\n        <div ref={scrollRoot} className=\"thread-scroll flex-1 overflow-y-auto\">\n          {isEmpty ? (\n            <div className=\"mx-auto flex h-full max-w-2xl flex-col items-center justify-center gap-6 px-6\">\n              <div className=\"bg-primary/10 text-primary flex size-12 items-center justify-center rounded-full\">\n                <Sparkles className=\"size-6\" />\n              </div>\n              <div className=\"text-center\">\n                <h2 className=\"text-xl font-semibold tracking-tight\">How can I help today?</h2>\n                <p className=\"text-muted-foreground mt-1 text-sm\">Ask anything, or try one of these to get started.</p>\n              </div>\n              <div className=\"grid w-full grid-cols-2 gap-2\">\n                {suggestions.map((s) => {\n                  const Icon = s.icon\n                  return (\n                    <button\n                      key={s.label}\n                      className=\"bg-card hover:bg-muted/60 group flex items-start gap-3 rounded-lg border p-3 text-left transition-colors\"\n                      onClick={() => send(s.body)}\n                    >\n                      <div className=\"bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary flex size-8 shrink-0 items-center justify-center rounded-md transition-colors\">\n                        <Icon className=\"size-4\" />\n                      </div>\n                      <div className=\"min-w-0\">\n                        <p className=\"text-foreground text-xs font-semibold\">{s.label}</p>\n                        <p className=\"text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed\">{s.body}</p>\n                      </div>\n                    </button>\n                  )\n                })}\n              </div>\n            </div>\n          ) : (\n            <div className=\"mx-auto max-w-3xl space-y-6 px-6 py-6\">\n              {turns.map((t) => (\n                <div key={t.id} className=\"group\">\n                  {t.role === 'user' ? (\n                    <div className=\"flex justify-end\">\n                      <div className=\"bg-primary/10 text-foreground max-w-[80%] rounded-2xl rounded-tr-md px-4 py-2.5 text-sm leading-relaxed\">\n                        {t.body}\n                      </div>\n                    </div>\n                  ) : (\n                    <div className=\"flex items-start gap-3\">\n                      <div className=\"bg-primary/10 text-primary mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full\">\n                        <Sparkles className=\"size-3.5\" />\n                      </div>\n                      <div className=\"min-w-0 flex-1 space-y-3\">\n                        <p className=\"text-foreground text-sm leading-relaxed whitespace-pre-wrap\">{t.body}</p>\n                        {t.code && (\n                          <div className=\"bg-muted/60 overflow-hidden rounded-lg border\">\n                            <div className=\"bg-muted/80 text-muted-foreground flex items-center justify-between px-3 py-1.5 font-mono text-[10px]\">\n                              <span>{t.code.lang}</span>\n                              <button\n                                className=\"hover:text-foreground inline-flex items-center gap-1 transition-colors\"\n                                onClick={() => copyTurn(t.id + '-code', t.code!.content)}\n                              >\n                                {copiedId === t.id + '-code' ? (\n                                  <Check className=\"text-success size-3\" />\n                                ) : (\n                                  <Copy className=\"size-3\" />\n                                )}\n                                {copiedId === t.id + '-code' ? 'Copied' : 'Copy'}\n                              </button>\n                            </div>\n                            <pre className=\"overflow-x-auto px-3 py-2.5 font-mono text-[12px] leading-relaxed\">\n                              <code>{t.code.content}</code>\n                            </pre>\n                          </div>\n                        )}\n                        <div className=\"flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100\">\n                          <button\n                            className=\"text-muted-foreground hover:bg-muted hover:text-foreground inline-flex h-7 items-center gap-1 rounded-md px-2 text-[11px] transition-colors\"\n                            onClick={() => copyTurn(t.id, t.body)}\n                          >\n                            {copiedId === t.id ? (\n                              <Check className=\"text-success size-3\" />\n                            ) : (\n                              <Copy className=\"size-3\" />\n                            )}\n                            {copiedId === t.id ? 'Copied' : 'Copy'}\n                          </button>\n                          <button\n                            className=\"text-muted-foreground hover:bg-muted hover:text-foreground inline-flex size-7 items-center justify-center rounded-md transition-colors\"\n                            aria-label=\"Regenerate\"\n                            onClick={() => onRegenerate?.(t.id)}\n                          >\n                            <RefreshCw className=\"size-3\" />\n                          </button>\n                          <button\n                            className=\"text-muted-foreground hover:bg-muted hover:text-success inline-flex size-7 items-center justify-center rounded-md transition-colors\"\n                            aria-label=\"Good response\"\n                          >\n                            <ThumbsUp className=\"size-3\" />\n                          </button>\n                          <button\n                            className=\"text-muted-foreground hover:bg-muted hover:text-destructive inline-flex size-7 items-center justify-center rounded-md transition-colors\"\n                            aria-label=\"Bad response\"\n                          >\n                            <ThumbsDown className=\"size-3\" />\n                          </button>\n                          <span className=\"text-muted-foreground ml-auto text-[10px] tabular-nums\">\n                            {formatTime(t.createdAt)}\n                          </span>\n                        </div>\n                      </div>\n                    </div>\n                  )}\n                </div>\n              ))}\n\n              {isStreaming && (\n                <div className=\"flex items-start gap-3\">\n                  <div className=\"bg-primary/10 text-primary mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full\">\n                    <Sparkles className=\"size-3.5\" />\n                  </div>\n                  <div className=\"text-muted-foreground flex items-center gap-1.5 pt-1.5\">\n                    <span className=\"typing-dot\" />\n                    <span className=\"typing-dot\" style={{ animationDelay: '0.15s' }} />\n                    <span className=\"typing-dot\" style={{ animationDelay: '0.3s' }} />\n                  </div>\n                </div>\n              )}\n            </div>\n          )}\n        </div>\n\n        <div className=\"bg-muted/20 border-t px-4 py-3\">\n          <div className=\"mx-auto max-w-3xl\">\n            <div className=\"bg-background border-input/60 focus-within:border-primary/40 focus-within:ring-primary/10 group flex flex-col overflow-hidden rounded-2xl border shadow-sm transition-all duration-150 focus-within:shadow-md focus-within:ring-4\">\n              <textarea\n                value={draft}\n                onChange={(e) => setDraft(e.target.value)}\n                placeholder={`Message ${activeModel.label}`}\n                rows={1}\n                className=\"placeholder:text-muted-foreground/70 max-h-48 min-h-[48px] w-full resize-none border-0 bg-transparent px-4 pt-3 pb-1 text-sm leading-relaxed shadow-none outline-none focus-visible:ring-0\"\n                onKeyDown={onComposerKey}\n              />\n              <div className=\"flex items-center justify-between gap-2 px-2 pb-2\">\n                <div className=\"flex items-center gap-0.5\">\n                  <Button\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    className=\"text-muted-foreground hover:text-foreground size-8\"\n                    aria-label=\"Attach file\"\n                  >\n                    <Paperclip className=\"size-4\" />\n                  </Button>\n                  <span className=\"text-muted-foreground bg-muted ml-1 inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-[11px] font-medium\">\n                    <Sparkles className=\"text-primary size-3\" />\n                    {activeModel.label}\n                  </span>\n                </div>\n                <div className=\"flex items-center gap-2\">\n                  <kbd className=\"bg-muted text-muted-foreground hidden h-5 items-center gap-0.5 rounded border px-1.5 font-mono text-[10px] sm:inline-flex\">\n                    <span>↵</span>\n                  </kbd>\n                  {isStreaming ? (\n                    <Button\n                      variant=\"outline\"\n                      className=\"h-8 gap-1.5 rounded-lg px-3 text-xs font-medium\"\n                      aria-label=\"Stop generating\"\n                      onClick={stop}\n                    >\n                      <Square className=\"size-3 fill-current\" />\n                      Stop\n                    </Button>\n                  ) : (\n                    <Button\n                      className=\"h-8 gap-1.5 rounded-lg px-3 text-xs font-medium\"\n                      disabled={!draft.trim()}\n                      aria-label=\"Send\"\n                      onClick={() => send()}\n                    >\n                      Send\n                      <ArrowUp className=\"size-3.5\" />\n                    </Button>\n                  )}\n                </div>\n              </div>\n            </div>\n            <p className=\"text-muted-foreground/70 mt-2 text-center text-[10px]\">\n              AI can make mistakes. Verify important info.\n            </p>\n          </div>\n        </div>\n      </section>\n\n      <style>{`\n        .thread-scroll::-webkit-scrollbar {\n          width: 6px;\n        }\n        .thread-scroll::-webkit-scrollbar-thumb {\n          background: var(--border);\n          border-radius: 3px;\n        }\n        .typing-dot {\n          display: inline-block;\n          width: 6px;\n          height: 6px;\n          border-radius: 9999px;\n          background: currentColor;\n          opacity: 0.5;\n          animation: typing-bounce 1s infinite ease-in-out;\n        }\n        @keyframes typing-bounce {\n          0%,\n          80%,\n          100% {\n            transform: translateY(0);\n            opacity: 0.35;\n          }\n          40% {\n            transform: translateY(-3px);\n            opacity: 0.9;\n          }\n        }\n      `}</style>\n    </div>\n  )\n}\n",
      "type": "registry:block",
      "target": "~/components/blocks/AiLlmChat.tsx"
    }
  ],
  "dependencies": [
    "lucide-react"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/react/avatar.json",
    "https://uipkge.dev/r/react/button.json",
    "https://uipkge.dev/r/react/input.json",
    "https://uipkge.dev/r/react/textarea.json"
  ],
  "description": "ChatGPT/Claude-style LLM chat surface. Collapsible left sidebar with New chat, searchable thread history grouped Today/Yesterday/Previous 7 days/Earlier, account row. Centered conversation: empty state with suggested-prompt tiles, user/assistant turns with code-block rendering and copy/regenerate/feedback actions, streaming dots, stop button. Composer with model picker (Opus/Sonnet/Haiku), attach, Enter-to-send, autosizing textarea. Stateful demo — wire `send()` to your streaming endpoint.",
  "categories": [
    "communication",
    "ai"
  ]
}