{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "chat-thread",
  "title": "Chat Thread",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-vue/blocks/chat-thread/ChatThread.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, nextTick, ref, watch } from 'vue'\nimport { ArrowRight, AtSign, Paperclip, Phone, Smile, Video, MoreVertical, Check, CheckCheck } from 'lucide-vue-next'\nimport { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'\nimport { Button } from '@/components/ui/button'\nimport { Textarea } from '@/components/ui/textarea'\n\ntype MessageStatus = 'sent' | 'delivered' | 'read'\n\ninterface Message {\n  id: string\n  author: 'me' | 'them'\n  body: string\n  sentAt: Date\n  status?: MessageStatus\n}\n\nconst now = new Date()\nconst yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)\n\nconst peer = {\n  name: 'Marcus Rivera',\n  handle: '@marcus',\n  avatarUrl: '',\n  initials: 'MR',\n  online: true,\n  lastSeen: 'Active now',\n}\n\nconst messages = ref<Message[]>([\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 + 14 * 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 + 21 * 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 + 23 * 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\nconst draft = ref('')\nconst scrollRoot = ref<HTMLElement | null>(null)\nconst peerTyping = ref(false)\n\nconst groupedMessages = computed(() => {\n  const groups: Array<{ label: string; items: Message[] }> = []\n  for (const m of messages.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  const sameDay = d.toDateString() === now.toDateString()\n  if (sameDay) return 'Today'\n  const sameYesterday = d.toDateString() === yesterday.toDateString()\n  if (sameYesterday) 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\nasync function send() {\n  const body = draft.value.trim()\n  if (!body) return\n  messages.value.push({\n    id: `m${Date.now()}`,\n    author: 'me',\n    body,\n    sentAt: new Date(),\n    status: 'sent',\n  })\n  draft.value = ''\n  await nextTick()\n  scrollToBottom()\n  peerTyping.value = true\n  window.setTimeout(() => {\n    peerTyping.value = false\n  }, 1800)\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(messages, () => nextTick(scrollToBottom), { deep: true })\n</script>\n\n<template>\n  <div class=\"bg-card text-card-foreground flex h-[680px] w-full flex-col overflow-hidden rounded-xl border shadow-sm\">\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            <AvatarImage v-if=\"peer.avatarUrl\" :src=\"peer.avatarUrl\" :alt=\"peer.name\" />\n            <AvatarFallback class=\"text-xs font-medium\">{{ peer.initials }}</AvatarFallback>\n          </Avatar>\n          <span\n            v-if=\"peer.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\">{{ peer.name }}</p>\n          <p class=\"text-muted-foreground truncate text-xs\">{{ peer.lastSeen }}</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]\">{{ peer.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              <CheckCheck v-else-if=\"m.author === 'me' && m.status === 'delivered'\" class=\"size-3\" />\n              <Check v-else-if=\"m.author === 'me' && m.status === 'sent'\" class=\"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]\">{{ peer.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 ${peer.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      <p class=\"text-muted-foreground/70 mt-2 text-center text-[10px]\">\n        <kbd class=\"bg-muted/60 rounded px-1 py-0.5 font-mono text-[9px]\">Enter</kbd>\n        to send\n        <span class=\"mx-1.5 opacity-50\">·</span>\n        <kbd class=\"bg-muted/60 rounded px-1 py-0.5 font-mono text-[9px]\">Shift</kbd>\n        +\n        <kbd class=\"bg-muted/60 rounded px-1 py-0.5 font-mono text-[9px]\">↵</kbd>\n        for newline\n      </p>\n    </div>\n  </div>\n</template>\n\n<style scoped>\n.chat-scroll::-webkit-scrollbar {\n  width: 6px;\n}\n.chat-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/ChatThread.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/textarea.json"
  ],
  "description": "Single-pane conversation surface. Peer header (avatar + online dot + call/video/more), grouped messages with day separators (Today/Yesterday/date), own vs. peer bubbles, read/delivered/sent ticks, typing indicator, composer with attach/emoji and Enter-to-send. Stateful demo; swap the `messages` ref for your transport.",
  "categories": [
    "communication"
  ]
}