{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-chat",
  "title": "AI Chat",
  "description": "A chat layout with a message log that stays scrolled to the newest message while replies stream, a jump-to-latest button, an empty state with suggestions and a footer for the input.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "registry/tealmac/ai-chat.tsx",
      "content": "// Adapted from Ballmac UI (https://github.com/vamsiy78/ballmac-ui), MIT License, Copyright (c) 2026 Ballmac.\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDown } from \"lucide-react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\n\nconst snappy = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 34,\n  mass: 0.8,\n} as const\n\n/** Pixels from the bottom that still count as \"at the bottom\". */\nconst BOTTOM_THRESHOLD = 48\n\ntype ChatScroll = {\n  /** Callback ref for the scroll container. */\n  scrollRef: (node: HTMLDivElement | null) => void\n  /** Callback ref for the element whose height grows as content streams in. */\n  contentRef: (node: HTMLDivElement | null) => void\n  /** Whether the view is pinned to the newest content. */\n  isAtBottom: boolean\n  /** Whether the log is taller than its viewport (it is then focusable for keyboard scrolling). */\n  isScrollable: boolean\n  /** Scroll to the newest content and stick there while it grows. */\n  scrollToBottom: (behavior?: ScrollBehavior) => void\n}\n\nfunction useStickToBottom(): ChatScroll {\n  const [scroller, setScroller] = React.useState<HTMLDivElement | null>(null)\n  const [content, setContent] = React.useState<HTMLDivElement | null>(null)\n  const stick = React.useRef(true)\n  const [isAtBottom, setIsAtBottom] = React.useState(true)\n  const [isScrollable, setIsScrollable] = React.useState(false)\n  const reduceMotion = useReducedMotion()\n\n  const scrollToBottom = React.useCallback(\n    (behavior: ScrollBehavior = reduceMotion ? \"auto\" : \"smooth\") => {\n      stick.current = true\n      setIsAtBottom(true)\n      scroller?.scrollTo({ top: scroller.scrollHeight, behavior })\n    },\n    [reduceMotion, scroller]\n  )\n\n  React.useEffect(() => {\n    if (!scroller) return\n    let lastTop = scroller.scrollTop\n    const onScroll = () => {\n      const distance =\n        scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight\n      const atBottom = distance <= BOTTOM_THRESHOLD\n      // Only an upward scroll releases the pin; smooth scrolls towards the bottom keep it.\n      if (scroller.scrollTop < lastTop - 1 && !atBottom) stick.current = false\n      if (atBottom) stick.current = true\n      lastTop = scroller.scrollTop\n      setIsAtBottom(stick.current || atBottom)\n    }\n    const follow = () => {\n      setIsScrollable(scroller.scrollHeight > scroller.clientHeight + 1)\n      if (stick.current) {\n        scroller.scrollTo({ top: scroller.scrollHeight, behavior: \"instant\" })\n        lastTop = scroller.scrollTop\n      } else onScroll()\n    }\n\n    follow()\n    scroller.addEventListener(\"scroll\", onScroll, { passive: true })\n    const observer = new ResizeObserver(follow)\n    observer.observe(scroller)\n    if (content) observer.observe(content)\n    return () => {\n      scroller.removeEventListener(\"scroll\", onScroll)\n      observer.disconnect()\n    }\n  }, [scroller, content])\n\n  return {\n    scrollRef: setScroller,\n    contentRef: setContent,\n    isAtBottom,\n    isScrollable,\n    scrollToBottom,\n  }\n}\n\nconst ChatContext = React.createContext<ChatScroll | null>(null)\n\n/** Read the scroll state of the surrounding <Chat>, e.g. to jump to the bottom after the user sends a message. */\nfunction useChatScroll() {\n  const ctx = React.useContext(ChatContext)\n  if (!ctx) throw new Error(\"useChatScroll must be used inside <Chat>.\")\n  return ctx\n}\n\ntype ChatProps = React.ComponentProps<\"div\">\n\n/** Full-height column: messages scroll, the footer stays put. Give it (or its parent) a height. */\nfunction Chat({ className, ...props }: ChatProps) {\n  const scroll = useStickToBottom()\n  return (\n    <ChatContext.Provider value={scroll}>\n      <div\n        data-slot=\"chat\"\n        className={cn(\"flex h-full min-h-0 w-full flex-col\", className)}\n        {...props}\n      />\n    </ChatContext.Provider>\n  )\n}\n\ntype ChatMessagesProps = React.ComponentProps<\"div\"> & {\n  /** Classes for the inner column that holds the messages (width, gap, padding). */\n  contentClassName?: string\n  /** Accessible name of the log. */\n  label?: string\n  /** Accessible name of the jump-to-latest button. */\n  scrollButtonLabel?: string\n}\n\n/**\n * The scrolling message log. Sticks to the bottom while content streams in, unless the\n * reader scrolled up; then shows a \"scroll to latest\" button.\n */\nfunction ChatMessages({\n  label,\n  scrollButtonLabel,\n  contentClassName,\n  className,\n  children,\n  ...props\n}: ChatMessagesProps) {\n  label ??= \"Conversation\"\n  scrollButtonLabel ??= \"Scroll to latest message\"\n  const ctx = React.useContext(ChatContext)\n  const local = useStickToBottom()\n  const { scrollRef, contentRef, isAtBottom, isScrollable, scrollToBottom } =\n    ctx ?? local\n  const reduceMotion = useReducedMotion()\n  // A scrolling log must be reachable by keyboard; when it does not scroll it needs no tab stop.\n  const scrollTabIndex = isScrollable ? 0 : undefined\n\n  return (\n    <div data-slot=\"chat-messages-viewport\" className=\"relative min-h-0 flex-1\">\n      <div\n        ref={scrollRef}\n        data-slot=\"chat-messages\"\n        role=\"log\"\n        aria-live=\"polite\"\n        aria-label={label}\n        tabIndex={scrollTabIndex}\n        className={cn(\n          \"focus-visible:ring-ring/50 absolute inset-0 overflow-y-auto overscroll-contain outline-none focus-visible:ring-[3px] focus-visible:ring-inset\",\n          className\n        )}\n        {...props}\n      >\n        <div\n          ref={contentRef}\n          data-slot=\"chat-messages-content\"\n          className={cn(\n            \"mx-auto flex min-h-full w-full max-w-3xl flex-col gap-6 px-4 py-6\",\n            contentClassName\n          )}\n        >\n          {children}\n        </div>\n      </div>\n      <AnimatePresence>\n        {!isAtBottom ? (\n          <motion.div\n            key=\"scroll-to-latest\"\n            className=\"absolute bottom-3 left-1/2 -translate-x-1/2\"\n            initial={\n              reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.96 }\n            }\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            exit={\n              reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.96 }\n            }\n            transition={reduceMotion ? { duration: 0.12 } : snappy}\n          >\n            <Button\n              data-slot=\"chat-scroll-button\"\n              type=\"button\"\n              variant=\"outline\"\n              size=\"icon\"\n              aria-label={scrollButtonLabel}\n              title={scrollButtonLabel}\n              onClick={() => scrollToBottom()}\n              className=\"bg-background size-8 rounded-full shadow-[0_2px_8px_0_rgb(0_0_0/0.08)]\"\n            >\n              <ArrowDown />\n            </Button>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  )\n}\n\ntype ChatEmptyProps = Omit<React.ComponentProps<\"div\">, \"title\"> & {\n  /** Small mark above the title (an icon or logo). */\n  icon?: React.ReactNode\n  /** Headline, e.g. \"How can I help?\" */\n  title?: React.ReactNode\n  /** One line under the title. */\n  description?: React.ReactNode\n}\n\n/** Empty state shown before the first message. Put <ChatSuggestions> in children. */\nfunction ChatEmpty({\n  icon,\n  title,\n  description,\n  className,\n  children,\n  ...props\n}: ChatEmptyProps) {\n  return (\n    <div\n      data-slot=\"chat-empty\"\n      className={cn(\n        \"m-auto flex w-full max-w-md flex-col items-center gap-3 px-2 py-8 text-center\",\n        className\n      )}\n      {...props}\n    >\n      {icon ? (\n        <div\n          aria-hidden=\"true\"\n          className=\"bg-card text-muted-foreground mb-1 flex size-10 items-center justify-center rounded-xl border [&_svg]:size-5\"\n        >\n          {icon}\n        </div>\n      ) : null}\n      {title ? (\n        <h2 className=\"text-base font-semibold tracking-tight text-balance\">\n          {title}\n        </h2>\n      ) : null}\n      {description ? (\n        <p className=\"text-muted-foreground text-sm text-balance\">\n          {description}\n        </p>\n      ) : null}\n      {children}\n    </div>\n  )\n}\n\nconst SuggestionsContext = React.createContext<\n  ((value: string) => void) | undefined\n>(undefined)\n\ntype ChatSuggestionsProps = Omit<React.ComponentProps<\"ul\">, \"onSelect\"> & {\n  /** Called with a suggestion's value when it is clicked. Usually sends it as a message. */\n  onSelect?: (value: string) => void\n}\n\nfunction ChatSuggestions({\n  onSelect,\n  className,\n  children,\n  ...props\n}: ChatSuggestionsProps) {\n  return (\n    <SuggestionsContext.Provider value={onSelect}>\n      <ul\n        data-slot=\"chat-suggestions\"\n        aria-label=\"Suggestions\"\n        className={cn(\"mt-2 flex flex-wrap justify-center gap-2\", className)}\n        {...props}\n      >\n        {React.Children.map(children, (child) =>\n          child == null ? null : <li className=\"max-w-full\">{child}</li>\n        )}\n      </ul>\n    </SuggestionsContext.Provider>\n  )\n}\n\ntype ChatSuggestionProps = React.ComponentProps<\"button\"> & {\n  /** Text passed to onSelect. Defaults to the button's text when children is a string. */\n  value?: string\n}\n\nfunction ChatSuggestion({\n  value,\n  className,\n  children,\n  onClick,\n  ...props\n}: ChatSuggestionProps) {\n  const onSelect = React.useContext(SuggestionsContext)\n  return (\n    <button\n      data-slot=\"chat-suggestion\"\n      type=\"button\"\n      onClick={(event) => {\n        onClick?.(event)\n        if (event.defaultPrevented) return\n        const text = value ?? (typeof children === \"string\" ? children : \"\")\n        if (text) onSelect?.(text)\n      }}\n      className={cn(\n        \"bg-card text-foreground hover:border-foreground/20 hover:bg-accent focus-visible:ring-ring/50 inline-flex min-h-8 max-w-full items-center rounded-full border px-3 py-1 text-start text-[13px] transition-colors duration-150 outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n    </button>\n  )\n}\n\ntype ChatFooterProps = React.ComponentProps<\"div\"> & {\n  /** Classes for the inner column (match ChatMessages' width). */\n  contentClassName?: string\n}\n\n/** Area under the log for the prompt input and a short disclaimer. */\nfunction ChatFooter({\n  contentClassName,\n  className,\n  children,\n  ...props\n}: ChatFooterProps) {\n  return (\n    <div\n      data-slot=\"chat-footer\"\n      className={cn(\n        \"bg-background sticky bottom-0 shrink-0 px-4 pt-2 pb-4\",\n        className\n      )}\n      {...props}\n    >\n      <div className={cn(\"mx-auto w-full max-w-3xl\", contentClassName)}>\n        {children}\n      </div>\n    </div>\n  )\n}\n\nexport {\n  Chat,\n  ChatMessages,\n  ChatEmpty,\n  ChatSuggestions,\n  ChatSuggestion,\n  ChatFooter,\n  useChatScroll,\n  type ChatProps,\n  type ChatMessagesProps,\n  type ChatEmptyProps,\n  type ChatSuggestionsProps,\n  type ChatSuggestionProps,\n  type ChatFooterProps,\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}