{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "chat-two-pane",
  "title": "Chat Two Pane",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-angular/blocks/chat-two-pane/chat-two-pane.component.ts",
      "content": "import {\n  Component,\n  DestroyRef,\n  ElementRef,\n  ViewChild,\n  afterRenderEffect,\n  computed,\n  inject,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport {\n  ArrowRight,\n  AtSign,\n  CheckCheck,\n  Filter,\n  LucideAngularModule,\n  MoreVertical,\n  Paperclip,\n  Phone,\n  Pin,\n  Search,\n  Smile,\n  SquarePen,\n  Video,\n} from 'lucide-angular'\nimport { cn } from '@/lib/utils'\nimport { UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent } from '@/ui/avatar/avatar.component'\nimport { UiButtonComponent } from '@/ui/button/button.component'\nimport { UiInputComponent } from '@/ui/input/input.component'\nimport { UiTextareaComponent } from '@/ui/textarea/textarea.component'\n\nexport interface ChatTwoPaneConversation {\n  id: string\n  name: string\n  initials: string\n  avatarUrl?: string\n  lastMessage: string\n  lastAt: Date\n  unread: number\n  online: boolean\n  pinned?: boolean\n  muted?: boolean\n}\n\nexport interface ChatTwoPaneMessage {\n  id: string\n  author: 'me' | 'them'\n  body: string\n  sentAt: Date\n  status?: 'sent' | 'delivered' | 'read'\n}\n\nexport interface ChatTwoPaneMessageGroup {\n  label: string\n  items: ChatTwoPaneMessage[]\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)\n\nconst initialConversations: ChatTwoPaneConversation[] = [\n  {\n    id: 'c1',\n    name: 'Marcus Rivera',\n    initials: 'MR',\n    lastMessage: 'Yes! I will bring the kanban deck. Want to push it to 10:30?',\n    lastAt: new Date(now.getTime() - 8 * 60 * 1000),\n    unread: 2,\n    online: true,\n    pinned: true,\n  },\n  {\n    id: 'c2',\n    name: 'Sarah Connor',\n    initials: 'SC',\n    lastMessage: 'Approved — feel free to take Mar 15-18 off.',\n    lastAt: new Date(now.getTime() - 52 * 60 * 1000),\n    unread: 0,\n    online: true,\n  },\n  {\n    id: 'c3',\n    name: 'Design Team',\n    initials: 'DT',\n    lastMessage: 'Priya: pushed the new color tokens to staging.',\n    lastAt: new Date(now.getTime() - 3 * 60 * 60 * 1000),\n    unread: 5,\n    online: false,\n    pinned: true,\n  },\n  {\n    id: 'c4',\n    name: 'Alice Johnson',\n    initials: 'AJ',\n    lastMessage: 'Thanks! Will follow up after lunch.',\n    lastAt: new Date(now.getTime() - 5 * 60 * 60 * 1000),\n    unread: 0,\n    online: false,\n  },\n  {\n    id: 'c5',\n    name: 'Engineering',\n    initials: 'EN',\n    lastMessage: 'Devon: deploy is green, going home.',\n    lastAt: yesterday,\n    unread: 0,\n    online: false,\n    muted: true,\n  },\n  {\n    id: 'c6',\n    name: 'Priya Shah',\n    initials: 'PS',\n    lastMessage: 'Sent the spec — let me know what you think.',\n    lastAt: new Date(now.getTime() - 28 * 60 * 60 * 1000),\n    unread: 0,\n    online: false,\n  },\n  {\n    id: 'c7',\n    name: 'Devon Patel',\n    initials: 'DP',\n    lastMessage: 'You: pushed the fix, should be live in 10.',\n    lastAt: new Date(now.getTime() - 50 * 60 * 60 * 1000),\n    unread: 0,\n    online: true,\n  },\n]\n\nconst initialMessagesByConvo: Record<string, ChatTwoPaneMessage[]> = {\n  c1: [\n    {\n      id: 'm1',\n      author: 'them',\n      body: 'Hey — did you get a chance to look at the onboarding doc?',\n      sentAt: new Date(yesterday.getTime() + 9 * 60 * 60 * 1000),\n    },\n    {\n      id: 'm2',\n      author: 'me',\n      body: 'Yeah, just finished. Two small notes on the time-off flow but otherwise looks solid.',\n      sentAt: new Date(yesterday.getTime() + 9 * 60 * 60 * 1000 + 7 * 60 * 1000),\n      status: 'read',\n    },\n    {\n      id: 'm3',\n      author: 'them',\n      body: 'Perfect. Drop them in the doc and I will action this afternoon.',\n      sentAt: new Date(yesterday.getTime() + 9 * 60 * 60 * 1000 + 9 * 60 * 1000),\n    },\n    {\n      id: 'm4',\n      author: 'me',\n      body: 'Done. Also — are we still on for the dashboard review tomorrow at 10?',\n      sentAt: new Date(now.getTime() - 42 * 60 * 1000),\n      status: 'read',\n    },\n    {\n      id: 'm5',\n      author: 'them',\n      body: 'Yes! I will bring the kanban deck. Want to push it to 10:30 so we can join from the standup?',\n      sentAt: new Date(now.getTime() - 8 * 60 * 1000),\n    },\n  ],\n  c2: [\n    {\n      id: 'm1',\n      author: 'me',\n      body: 'Hey Sarah — submitting time-off for Mar 15-18.',\n      sentAt: new Date(now.getTime() - 95 * 60 * 1000),\n      status: 'read',\n    },\n    {\n      id: 'm2',\n      author: 'them',\n      body: 'Approved — feel free to take Mar 15-18 off.',\n      sentAt: new Date(now.getTime() - 52 * 60 * 1000),\n    },\n  ],\n  c3: [\n    {\n      id: 'm1',\n      author: 'them',\n      body: 'Priya: pushed the new color tokens to staging. Reviews welcome.',\n      sentAt: new Date(now.getTime() - 3 * 60 * 60 * 1000),\n    },\n  ],\n  c4: [\n    {\n      id: 'm1',\n      author: 'them',\n      body: 'Thanks! Will follow up after lunch.',\n      sentAt: new Date(now.getTime() - 5 * 60 * 60 * 1000),\n    },\n  ],\n  c5: [{ id: 'm1', author: 'them', body: 'Devon: deploy is green, going home.', sentAt: yesterday }],\n  c6: [\n    {\n      id: 'm1',\n      author: 'them',\n      body: 'Sent the spec — let me know what you think.',\n      sentAt: new Date(now.getTime() - 28 * 60 * 60 * 1000),\n    },\n  ],\n  c7: [\n    {\n      id: 'm1',\n      author: 'me',\n      body: 'pushed the fix, should be live in 10.',\n      sentAt: new Date(now.getTime() - 50 * 60 * 60 * 1000),\n      status: 'read',\n    },\n  ],\n}\n\nfunction dayLabel(d: Date): string {\n  if (d.toDateString() === now.toDateString()) return 'Today'\n  if (d.toDateString() === yesterday.toDateString()) return 'Yesterday'\n  return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })\n}\n\nfunction formatTime(d: Date): string {\n  return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })\n}\n\nfunction formatConvoTime(d: Date): string {\n  if (d >= todayStart) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })\n  if (d.toDateString() === yesterday.toDateString()) return 'Yesterday'\n  const diffDays = Math.floor((now.getTime() - d.getTime()) / (24 * 60 * 60 * 1000))\n  if (diffDays < 7) return d.toLocaleDateString(undefined, { weekday: 'short' })\n  return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })\n}\n\n// The typing dots (React injects the same rules as a <style> tag): a keyframe animation,\n// which a utility cannot declare. Longhand animation properties so the per-dot\n// `[animation-delay:…]` utilities (React's inline animationDelay) are not reset by an\n// unlayered `animation` shorthand. The thin scrollbars are `[&::-webkit-scrollbar]` utilities.\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`\n\n/**\n * Two-pane messaging surface (Angular port of the React `ChatTwoPane` block). Left rail:\n * searchable conversation list; right pane: the active thread and an Enter-to-send composer.\n * Stateful demo -- replace `conversations` and `messagesByConvo` with your data layer.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-chat-two-pane, [ui-chat-two-pane]',\n  standalone: true,\n  host: { class: 'contents' },\n  imports: [\n    LucideAngularModule,\n    UiAvatarComponent,\n    UiAvatarFallbackComponent,\n    UiAvatarImageComponent,\n    UiButtonComponent,\n    UiInputComponent,\n    UiTextareaComponent,\n  ],\n  styles: [TYPING_STYLES],\n  template: `\n    <div\n      data-slot=\"chat-two-pane\"\n      class=\"bg-card text-card-foreground grid h-[680px] w-full grid-cols-[280px_1fr] overflow-hidden rounded-xl border shadow-sm\"\n    >\n      <aside class=\"bg-muted/30 flex min-w-0 flex-col border-r\">\n        <div class=\"flex h-14 shrink-0 items-center justify-between gap-2 border-b px-4\">\n          <h2 class=\"text-sm font-semibold tracking-tight\">Messages</h2>\n          <div class=\"flex items-center gap-1\">\n            <button ui-button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Filter\">\n              <lucide-icon [img]=\"Filter\" class=\"size-4\" />\n            </button>\n            <button ui-button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"New conversation\">\n              <lucide-icon [img]=\"SquarePen\" class=\"size-4\" />\n            </button>\n          </div>\n        </div>\n        <div class=\"px-3 pt-3 pb-2\">\n          <div class=\"relative\">\n            <!-- [class] (not class=) styles only the svg: lucide-angular copies a static class onto\n                 both its host and the svg, which would apply the absolute offsets twice. -->\n            <lucide-icon\n              [img]=\"Search\"\n              [class]=\"'text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2'\"\n            />\n            <ui-input\n              [value]=\"search()\"\n              (valueChange)=\"search.set($event)\"\n              placeholder=\"Search\"\n              class=\"h-8 rounded-lg pl-8 text-xs\"\n            />\n          </div>\n        </div>\n        <div\n          class=\"[&::-webkit-scrollbar-thumb]:bg-border flex-1 overflow-y-auto pb-3 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-[3px]\"\n        >\n          @for (c of filteredConvos(); track c.id) {\n            <button type=\"button\" [class]=\"convoRowClass(c)\" (click)=\"selectConvo(c.id)\">\n              <div class=\"relative shrink-0\">\n                <ui-avatar class=\"size-9\">\n                  @if (c.avatarUrl) {\n                    <ui-avatar-image [src]=\"c.avatarUrl\" [alt]=\"c.name\" />\n                  }\n                  <ui-avatar-fallback class=\"text-xs\">{{ c.initials }}</ui-avatar-fallback>\n                </ui-avatar>\n                @if (c.online) {\n                  <span\n                    class=\"bg-success ring-card absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2\"\n                  ></span>\n                }\n              </div>\n              <div class=\"min-w-0 flex-1\">\n                <div class=\"flex items-center justify-between gap-1.5\">\n                  <p [class]=\"convoNameClass(c)\">{{ c.name }}</p>\n                  <span class=\"text-muted-foreground shrink-0 text-xs tabular-nums\">\n                    {{ formatConvoTime(c.lastAt) }}\n                  </span>\n                </div>\n                <div class=\"mt-0.5 flex items-center justify-between gap-1.5\">\n                  <p [class]=\"convoPreviewClass(c)\">{{ c.lastMessage }}</p>\n                  <div class=\"flex shrink-0 items-center gap-1\">\n                    @if (c.pinned) {\n                      <lucide-icon [img]=\"Pin\" class=\"text-muted-foreground size-3\" />\n                    }\n                    @if (c.unread > 0) {\n                      <span\n                        class=\"bg-primary text-primary-foreground inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-xs font-bold tabular-nums\"\n                      >\n                        {{ c.unread }}\n                      </span>\n                    }\n                  </div>\n                </div>\n              </div>\n            </button>\n          }\n        </div>\n      </aside>\n\n      <section class=\"flex 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-3\">\n            <div class=\"relative\">\n              <ui-avatar class=\"size-9\">\n                <ui-avatar-fallback class=\"text-xs font-medium\">{{ activeConvo().initials }}</ui-avatar-fallback>\n              </ui-avatar>\n              @if (activeConvo().online) {\n                <span class=\"bg-success ring-card absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2\"></span>\n              }\n            </div>\n            <div class=\"min-w-0\">\n              <p class=\"truncate text-sm font-semibold tracking-tight\">{{ activeConvo().name }}</p>\n              <p class=\"text-muted-foreground truncate text-xs\">\n                {{ activeConvo().online ? 'Active now' : 'Active 2h ago' }}\n              </p>\n            </div>\n          </div>\n          <div class=\"flex items-center gap-1\">\n            <button ui-button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Voice call\">\n              <lucide-icon [img]=\"Phone\" class=\"size-4\" />\n            </button>\n            <button ui-button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Video call\">\n              <lucide-icon [img]=\"Video\" class=\"size-4\" />\n            </button>\n            <button ui-button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"More options\">\n              <lucide-icon [img]=\"MoreVertical\" class=\"size-4\" />\n            </button>\n          </div>\n        </header>\n\n        <div\n          #scrollRoot\n          class=\"[&::-webkit-scrollbar-thumb]:bg-border flex-1 space-y-5 overflow-y-auto px-4 py-4 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-[3px]\"\n        >\n          @for (group of groupedMessages(); track group.label) {\n            <div class=\"space-y-3\">\n              <div class=\"flex items-center justify-center\">\n                <span\n                  class=\"text-muted-foreground bg-muted/60 rounded-full px-2.5 py-0.5 text-xs font-medium tracking-wide uppercase\"\n                >\n                  {{ group.label }}\n                </span>\n              </div>\n              @for (m of group.items; track m.id) {\n                <div [class]=\"messageRowClass(m)\">\n                  @if (m.author === 'them') {\n                    <ui-avatar class=\"size-7\">\n                      <ui-avatar-fallback class=\"text-xs\">{{ activeConvo().initials }}</ui-avatar-fallback>\n                    </ui-avatar>\n                  }\n                  <div [class]=\"messageColumnClass(m)\">\n                    <div [class]=\"bubbleClass(m)\">{{ m.body }}</div>\n                    <div class=\"text-muted-foreground flex items-center gap-1 px-1 text-xs tabular-nums\">\n                      <span>{{ formatTime(m.sentAt) }}</span>\n                      @if (m.author === 'me' && m.status === 'read') {\n                        <lucide-icon [img]=\"CheckCheck\" class=\"text-info size-3\" />\n                      }\n                    </div>\n                  </div>\n                </div>\n              }\n            </div>\n          }\n\n          @if (peerTyping()) {\n            <div class=\"flex items-end gap-2\">\n              <ui-avatar class=\"size-7\">\n                <ui-avatar-fallback class=\"text-xs\">{{ activeConvo().initials }}</ui-avatar-fallback>\n              </ui-avatar>\n              <div class=\"bg-muted text-muted-foreground flex items-center gap-1 rounded-2xl rounded-bl-md px-3 py-2.5\">\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 class=\"bg-muted/20 border-t px-4 py-3\">\n          <div\n            class=\"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-colors duration-150 focus-within:shadow-md focus-within:ring-4\"\n          >\n            <ui-textarea\n              [value]=\"draft()\"\n              (valueChange)=\"draft.set($event)\"\n              [placeholder]=\"composerPlaceholder()\"\n              rows=\"1\"\n              inputClassName=\"placeholder:text-muted-foreground/70 max-h-40 min-h-[44px] resize-none border-0 bg-transparent px-4 pt-3 pb-1 text-sm leading-relaxed shadow-none focus-visible:ring-0\"\n              (keyDown)=\"onComposerKey($event)\"\n            />\n            <div class=\"flex items-center justify-between gap-2 px-2 pb-2\">\n              <div class=\"flex items-center gap-0.5\">\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                >\n                  <lucide-icon [img]=\"Paperclip\" class=\"size-4\" />\n                </button>\n                <button\n                  ui-button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  class=\"text-muted-foreground hover:text-foreground size-8\"\n                  aria-label=\"Emoji\"\n                >\n                  <lucide-icon [img]=\"Smile\" class=\"size-4\" />\n                </button>\n                <button\n                  ui-button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  class=\"text-muted-foreground hover:text-foreground size-8\"\n                  aria-label=\"Mention\"\n                >\n                  <lucide-icon [img]=\"AtSign\" class=\"size-4\" />\n                </button>\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                  <span>↵</span>\n                </kbd>\n                <button\n                  ui-button\n                  class=\"h-8 gap-1.5 rounded-lg px-3 text-xs font-medium\"\n                  [disabled]=\"!draft().trim()\"\n                  aria-label=\"Send\"\n                  (click)=\"send()\"\n                >\n                  Send\n                  <lucide-icon [img]=\"ArrowRight\" class=\"size-3.5\" />\n                </button>\n              </div>\n            </div>\n          </div>\n        </div>\n      </section>\n    </div>\n  `,\n})\nexport class UiChatTwoPaneComponent {\n  protected readonly ArrowRight = ArrowRight\n  protected readonly AtSign = AtSign\n  protected readonly CheckCheck = CheckCheck\n  protected readonly Filter = Filter\n  protected readonly MoreVertical = MoreVertical\n  protected readonly Paperclip = Paperclip\n  protected readonly Phone = Phone\n  protected readonly Pin = Pin\n  protected readonly Search = Search\n  protected readonly Smile = Smile\n  protected readonly SquarePen = SquarePen\n  protected readonly Video = Video\n\n  @ViewChild('scrollRoot', { static: true }) scrollRoot?: ElementRef<HTMLDivElement>\n\n  readonly conversations = signal<ChatTwoPaneConversation[]>(initialConversations.map((c) => ({ ...c })))\n  /** Per-conversation message store (React keeps it in a ref + version counter). */\n  readonly messagesByConvo = signal<Record<string, ChatTwoPaneMessage[]>>(\n    Object.fromEntries(Object.entries(initialMessagesByConvo).map(([id, list]) => [id, [...list]])),\n  )\n  readonly search = signal('')\n  readonly activeId = signal('c1')\n  readonly draft = signal('')\n  readonly peerTyping = signal(false)\n\n  private typingTimer: ReturnType<typeof setTimeout> | null = null\n\n  readonly activeConvo = computed(() => this.conversations().find((c) => c.id === this.activeId())!)\n  readonly activeMessages = computed<ChatTwoPaneMessage[]>(() => this.messagesByConvo()[this.activeId()] ?? [])\n  readonly composerPlaceholder = computed(() => `Message ${this.activeConvo().name.split(' ')[0]}`)\n\n  readonly filteredConvos = computed(() => {\n    const q = this.search().trim().toLowerCase()\n    const list = q\n      ? this.conversations().filter((c) => c.name.toLowerCase().includes(q) || c.lastMessage.toLowerCase().includes(q))\n      : this.conversations()\n    return [...list].sort((a, b) => {\n      if (a.pinned !== b.pinned) return a.pinned ? -1 : 1\n      return b.lastAt.getTime() - a.lastAt.getTime()\n    })\n  })\n\n  readonly groupedMessages = computed(() => {\n    const groups: ChatTwoPaneMessageGroup[] = []\n    for (const m of this.activeMessages()) {\n      const label = dayLabel(m.sentAt)\n      const last = groups[groups.length - 1]\n      if (last && last.label === label) last.items.push(m)\n      else groups.push({ label, items: [m] })\n    }\n    return groups\n  })\n\n  constructor() {\n    // React: scroll to the newest message when the active thread changes and after a send.\n    afterRenderEffect(() => {\n      this.activeId()\n      this.activeMessages().length\n      this.scrollToBottom()\n    })\n    inject(DestroyRef).onDestroy(() => {\n      if (this.typingTimer) clearTimeout(this.typingTimer)\n    })\n  }\n\n  formatTime(d: Date): string {\n    return formatTime(d)\n  }\n\n  formatConvoTime(d: Date): string {\n    return formatConvoTime(d)\n  }\n\n  convoRowClass(c: ChatTwoPaneConversation): string {\n    return cn(\n      'flex w-full items-start gap-3 px-3 py-2.5 text-left transition-colors',\n      c.id === this.activeId() ? 'bg-primary/10' : 'hover:bg-muted/60',\n    )\n  }\n\n  convoNameClass(c: ChatTwoPaneConversation): string {\n    return cn('truncate text-sm', c.unread > 0 ? 'font-semibold' : 'font-medium')\n  }\n\n  convoPreviewClass(c: ChatTwoPaneConversation): string {\n    return cn('truncate text-xs', c.unread > 0 ? 'text-foreground/80' : 'text-muted-foreground')\n  }\n\n  messageRowClass(m: ChatTwoPaneMessage): string {\n    return cn('flex items-end gap-2', m.author === 'me' ? 'justify-end' : 'justify-start')\n  }\n\n  messageColumnClass(m: ChatTwoPaneMessage): string {\n    return cn('flex max-w-[78%] flex-col gap-1', m.author === 'me' ? 'items-end' : 'items-start')\n  }\n\n  bubbleClass(m: ChatTwoPaneMessage): string {\n    return cn(\n      'rounded-2xl px-3.5 py-2 text-sm leading-relaxed shadow-sm',\n      m.author === 'me' ? 'bg-primary text-primary-foreground rounded-br-md' : 'bg-muted text-foreground rounded-bl-md',\n    )\n  }\n\n  selectConvo(id: string): void {\n    this.activeId.set(id)\n    this.conversations.update((list) => list.map((c) => (c.id === id ? { ...c, unread: 0 } : c)))\n  }\n\n  send(): void {\n    const body = this.draft().trim()\n    if (!body) return\n    const id = this.activeId()\n    this.messagesByConvo.update((store) => ({\n      ...store,\n      [id]: [...(store[id] ?? []), { id: `m${Date.now()}`, author: 'me', body, sentAt: new Date(), status: 'sent' }],\n    }))\n    this.draft.set('')\n    this.conversations.update((list) =>\n      list.map((c) => (c.id === id ? { ...c, lastMessage: `You: ${body}`, lastAt: new Date() } : c)),\n    )\n    this.peerTyping.set(true)\n    if (this.typingTimer) clearTimeout(this.typingTimer)\n    this.typingTimer = setTimeout(() => this.peerTyping.set(false), 1600)\n  }\n\n  onComposerKey(e: KeyboardEvent): void {\n    if (e.key === 'Enter' && !e.shiftKey) {\n      e.preventDefault()\n      this.send()\n    }\n  }\n\n  private scrollToBottom(): void {\n    const el = this.scrollRoot?.nativeElement\n    if (!el) return\n    el.scrollTop = el.scrollHeight\n  }\n}\n",
      "type": "registry:block",
      "target": "~/src/app/components/blocks/chat-two-pane/chat-two-pane.component.ts"
    },
    {
      "path": "packages/registry-angular/blocks/chat-two-pane/index.ts",
      "content": "export {\n  UiChatTwoPaneComponent,\n  type ChatTwoPaneConversation,\n  type ChatTwoPaneMessage,\n  type ChatTwoPaneMessageGroup,\n} from './chat-two-pane.component'\n",
      "type": "registry:block",
      "target": "~/src/app/components/blocks/chat-two-pane/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/input.json",
    "https://uipkge.dev/r/angular/textarea.json"
  ],
  "description": "Two-pane messaging surface. Left rail: searchable conversation list with avatars, online dots, pinned section, unread badges, mute icons, last-message preview, smart timestamps. Right pane: active thread with day separators, own/peer bubbles, read ticks, typing indicator, attach/emoji/send composer. Stateful demo — replace `conversations` and `messagesByConvo` with your data layer.",
  "categories": [
    "communication",
    "layout"
  ]
}