{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "ai-llm-chat",
  "title": "Ai Llm Chat",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-angular/blocks/ai-llm-chat/ai-llm-chat.component.ts",
      "content": "import {\n  Component,\n  DestroyRef,\n  ElementRef,\n  EventEmitter,\n  Output,\n  ViewChild,\n  afterNextRender,\n  afterRenderEffect,\n  computed,\n  inject,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport {\n  ArrowUp,\n  BookOpen,\n  Check,\n  ChevronDown,\n  Code,\n  Copy,\n  Lightbulb,\n  LucideAngularModule,\n  MessageSquarePlus,\n  MoreHorizontal,\n  Paperclip,\n  PanelLeft,\n  PanelLeftClose,\n  PenLine,\n  Pencil,\n  RefreshCw,\n  Search,\n  Settings,\n  Sparkles,\n  Square,\n  ThumbsDown,\n  ThumbsUp,\n  Trash2,\n  X,\n  type LucideIconData,\n} from 'lucide-angular'\nimport { cn } from '@/lib/utils'\nimport { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component'\nimport { UiButtonComponent } from '@/ui/button/button.component'\nimport { UiInputComponent } from '@/ui/input/input.component'\nimport {\n  UiDropdownMenuComponent,\n  UiDropdownMenuContentComponent,\n  UiDropdownMenuItemComponent,\n  UiDropdownMenuSeparatorComponent,\n  UiDropdownMenuTriggerComponent,\n} from '@/ui/dropdown-menu/dropdown-menu.component'\n\nexport type AiLlmChatRole = 'user' | 'assistant'\nexport type AiLlmChatFeedback = 'up' | 'down'\n\nexport interface AiLlmChatTurn {\n  id: string\n  role: AiLlmChatRole\n  body: string\n  code?: { lang: string; content: string }\n  createdAt: Date\n}\n\nexport interface AiLlmChatThread {\n  id: string\n  title: string\n  updatedAt: Date\n  turns: AiLlmChatTurn[]\n}\n\ninterface Model {\n  id: string\n  label: string\n  hint: string\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 PLACEHOLDER_REPLY =\n  'Here is a placeholder response. Wire `onSend` to your streaming endpoint and push assistant tokens onto the last turn as they arrive.'\n\nconst suggestions: { icon: LucideIconData; label: string; body: string }[] = [\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: Model[] = [\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: AiLlmChatThread[] = [\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\nfunction titleFromBody(body: string): string {\n  return body.slice(0, 48) + (body.length > 48 ? '…' : '')\n}\n\nfunction newThread(): AiLlmChatThread {\n  return { id: `t${Date.now()}`, title: 'New chat', updatedAt: new Date(), turns: [] }\n}\n\n// The streaming dots' keyframes (React injects the same rules as a <style> tag). Longhand\n// animation properties so the per-dot `[animation-delay:…]` utilities (React's inline\n// animationDelay) are not reset by an unlayered `animation` shorthand.\nconst TYPING_STYLES = `\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-name: typing-bounce;\n  animation-duration: 1s;\n  animation-iteration-count: infinite;\n  animation-timing-function: 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@media (prefers-reduced-motion: reduce) {\n  .typing-dot {\n    animation-name: none;\n    opacity: 0.7;\n  }\n}\n`\n\n/**\n * ChatGPT/Claude-style LLM chat surface (Angular port of the React `AiLlmChat` block).\n * Stateful demo: `send` pushes a placeholder assistant turn on a timer. Listen to\n * `(send)` / `(regenerate)` / `(stop)` (React `onSend` / `onRegenerate` / `onStop`) and\n * wire them to your streaming endpoint.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-ai-llm-chat, [ui-ai-llm-chat]',\n  standalone: true,\n  host: { class: 'contents' },\n  imports: [\n    LucideAngularModule,\n    UiAvatarComponent,\n    UiAvatarFallbackComponent,\n    UiButtonComponent,\n    UiInputComponent,\n    UiDropdownMenuComponent,\n    UiDropdownMenuTriggerComponent,\n    UiDropdownMenuContentComponent,\n    UiDropdownMenuItemComponent,\n    UiDropdownMenuSeparatorComponent,\n  ],\n  styles: [TYPING_STYLES],\n  template: `\n    <ng-template #searchIcon><lucide-icon [img]=\"Search\" class=\"size-3.5\" /></ng-template>\n    <div data-slot=\"ai-llm-chat\" [class]=\"rootClass()\">\n      <aside class=\"bg-muted/30 flex min-h-0 min-w-0 flex-col overflow-hidden border-r\">\n        <div class=\"flex h-14 shrink-0 items-center gap-2 border-b px-3\">\n          <button ui-button class=\"h-9 flex-1 justify-start gap-2 rounded-lg\" variant=\"outline\" (click)=\"newChat()\">\n            <lucide-icon [img]=\"MessageSquarePlus\" class=\"size-4\" />\n            New chat\n          </button>\n          <button\n            ui-button\n            variant=\"ghost\"\n            size=\"icon\"\n            class=\"size-8 shrink-0\"\n            aria-label=\"Collapse sidebar\"\n            (click)=\"sidebarOpen.set(false)\"\n          >\n            <lucide-icon [img]=\"PanelLeftClose\" class=\"size-4\" />\n          </button>\n        </div>\n        <div class=\"px-3 pt-3 pb-2\">\n          <ui-input\n            [value]=\"sidebarSearch()\"\n            (valueChange)=\"sidebarSearch.set($event)\"\n            placeholder=\"Search chats\"\n            size=\"small\"\n            [prefixIcon]=\"searchIcon\"\n            class=\"rounded-lg\"\n          />\n        </div>\n        <div class=\"min-h-0 flex-1 overflow-y-auto pb-3\">\n          @if (filteredThreads().length === 0) {\n            <p class=\"text-muted-foreground px-3 py-6 text-center text-xs\">\n              No chats match “{{ sidebarSearch().trim() }}”\n            </p>\n          }\n          @for (group of groupedThreads(); track group.key) {\n            <p class=\"text-muted-foreground px-3 pt-3 pb-1 text-xs font-medium tracking-wide uppercase\">\n              {{ group.key }}\n            </p>\n            @for (t of group.items; track t.id) {\n              <div [class]=\"threadRowClass(t)\">\n                @if (renamingId() === t.id) {\n                  <!-- No autofocus, on purpose: React's autoFocus here is swallowed by Radix's menu focus\n                       trap, so the field opens unfocused and focus returns to the row's trigger. Focusing it\n                       now would be blurred by that focus return, which commits and closes the rename. -->\n                  <input\n                    [value]=\"renameDraft()\"\n                    (input)=\"onRenameInput($event)\"\n                    class=\"border-input bg-background focus-visible:ring-ring h-7 min-w-0 flex-1 rounded-md border px-2 text-sm outline-none focus-visible:ring-2\"\n                    (keydown)=\"onRenameKey($event)\"\n                    (blur)=\"commitRename()\"\n                  />\n                } @else {\n                  <button type=\"button\" [class]=\"threadTitleClass(t)\" (click)=\"selectThread(t.id)\">\n                    {{ t.title }}\n                  </button>\n                }\n                <ui-dropdown-menu>\n                  <button\n                    ui-button\n                    ui-dropdown-menu-trigger\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    class=\"text-muted-foreground size-7 shrink-0 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100\"\n                    aria-label=\"Thread actions\"\n                    (click)=\"$event.stopPropagation()\"\n                  >\n                    <lucide-icon [img]=\"MoreHorizontal\" class=\"size-3.5\" />\n                  </button>\n                  <ui-dropdown-menu-content align=\"end\" class=\"w-40\">\n                    <ui-dropdown-menu-item (select)=\"startRename(t)\">\n                      <lucide-icon [img]=\"Pencil\" class=\"size-3.5\" />\n                      Rename\n                    </ui-dropdown-menu-item>\n                    <ui-dropdown-menu-separator />\n                    <ui-dropdown-menu-item variant=\"destructive\" (select)=\"deleteThread(t.id)\">\n                      <lucide-icon [img]=\"Trash2\" class=\"size-3.5\" />\n                      Delete\n                    </ui-dropdown-menu-item>\n                  </ui-dropdown-menu-content>\n                </ui-dropdown-menu>\n              </div>\n            }\n          }\n        </div>\n        <div class=\"border-t px-3 py-3\">\n          <div class=\"flex items-center gap-2\">\n            <ui-avatar class=\"size-8\">\n              <ui-avatar-fallback class=\"text-xs font-medium\">U</ui-avatar-fallback>\n            </ui-avatar>\n            <div class=\"min-w-0 flex-1\">\n              <p class=\"truncate text-xs font-medium\">You</p>\n              <p class=\"text-muted-foreground truncate text-xs\">Free plan</p>\n            </div>\n            <ui-dropdown-menu>\n              <button\n                ui-button\n                ui-dropdown-menu-trigger\n                variant=\"ghost\"\n                size=\"icon\"\n                class=\"size-8\"\n                aria-label=\"Account menu\"\n              >\n                <lucide-icon [img]=\"MoreHorizontal\" class=\"size-4\" />\n              </button>\n              <ui-dropdown-menu-content align=\"end\" class=\"w-44\">\n                <ui-dropdown-menu-item>\n                  <lucide-icon [img]=\"Settings\" class=\"size-3.5\" />\n                  Settings\n                </ui-dropdown-menu-item>\n                <ui-dropdown-menu-item>Upgrade plan</ui-dropdown-menu-item>\n              </ui-dropdown-menu-content>\n            </ui-dropdown-menu>\n          </div>\n        </div>\n      </aside>\n\n      <section class=\"flex min-h-0 min-w-0 flex-col\">\n        <header class=\"flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4\">\n          <div class=\"flex min-w-0 items-center gap-2\">\n            @if (!sidebarOpen()) {\n              <button\n                ui-button\n                variant=\"ghost\"\n                size=\"icon\"\n                class=\"size-8\"\n                aria-label=\"Open sidebar\"\n                (click)=\"sidebarOpen.set(true)\"\n              >\n                <lucide-icon [img]=\"PanelLeft\" class=\"size-4\" />\n              </button>\n            }\n            <ui-dropdown-menu>\n              <button\n                type=\"button\"\n                ui-dropdown-menu-trigger\n                class=\"hover:bg-muted/60 flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium transition-colors\"\n              >\n                <lucide-icon [img]=\"Sparkles\" class=\"text-primary size-3.5\" />\n                {{ activeModel().label }}\n                <lucide-icon [img]=\"ChevronDown\" class=\"text-muted-foreground size-3.5\" />\n              </button>\n              <ui-dropdown-menu-content align=\"start\" class=\"w-56\">\n                @for (m of models; track m.id) {\n                  <ui-dropdown-menu-item (select)=\"activeModel.set(m)\">\n                    <div class=\"min-w-0 flex-1\">\n                      <p class=\"font-medium\">{{ m.label }}</p>\n                      <p class=\"text-muted-foreground text-xs\">{{ m.hint }}</p>\n                    </div>\n                    @if (m.id === activeModel().id) {\n                      <lucide-icon [img]=\"Check\" class=\"text-primary size-3.5 shrink-0\" />\n                    }\n                  </ui-dropdown-menu-item>\n                }\n              </ui-dropdown-menu-content>\n            </ui-dropdown-menu>\n          </div>\n          <ui-dropdown-menu>\n            <button\n              ui-button\n              ui-dropdown-menu-trigger\n              variant=\"ghost\"\n              size=\"icon\"\n              class=\"size-8\"\n              aria-label=\"More options\"\n            >\n              <lucide-icon [img]=\"MoreHorizontal\" class=\"size-4\" />\n            </button>\n            <ui-dropdown-menu-content align=\"end\" class=\"w-48\">\n              <ui-dropdown-menu-item [disabled]=\"isEmpty()\" (select)=\"copyConversation()\">\n                <lucide-icon [img]=\"Copy\" class=\"size-3.5\" />\n                {{ copiedId() === 'conversation' ? 'Copied' : 'Copy conversation' }}\n              </ui-dropdown-menu-item>\n              <ui-dropdown-menu-separator />\n              <ui-dropdown-menu-item variant=\"destructive\" [disabled]=\"isEmpty()\" (select)=\"clearThread()\">\n                <lucide-icon [img]=\"Trash2\" class=\"size-3.5\" />\n                Clear thread\n              </ui-dropdown-menu-item>\n            </ui-dropdown-menu-content>\n          </ui-dropdown-menu>\n        </header>\n\n        <div\n          #scrollRoot\n          class=\"min-h-0 flex-1 overflow-y-auto\"\n          role=\"log\"\n          aria-live=\"polite\"\n          [attr.aria-busy]=\"isStreaming()\"\n        >\n          @if (isEmpty()) {\n            <div class=\"mx-auto flex h-full max-w-2xl flex-col items-center justify-center gap-6 px-6\">\n              <div class=\"bg-primary/10 text-primary flex size-12 items-center justify-center rounded-full\">\n                <lucide-icon [img]=\"Sparkles\" class=\"size-6\" />\n              </div>\n              <div class=\"text-center\">\n                <h2 class=\"text-xl font-semibold tracking-tight\">How can I help today?</h2>\n                <p class=\"text-muted-foreground mt-1 text-sm\">Ask anything, or try one of these to get started.</p>\n              </div>\n              <div class=\"grid w-full grid-cols-1 gap-2 sm:grid-cols-2\">\n                @for (s of suggestions; track s.label) {\n                  <button\n                    type=\"button\"\n                    class=\"bg-card hover:bg-muted/60 group flex items-start gap-3 rounded-lg border p-3 text-left transition-colors\"\n                    (click)=\"send(s.body)\"\n                  >\n                    <div\n                      class=\"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                    >\n                      <lucide-icon [img]=\"s.icon\" class=\"size-4\" />\n                    </div>\n                    <div class=\"min-w-0\">\n                      <p class=\"text-foreground text-xs font-medium\">{{ s.label }}</p>\n                      <p class=\"text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed\">{{ s.body }}</p>\n                    </div>\n                  </button>\n                }\n              </div>\n            </div>\n          } @else {\n            <div class=\"mx-auto max-w-3xl space-y-6 px-6 py-6\">\n              @for (t of turns(); track t.id) {\n                <div class=\"group\">\n                  @if (t.role === 'user') {\n                    <div class=\"flex justify-end\">\n                      <div\n                        class=\"bg-primary/10 text-foreground max-w-[80%] rounded-2xl rounded-tr-md px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap\"\n                      >\n                        {{ t.body }}\n                      </div>\n                    </div>\n                  } @else {\n                    <div class=\"flex items-start gap-3\">\n                      <div\n                        class=\"bg-primary/10 text-primary mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full\"\n                      >\n                        <lucide-icon [img]=\"Sparkles\" class=\"size-3.5\" />\n                      </div>\n                      <div class=\"min-w-0 flex-1 space-y-3\">\n                        <p class=\"text-foreground text-sm leading-relaxed whitespace-pre-wrap\">{{ t.body }}</p>\n                        @if (t.code; as code) {\n                          <div class=\"bg-muted/60 overflow-hidden rounded-lg border\">\n                            <div\n                              class=\"bg-muted/80 text-muted-foreground flex items-center justify-between px-3 py-1.5 font-mono text-xs\"\n                            >\n                              <span>{{ code.lang }}</span>\n                              <button\n                                type=\"button\"\n                                class=\"hover:text-foreground inline-flex items-center gap-1 transition-colors\"\n                                (click)=\"copyTurn(t.id + '-code', code.content)\"\n                              >\n                                @if (copiedId() === t.id + '-code') {\n                                  <lucide-icon [img]=\"Check\" class=\"text-success size-3\" />\n                                } @else {\n                                  <lucide-icon [img]=\"Copy\" class=\"size-3\" />\n                                }\n                                {{ copiedId() === t.id + '-code' ? 'Copied' : 'Copy' }}\n                              </button>\n                            </div>\n                            <pre\n                              class=\"overflow-x-auto px-3 py-2.5 font-mono text-xs leading-relaxed\"\n                            ><code>{{ code.content }}</code></pre>\n                          </div>\n                        }\n                        <div\n                          class=\"flex items-center gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 sm:focus-within:opacity-100\"\n                        >\n                          <button\n                            type=\"button\"\n                            class=\"text-muted-foreground hover:bg-muted hover:text-foreground inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs transition-colors\"\n                            (click)=\"copyTurn(t.id, t.body)\"\n                          >\n                            @if (copiedId() === t.id) {\n                              <lucide-icon [img]=\"Check\" class=\"text-success size-3\" />\n                            } @else {\n                              <lucide-icon [img]=\"Copy\" class=\"size-3\" />\n                            }\n                            {{ copiedId() === t.id ? 'Copied' : 'Copy' }}\n                          </button>\n                          <button\n                            type=\"button\"\n                            class=\"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                            (click)=\"regenerate(t.id)\"\n                          >\n                            <lucide-icon [img]=\"RefreshCw\" class=\"size-3\" />\n                          </button>\n                          <button\n                            type=\"button\"\n                            [class]=\"feedbackClass(t.id, 'up')\"\n                            aria-label=\"Good response\"\n                            [attr.aria-pressed]=\"hasFeedback(t.id, 'up')\"\n                            (click)=\"toggleFeedback(t.id, 'up')\"\n                          >\n                            <lucide-icon [img]=\"ThumbsUp\" class=\"size-3\" />\n                          </button>\n                          <button\n                            type=\"button\"\n                            [class]=\"feedbackClass(t.id, 'down')\"\n                            aria-label=\"Bad response\"\n                            [attr.aria-pressed]=\"hasFeedback(t.id, 'down')\"\n                            (click)=\"toggleFeedback(t.id, 'down')\"\n                          >\n                            <lucide-icon [img]=\"ThumbsDown\" class=\"size-3\" />\n                          </button>\n                          <span class=\"text-muted-foreground ml-auto text-xs tabular-nums\">{{\n                            formatTime(t.createdAt)\n                          }}</span>\n                        </div>\n                      </div>\n                    </div>\n                  }\n                </div>\n              }\n\n              @if (isStreaming()) {\n                <div class=\"flex items-start gap-3\">\n                  <div\n                    class=\"bg-primary/10 text-primary mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full\"\n                  >\n                    <lucide-icon [img]=\"Sparkles\" class=\"size-3.5\" />\n                  </div>\n                  <div class=\"text-muted-foreground flex items-center gap-1.5 pt-1.5\" aria-label=\"Generating\">\n                    <span class=\"typing-dot\"></span>\n                    <span class=\"typing-dot [animation-delay:0.15s]\"></span>\n                    <span class=\"typing-dot [animation-delay:0.3s]\"></span>\n                  </div>\n                </div>\n              }\n            </div>\n          }\n        </div>\n\n        <div class=\"bg-muted/20 shrink-0 border-t px-4 py-3\">\n          <div class=\"mx-auto max-w-3xl\">\n            <div\n              class=\"bg-background border-input/60 focus-within:border-primary/40 focus-within:ring-primary/10 flex flex-col overflow-hidden rounded-2xl border shadow-sm transition-colors duration-150 focus-within:shadow-md focus-within:ring-4\"\n            >\n              @if (attachment(); as file) {\n                <div class=\"flex items-center gap-2 px-3 pt-3\">\n                  <span\n                    class=\"bg-muted text-foreground inline-flex max-w-full items-center gap-1.5 rounded-md px-2 py-1 text-xs\"\n                  >\n                    <lucide-icon [img]=\"Paperclip\" class=\"size-3 shrink-0\" />\n                    <span class=\"truncate\">{{ file.name }}</span>\n                    <button\n                      type=\"button\"\n                      class=\"hover:text-destructive\"\n                      aria-label=\"Remove attachment\"\n                      (click)=\"attachment.set(null)\"\n                    >\n                      <lucide-icon [img]=\"X\" class=\"size-3\" />\n                    </button>\n                  </span>\n                </div>\n              }\n              <textarea\n                #composer\n                [value]=\"draft()\"\n                (input)=\"onDraftInput($event)\"\n                [placeholder]=\"'Message ' + activeModel().label\"\n                rows=\"1\"\n                class=\"placeholder:text-muted-foreground/70 max-h-48 min-h-12 w-full resize-none border-0 bg-transparent px-4 pt-3 pb-1 text-sm leading-relaxed outline-none\"\n                (keydown)=\"onComposerKey($event)\"\n              ></textarea>\n              <div class=\"flex items-center justify-between gap-2 px-2 pb-2\">\n                <div class=\"flex items-center gap-0.5\">\n                  <input #fileInput type=\"file\" class=\"sr-only\" (change)=\"onAttach($event)\" />\n                  <button\n                    ui-button\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    class=\"text-muted-foreground hover:text-foreground size-8\"\n                    aria-label=\"Attach file\"\n                    (click)=\"fileInput.click()\"\n                  >\n                    <lucide-icon [img]=\"Paperclip\" class=\"size-4\" />\n                  </button>\n                  <span\n                    class=\"text-muted-foreground bg-muted ml-1 inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium\"\n                  >\n                    <lucide-icon [img]=\"Sparkles\" class=\"text-primary size-3\" />\n                    {{ activeModel().label }}\n                  </span>\n                </div>\n                <div class=\"flex items-center gap-2\">\n                  <kbd\n                    class=\"bg-muted text-muted-foreground hidden h-5 items-center gap-0.5 rounded border px-1.5 font-mono text-xs sm:inline-flex\"\n                  >\n                    ↵\n                  </kbd>\n                  @if (isStreaming()) {\n                    <button\n                      ui-button\n                      variant=\"outline\"\n                      class=\"h-8 gap-1.5 rounded-lg px-3 text-xs font-medium\"\n                      aria-label=\"Stop generating\"\n                      (click)=\"stop()\"\n                    >\n                      <lucide-icon [img]=\"Square\" class=\"size-3 fill-current\" />\n                      Stop\n                    </button>\n                  } @else {\n                    <button\n                      ui-button\n                      class=\"h-8 gap-1.5 rounded-lg px-3 text-xs font-medium\"\n                      [disabled]=\"!draft().trim() && !attachment()\"\n                      aria-label=\"Send\"\n                      (click)=\"send()\"\n                    >\n                      Send\n                      <lucide-icon [img]=\"ArrowUp\" class=\"size-3.5\" />\n                    </button>\n                  }\n                </div>\n              </div>\n            </div>\n            <p class=\"text-muted-foreground/70 mt-2 text-center text-xs\">\n              AI can make mistakes. Verify important info.\n            </p>\n          </div>\n        </div>\n      </section>\n    </div>\n  `,\n})\nexport class UiAiLlmChatComponent {\n  /** React `onSend`: the user turn's content (draft plus \"Attached: <file>\"). */\n  @Output('send') readonly sent = new EventEmitter<string>()\n  /** React `onRegenerate`: the id of the assistant turn being regenerated. */\n  @Output('regenerate') readonly regenerated = new EventEmitter<string>()\n  /** React `onStop`: the user stopped the stream. */\n  @Output('stop') readonly stopped = new EventEmitter<void>()\n\n  @ViewChild('scrollRoot', { static: true }) scrollRoot?: ElementRef<HTMLDivElement>\n  @ViewChild('composer', { static: true }) composer?: ElementRef<HTMLTextAreaElement>\n\n  protected readonly suggestions = suggestions\n  protected readonly models = models\n\n  readonly threads = signal<AiLlmChatThread[]>(initialThreads)\n  readonly activeId = signal('t1')\n  readonly draft = signal('')\n  readonly sidebarOpen = signal(true)\n  readonly sidebarSearch = signal('')\n  readonly activeModel = signal<Model>(models[0]!)\n  readonly isStreaming = signal(false)\n  readonly copiedId = signal<string | null>(null)\n  readonly feedback = signal<Partial<Record<string, AiLlmChatFeedback>>>({})\n  readonly attachment = signal<{ name: string } | null>(null)\n  readonly renamingId = signal<string | null>(null)\n  readonly renameDraft = signal('')\n\n  private streamTimer: ReturnType<typeof setTimeout> | null = null\n  private copyTimer: ReturnType<typeof setTimeout> | null = null\n\n  readonly activeThread = computed(() => this.threads().find((t) => t.id === this.activeId()) ?? this.threads()[0])\n  readonly turns = computed(() => this.activeThread()?.turns ?? [])\n  readonly isEmpty = computed(() => this.turns().length === 0)\n\n  readonly filteredThreads = computed(() => {\n    const q = this.sidebarSearch().trim().toLowerCase()\n    return q ? this.threads().filter((t) => t.title.toLowerCase().includes(q)) : this.threads()\n  })\n\n  readonly groupedThreads = computed(() => {\n    const today: AiLlmChatThread[] = []\n    const ydy: AiLlmChatThread[] = []\n    const week: AiLlmChatThread[] = []\n    const earlier: AiLlmChatThread[] = []\n    for (const t of [...this.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 [\n      { key: 'Today', items: today },\n      { key: 'Yesterday', items: ydy },\n      { key: 'Previous 7 days', items: week },\n      { key: 'Earlier', items: earlier },\n    ].filter((g) => g.items.length > 0)\n  })\n\n  readonly rootClass = computed(() =>\n    cn(\n      'bg-background text-foreground grid h-[680px] w-full overflow-hidden rounded-xl border shadow-sm',\n      this.sidebarOpen() ? 'grid-cols-[16.25rem_1fr]' : 'grid-cols-[0fr_1fr]',\n    ),\n  )\n\n  constructor() {\n    // React's mount effect: start open, collapse after the first render on narrow screens.\n    afterNextRender(() => {\n      if (window.innerWidth < 768) this.sidebarOpen.set(false)\n    })\n    // React effects: keep the log scrolled to the newest turn, and autosize the composer.\n    afterRenderEffect(() => {\n      this.activeId()\n      this.turns().length\n      this.isStreaming()\n      this.scrollToBottom()\n    })\n    afterRenderEffect(() => {\n      this.draft()\n      this.autosizeComposer()\n    })\n    inject(DestroyRef).onDestroy(() => {\n      if (this.streamTimer) clearTimeout(this.streamTimer)\n      if (this.copyTimer) clearTimeout(this.copyTimer)\n    })\n  }\n\n  formatTime(d: Date): string {\n    return formatTime(d)\n  }\n\n  threadRowClass(t: AiLlmChatThread): string {\n    return cn(\n      'group flex w-full items-center gap-0.5 px-1.5 py-0.5',\n      t.id === this.activeId() ? 'bg-primary/10' : 'hover:bg-muted/60',\n    )\n  }\n\n  threadTitleClass(t: AiLlmChatThread): string {\n    return cn(\n      'min-w-0 flex-1 truncate px-1.5 py-1.5 text-left text-sm',\n      t.id === this.activeId() ? 'text-foreground font-medium' : 'text-foreground/80',\n    )\n  }\n\n  hasFeedback(id: string, value: AiLlmChatFeedback): boolean {\n    return this.feedback()[id] === value\n  }\n\n  feedbackClass(id: string, value: AiLlmChatFeedback): string {\n    const on = this.hasFeedback(id, value)\n    const tone =\n      value === 'up'\n        ? on\n          ? 'text-success'\n          : 'text-muted-foreground hover:text-success'\n        : on\n          ? 'text-destructive'\n          : 'text-muted-foreground hover:text-destructive'\n    return cn('hover:bg-muted inline-flex size-7 items-center justify-center rounded-md transition-colors', tone)\n  }\n\n  private scrollToBottom(): void {\n    const el = this.scrollRoot?.nativeElement\n    if (!el) return\n    el.scrollTop = el.scrollHeight\n  }\n\n  private autosizeComposer(): void {\n    const el = this.composer?.nativeElement\n    if (!el) return\n    el.style.height = 'auto'\n    el.style.height = `${Math.min(el.scrollHeight, 192)}px`\n  }\n\n  selectThread(id: string): void {\n    this.activeId.set(id)\n    this.renamingId.set(null)\n    if (window.innerWidth < 768) this.sidebarOpen.set(false)\n  }\n\n  newChat(): void {\n    const t = newThread()\n    this.threads.update((prev) => [t, ...prev])\n    this.activeId.set(t.id)\n    this.draft.set('')\n    this.attachment.set(null)\n    requestAnimationFrame(() => this.composer?.nativeElement.focus())\n  }\n\n  private patchThread(id: string, updater: (t: AiLlmChatThread) => AiLlmChatThread): void {\n    this.threads.update((prev) => prev.map((t) => (t.id === id ? updater(t) : t)))\n  }\n\n  private startStream(threadId: string): void {\n    this.isStreaming.set(true)\n    if (this.streamTimer) clearTimeout(this.streamTimer)\n    this.streamTimer = setTimeout(() => {\n      this.patchThread(threadId, (t) => ({\n        ...t,\n        turns: [\n          ...t.turns,\n          { id: `a${Date.now()}`, role: 'assistant', body: PLACEHOLDER_REPLY, createdAt: new Date() },\n        ],\n      }))\n      this.isStreaming.set(false)\n      this.streamTimer = null\n    }, 1400)\n  }\n\n  send(prefill?: string): void {\n    const body = (prefill ?? this.draft()).trim()\n    const attachment = this.attachment()\n    if ((!body && !attachment) || this.isStreaming()) return\n    const content = attachment ? `${body}${body ? '\\n\\n' : ''}Attached: ${attachment.name}` : body\n    const threadId = this.activeId()\n    this.patchThread(threadId, (t) => ({\n      ...t,\n      updatedAt: new Date(),\n      title: t.title === 'New chat' ? titleFromBody(content) : t.title,\n      turns: [...t.turns, { id: `u${Date.now()}`, role: 'user', body: content, createdAt: new Date() }],\n    }))\n    this.draft.set('')\n    this.attachment.set(null)\n    this.sent.emit(content)\n    this.startStream(threadId)\n  }\n\n  stop(): void {\n    if (this.streamTimer) {\n      clearTimeout(this.streamTimer)\n      this.streamTimer = null\n    }\n    this.isStreaming.set(false)\n    this.stopped.emit()\n  }\n\n  regenerate(id: string): void {\n    if (this.isStreaming()) return\n    const threadId = this.activeId()\n    this.patchThread(threadId, (t) => {\n      const idx = t.turns.findIndex((turn) => turn.id === id)\n      if (idx < 0) return t\n      return { ...t, turns: t.turns.slice(0, idx) }\n    })\n    this.regenerated.emit(id)\n    this.startStream(threadId)\n  }\n\n  copyTurn(id: string, body: string): void {\n    if (typeof navigator !== 'undefined' && navigator.clipboard) {\n      navigator.clipboard.writeText(body).catch(() => {})\n    }\n    this.copiedId.set(id)\n    if (this.copyTimer) clearTimeout(this.copyTimer)\n    this.copyTimer = setTimeout(() => {\n      this.copiedId.update((curr) => (curr === id ? null : curr))\n    }, 1500)\n  }\n\n  copyConversation(): void {\n    const text = this.turns()\n      .map((t) => `${t.role === 'user' ? 'You' : 'Assistant'}: ${t.body}`)\n      .join('\\n\\n')\n    this.copyTurn('conversation', text)\n  }\n\n  clearThread(): void {\n    this.stop()\n    this.patchThread(this.activeId(), (t) => ({ ...t, turns: [], title: 'New chat' }))\n  }\n\n  deleteThread(id: string): void {\n    const remaining = this.threads().filter((t) => t.id !== id)\n    if (remaining.length === 0) {\n      const t = newThread()\n      this.activeId.set(t.id)\n      this.threads.set([t])\n      return\n    }\n    if (id === this.activeId()) this.activeId.set(remaining[0]!.id)\n    this.threads.set(remaining)\n  }\n\n  startRename(t: AiLlmChatThread): void {\n    this.renamingId.set(t.id)\n    this.renameDraft.set(t.title)\n  }\n\n  commitRename(): void {\n    const id = this.renamingId()\n    if (!id) return\n    const next = this.renameDraft().trim()\n    if (next) this.threads.update((prev) => prev.map((t) => (t.id === id ? { ...t, title: next } : t)))\n    this.renamingId.set(null)\n  }\n\n  toggleFeedback(id: string, value: AiLlmChatFeedback): void {\n    this.feedback.update((prev) => {\n      const next = { ...prev }\n      if (next[id] === value) delete next[id]\n      else next[id] = value\n      return next\n    })\n  }\n\n  onRenameInput(event: Event): void {\n    this.renameDraft.set((event.target as HTMLInputElement).value)\n  }\n\n  onRenameKey(event: KeyboardEvent): void {\n    if (event.key === 'Enter') {\n      event.preventDefault()\n      this.commitRename()\n    }\n    if (event.key === 'Escape') {\n      event.preventDefault()\n      this.renamingId.set(null)\n    }\n  }\n\n  onDraftInput(event: Event): void {\n    this.draft.set((event.target as HTMLTextAreaElement).value)\n  }\n\n  onComposerKey(event: KeyboardEvent): void {\n    if (event.key === 'Enter' && !event.shiftKey) {\n      event.preventDefault()\n      this.send()\n    }\n  }\n\n  onAttach(event: Event): void {\n    const input = event.target as HTMLInputElement\n    const file = input.files?.[0]\n    if (file) this.attachment.set({ name: file.name })\n    input.value = ''\n  }\n\n  protected readonly MessageSquarePlus = MessageSquarePlus\n  protected readonly PanelLeftClose = PanelLeftClose\n  protected readonly PanelLeft = PanelLeft\n  protected readonly Search = Search\n  protected readonly MoreHorizontal = MoreHorizontal\n  protected readonly Pencil = Pencil\n  protected readonly Trash2 = Trash2\n  protected readonly Settings = Settings\n  protected readonly Sparkles = Sparkles\n  protected readonly ChevronDown = ChevronDown\n  protected readonly Check = Check\n  protected readonly Copy = Copy\n  protected readonly RefreshCw = RefreshCw\n  protected readonly ThumbsUp = ThumbsUp\n  protected readonly ThumbsDown = ThumbsDown\n  protected readonly Paperclip = Paperclip\n  protected readonly X = X\n  protected readonly Square = Square\n  protected readonly ArrowUp = ArrowUp\n}\n",
      "type": "registry:block",
      "target": "~/src/app/components/blocks/ai-llm-chat/ai-llm-chat.component.ts"
    },
    {
      "path": "packages/registry-angular/blocks/ai-llm-chat/index.ts",
      "content": "export {\n  UiAiLlmChatComponent,\n  type AiLlmChatFeedback,\n  type AiLlmChatRole,\n  type AiLlmChatThread,\n  type AiLlmChatTurn,\n} from './ai-llm-chat.component'\n",
      "type": "registry:block",
      "target": "~/src/app/components/blocks/ai-llm-chat/index.ts"
    }
  ],
  "dependencies": [
    "lucide-angular"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/avatar.json",
    "https://uipkge.dev/r/angular/button.json",
    "https://uipkge.dev/r/angular/dropdown-menu.json",
    "https://uipkge.dev/r/angular/input.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": [
    "ai",
    "communication"
  ]
}