{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"conversation","type":"registry:ui","title":"Conversation","description":"The scrolling part of a chat with an AI assistant: it follows the newest message while it streams, inside a panel that keeps its height.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/lib-motion.json","https://dashral.com/r/button.json","https://dashral.com/r/empty-state.json","https://dashral.com/r/scroll-shadow.json"],"files":[{"path":"registry/dashral/conversation.tsx","type":"registry:file","target":"components/dashral/conversation.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDown } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { reducedMotion } from \"./lib/motion\"\nimport { cn } from \"./lib/utils\"\nimport { Button } from \"./button\"\nimport { EmptyState, type EmptyStateProps } from \"./empty-state\"\nimport { ScrollShadow } from \"./scroll-shadow\"\n\ntype ConversationEmpty = Pick<EmptyStateProps, \"icon\" | \"title\" | \"description\" | \"actions\">\n\ntype ConversationProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The messages, oldest first: Message, or anything of your own. */\n  children?: React.ReactNode\n  /** What shows in the middle while there are no messages: EmptyState's props, or your own content. */\n  empty?: ConversationEmpty | React.ReactNode\n  /** Keeps the newest message in view while it streams, until the reader scrolls up. On by default. */\n  stickToBottom?: boolean\n  /** How far the top and bottom edges fade while there's more to scroll that way, in px. */\n  fade?: number\n  /** Show the scrollbar as well as the fade. */\n  scrollbar?: boolean\n  /** Names the conversation for screen readers. \"Conversation\" by default. */\n  \"aria-label\"?: string\n  classNames?: { viewport?: string; content?: string; jump?: string }\n}\n\n/** How close to the bottom (in px) still counts as being at the bottom. */\nconst NEAR_BOTTOM = 48\n\nconst isEmptyProps = (value: unknown): value is ConversationEmpty =>\n  typeof value === \"object\" && value !== null && !React.isValidElement(value) && !Array.isArray(value)\n\n/**\n * The scrolling part of a chat with an assistant. Give it a fixed height (a flex column's remaining space, say) and\n * it scrolls inside, its edges fading while there's more above or below: the newest message stays in view while it streams, until the reader scrolls up to read, when\n * \"Jump to latest\" appears. Sending a message brings the bottom back. With no messages, `empty` fills the middle.\n */\nfunction Conversation({ children, empty, stickToBottom = true, fade = 32, scrollbar = false, className, classNames, \"aria-label\": ariaLabel, ...props }: ConversationProps) {\n  const { t } = useLocale()\n  const viewport = React.useRef<HTMLElement>(null)\n  const content = React.useRef<HTMLDivElement>(null)\n  const [atBottom, setAtBottom] = React.useState(true)\n  // Read inside the observer, which keeps its first closure.\n  const following = React.useRef(true)\n  const lastTop = React.useRef(0)\n  const hasMessages = React.Children.toArray(children).length > 0\n\n  const scrollToBottom = React.useCallback((smooth: boolean) => {\n    const el = viewport.current\n    if (!el) return\n    el.scrollTo({ top: el.scrollHeight, behavior: smooth && !reducedMotion() ? \"smooth\" : \"auto\" })\n  }, [])\n\n  // As the content grows (a new message, words streaming in), follow it while the reader is at the bottom; a message\n  // of your own brings the bottom back wherever you were.\n  React.useLayoutEffect(() => {\n    const el = content.current\n    // An empty state reads from its top: following the bottom only starts with the first message.\n    if (!el || !stickToBottom || !hasMessages) return\n    let count = el.childElementCount\n    const observer = new ResizeObserver(() => {\n      const added = el.childElementCount > count\n      count = el.childElementCount\n      // Your own message: the last child is a Message from you (or wraps one).\n      const last = el.lastElementChild\n      const sentByYou = added && Boolean(last?.matches('[data-from=\"user\"]') || last?.firstElementChild?.matches('[data-from=\"user\"]'))\n      if (following.current || sentByYou) {\n        following.current = true\n        setAtBottom(true)\n        scrollToBottom(false)\n      }\n    })\n    observer.observe(el)\n    scrollToBottom(false)\n    return () => observer.disconnect()\n  }, [stickToBottom, scrollToBottom, hasMessages])\n\n  // Only scrolling up stops the following: scrolling down (yours, or the composer's own smooth jump) never does,\n  // and reaching the bottom starts it again.\n  function onScroll() {\n    const el = viewport.current\n    if (!el) return\n    const near = el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM\n    if (near) following.current = true\n    else if (el.scrollTop < lastTop.current) following.current = false\n    lastTop.current = el.scrollTop\n    setAtBottom(near)\n  }\n\n  return (\n    <div data-slot=\"conversation\" className={cn(\"relative flex min-h-0 flex-1 flex-col\", className)} {...props}>\n      {/* Its edges fade while there's more to scroll that way: the fade says the conversation goes on. */}\n      <ScrollShadow\n        ref={viewport}\n        onScroll={onScroll}\n        size={fade}\n        scrollbar={scrollbar}\n        data-slot=\"conversation-viewport\"\n        // A scroll area with the keyboard too (arrow keys, Page Up/Down) when nothing inside has focus.\n        tabIndex={hasMessages ? 0 : -1}\n        className={cn(\"min-h-0 flex-1 overscroll-contain outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset\", classNames?.viewport)}\n      >\n        <div\n          ref={content}\n          role=\"log\"\n          aria-label={ariaLabel ?? t(\"conversation\")}\n          className={cn(\"flex min-h-full flex-col gap-6 p-4\", !hasMessages && \"justify-center\", classNames?.content)}\n        >\n          {hasMessages\n            ? children\n            : isEmptyProps(empty)\n              ? <EmptyState variant=\"plain\" titleAs=\"h2\" {...empty} />\n              : empty}\n        </div>\n      </ScrollShadow>\n      {stickToBottom && hasMessages && !atBottom && (\n        <Button\n          type=\"button\"\n          variant=\"outline\"\n          size=\"icon-sm\"\n          aria-label={t(\"jumpToLatest\")}\n          onClick={() => {\n            following.current = true\n            setAtBottom(true)\n            scrollToBottom(true)\n          }}\n          className={cn(\n            \"absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full bg-background shadow-md animate-in fade-in-0 zoom-in-95 duration-(--duration-fast)\",\n            classNames?.jump\n          )}\n        >\n          <ArrowDown aria-hidden />\n        </Button>\n      )}\n    </div>\n  )\n}\n\nexport { Conversation, type ConversationProps, type ConversationEmpty }\n"}],"cssVars":{"theme":{"ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{}}}