Menu

Change language

GitHub repo

Chat Two Pane

blockcommunication

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.

Installation

$npx uipkge-ng@latest add chat-two-pane
Manifest:https://uipkge.dev/r/angular/chat-two-pane.jsonInstalls to:src/app/components/blocks/chat-two-pane/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

ChatTwoPaneConversation
interface ChatTwoPaneConversation {
  id: string
  name: string
  initials: string
  avatarUrl?: string
  lastMessage: string
  lastAt: Date
  unread: number
  online: boolean
  pinned?: boolean
  muted?: boolean
}
ChatTwoPaneMessage
interface ChatTwoPaneMessage {
  id: string
  author: 'me' | 'them'
  body: string
  sentAt: Date
  status?: 'sent' | 'delivered' | 'read'
}
ChatTwoPaneMessageGroup
interface ChatTwoPaneMessageGroup {
  label: string
  items: ChatTwoPaneMessage[]
}

Files installed (2)

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

Raw manifest:https://uipkge.dev/r/angular/chat-two-pane.json