{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-02",
  "type": "registry:block",
  "title": "Chat with Model Menu",
  "description": "A chat with model menu block.",
  "author": "ephraim duncan <https://ephraimduncan.com>",
  "registryDependencies": [
    "bubble",
    "button",
    "dropdown-menu",
    "input-group",
    "message",
    "message-scroller",
    "textarea"
  ],
  "dependencies": [
    "@tabler/icons-react"
  ],
  "files": [
    {
      "path": "content/components/chat/chat-02.tsx",
      "type": "registry:component",
      "target": "components/chat-02.tsx",
      "content": "'use client';\n\nimport {\n  IconArrowUp,\n  IconChevronDown,\n  IconCopy,\n  IconDots,\n  IconMicrophone,\n  IconPaperclip,\n  IconPencil,\n  IconPhoto,\n  IconPlayerStopFilled,\n  IconPlus,\n  IconRefresh,\n  IconThumbDown,\n  IconThumbUp,\n  IconVolume,\n  IconWorldSearch,\n} from '@tabler/icons-react';\nimport type React from 'react';\nimport { useState } from 'react';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Button } from '@/components/ui/button';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu';\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupButton,\n  InputGroupTextarea,\n} from '@/components/ui/input-group';\nimport {\n  Message,\n  MessageContent,\n  MessageFooter,\n} from '@/components/ui/message';\nimport {\n  MessageScroller,\n  MessageScrollerButton,\n  MessageScrollerContent,\n  MessageScrollerItem,\n  MessageScrollerProvider,\n  MessageScrollerViewport,\n} from '@/components/ui/message-scroller';\nimport { Textarea } from '@/components/ui/textarea';\nimport { cn } from '@/lib/utils';\n\ntype Turn = {\n  id: string;\n  role: 'user' | 'assistant';\n  text: string;\n  streaming?: boolean;\n};\n\nconst thread: Turn[] = [\n  {\n    id: '1',\n    role: 'user',\n    text: 'Is it true that a day on Venus is longer than its year?',\n  },\n  {\n    id: '2',\n    role: 'assistant',\n    text: 'Yes. Venus takes 243 Earth days to rotate once, but only 225 days to orbit the Sun, so its day is longer than its year.\\n\\nIt also spins in the opposite direction to most planets, which means the Sun rises in the west there.',\n  },\n  { id: '3', role: 'user', text: 'How hot does it get on the surface?' },\n  {\n    id: '4',\n    role: 'assistant',\n    text: 'The surface sits around 465°C, hot enough to melt lead. The atmosphere traps so much heat that Venus stays hotter than Mercury, even though Mercury is much closer to the Sun.',\n  },\n  {\n    id: '5',\n    role: 'user',\n    text: 'How long would a person survive there without a suit?',\n  },\n  {\n    id: '6',\n    role: 'assistant',\n    text: \"A few seconds at most. The pressure down there is 92 times Earth's, similar to being 900 meters underwater, and the heat alone would be fatal almost immediately.\\n\\nFor reference, the Soviet Venera landers were built for those conditions and still failed within about two hours.\",\n  },\n];\n\nconst replies = [\n  'Good question. Venus has no moons and no rings, which makes it unusual among the inner planets. Most explanations point to its slow, retrograde spin: any moon it once had would have drifted inward and been pulled apart by tidal forces.\\n\\nMercury is the only other moonless planet in the solar system.',\n  'The clouds are mostly sulfuric acid droplets, sitting about 50 to 70 km above the surface. They reflect roughly 75% of incoming sunlight, which is why Venus is the brightest object in our sky after the Moon.\\n\\nBelow the clouds the air is almost entirely carbon dioxide, with pressure about 92 times that of Earth at sea level.',\n  'Several landers have made it down. The Soviet Venera 7 was the first to transmit from the surface in 1970, and Venera 13 survived for 127 minutes in 1982, long enough to send back the first color photos.\\n\\nNothing has lasted more than a couple of hours; the heat and pressure destroy electronics quickly.',\n];\n\nconst levels = [\n  { name: 'Instant', hint: '5.5' },\n  { name: 'Medium' },\n  { name: 'High' },\n  { name: 'Extra High' },\n  { name: 'Pro' },\n];\n\nconst models = [\n  'GPT-6',\n  'GPT-5.6 Sol',\n  'GPT-6 Astra',\n  'GPT-5.6 Terra',\n  'GPT-5.6 Luna',\n];\n\nconst thinkDelay = 600;\nconst wordDelay = 35;\n\nconst easeOut = 'ease-[cubic-bezier(0.23,1,0.32,1)]';\nconst press = `${easeOut} transition-[scale,background-color] duration-150 active:scale-[0.96] motion-reduce:active:scale-100`;\nconst enter = `${easeOut} motion-reduce:slide-in-from-bottom-0 fade-in slide-in-from-bottom-1 animate-in duration-200`;\nconst swap = `${easeOut} fade-in zoom-in-95 motion-reduce:zoom-in-100 animate-in duration-150`;\nconst item = 'rounded-lg px-2.5 py-1.75 text-[13px]';\n\nexport default function Chat02() {\n  const [turns, setTurns] = useState(thread);\n  const [draft, setDraft] = useState('');\n  const [tall, setTall] = useState(false);\n  const [listening, setListening] = useState(false);\n  const [level, setLevel] = useState('Extra High');\n  const [model, setModel] = useState('GPT-5.6 Sol');\n  const [replyIndex, setReplyIndex] = useState(0);\n  const [edit, setEdit] = useState<{ id: string; text: string } | null>(null);\n  const streaming = turns.some((t) => t.streaming);\n\n  const stream = (replyId: string) => {\n    const words = replies[replyIndex % replies.length].split(' ');\n    setReplyIndex(replyIndex + 1);\n    setTurns((prev) =>\n      prev.map((t) =>\n        t.id === replyId ? { ...t, text: '', streaming: true } : t\n      )\n    );\n    let shown = 0;\n    setTimeout(() => {\n      const timer = setInterval(() => {\n        shown += 1;\n        const done = shown >= words.length;\n        setTurns((prev) =>\n          prev.map((t) =>\n            t.id === replyId\n              ? {\n                  ...t,\n                  text: words.slice(0, shown).join(' '),\n                  streaming: !done,\n                }\n              : t\n          )\n        );\n        if (done) {\n          clearInterval(timer);\n        }\n      }, wordDelay);\n    }, thinkDelay);\n  };\n\n  const send = (e: React.FormEvent) => {\n    e.preventDefault();\n    const text = draft.trim();\n    if (!text || streaming) {\n      return;\n    }\n    const replyId = crypto.randomUUID();\n    setTurns([\n      ...turns,\n      { id: crypto.randomUUID(), role: 'user', text },\n      { id: replyId, role: 'assistant', text: '' },\n    ]);\n    setDraft('');\n    setTall(false);\n    stream(replyId);\n  };\n\n  const resend = () => {\n    const text = edit?.text.trim();\n    if (!(edit && text) || streaming) {\n      return;\n    }\n    const at = turns.findIndex((t) => t.id === edit.id);\n    const reply = turns[at + 1];\n    setTurns(turns.map((t) => (t.id === edit.id ? { ...t, text } : t)));\n    setEdit(null);\n    if (reply?.role === 'assistant') {\n      stream(reply.id);\n    }\n  };\n\n  const body = (turn: Turn, align: 'start' | 'end') => {\n    if (edit?.id === turn.id) {\n      return (\n        <>\n          <Textarea\n            aria-label=\"Edit message\"\n            autoFocus\n            className={cn(\n              enter,\n              'min-h-0 w-md max-w-full resize-none rounded-[18px] border-0 bg-muted px-3.5 py-2.5 text-[15px]/6 focus-visible:ring-0 md:text-[15px]/6'\n            )}\n            onChange={(e) => setEdit({ ...edit, text: e.target.value })}\n            onKeyDown={(e) => {\n              if (e.key === 'Escape') {\n                setEdit(null);\n              }\n              if (e.key === 'Enter' && !e.shiftKey) {\n                e.preventDefault();\n                resend();\n              }\n            }}\n            value={edit.text}\n          />\n          <div className={cn(enter, 'flex items-center gap-1.5 self-end')}>\n            <Button\n              className={cn(press, 'rounded-full')}\n              onClick={() => setEdit(null)}\n              size=\"sm\"\n              variant=\"secondary\"\n            >\n              Cancel\n            </Button>\n            <Button\n              className={cn(press, 'rounded-full')}\n              disabled={!edit.text.trim() || streaming}\n              onClick={resend}\n              size=\"sm\"\n            >\n              Send\n            </Button>\n          </div>\n        </>\n      );\n    }\n    if (turn.streaming && !turn.text) {\n      return (\n        <span className=\"shimmer text-[15px]/6 text-muted-foreground\">\n          Thinking…\n        </span>\n      );\n    }\n    return turn.text.split('\\n\\n').map((paragraph) => (\n      <Bubble\n        align={align}\n        className=\"max-w-md data-[variant=ghost]:max-w-full\"\n        key={paragraph}\n        variant={turn.role === 'user' ? 'muted' : 'ghost'}\n      >\n        <BubbleContent className=\"rounded-[18px] px-3.5 py-1.75 text-[15px]/5.5 group-data-[variant=ghost]/bubble:text-[15px]/6\">\n          {paragraph}\n        </BubbleContent>\n      </Bubble>\n    ));\n  };\n\n  const action = (\n    label: string,\n    icon: React.ReactNode,\n    onClick?: () => void\n  ) => (\n    <Button\n      aria-label={label}\n      className={cn(press, 'rounded-lg')}\n      onClick={onClick}\n      size=\"icon-sm\"\n      variant=\"ghost\"\n    >\n      {icon}\n    </Button>\n  );\n\n  return (\n    <div className=\"flex h-dvh w-full flex-col bg-background\">\n      <MessageScrollerProvider autoScroll>\n        <MessageScroller>\n          <MessageScrollerViewport>\n            <MessageScrollerContent className=\"mx-auto w-full max-w-2xl gap-4.5 px-4 pt-10 pb-4\">\n              {turns.map((turn) => {\n                const mine = turn.role === 'user';\n                const align = mine ? 'end' : 'start';\n                return (\n                  <MessageScrollerItem\n                    className={cn(\n                      mine && 'pt-4.5 first:pt-0',\n                      !thread.some((t) => t.id === turn.id) && enter\n                    )}\n                    key={turn.id}\n                    messageId={turn.id}\n                  >\n                    <Message align={align}>\n                      <MessageContent className=\"gap-2.5\">\n                        {body(turn, align)}\n                        {turn.text && edit?.id !== turn.id && (\n                          <MessageFooter className=\"-mx-2 gap-0.5\">\n                            {action('Copy', <IconCopy stroke={1.6} />, () =>\n                              navigator.clipboard.writeText(turn.text)\n                            )}\n                            {mine\n                              ? action(\n                                  'Edit',\n                                  <IconPencil stroke={1.6} />,\n                                  () =>\n                                    setEdit({ id: turn.id, text: turn.text })\n                                )\n                              : action(\n                                  'Regenerate',\n                                  <IconRefresh stroke={1.6} />,\n                                  () => stream(turn.id)\n                                )}\n                            {!mine && (\n                              <DropdownMenu>\n                                <DropdownMenuTrigger\n                                  render={action(\n                                    'More',\n                                    <IconDots stroke={1.6} />\n                                  )}\n                                />\n                                <DropdownMenuContent\n                                  align=\"start\"\n                                  className=\"w-44 rounded-[14px] p-1.5\"\n                                >\n                                  <DropdownMenuGroup className=\"flex flex-col gap-0.5\">\n                                    <DropdownMenuItem\n                                      className={cn(item, 'gap-2.5')}\n                                    >\n                                      <IconThumbUp stroke={1.6} />\n                                      Good response\n                                    </DropdownMenuItem>\n                                    <DropdownMenuItem\n                                      className={cn(item, 'gap-2.5')}\n                                    >\n                                      <IconThumbDown stroke={1.6} />\n                                      Bad response\n                                    </DropdownMenuItem>\n                                    <DropdownMenuItem\n                                      className={cn(item, 'gap-2.5')}\n                                    >\n                                      <IconVolume stroke={1.6} />\n                                      Read aloud\n                                    </DropdownMenuItem>\n                                  </DropdownMenuGroup>\n                                </DropdownMenuContent>\n                              </DropdownMenu>\n                            )}\n                          </MessageFooter>\n                        )}\n                      </MessageContent>\n                    </Message>\n                  </MessageScrollerItem>\n                );\n              })}\n            </MessageScrollerContent>\n          </MessageScrollerViewport>\n          <MessageScrollerButton\n            className={cn(\n              easeOut,\n              'bottom-2 h-7 gap-1.5 rounded-full border-0 px-3.5 font-medium text-muted-foreground text-xs shadow-[0_0_0_1px_oklch(0_0_0/0.06),0_2px_8px_oklch(0_0_0/0.08)] transition-[translate,scale,opacity,background-color] hover:text-foreground active:not-aria-[haspopup]:translate-y-0 data-[active=true]:active:scale-[0.96] dark:shadow-[0_0_0_1px_oklch(1_0_0/0.1),0_2px_8px_oklch(0_0_0/0.3)]'\n            )}\n            size=\"sm\"\n          >\n            <IconChevronDown className=\"size-3.5\" stroke={1.75} />\n            Jump to latest\n          </MessageScrollerButton>\n        </MessageScroller>\n      </MessageScrollerProvider>\n\n      <form\n        className=\"mx-auto flex w-full max-w-2xl flex-col items-center gap-2.5 px-4 pt-2 pb-3.5\"\n        onSubmit={send}\n      >\n        <InputGroup\n          className={cn(\n            'min-h-11 items-end rounded-[22px] bg-background px-0.5 shadow-xs has-[[data-slot=input-group-control]:focus-visible]:ring-0',\n            tall && 'flex-wrap rounded-3xl'\n          )}\n        >\n          <InputGroupAddon\n            className={cn('pb-1.25 pl-2.5', tall && 'order-none')}\n          >\n            <DropdownMenu>\n              <DropdownMenuTrigger\n                render={\n                  <InputGroupButton\n                    aria-label=\"Add attachment\"\n                    className={cn(press, 'rounded-full')}\n                    size=\"icon-sm\"\n                  />\n                }\n              >\n                <IconPlus stroke={1.8} />\n              </DropdownMenuTrigger>\n              <DropdownMenuContent\n                align=\"start\"\n                className=\"w-56 rounded-[14px] p-1.5\"\n                sideOffset={10}\n              >\n                <DropdownMenuGroup className=\"flex flex-col gap-0.5\">\n                  <DropdownMenuItem className={cn(item, 'gap-2.5')}>\n                    <IconPaperclip stroke={1.6} />\n                    Add files\n                  </DropdownMenuItem>\n                  <DropdownMenuItem className={cn(item, 'gap-2.5')}>\n                    <IconPhoto stroke={1.6} />\n                    Add photos\n                  </DropdownMenuItem>\n                  <DropdownMenuItem className={cn(item, 'gap-2.5')}>\n                    <IconWorldSearch stroke={1.6} />\n                    Search the web\n                  </DropdownMenuItem>\n                </DropdownMenuGroup>\n              </DropdownMenuContent>\n            </DropdownMenu>\n          </InputGroupAddon>\n          <InputGroupTextarea\n            className={cn(\n              'max-h-48 min-h-0 py-2.5 pl-1 text-[15px]/6 md:text-[15px]/6',\n              tall && '-order-1 basis-full px-2.75 py-1.5'\n            )}\n            onChange={(e) => {\n              setDraft(e.target.value);\n              setTall(e.target.scrollHeight > 44);\n            }}\n            onKeyDown={(e) => {\n              if (e.key === 'Enter' && !e.shiftKey) {\n                e.preventDefault();\n                e.currentTarget.form?.requestSubmit();\n              }\n            }}\n            placeholder={listening ? 'Listening…' : 'Ask anything'}\n            rows={1}\n            value={draft}\n          />\n          <InputGroupAddon\n            align=\"inline-end\"\n            className={cn('gap-1.5 pr-2.5 pb-1.25', tall && 'ml-auto')}\n          >\n            {draft.trim() ? (\n              <InputGroupButton\n                aria-label=\"Send\"\n                className={cn(swap, press, 'rounded-full')}\n                disabled={streaming}\n                size=\"icon-sm\"\n                type=\"submit\"\n                variant=\"default\"\n              >\n                <IconArrowUp stroke={2} />\n              </InputGroupButton>\n            ) : (\n              <InputGroupButton\n                aria-label={listening ? 'Stop listening' : 'Voice input'}\n                aria-pressed={listening}\n                className={cn(\n                  swap,\n                  press,\n                  'rounded-full',\n                  listening &&\n                    'bg-destructive/15 text-destructive hover:bg-destructive/25'\n                )}\n                onClick={() => setListening(!listening)}\n                size=\"icon-sm\"\n              >\n                {listening ? (\n                  <IconPlayerStopFilled className=\"animate-pulse motion-reduce:animate-none\" />\n                ) : (\n                  <IconMicrophone stroke={1.8} />\n                )}\n              </InputGroupButton>\n            )}\n            <DropdownMenu>\n              <DropdownMenuTrigger\n                render={\n                  <InputGroupButton\n                    aria-label=\"Intelligence level\"\n                    className={cn(\n                      press,\n                      'h-8 gap-1.25 rounded-full bg-muted px-3 font-medium text-[13px] text-muted-foreground hover:bg-muted'\n                    )}\n                    size=\"sm\"\n                  />\n                }\n              >\n                {level}\n                <IconChevronDown className=\"size-3\" stroke={2} />\n              </DropdownMenuTrigger>\n              <DropdownMenuContent\n                align=\"end\"\n                className=\"w-49 rounded-[14px] p-1.5\"\n                side=\"top\"\n                sideOffset={10}\n              >\n                <DropdownMenuGroup className=\"flex flex-col gap-0.5\">\n                  <DropdownMenuLabel className=\"px-2.5 pt-1 pb-0.5 text-[11px]\">\n                    Intelligence\n                  </DropdownMenuLabel>\n                  <DropdownMenuRadioGroup\n                    onValueChange={setLevel}\n                    value={level}\n                  >\n                    {levels.map((l) => (\n                      <DropdownMenuRadioItem\n                        className={cn(item, 'pr-8 pl-2.5')}\n                        key={l.name}\n                        value={l.name}\n                      >\n                        {l.name}\n                        {l.hint && (\n                          <span className=\"text-muted-foreground text-xs\">\n                            {l.hint}\n                          </span>\n                        )}\n                      </DropdownMenuRadioItem>\n                    ))}\n                  </DropdownMenuRadioGroup>\n                  <DropdownMenuSeparator className=\"mx-1.5 my-1\" />\n                  <DropdownMenuSub>\n                    <DropdownMenuSubTrigger\n                      className={cn(\n                        item,\n                        '[&>svg]:size-3.5 [&>svg]:text-muted-foreground'\n                      )}\n                    >\n                      {model}\n                    </DropdownMenuSubTrigger>\n                    <DropdownMenuSubContent className=\"w-44 rounded-[14px] p-1.5\">\n                      <DropdownMenuRadioGroup\n                        className=\"flex flex-col gap-0.5\"\n                        onValueChange={setModel}\n                        value={model}\n                      >\n                        {models.map((m) => (\n                          <DropdownMenuRadioItem\n                            className={cn(item, 'pr-8 pl-2.5')}\n                            key={m}\n                            value={m}\n                          >\n                            {m}\n                          </DropdownMenuRadioItem>\n                        ))}\n                      </DropdownMenuRadioGroup>\n                    </DropdownMenuSubContent>\n                  </DropdownMenuSub>\n                </DropdownMenuGroup>\n              </DropdownMenuContent>\n            </DropdownMenu>\n          </InputGroupAddon>\n        </InputGroup>\n        <p className=\"text-muted-foreground text-xs\">\n          AI can make mistakes. Check important info.\n        </p>\n      </form>\n    </div>\n  );\n}\n"
    }
  ],
  "categories": [
    "chat"
  ]
}