{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "conversation",
  "type": "registry:ui",
  "title": "Conversation",
  "description": "Stick-to-bottom chat viewport with a floating scroll-to-latest key.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://seamui.dev/r/utils.json",
    "https://seamui.dev/r/motion.json",
    "https://seamui.dev/r/button.json"
  ],
  "files": [
    {
      "path": "registry/seam/ui/conversation.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, motion } from \"motion/react\"\nimport { ArrowDown } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { springs, fades, depth, reduced, useReducedMotion } from \"@/lib/motion\"\nimport { Button } from \"./button\"\n\ntype StickToBottom = {\n  scrollRef: React.RefObject<HTMLDivElement | null>\n  contentRef: React.RefObject<HTMLDivElement | null>\n  isAtBottom: boolean\n  scrollToBottom: (behavior?: ScrollBehavior) => void\n}\n\nconst ConversationContext = React.createContext<StickToBottom | null>(null)\n\nfunction useConversation() {\n  const ctx = React.useContext(ConversationContext)\n  if (!ctx) {\n    throw new Error(\"Conversation parts must be used within <Conversation>.\")\n  }\n  return ctx\n}\n\n// Owned stick-to-bottom: the viewport follows new content while the user is at\n// the bottom, releases the moment they scroll up, and re-arms when they return.\n// No dependency — you own this code.\nfunction useStickToBottom(threshold = 24): StickToBottom {\n  const scrollRef = React.useRef<HTMLDivElement>(null)\n  const contentRef = React.useRef<HTMLDivElement>(null)\n  const stuckRef = React.useRef(true)\n  const [isAtBottom, setIsAtBottom] = React.useState(true)\n  const reduceMotion = useReducedMotion()\n\n  const scrollToBottom = React.useCallback(\n    (behavior?: ScrollBehavior) => {\n      const el = scrollRef.current\n      if (!el) return\n      el.scrollTo({\n        top: el.scrollHeight,\n        // Explicit jumps are smooth; reduced motion never animates position.\n        behavior: behavior ?? (reduceMotion ? \"auto\" : \"smooth\"),\n      })\n    },\n    [reduceMotion]\n  )\n\n  React.useEffect(() => {\n    const el = scrollRef.current\n    if (!el) return\n    const onScroll = () => {\n      const atBottom =\n        el.scrollHeight - el.scrollTop - el.clientHeight <= threshold\n      stuckRef.current = atBottom\n      setIsAtBottom(atBottom)\n    }\n    el.addEventListener(\"scroll\", onScroll, { passive: true })\n    onScroll()\n    return () => el.removeEventListener(\"scroll\", onScroll)\n  }, [threshold])\n\n  React.useEffect(() => {\n    const el = scrollRef.current\n    const content = contentRef.current\n    if (!el || !content) return\n    // While stuck, pin to bottom instantly as content grows — an animated\n    // follow would lag behind streamed tokens and read as jank.\n    const obs = new ResizeObserver(() => {\n      if (stuckRef.current) el.scrollTop = el.scrollHeight\n    })\n    obs.observe(content)\n    return () => obs.disconnect()\n  }, [])\n\n  return { scrollRef, contentRef, isAtBottom, scrollToBottom }\n}\n\nfunction Conversation({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  const stick = useStickToBottom()\n\n  return (\n    <ConversationContext.Provider value={stick}>\n      <div\n        data-slot=\"conversation\"\n        className={cn(\"relative flex min-h-0 flex-1 flex-col\", className)}\n        {...props}\n      >\n        <div\n          ref={stick.scrollRef}\n          data-slot=\"conversation-viewport\"\n          role=\"log\"\n          aria-live=\"polite\"\n          aria-relevant=\"additions\"\n          tabIndex={0}\n          className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none\"\n        >\n          {children}\n        </div>\n      </div>\n    </ConversationContext.Provider>\n  )\n}\n\nfunction ConversationContent({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  const { contentRef } = useConversation()\n  return (\n    <div\n      ref={contentRef}\n      data-slot=\"conversation-content\"\n      className={cn(\n        \"mx-auto flex w-full max-w-2xl flex-col gap-1 p-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\n// Floating \"jump to latest\" key — an embossed control rising at overlay depth,\n// shown only when the viewport is released from the bottom.\nfunction ConversationScrollButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof Button>) {\n  const { isAtBottom, scrollToBottom } = useConversation()\n  const reduceMotion = useReducedMotion()\n\n  return (\n    // Center horizontally with flex so motion's transform (y + scale) doesn't\n    // fight a Tailwind translate on the animated element.\n    <div className=\"pointer-events-none absolute inset-x-0 bottom-4 z-10 flex justify-center\">\n      <AnimatePresence>\n        {!isAtBottom && (\n          <motion.div\n            data-slot=\"conversation-scroll-button\"\n            className=\"pointer-events-auto\"\n            initial={\n              reduceMotion ? reduced.fadeIn.initial : depth.overlay.initial\n            }\n            animate={depth.overlay.animate}\n            exit={reduceMotion ? reduced.fadeIn.exit : depth.overlay.exit}\n            transition={reduceMotion ? fades.normal : springs.surface}\n          >\n            <Button\n              variant=\"secondary\"\n              size=\"icon\"\n              className={cn(\"rounded-full shadow-overlay\", className)}\n              onClick={() => scrollToBottom()}\n              aria-label=\"Scroll to latest\"\n              {...props}\n            >\n              <ArrowDown />\n            </Button>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  )\n}\n\nexport {\n  Conversation,\n  ConversationContent,\n  ConversationScrollButton,\n  useConversation,\n}\n",
      "type": "registry:ui",
      "target": "components/ui/conversation.tsx"
    }
  ]
}