{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-two-pane",
  "title": "Chat Two Pane",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-react/blocks/chat-two-pane/ChatTwoPane.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\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-react'\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 initialConversations: 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 initialMessagesByConvo: 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\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\nexport function ChatTwoPane() {\n  const [conversations, setConversations] = React.useState<Conversation[]>(initialConversations)\n  // Mutable per-conversation message store. A version counter forces re-render\n  // when a thread is mutated in place (mirrors the Vue object mutation model).\n  const messagesByConvo = React.useRef<Record<string, Message[]>>(initialMessagesByConvo)\n  const [, setMsgVersion] = React.useState(0)\n  const [search, setSearch] = React.useState('')\n  const [activeId, setActiveId] = React.useState<string>('c1')\n  const [draft, setDraft] = React.useState('')\n  const [peerTyping, setPeerTyping] = React.useState(false)\n  const scrollRoot = React.useRef<HTMLDivElement | null>(null)\n  const composerRef = React.useRef<HTMLTextAreaElement | null>(null)\n  const sendRef = React.useRef<() => void>(() => {})\n\n  const activeConvo = React.useMemo(\n    () => conversations.find((c) => c.id === activeId)!,\n    [conversations, activeId],\n  )\n  const activeMessages = React.useMemo<Message[]>(\n    () => messagesByConvo.current[activeId] ?? [],\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [activeId, peerTyping, draft],\n  )\n\n  const filteredConvos = React.useMemo(() => {\n    const q = search.trim().toLowerCase()\n    const list = q\n      ? conversations.filter((c) => c.name.toLowerCase().includes(q) || c.lastMessage.toLowerCase().includes(q))\n      : 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  }, [conversations, search])\n\n  const groupedMessages = React.useMemo(() => {\n    const groups: Array<{ label: string; items: Message[] }> = []\n    for (const m of 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  }, [activeMessages])\n\n  function scrollToBottom() {\n    if (!scrollRoot.current) return\n    scrollRoot.current.scrollTop = scrollRoot.current.scrollHeight\n  }\n\n  function selectConvo(id: string) {\n    setActiveId(id)\n    setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, unread: 0 } : c)))\n  }\n\n  // Scroll to bottom whenever the active thread changes.\n  React.useEffect(() => {\n    scrollToBottom()\n  }, [activeId])\n\n  function send() {\n    const body = draft.trim()\n    if (!body) return\n    const list = messagesByConvo.current[activeId] ?? []\n    list.push({ id: `m${Date.now()}`, author: 'me', body, sentAt: new Date(), status: 'sent' })\n    messagesByConvo.current[activeId] = list\n    setDraft('')\n    setConversations((prev) =>\n      prev.map((c) => (c.id === activeId ? { ...c, lastMessage: `You: ${body}`, lastAt: new Date() } : c)),\n    )\n    setMsgVersion((v) => v + 1)\n    requestAnimationFrame(scrollToBottom)\n    setPeerTyping(true)\n    window.setTimeout(() => setPeerTyping(false), 1600)\n  }\n\n  // The Textarea primitive's `onKeyDown` prop fires with no event, so attach a\n  // native keydown listener to the underlying <textarea> for Enter-to-send.\n  React.useEffect(() => {\n    sendRef.current = send\n  })\n\n  React.useEffect(() => {\n    const el = composerRef.current\n    if (!el) return\n    function onComposerKey(e: KeyboardEvent) {\n      if (e.key === 'Enter' && !e.shiftKey) {\n        e.preventDefault()\n        sendRef.current()\n      }\n    }\n    el.addEventListener('keydown', onComposerKey)\n    return () => el.removeEventListener('keydown', onComposerKey)\n  }, [])\n\n  return (\n    <div className=\"bg-card text-card-foreground grid h-[680px] w-full grid-cols-[280px_1fr] overflow-hidden rounded-xl border shadow-sm\">\n      <aside className=\"bg-muted/30 flex min-w-0 flex-col border-r\">\n        <div className=\"flex h-14 shrink-0 items-center justify-between gap-2 border-b px-4\">\n          <h2 className=\"text-sm font-semibold tracking-tight\">Messages</h2>\n          <div className=\"flex items-center gap-1\">\n            <Button variant=\"ghost\" size=\"icon\" className=\"size-8\" aria-label=\"Filter\">\n              <Filter className=\"size-4\" />\n            </Button>\n            <Button variant=\"ghost\" size=\"icon\" className=\"size-8\" aria-label=\"New conversation\">\n              <SquarePen className=\"size-4\" />\n            </Button>\n          </div>\n        </div>\n        <div className=\"px-3 pt-3 pb-2\">\n          <div className=\"relative\">\n            <Search className=\"text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2\" />\n            <Input\n              value={search}\n              onChange={(e) => setSearch(e.target.value)}\n              placeholder=\"Search\"\n              className=\"h-8 rounded-lg pl-8 text-xs\"\n            />\n          </div>\n        </div>\n        <div className=\"convo-scroll flex-1 overflow-y-auto pb-3\">\n          {filteredConvos.map((c) => (\n            <button\n              key={c.id}\n              className={`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              onClick={() => selectConvo(c.id)}\n            >\n              <div className=\"relative shrink-0\">\n                <Avatar className=\"size-9\">\n                  {c.avatarUrl && <AvatarImage src={c.avatarUrl} alt={c.name} />}\n                  <AvatarFallback className=\"text-[11px]\">{c.initials}</AvatarFallback>\n                </Avatar>\n                {c.online && (\n                  <span className=\"bg-success ring-card absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2\" />\n                )}\n              </div>\n              <div className=\"min-w-0 flex-1\">\n                <div className=\"flex items-center justify-between gap-1.5\">\n                  <p className={`truncate text-[13px] ${c.unread > 0 ? 'font-semibold' : 'font-medium'}`}>\n                    {c.name}\n                  </p>\n                  <span className=\"text-muted-foreground shrink-0 text-[10px] tabular-nums\">\n                    {formatConvoTime(c.lastAt)}\n                  </span>\n                </div>\n                <div className=\"mt-0.5 flex items-center justify-between gap-1.5\">\n                  <p className={`truncate text-xs ${c.unread > 0 ? 'text-foreground/80' : 'text-muted-foreground'}`}>\n                    {c.lastMessage}\n                  </p>\n                  <div className=\"flex shrink-0 items-center gap-1\">\n                    {c.pinned && <Pin className=\"text-muted-foreground size-3\" />}\n                    {c.unread > 0 && (\n                      <span className=\"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                        {c.unread}\n                      </span>\n                    )}\n                  </div>\n                </div>\n              </div>\n            </button>\n          ))}\n        </div>\n      </aside>\n\n      <section className=\"flex min-w-0 flex-col\">\n        <header className=\"flex h-14 shrink-0 items-center justify-between gap-3 border-b px-4\">\n          <div className=\"flex min-w-0 items-center gap-3\">\n            <div className=\"relative\">\n              <Avatar className=\"size-9\">\n                <AvatarFallback className=\"text-xs font-medium\">{activeConvo.initials}</AvatarFallback>\n              </Avatar>\n              {activeConvo.online && (\n                <span className=\"bg-success ring-card absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2\" />\n              )}\n            </div>\n            <div className=\"min-w-0\">\n              <p className=\"truncate text-sm font-semibold tracking-tight\">{activeConvo.name}</p>\n              <p className=\"text-muted-foreground truncate text-xs\">\n                {activeConvo.online ? 'Active now' : 'Active 2h ago'}\n              </p>\n            </div>\n          </div>\n          <div className=\"flex items-center gap-1\">\n            <Button variant=\"ghost\" size=\"icon\" className=\"size-8\" aria-label=\"Voice call\">\n              <Phone className=\"size-4\" />\n            </Button>\n            <Button variant=\"ghost\" size=\"icon\" className=\"size-8\" aria-label=\"Video call\">\n              <Video className=\"size-4\" />\n            </Button>\n            <Button variant=\"ghost\" size=\"icon\" className=\"size-8\" aria-label=\"More options\">\n              <MoreVertical className=\"size-4\" />\n            </Button>\n          </div>\n        </header>\n\n        <div ref={scrollRoot} className=\"chat-scroll flex-1 space-y-5 overflow-y-auto px-4 py-4\">\n          {groupedMessages.map((group) => (\n            <div key={group.label} className=\"space-y-3\">\n              <div className=\"flex items-center justify-center\">\n                <span className=\"text-muted-foreground bg-muted/60 rounded-full px-2.5 py-0.5 text-[10px] font-medium tracking-wide uppercase\">\n                  {group.label}\n                </span>\n              </div>\n              {group.items.map((m) => (\n                <div\n                  key={m.id}\n                  className={`flex items-end gap-2 ${m.author === 'me' ? 'justify-end' : 'justify-start'}`}\n                >\n                  {m.author === 'them' && (\n                    <Avatar className=\"size-7\">\n                      <AvatarFallback className=\"text-[10px]\">{activeConvo.initials}</AvatarFallback>\n                    </Avatar>\n                  )}\n                  <div\n                    className={`flex max-w-[78%] flex-col gap-1 ${m.author === 'me' ? 'items-end' : 'items-start'}`}\n                  >\n                    <div\n                      className={`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 className=\"text-muted-foreground flex items-center gap-1 px-1 text-[10px] tabular-nums\">\n                      <span>{formatTime(m.sentAt)}</span>\n                      {m.author === 'me' && m.status === 'read' && <CheckCheck className=\"text-info size-3\" />}\n                    </div>\n                  </div>\n                </div>\n              ))}\n            </div>\n          ))}\n\n          {peerTyping && (\n            <div className=\"flex items-end gap-2\">\n              <Avatar className=\"size-7\">\n                <AvatarFallback className=\"text-[10px]\">{activeConvo.initials}</AvatarFallback>\n              </Avatar>\n              <div className=\"bg-muted text-muted-foreground flex items-center gap-1 rounded-2xl rounded-bl-md px-3 py-2.5\">\n                <span className=\"typing-dot\" />\n                <span className=\"typing-dot\" style={{ animationDelay: '0.15s' }} />\n                <span className=\"typing-dot\" style={{ animationDelay: '0.3s' }} />\n              </div>\n            </div>\n          )}\n        </div>\n\n        <div className=\"bg-muted/20 border-t px-4 py-3\">\n          <div className=\"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            <Textarea\n              ref={composerRef}\n              value={draft}\n              onValueChange={setDraft}\n              placeholder={`Message ${activeConvo.name.split(' ')[0]}`}\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            />\n            <div className=\"flex items-center justify-between gap-2 px-2 pb-2\">\n              <div className=\"flex items-center gap-0.5\">\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  className=\"text-muted-foreground hover:text-foreground size-8\"\n                  aria-label=\"Attach file\"\n                >\n                  <Paperclip className=\"size-4\" />\n                </Button>\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  className=\"text-muted-foreground hover:text-foreground size-8\"\n                  aria-label=\"Emoji\"\n                >\n                  <Smile className=\"size-4\" />\n                </Button>\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  className=\"text-muted-foreground hover:text-foreground size-8\"\n                  aria-label=\"Mention\"\n                >\n                  <AtSign className=\"size-4\" />\n                </Button>\n              </div>\n              <div className=\"flex items-center gap-2\">\n                <kbd className=\"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                  <span>↵</span>\n                </kbd>\n                <Button\n                  className=\"h-8 gap-1.5 rounded-lg px-3 text-xs font-medium\"\n                  disabled={!draft.trim()}\n                  aria-label=\"Send\"\n                  onClick={send}\n                >\n                  Send\n                  <ArrowRight className=\"size-3.5\" />\n                </Button>\n              </div>\n            </div>\n          </div>\n        </div>\n      </section>\n\n      <style>{`\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    </div>\n  )\n}\n",
      "type": "registry:block",
      "target": "~/components/blocks/ChatTwoPane.tsx"
    }
  ],
  "dependencies": [
    "lucide-react"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/react/avatar.json",
    "https://uipkge.dev/r/react/button.json",
    "https://uipkge.dev/r/react/input.json",
    "https://uipkge.dev/r/react/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"
  ]
}