{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "conversation",
  "title": "Conversation",
  "description": "A thread scroll container that follows a streaming reply and yields to the reader.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/conversation/conversation.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDown } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type ConversationProps = React.HTMLAttributes<HTMLDivElement> & {\n  stickToBottom?: boolean\n  threshold?: number\n  jumpLabel?: string\n  showJumpButton?: boolean\n  onFollowChange?: (following: boolean) => void\n}\n\n/** How close to the bottom still counts as \"reading the latest message\". */\nconst DEFAULT_THRESHOLD = 48\n\nexport function Conversation({\n  stickToBottom = true,\n  threshold = DEFAULT_THRESHOLD,\n  jumpLabel = \"Jump to latest\",\n  showJumpButton = true,\n  onFollowChange,\n  children,\n  className,\n  ...rootProps\n}: ConversationProps) {\n  const viewportRef = React.useRef<HTMLDivElement>(null)\n  const contentRef = React.useRef<HTMLDivElement>(null)\n  const [following, setFollowing] = React.useState(stickToBottom)\n  const [above, setAbove] = React.useState(false)\n\n  const report = React.useRef(onFollowChange)\n  React.useEffect(() => {\n    report.current = onFollowChange\n  })\n\n  const atBottom = React.useCallback(() => {\n    const viewport = viewportRef.current\n    if (!viewport) return true\n\n    const distance =\n      viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight\n\n    return distance <= threshold\n  }, [threshold])\n\n  const scrollToBottom = React.useCallback((behavior: ScrollBehavior) => {\n    const viewport = viewportRef.current\n    if (!viewport) return\n\n    viewport.scrollTo({ top: viewport.scrollHeight, behavior })\n  }, [])\n\n  // Following is driven by where the reader is, so it is only ever changed\n  // from a scroll event or a resize, never during render.\n  React.useEffect(() => {\n    const viewport = viewportRef.current\n    if (!viewport) return\n\n    const onScroll = () => {\n      setAbove(viewport.scrollTop > 1)\n      if (!stickToBottom) return\n\n      const next = atBottom()\n      setFollowing((current) => {\n        if (current === next) return current\n        report.current?.(next)\n        return next\n      })\n    }\n\n    viewport.addEventListener(\"scroll\", onScroll, { passive: true })\n    return () => viewport.removeEventListener(\"scroll\", onScroll)\n  }, [stickToBottom, atBottom])\n\n  // New content arrives constantly while a reply streams, so follow the\n  // bottom only while the reader has not scrolled away from it.\n  React.useEffect(() => {\n    const content = contentRef.current\n    if (!content || !stickToBottom) return\n\n    const observer = new ResizeObserver(() => {\n      if (!following) return\n      scrollToBottom(\"auto\")\n    })\n\n    observer.observe(content)\n    return () => observer.disconnect()\n  }, [stickToBottom, following, scrollToBottom])\n\n  const jump = () => {\n    setFollowing(true)\n    report.current?.(true)\n    scrollToBottom(\"smooth\")\n  }\n\n  return (\n    <div\n      data-slot=\"conversation\"\n      data-following={following || undefined}\n      className={cn(\"relative min-h-0\", className)}\n      {...rootProps}\n    >\n      <div\n        ref={viewportRef}\n        data-slot=\"conversation-viewport\"\n        className=\"h-full overflow-y-auto overscroll-contain\"\n      >\n        <div ref={contentRef} data-slot=\"conversation-content\">\n          {children}\n        </div>\n      </div>\n\n      {/* A thread scrolled down cuts its top line in half, which reads as a\n          rendering fault rather than as there being more above it. */}\n      <div\n        aria-hidden=\"true\"\n        data-slot=\"conversation-fade\"\n        className={cn(\n          \"from-background pointer-events-none absolute inset-x-0 top-0 h-8 rounded-t-[inherit] bg-gradient-to-b to-transparent transition-opacity duration-150 motion-reduce:transition-none\",\n          above ? \"opacity-100\" : \"opacity-0\"\n        )}\n      />\n\n      {showJumpButton && stickToBottom ? (\n        <button\n          type=\"button\"\n          data-slot=\"conversation-jump\"\n          aria-hidden={following || undefined}\n          tabIndex={following ? -1 : undefined}\n          className={cn(\n            \"bg-foreground text-background focus-visible:ring-ring absolute bottom-3 left-1/2 inline-flex min-h-9 items-center gap-1.5 rounded-full px-3 text-xs font-semibold shadow-md transition-[opacity,transform] duration-200 focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none\",\n            following\n              ? \"pointer-events-none -translate-x-1/2 translate-y-1 opacity-0\"\n              : \"-translate-x-1/2 opacity-100\"\n          )}\n          onClick={jump}\n        >\n          <ArrowDown aria-hidden=\"true\" size={13} />\n          {jumpLabel}\n        </button>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "ai",
    "chat",
    "scroll",
    "agent"
  ],
  "type": "registry:ui"
}