{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-conversation",
  "title": "AI Conversation",
  "description": "Display AI conversation messages with streaming support.",
  "registryDependencies": [
    "ai-message",
    "ai-thinking",
    "button"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/ai/ai-conversation.tsx",
      "content": "\"use client\";\n\nimport { MessageCircle, Sparkles } from \"lucide-react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/registry/new-york/ui/button\";\nimport AIMessage from \"./ai-message\";\nimport AIThinking from \"./ai-thinking\";\n\nexport interface Message {\n  id: string;\n  role: \"user\" | \"assistant\";\n  content: string;\n  timestamp?: Date;\n}\n\ninterface AIConversationProps {\n  messages?: Message[];\n  isThinking?: boolean;\n  isStreaming?: boolean;\n  onRegenerate?: (messageId: string) => void;\n  onEdit?: (messageId: string) => void;\n  onNewMessage?: () => void;\n  className?: string;\n  maxHeight?: string;\n  emptyStateTitle?: string;\n  emptyStateDescription?: string;\n}\n\nconst SCROLL_THRESHOLD = 100;\nconst SCROLL_DEBOUNCE_MS = 150;\n\nfunction useAutoScroll(\n  messages: Message[],\n  isThinking: boolean,\n  isStreaming: boolean,\n  containerRef: React.RefObject<HTMLDivElement | null>\n) {\n  const shouldScrollRef = useRef(true);\n  const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n\n  useEffect(() => {\n    if (!containerRef.current) return;\n\n    const container = containerRef.current;\n    const isAtBottom =\n      container.scrollHeight - container.scrollTop <=\n      container.clientHeight + SCROLL_THRESHOLD;\n\n    shouldScrollRef.current = isAtBottom;\n  }, [messages, containerRef]);\n\n  useEffect(() => {\n    if (!(containerRef.current && shouldScrollRef.current)) return;\n\n    if (scrollTimeoutRef.current) {\n      clearTimeout(scrollTimeoutRef.current);\n    }\n\n    scrollTimeoutRef.current = setTimeout(() => {\n      if (!containerRef.current) return;\n\n      if (typeof window !== \"undefined\") {\n        const prefersReducedMotion = window.matchMedia(\n          \"(prefers-reduced-motion: reduce)\"\n        ).matches;\n\n        const scrollBehavior = prefersReducedMotion ? \"auto\" : \"smooth\";\n        containerRef.current.scrollTo({\n          top: containerRef.current.scrollHeight,\n          behavior: scrollBehavior,\n        });\n      } else {\n        containerRef.current.scrollTop = containerRef.current.scrollHeight;\n      }\n    }, SCROLL_DEBOUNCE_MS);\n\n    return () => {\n      if (scrollTimeoutRef.current) {\n        clearTimeout(scrollTimeoutRef.current);\n      }\n    };\n  }, [messages, isThinking, isStreaming, containerRef]);\n}\n\nfunction useKeyboardNavigation(\n  messages: Message[],\n  containerRef: React.RefObject<HTMLDivElement | null>\n) {\n  const [focusedIndex, setFocusedIndex] = useState<number | null>(null);\n  const messageRefs = useRef<(HTMLDivElement | null)[]>([]);\n\n  useEffect(() => {\n    messageRefs.current = messageRefs.current.slice(0, messages.length);\n  }, [messages.length]);\n\n  const handleKeyDown = useCallback(\n    (e: KeyboardEvent) => {\n      if (\n        !(\n          containerRef.current &&\n          containerRef.current.contains(document.activeElement)\n        )\n      ) {\n        return;\n      }\n\n      const target = e.target as HTMLElement;\n      if (\n        target instanceof HTMLInputElement ||\n        target instanceof HTMLTextAreaElement ||\n        target.isContentEditable\n      ) {\n        return;\n      }\n\n      switch (e.key) {\n        case \"ArrowDown\": {\n          e.preventDefault();\n          if (focusedIndex === null) {\n            setFocusedIndex(0);\n            messageRefs.current[0]?.focus();\n          } else if (focusedIndex < messages.length - 1) {\n            const nextIndex = focusedIndex + 1;\n            setFocusedIndex(nextIndex);\n            messageRefs.current[nextIndex]?.focus();\n            messageRefs.current[nextIndex]?.scrollIntoView({\n              block: \"nearest\",\n              behavior: \"smooth\",\n            });\n          }\n          break;\n        }\n        case \"ArrowUp\": {\n          e.preventDefault();\n          if (focusedIndex !== null && focusedIndex > 0) {\n            const prevIndex = focusedIndex - 1;\n            setFocusedIndex(prevIndex);\n            messageRefs.current[prevIndex]?.focus();\n            messageRefs.current[prevIndex]?.scrollIntoView({\n              block: \"nearest\",\n              behavior: \"smooth\",\n            });\n          } else {\n            setFocusedIndex(null);\n            containerRef.current?.focus();\n          }\n          break;\n        }\n        case \"Home\": {\n          e.preventDefault();\n          if (messages.length > 0) {\n            setFocusedIndex(0);\n            messageRefs.current[0]?.focus();\n            messageRefs.current[0]?.scrollIntoView({\n              block: \"start\",\n              behavior: \"smooth\",\n            });\n          }\n          break;\n        }\n        case \"End\": {\n          e.preventDefault();\n          if (messages.length > 0) {\n            const lastIndex = messages.length - 1;\n            setFocusedIndex(lastIndex);\n            messageRefs.current[lastIndex]?.focus();\n            messageRefs.current[lastIndex]?.scrollIntoView({\n              block: \"end\",\n              behavior: \"smooth\",\n            });\n          }\n          break;\n        }\n        case \"Escape\": {\n          e.preventDefault();\n          setFocusedIndex(null);\n          containerRef.current?.focus();\n          break;\n        }\n      }\n    },\n    [focusedIndex, messages.length, containerRef]\n  );\n\n  useEffect(() => {\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => {\n      window.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  }, [handleKeyDown]);\n\n  return { messageRefs, focusedIndex, setFocusedIndex };\n}\n\ninterface EmptyStateProps {\n  title?: string;\n  description?: string;\n  onNewMessage?: () => void;\n}\n\nfunction EmptyState({\n  title = \"Start a conversation\",\n  description = \"Type a message below to begin chatting with the AI assistant.\",\n  onNewMessage,\n}: EmptyStateProps) {\n  return (\n    <div\n      aria-live=\"polite\"\n      className=\"flex flex-1 flex-col items-center justify-center gap-4 p-8\"\n      role=\"status\"\n    >\n      <div className=\"flex size-16 items-center justify-center rounded-full bg-muted\">\n        <MessageCircle\n          aria-hidden=\"true\"\n          className=\"size-8 text-muted-foreground\"\n        />\n      </div>\n      <div className=\"flex flex-col items-center gap-2 text-center\">\n        <h2 className=\"font-semibold text-foreground text-lg\">{title}</h2>\n        <p className=\"max-w-md text-muted-foreground text-sm\">{description}</p>\n      </div>\n      {onNewMessage && (\n        <Button\n          aria-label=\"Start new conversation\"\n          className=\"min-h-[44px] min-w-[44px]\"\n          onClick={onNewMessage}\n          size=\"lg\"\n        >\n          <Sparkles aria-hidden=\"true\" className=\"size-4\" />\n          New Message\n        </Button>\n      )}\n    </div>\n  );\n}\n\ninterface UserMessageProps {\n  content: string;\n  messageId: string;\n  index: number;\n  isFocused: boolean;\n  messageRef: (el: HTMLDivElement | null) => void;\n}\n\nfunction UserMessage({\n  content,\n  messageId,\n  index,\n  isFocused,\n  messageRef,\n}: UserMessageProps) {\n  return (\n    <div\n      aria-label={`User message ${messageId}`}\n      className={cn(\n        \"flex justify-end rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n        isFocused && \"ring-2 ring-ring ring-offset-2\"\n      )}\n      id={`user-message-${messageId}`}\n      ref={messageRef}\n      role=\"article\"\n      tabIndex={0}\n    >\n      <div className=\"flex min-h-[44px] max-w-[80%] items-center rounded-lg rounded-tr-none bg-primary px-4 py-3 text-primary-foreground\">\n        <p className=\"wrap-break-word whitespace-pre-wrap text-sm leading-relaxed\">\n          {content}\n        </p>\n      </div>\n    </div>\n  );\n}\n\ninterface AssistantMessageProps {\n  message: Message;\n  index: number;\n  isLastMessage: boolean;\n  isStreaming: boolean;\n  isFocused: boolean;\n  messageRef: (el: HTMLDivElement | null) => void;\n  onRegenerate?: (messageId: string) => void;\n  onEdit?: (messageId: string) => void;\n}\n\nfunction AssistantMessage({\n  message,\n  index,\n  isLastMessage,\n  isStreaming,\n  isFocused,\n  messageRef,\n  onRegenerate,\n  onEdit,\n}: AssistantMessageProps) {\n  const isLastAI = isLastMessage && message.role === \"assistant\";\n\n  return (\n    <div\n      aria-label={`Assistant message ${index + 1}${isStreaming && isLastAI ? \", streaming\" : \"\"}`}\n      aria-live={isStreaming && isLastAI ? \"polite\" : \"off\"}\n      className={cn(\n        \"min-h-[44px] rounded-lg border bg-card p-6 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n        isFocused && \"ring-2 ring-ring ring-offset-2\"\n      )}\n      ref={messageRef}\n      role=\"article\"\n      tabIndex={0}\n    >\n      <AIMessage\n        className=\"shadow-none\"\n        content={message.content}\n        isStreaming={isStreaming && isLastAI}\n        onEdit={onEdit ? () => onEdit(message.id) : undefined}\n        onRegenerate={onRegenerate ? () => onRegenerate(message.id) : undefined}\n      />\n    </div>\n  );\n}\n\ninterface MessageListProps {\n  messages: Message[];\n  isStreaming: boolean;\n  messageRefs: React.MutableRefObject<(HTMLDivElement | null)[]>;\n  focusedIndex: number | null;\n  onRegenerate?: (messageId: string) => void;\n  onEdit?: (messageId: string) => void;\n}\n\nfunction MessageList({\n  messages,\n  isStreaming,\n  messageRefs,\n  focusedIndex,\n  onRegenerate,\n  onEdit,\n}: MessageListProps) {\n  return (\n    <div\n      aria-label=\"Conversation messages\"\n      aria-live=\"polite\"\n      className=\"flex flex-col gap-6 p-4\"\n      role=\"log\"\n    >\n      {messages.map((message, index) => {\n        const isLastMessage = index === messages.length - 1;\n        const isFocused = focusedIndex === index;\n\n        if (message.role === \"user\") {\n          return (\n            <UserMessage\n              content={message.content}\n              index={index}\n              isFocused={isFocused}\n              key={message.id}\n              messageId={message.id}\n              messageRef={(el) => {\n                messageRefs.current[index] = el;\n              }}\n            />\n          );\n        }\n\n        return (\n          <AssistantMessage\n            index={index}\n            isFocused={isFocused}\n            isLastMessage={isLastMessage}\n            isStreaming={isStreaming}\n            key={message.id}\n            message={message}\n            messageRef={(el) => {\n              messageRefs.current[index] = el;\n            }}\n            onEdit={onEdit}\n            onRegenerate={onRegenerate}\n          />\n        );\n      })}\n    </div>\n  );\n}\n\nexport default function AIConversation({\n  messages: initialMessages = [],\n  isThinking = false,\n  isStreaming = false,\n  onRegenerate,\n  onEdit,\n  onNewMessage,\n  className,\n  maxHeight = \"600px\",\n  emptyStateTitle,\n  emptyStateDescription,\n}: AIConversationProps) {\n  const [messages, setMessages] = useState<Message[]>(() => initialMessages);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const prevInitialMessagesRef = useRef(initialMessages);\n  const { messageRefs, focusedIndex, setFocusedIndex } = useKeyboardNavigation(\n    messages,\n    containerRef\n  );\n\n  useAutoScroll(messages, isThinking, isStreaming, containerRef);\n\n  useEffect(() => {\n    if (\n      prevInitialMessagesRef.current !== initialMessages &&\n      initialMessages.length > 0\n    ) {\n      prevInitialMessagesRef.current = initialMessages;\n      const timeoutId = setTimeout(() => {\n        setMessages(initialMessages);\n      }, 0);\n      return () => clearTimeout(timeoutId);\n    }\n  }, [initialMessages]);\n\n  const handleContainerFocus = useCallback(() => {\n    setFocusedIndex(null);\n  }, [setFocusedIndex]);\n\n  return (\n    <div\n      aria-label=\"AI conversation\"\n      className={cn(\n        \"flex flex-col overflow-hidden rounded-xl border bg-background shadow-xs\",\n        className\n      )}\n      role=\"region\"\n    >\n      <div\n        className=\"flex-1 overflow-y-auto overscroll-contain focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n        onFocus={handleContainerFocus}\n        ref={containerRef}\n        style={{ maxHeight }}\n        tabIndex={0}\n      >\n        {messages.length === 0 ? (\n          <EmptyState\n            description={emptyStateDescription}\n            onNewMessage={onNewMessage}\n            title={emptyStateTitle}\n          />\n        ) : (\n          <MessageList\n            focusedIndex={focusedIndex}\n            isStreaming={isStreaming}\n            messageRefs={messageRefs}\n            messages={messages}\n            onEdit={onEdit}\n            onRegenerate={onRegenerate}\n          />\n        )}\n        {isThinking && (\n          <div aria-live=\"polite\" className=\"px-4 pb-4\" role=\"status\">\n            <AIThinking />\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "block",
    "ai"
  ],
  "type": "registry:ui"
}