{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "chat-two-pane",
  "title": "Chat Two Pane",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-vue/blocks/chat-two-pane/ChatTwoPane.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, nextTick, ref, watch } from 'vue'\nimport {\n  ArrowRight,\n  AtSign,\n  Paperclip,\n  Phone,\n  Smile,\n  Video,\n  MoreVertical,\n  Search,\n  Pin,\n  CheckCheck,\n  SquarePen,\n  Filter,\n} from 'lucide-vue-next'\nimport { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Textarea } from '@/components/ui/textarea'\n\ninterface Conversation {\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\ninterface Message {\n  id: string\n  author: 'me' | 'them'\n  body: string\n  sentAt: Date\n  status?: 'sent' | 'delivered' | 'read'\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 conversations = ref<Conversation[]>([\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 messagesByConvo: Record<string, Message[]> = {\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\nconst search = ref('')\nconst activeId = ref<string>('c1')\nconst draft = ref('')\nconst scrollRoot = ref<HTMLElement | null>(null)\nconst peerTyping = ref(false)\n\nconst activeConvo = computed(() => conversations.value.find((c) => c.id === activeId.value)!)\nconst activeMessages = computed<Message[]>(() => messagesByConvo[activeId.value] ?? [])\n\nconst filteredConvos = computed(() => {\n  const q = search.value.trim().toLowerCase()\n  const list = q\n    ? conversations.value.filter((c) => c.name.toLowerCase().includes(q) || c.lastMessage.toLowerCase().includes(q))\n    : conversations.value\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\nconst groupedMessages = computed(() => {\n  const groups: Array<{ label: string; items: Message[] }> = []\n  for (const m of activeMessages.value) {\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\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\nfunction selectConvo(id: string) {\n  activeId.value = id\n  const c = conversations.value.find((c) => c.id === id)\n  if (c) c.unread = 0\n  nextTick(scrollToBottom)\n}\n\nasync function send() {\n  const body = draft.value.trim()\n  if (!body) return\n  const list = messagesByConvo[activeId.value] ?? []\n  list.push({ id: `m${Date.now()}`, author: 'me', body, sentAt: new Date(), status: 'sent' })\n  messagesByConvo[activeId.value] = list\n  draft.value = ''\n  activeConvo.value.lastMessage = `You: ${body}`\n  activeConvo.value.lastAt = new Date()\n  await nextTick()\n  scrollToBottom()\n  peerTyping.value = true\n  window.setTimeout(() => (peerTyping.value = false), 1600)\n}\n\nfunction onComposerKey(e: KeyboardEvent) {\n  if (e.key === 'Enter' && !e.shiftKey) {\n    e.preventDefault()\n    send()\n  }\n}\n\nfunction scrollToBottom() {\n  if (!scrollRoot.value) return\n  scrollRoot.value.scrollTop = scrollRoot.value.scrollHeight\n}\n\nwatch(activeId, () => nextTick(scrollToBottom))\n</script>\n\n<template>\n  <div\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 variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Filter\">\n            <Filter class=\"size-4\" />\n          </Button>\n          <Button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"New conversation\">\n            <SquarePen class=\"size-4\" />\n          </Button>\n        </div>\n      </div>\n      <div class=\"px-3 pt-3 pb-2\">\n        <div class=\"relative\">\n          <Search class=\"text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2\" />\n          <Input v-model=\"search\" placeholder=\"Search\" class=\"h-8 rounded-lg pl-8 text-xs\" />\n        </div>\n      </div>\n      <div class=\"convo-scroll flex-1 overflow-y-auto pb-3\">\n        <button\n          v-for=\"c in filteredConvos\"\n          :key=\"c.id\"\n          :class=\"[\n            'flex w-full items-start gap-3 px-3 py-2.5 text-left transition-colors',\n            c.id === activeId ? 'bg-primary/10' : 'hover:bg-muted/60',\n          ]\"\n          @click=\"selectConvo(c.id)\"\n        >\n          <div class=\"relative shrink-0\">\n            <Avatar class=\"size-9\">\n              <AvatarImage v-if=\"c.avatarUrl\" :src=\"c.avatarUrl\" :alt=\"c.name\" />\n              <AvatarFallback class=\"text-[11px]\">{{ c.initials }}</AvatarFallback>\n            </Avatar>\n            <span\n              v-if=\"c.online\"\n              class=\"bg-success ring-card absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2\"\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=\"['truncate text-[13px]', c.unread > 0 ? 'font-semibold' : 'font-medium']\">\n                {{ c.name }}\n              </p>\n              <span class=\"text-muted-foreground shrink-0 text-[10px] 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=\"['truncate text-xs', c.unread > 0 ? 'text-foreground/80' : 'text-muted-foreground']\">\n                {{ c.lastMessage }}\n              </p>\n              <div class=\"flex shrink-0 items-center gap-1\">\n                <Pin v-if=\"c.pinned\" class=\"text-muted-foreground size-3\" />\n                <span\n                  v-if=\"c.unread > 0\"\n                  class=\"bg-primary text-primary-foreground inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-bold tabular-nums\"\n                >\n                  {{ c.unread }}\n                </span>\n              </div>\n            </div>\n          </div>\n        </button>\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            <Avatar class=\"size-9\">\n              <AvatarFallback class=\"text-xs font-medium\">{{ activeConvo.initials }}</AvatarFallback>\n            </Avatar>\n            <span\n              v-if=\"activeConvo.online\"\n              class=\"bg-success ring-card absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2\"\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 variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Voice call\">\n            <Phone class=\"size-4\" />\n          </Button>\n          <Button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"Video call\">\n            <Video class=\"size-4\" />\n          </Button>\n          <Button variant=\"ghost\" size=\"icon\" class=\"size-8\" aria-label=\"More options\">\n            <MoreVertical class=\"size-4\" />\n          </Button>\n        </div>\n      </header>\n\n      <div ref=\"scrollRoot\" class=\"chat-scroll flex-1 space-y-5 overflow-y-auto px-4 py-4\">\n        <div v-for=\"group in groupedMessages\" :key=\"group.label\" 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-[10px] font-medium tracking-wide uppercase\"\n            >\n              {{ group.label }}\n            </span>\n          </div>\n          <div\n            v-for=\"m in group.items\"\n            :key=\"m.id\"\n            :class=\"['flex items-end gap-2', m.author === 'me' ? 'justify-end' : 'justify-start']\"\n          >\n            <Avatar v-if=\"m.author === 'them'\" class=\"size-7\">\n              <AvatarFallback class=\"text-[10px]\">{{ activeConvo.initials }}</AvatarFallback>\n            </Avatar>\n            <div :class=\"['flex max-w-[78%] flex-col gap-1', m.author === 'me' ? 'items-end' : 'items-start']\">\n              <div\n                :class=\"[\n                  'rounded-2xl px-3.5 py-2 text-sm leading-relaxed shadow-sm',\n                  m.author === 'me'\n                    ? 'bg-primary text-primary-foreground rounded-br-md'\n                    : 'bg-muted text-foreground rounded-bl-md',\n                ]\"\n              >\n                {{ m.body }}\n              </div>\n              <div class=\"text-muted-foreground flex items-center gap-1 px-1 text-[10px] tabular-nums\">\n                <span>{{ formatTime(m.sentAt) }}</span>\n                <CheckCheck v-if=\"m.author === 'me' && m.status === 'read'\" class=\"text-info size-3\" />\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <div v-if=\"peerTyping\" class=\"flex items-end gap-2\">\n          <Avatar class=\"size-7\">\n            <AvatarFallback class=\"text-[10px]\">{{ activeConvo.initials }}</AvatarFallback>\n          </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\" />\n            <span class=\"typing-dot\" style=\"animation-delay: 0.15s\" />\n            <span class=\"typing-dot\" style=\"animation-delay: 0.3s\" />\n          </div>\n        </div>\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-all duration-150 focus-within:shadow-md focus-within:ring-4\"\n        >\n          <Textarea\n            v-model=\"draft\"\n            :placeholder=\"`Message ${activeConvo.name.split(' ')[0]}`\"\n            rows=\"1\"\n            class=\"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\"\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                variant=\"ghost\"\n                size=\"icon\"\n                class=\"text-muted-foreground hover:text-foreground size-8\"\n                aria-label=\"Attach file\"\n              >\n                <Paperclip class=\"size-4\" />\n              </Button>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                class=\"text-muted-foreground hover:text-foreground size-8\"\n                aria-label=\"Emoji\"\n              >\n                <Smile class=\"size-4\" />\n              </Button>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                class=\"text-muted-foreground hover:text-foreground size-8\"\n                aria-label=\"Mention\"\n              >\n                <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-[10px] sm:inline-flex\"\n              >\n                <span>↵</span>\n              </kbd>\n              <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                <ArrowRight class=\"size-3.5\" />\n              </Button>\n            </div>\n          </div>\n        </div>\n      </div>\n    </section>\n  </div>\n</template>\n\n<style scoped>\n.chat-scroll::-webkit-scrollbar,\n.convo-scroll::-webkit-scrollbar {\n  width: 6px;\n}\n.chat-scroll::-webkit-scrollbar-thumb,\n.convo-scroll::-webkit-scrollbar-thumb {\n  background: var(--border);\n  border-radius: 3px;\n}\n.typing-dot {\n  display: inline-block;\n  width: 6px;\n  height: 6px;\n  border-radius: 9999px;\n  background: currentColor;\n  opacity: 0.5;\n  animation: typing-bounce 1s infinite ease-in-out;\n}\n@keyframes typing-bounce {\n  0%,\n  80%,\n  100% {\n    transform: translateY(0);\n    opacity: 0.35;\n  }\n  40% {\n    transform: translateY(-3px);\n    opacity: 0.9;\n  }\n}\n</style>\n",
      "type": "registry:block",
      "target": "~/app/components/blocks/ChatTwoPane.vue"
    }
  ],
  "dependencies": [
    "lucide-vue-next"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/vue/avatar.json",
    "https://uipkge.dev/r/vue/button.json",
    "https://uipkge.dev/r/vue/input.json",
    "https://uipkge.dev/r/vue/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"
  ]
}