{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "conversation-thread",
  "title": "Conversation Thread",
  "description": "Compound component family for AI chat UIs that orchestrates auto-scroll, streaming indicators, empty states, and message rendering.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/conversation-thread/conversation-thread.tsx",
      "content": "\"use client\";\n\nimport {\n  createContext,\n  forwardRef,\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { ArrowDown, RefreshCw, ThumbsDown, ThumbsUp } from \"lucide-react\";\n\nimport { cn } from \"@vllnt/ui\";\nimport { ThinkingBlock } from \"@vllnt/ui\";\n\n/** A single tool call made by the assistant. */\nexport type ToolCall = {\n  id: string;\n  input?: Record<string, unknown>;\n  name: string;\n  result?: string;\n};\n\n/** A single message in the conversation. */\nexport type ConversationMessage = {\n  content: string;\n  id: string;\n  /** Whether the assistant still streams this individual message. */\n  isStreaming?: boolean;\n  role: \"assistant\" | \"user\";\n  /** AI reasoning/thinking content. The component renders this via ThinkingBlock. */\n  thinking?: string;\n  toolCalls?: ToolCall[];\n};\n\nexport type ConversationThreadProps = {\n  children?: ReactNode;\n  className?: string;\n  /** Whether the assistant generates a response. */\n  isStreaming?: boolean;\n  messages: ConversationMessage[];\n  onFeedback?: (messageId: string, feedback: \"negative\" | \"positive\") => void;\n  onRetry?: (messageId: string) => void;\n  /** Calls onSend with the suggestion text after the user clicks a ConversationSuggestions chip. */\n  onSend?: (message: string) => void;\n};\n\nexport type ConversationHeaderProps = {\n  children?: ReactNode;\n  className?: string;\n};\n\nexport type ConversationTitleProps = {\n  children?: ReactNode;\n  className?: string;\n};\n\nexport type ConversationMessagesProps = {\n  /** Overlay children: ConversationEmpty, ConversationScrollButton, ConversationLoading. */\n  children?: ReactNode;\n  className?: string;\n};\n\nexport type ConversationEmptyProps = {\n  children?: ReactNode;\n  className?: string;\n};\n\nexport type ConversationSuggestionsProps = {\n  className?: string;\n  suggestions?: string[];\n};\n\nexport type ConversationScrollButtonProps = {\n  className?: string;\n};\n\nexport type ConversationLoadingProps = {\n  className?: string;\n};\n\n// ---- Context ----\n\ntype ConversationThreadContextValue = {\n  handleScroll: () => void;\n  isAtBottom: boolean;\n  isStreaming: boolean;\n  messages: ConversationMessage[];\n  messagesEndRef: RefObject<HTMLDivElement | null>;\n  onFeedback?: (messageId: string, feedback: \"negative\" | \"positive\") => void;\n  onRetry?: (messageId: string) => void;\n  onSend?: (message: string) => void;\n  scrollContainerRef: RefObject<HTMLDivElement | null>;\n  scrollToBottom: () => void;\n};\n\nconst ConversationThreadContext =\n  createContext<ConversationThreadContextValue | null>(null);\n\nfunction useConversationThreadContext(): ConversationThreadContextValue {\n  const ctx = useContext(ConversationThreadContext);\n  if (!ctx) {\n    throw new Error(\n      \"ConversationThread compound components must be used within <ConversationThread>\",\n    );\n  }\n  return ctx;\n}\n\n// ---- Internal message item ----\n\ntype MessageActionsProps = {\n  messageId: string;\n};\n\nfunction MessageActions({ messageId }: MessageActionsProps) {\n  const { onFeedback, onRetry } = useConversationThreadContext();\n\n  return (\n    <div className=\"mt-2 flex items-center gap-1\">\n      {onRetry ? (\n        <button\n          aria-label=\"Retry message\"\n          className=\"rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n          onClick={() => {\n            onRetry(messageId);\n          }}\n          type=\"button\"\n        >\n          <RefreshCw className=\"size-3\" />\n        </button>\n      ) : null}\n      {onFeedback ? (\n        <>\n          <button\n            aria-label=\"Positive feedback\"\n            className=\"rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n            onClick={() => {\n              onFeedback(messageId, \"positive\");\n            }}\n            type=\"button\"\n          >\n            <ThumbsUp className=\"size-3\" />\n          </button>\n          <button\n            aria-label=\"Negative feedback\"\n            className=\"rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n            onClick={() => {\n              onFeedback(messageId, \"negative\");\n            }}\n            type=\"button\"\n          >\n            <ThumbsDown className=\"size-3\" />\n          </button>\n        </>\n      ) : null}\n    </div>\n  );\n}\n\ntype MessageItemProps = {\n  message: ConversationMessage;\n};\n\nfunction MessageItem({ message }: MessageItemProps) {\n  const isUser = message.role === \"user\";\n\n  return (\n    <div\n      className={cn(\n        \"mb-4 flex gap-3\",\n        isUser ? \"justify-end\" : \"justify-start\",\n      )}\n    >\n      <div\n        className={cn(\n          \"max-w-[80%] rounded-2xl px-4 py-3 text-sm\",\n          isUser\n            ? \"rounded-br-sm bg-primary text-primary-foreground\"\n            : \"rounded-bl-sm bg-muted text-foreground\",\n        )}\n      >\n        {!isUser && message.thinking ? (\n          <ThinkingBlock\n            isStreaming={message.isStreaming}\n            thinking={message.thinking}\n          />\n        ) : null}\n        {message.toolCalls && message.toolCalls.length > 0 ? (\n          <ul\n            aria-label=\"Tool calls\"\n            className=\"mb-2 flex flex-col gap-1 text-xs text-muted-foreground\"\n          >\n            {message.toolCalls.map((toolCall) => (\n              <li className=\"font-mono\" key={toolCall.id}>\n                {toolCall.name}\n              </li>\n            ))}\n          </ul>\n        ) : null}\n        <p className=\"whitespace-pre-wrap leading-relaxed\">{message.content}</p>\n        {isUser ? null : <MessageActions messageId={message.id} />}\n      </div>\n    </div>\n  );\n}\n\n// ---- Scroll hook ----\n\nfunction useConversationScroll(\n  messages: ConversationMessage[],\n  isStreaming: boolean,\n) {\n  const scrollContainerRef = useRef<HTMLDivElement>(null);\n  const messagesEndRef = useRef<HTMLDivElement>(null);\n  const isAtBottomRef = useRef(true);\n  const [isAtBottom, setIsAtBottom] = useState(true);\n\n  const scrollToBottom = useCallback(() => {\n    const element = messagesEndRef.current;\n    if (element && typeof element.scrollIntoView === \"function\") {\n      element.scrollIntoView({ behavior: \"smooth\" });\n    }\n  }, []);\n\n  const scrollToBottomInstant = useCallback(() => {\n    const element = messagesEndRef.current;\n    if (element && typeof element.scrollIntoView === \"function\") {\n      element.scrollIntoView({ behavior: \"instant\" });\n    }\n  }, []);\n\n  const handleScroll = useCallback(() => {\n    const container = scrollContainerRef.current;\n    if (!container) return;\n    const { clientHeight, scrollHeight, scrollTop } = container;\n    const nearBottom = scrollHeight - scrollTop - clientHeight <= 100;\n    isAtBottomRef.current = nearBottom;\n    setIsAtBottom(nearBottom);\n  }, []);\n\n  useEffect(() => {\n    if (!isAtBottomRef.current) return;\n    scrollToBottomInstant();\n  }, [messages, scrollToBottomInstant]);\n\n  useEffect(() => {\n    if (!isStreaming || !isAtBottomRef.current) return;\n    scrollToBottomInstant();\n  }, [isStreaming, scrollToBottomInstant]);\n\n  return {\n    handleScroll,\n    isAtBottom,\n    messagesEndRef,\n    scrollContainerRef,\n    scrollToBottom,\n  };\n}\n\n// ---- Root ----\n\n/**\n * Root provider for the ConversationThread compound component family.\n *\n * @example\n * ```tsx\n * <ConversationThread messages={messages} isStreaming={isStreaming} onSend={handleSend}>\n *   <ConversationHeader><ConversationTitle>Chat</ConversationTitle></ConversationHeader>\n *   <ConversationMessages>\n *     <ConversationEmpty>\n *       <ConversationSuggestions suggestions={[\"Hello!\", \"Help me with...\"]} />\n *     </ConversationEmpty>\n *     <ConversationScrollButton />\n *     <ConversationLoading />\n *   </ConversationMessages>\n * </ConversationThread>\n * ```\n */\nexport const ConversationThread = forwardRef<\n  HTMLDivElement,\n  ConversationThreadProps\n>(\n  (\n    {\n      children,\n      className,\n      isStreaming = false,\n      messages,\n      onFeedback,\n      onRetry,\n      onSend,\n    },\n    reference,\n  ) => {\n    const {\n      handleScroll,\n      isAtBottom,\n      messagesEndRef,\n      scrollContainerRef,\n      scrollToBottom,\n    } = useConversationScroll(messages, isStreaming);\n\n    const contextValue = useMemo<ConversationThreadContextValue>(\n      () => ({\n        handleScroll,\n        isAtBottom,\n        isStreaming,\n        messages,\n        messagesEndRef,\n        onFeedback,\n        onRetry,\n        onSend,\n        scrollContainerRef,\n        scrollToBottom,\n      }),\n      [\n        handleScroll,\n        isAtBottom,\n        isStreaming,\n        messages,\n        messagesEndRef,\n        onFeedback,\n        onRetry,\n        onSend,\n        scrollContainerRef,\n        scrollToBottom,\n      ],\n    );\n\n    return (\n      <ConversationThreadContext.Provider value={contextValue}>\n        <div\n          className={cn(\"flex h-full flex-col overflow-hidden\", className)}\n          ref={reference}\n        >\n          {children}\n        </div>\n      </ConversationThreadContext.Provider>\n    );\n  },\n);\nConversationThread.displayName = \"ConversationThread\";\n\n// ---- Compound components ----\n\n/** Optional header slot, rendered above the message list. */\nexport const ConversationHeader = forwardRef<\n  HTMLDivElement,\n  ConversationHeaderProps\n>(({ children, className }, reference) => {\n  return (\n    <div\n      className={cn(\"flex shrink-0 items-center border-b px-4 py-3\", className)}\n      ref={reference}\n    >\n      {children}\n    </div>\n  );\n});\nConversationHeader.displayName = \"ConversationHeader\";\n\n/** Title text for use inside ConversationHeader. */\nexport const ConversationTitle = forwardRef<\n  HTMLHeadingElement,\n  ConversationTitleProps\n>(({ children, className }, reference) => {\n  return (\n    <h2\n      className={cn(\"text-sm font-semibold leading-none\", className)}\n      ref={reference}\n    >\n      {children}\n    </h2>\n  );\n});\nConversationTitle.displayName = \"ConversationTitle\";\n\n/**\n * Scrollable message list container. Renders messages from context.\n * Pass ConversationEmpty, ConversationScrollButton, and ConversationLoading as children —\n * the component renders these as absolute overlays that read state from context.\n */\nexport const ConversationMessages = forwardRef<\n  HTMLDivElement,\n  ConversationMessagesProps\n>(({ children, className }, reference) => {\n  const { handleScroll, messages, messagesEndRef, scrollContainerRef } =\n    useConversationThreadContext();\n\n  return (\n    <div className={cn(\"relative min-h-0 flex-1\", className)} ref={reference}>\n      <div\n        aria-label=\"Conversation messages\"\n        aria-live=\"polite\"\n        className=\"absolute inset-0 overflow-y-auto\"\n        onScroll={handleScroll}\n        ref={scrollContainerRef}\n        role=\"log\"\n      >\n        <div className=\"flex flex-col p-4\">\n          {messages.map((message) => (\n            <MessageItem key={message.id} message={message} />\n          ))}\n          <div aria-hidden=\"true\" ref={messagesEndRef} />\n        </div>\n      </div>\n      {children}\n    </div>\n  );\n});\nConversationMessages.displayName = \"ConversationMessages\";\n\n/**\n * Shown when the message list is empty. Hides automatically once messages exist.\n * Renders as a centered overlay — pass ConversationSuggestions or custom content as children.\n */\nexport const ConversationEmpty = forwardRef<\n  HTMLDivElement,\n  ConversationEmptyProps\n>(({ children, className }, reference) => {\n  const { messages } = useConversationThreadContext();\n\n  if (messages.length > 0) return null;\n\n  return (\n    <div\n      className={cn(\n        \"pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-4 p-8\",\n        className,\n      )}\n      ref={reference}\n    >\n      <div className=\"pointer-events-auto flex flex-col items-center gap-4\">\n        {children}\n      </div>\n    </div>\n  );\n});\nConversationEmpty.displayName = \"ConversationEmpty\";\n\n/** Suggested prompt chips displayed in the empty state. Calls onSend when clicked. */\nexport const ConversationSuggestions = forwardRef<\n  HTMLDivElement,\n  ConversationSuggestionsProps\n>(({ className, suggestions = [] }, reference) => {\n  const { onSend } = useConversationThreadContext();\n\n  return (\n    <div\n      className={cn(\"flex flex-wrap justify-center gap-2\", className)}\n      ref={reference}\n    >\n      {suggestions.map((suggestion) => (\n        <button\n          className=\"rounded-full border bg-background px-4 py-2 text-sm transition-colors hover:bg-muted\"\n          key={suggestion}\n          onClick={() => onSend?.(suggestion)}\n          type=\"button\"\n        >\n          {suggestion}\n        </button>\n      ))}\n    </div>\n  );\n});\nConversationSuggestions.displayName = \"ConversationSuggestions\";\n\n/** Floating button that appears when the user scrolls up, to jump back to the bottom. */\nexport const ConversationScrollButton = forwardRef<\n  HTMLButtonElement,\n  ConversationScrollButtonProps\n>(({ className }, reference) => {\n  const { isAtBottom, scrollToBottom } = useConversationThreadContext();\n\n  if (isAtBottom) return null;\n\n  return (\n    <button\n      aria-label=\"Scroll to bottom\"\n      className={cn(\n        \"absolute bottom-4 right-4 flex size-8 items-center justify-center rounded-full border bg-background shadow-md transition-colors hover:bg-muted\",\n        className,\n      )}\n      onClick={scrollToBottom}\n      ref={reference}\n      type=\"button\"\n    >\n      <ArrowDown className=\"size-4\" />\n    </button>\n  );\n});\nConversationScrollButton.displayName = \"ConversationScrollButton\";\n\n/**\n * Typing indicator shown while the assistant is streaming a response.\n * Visible when isStreaming is true and the last message role is \"assistant\".\n */\nexport const ConversationLoading = forwardRef<\n  HTMLDivElement,\n  ConversationLoadingProps\n>(({ className }, reference) => {\n  const { isStreaming, messages } = useConversationThreadContext();\n  const lastMessage = messages.at(-1);\n\n  if (!isStreaming || lastMessage?.role !== \"assistant\") return null;\n\n  return (\n    <div\n      aria-label=\"Assistant is typing\"\n      className={cn(\n        \"absolute bottom-4 left-4 flex items-center gap-1\",\n        className,\n      )}\n      ref={reference}\n      role=\"status\"\n    >\n      <span\n        className=\"size-2 animate-pulse rounded-full bg-muted-foreground\"\n        style={{ animationDelay: \"-0.3s\" }}\n      />\n      <span\n        className=\"size-2 animate-pulse rounded-full bg-muted-foreground\"\n        style={{ animationDelay: \"-0.15s\" }}\n      />\n      <span className=\"size-2 animate-pulse rounded-full bg-muted-foreground\" />\n    </div>\n  );\n});\nConversationLoading.displayName = \"ConversationLoading\";\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component",
  "version": "0.2.1",
  "stability": "stable"
}
