{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-message",
  "title": "AI Message",
  "description": "Display AI messages with markdown support and code highlighting.",
  "dependencies": [
    "react-markdown",
    "remark-gfm",
    "shiki",
    "next-themes"
  ],
  "registryDependencies": [
    "button",
    "dropdown-menu"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/ai/ai-message.tsx",
      "content": "\"use client\";\n\nimport { Copy, Edit, MoreVertical, RotateCcw } from \"lucide-react\";\nimport { useTheme } from \"next-themes\";\nimport React, {\n  startTransition,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport ReactMarkdown from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\nimport { type BundledLanguage, codeToHtml } from \"shiki\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/registry/new-york/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuTrigger,\n} from \"@/registry/new-york/ui/dropdown-menu\";\n\nconst STREAMING_CONFIG = {\n  CHAR_DELAY: 20,\n  BATCH_SIZE: 3,\n} as const;\n\nconst COPY_FEEDBACK_DURATION = 2000;\n\ninterface AIMessageProps {\n  content: string;\n  isStreaming?: boolean;\n  onRegenerate?: () => void;\n  onEdit?: () => void;\n  className?: string;\n  skipCodeHighlighting?: boolean;\n}\n\nfunction useStreamingText(fullText: string, isStreaming: boolean): string {\n  const [displayedText, setDisplayedText] = useState(() =>\n    isStreaming ? \"\" : fullText\n  );\n  const timeoutRef = useRef<NodeJS.Timeout | null>(null);\n\n  useEffect(() => {\n    let initialTimeoutId: NodeJS.Timeout | null = null;\n\n    if (!isStreaming) {\n      initialTimeoutId = setTimeout(() => {\n        setDisplayedText(fullText);\n      }, 0);\n      return () => {\n        if (initialTimeoutId) {\n          clearTimeout(initialTimeoutId);\n        }\n      };\n    }\n\n    if (typeof window !== \"undefined\") {\n      const prefersReducedMotion = window.matchMedia(\n        \"(prefers-reduced-motion: reduce)\"\n      ).matches;\n\n      if (prefersReducedMotion) {\n        initialTimeoutId = setTimeout(() => {\n          setDisplayedText(fullText);\n        }, 0);\n        return () => {\n          if (initialTimeoutId) {\n            clearTimeout(initialTimeoutId);\n          }\n        };\n      }\n    }\n\n    initialTimeoutId = setTimeout(() => {\n      setDisplayedText(\"\");\n    }, 0);\n    let currentIndex = 0;\n\n    const streamNext = () => {\n      if (currentIndex >= fullText.length) {\n        return;\n      }\n\n      const nextIndex = Math.min(\n        currentIndex + STREAMING_CONFIG.BATCH_SIZE,\n        fullText.length\n      );\n      setDisplayedText(fullText.slice(0, nextIndex));\n      currentIndex = nextIndex;\n\n      timeoutRef.current = setTimeout(streamNext, STREAMING_CONFIG.CHAR_DELAY);\n    };\n\n    streamNext();\n\n    return () => {\n      if (initialTimeoutId) {\n        clearTimeout(initialTimeoutId);\n      }\n      if (timeoutRef.current) {\n        clearTimeout(timeoutRef.current);\n      }\n    };\n  }, [fullText, isStreaming]);\n\n  return displayedText;\n}\n\nfunction parseInlineStyle(styleText: string): React.CSSProperties {\n  const styleObject: React.CSSProperties = {};\n  for (const declaration of styleText.split(\";\")) {\n    const [prop, value] = declaration.split(\":\").map((s) => s.trim());\n    if (prop && value) {\n      const camelProp = prop.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());\n      (styleObject as Record<string, string>)[camelProp] = value;\n    }\n  }\n  return styleObject;\n}\n\nfunction domNodeToReact(\n  node: ChildNode,\n  key?: string | number\n): React.ReactNode {\n  if (node.nodeType === Node.TEXT_NODE) {\n    return node.textContent;\n  }\n  if (node.nodeType !== Node.ELEMENT_NODE) {\n    return null;\n  }\n\n  const el = node as HTMLElement;\n  const tagName = el.tagName.toLowerCase();\n  const isCodeBlock = tagName === \"pre\" || tagName === \"code\";\n\n  const props: Record<string, any> = { key };\n\n  if (el.className) {\n    props.className = isCodeBlock\n      ? `${el.className} whitespace-pre-wrap wrap-break-word`.trim()\n      : el.className.trim();\n  } else if (isCodeBlock) {\n    props.className = \"whitespace-pre-wrap wrap-break-word\";\n  }\n\n  const styleAttr = el.getAttribute(\"style\");\n  if (styleAttr) {\n    props.style = parseInlineStyle(styleAttr);\n  }\n\n  for (const attr of el.attributes) {\n    if (attr.name.startsWith(\"data-\")) {\n      props[attr.name] = attr.value;\n    }\n  }\n\n  const children = Array.from(el.childNodes).map((child, i) =>\n    domNodeToReact(child, i)\n  );\n\n  return React.createElement(tagName, props, ...children);\n}\n\ninterface CodeBlockHeaderProps {\n  filename?: string;\n}\n\nfunction CodeBlockHeader({ filename }: CodeBlockHeaderProps) {\n  if (!filename) return null;\n\n  return (\n    <div className=\"flex items-center border-border border-b bg-muted/30 px-4 py-2\">\n      <span className=\"font-mono text-muted-foreground text-sm\">\n        {filename}\n      </span>\n    </div>\n  );\n}\n\ninterface CodeBlockSkeletonProps {\n  filename?: string;\n}\n\nfunction CodeBlockSkeleton({ filename }: CodeBlockSkeletonProps) {\n  return (\n    <div className=\"rounded-lg border border-border bg-background\">\n      <CodeBlockHeader filename={filename} />\n      <div className=\"p-4\">\n        <div className=\"flex animate-pulse flex-col gap-2\">\n          <div className=\"h-4 rounded bg-muted\" />\n          <div className=\"h-4 w-3/4 rounded bg-muted\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n\ninterface CodeBlockFallbackProps {\n  code: string;\n  filename?: string;\n}\n\nfunction CodeBlockFallback({ code, filename }: CodeBlockFallbackProps) {\n  return (\n    <div className=\"rounded-lg border border-border bg-background\">\n      <CodeBlockHeader filename={filename} />\n      <pre className=\"overflow-x-auto p-4\">\n        <code className=\"wrap-break-word whitespace-pre-wrap font-mono text-sm\">\n          {code}\n        </code>\n      </pre>\n    </div>\n  );\n}\n\ninterface HighlightedCodeBlockProps {\n  code: string;\n  language: string;\n  filename?: string;\n  skipHighlighting?: boolean;\n}\n\nfunction HighlightedCodeBlock({\n  code,\n  language,\n  filename,\n  skipHighlighting = false,\n}: HighlightedCodeBlockProps) {\n  const { theme: websiteTheme } = useTheme();\n  const [highlightedCode, setHighlightedCode] = useState<string>(\"\");\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState(false);\n\n  const theme = useMemo(\n    () => (websiteTheme === \"dark\" ? \"github-dark\" : \"github-light\"),\n    [websiteTheme]\n  );\n  const lang = useMemo(\n    () => (language || \"text\") as BundledLanguage,\n    [language]\n  );\n\n  useEffect(() => {\n    if (skipHighlighting) {\n      startTransition(() => {\n        setHighlightedCode(\"\");\n        setLoading(false);\n        setError(false);\n      });\n      return;\n    }\n\n    let mounted = true;\n    startTransition(() => {\n      setLoading(true);\n      setError(false);\n    });\n\n    const highlightCode = async () => {\n      try {\n        const html = await codeToHtml(code, {\n          lang,\n          theme,\n        });\n        if (mounted) {\n          setHighlightedCode(html);\n          setLoading(false);\n          setError(false);\n        }\n      } catch {\n        if (mounted) {\n          setHighlightedCode(\"\");\n          setLoading(false);\n          setError(true);\n        }\n      }\n    };\n\n    highlightCode();\n\n    return () => {\n      mounted = false;\n    };\n  }, [code, lang, theme, skipHighlighting]);\n\n  if (skipHighlighting || error) {\n    return <CodeBlockFallback code={code} filename={filename} />;\n  }\n\n  if (loading) {\n    return <CodeBlockSkeleton filename={filename} />;\n  }\n\n  if (!highlightedCode) {\n    return <CodeBlockFallback code={code} filename={filename} />;\n  }\n\n  const parser = new DOMParser();\n  const doc = parser.parseFromString(highlightedCode, \"text/html\");\n  const pre = doc.body.querySelector(\"pre\");\n\n  if (!pre) {\n    return <CodeBlockFallback code={code} filename={filename} />;\n  }\n\n  return (\n    <div className=\"rounded-lg border border-border bg-background\">\n      <CodeBlockHeader filename={filename} />\n      <div className=\"overflow-x-auto\">{domNodeToReact(pre)}</div>\n    </div>\n  );\n}\n\ninterface MarkdownContentProps {\n  content: string;\n  skipCodeHighlighting?: boolean;\n}\n\nfunction MarkdownContent({\n  content,\n  skipCodeHighlighting = false,\n}: MarkdownContentProps) {\n  const codeComponent = useCallback(\n    ({\n      node,\n      className,\n      children,\n      ...props\n    }: {\n      node?: any;\n      className?: string;\n      children?: React.ReactNode;\n    }) => {\n      const match = /language-(\\w+)/.exec(className || \"\");\n      const language = match ? match[1] : \"\";\n      const codeString = String(children).replace(/\\n$/, \"\");\n\n      if (language) {\n        return (\n          <HighlightedCodeBlock\n            code={codeString}\n            language={language}\n            skipHighlighting={skipCodeHighlighting}\n          />\n        );\n      }\n\n      return (\n        <code\n          className={cn(\n            \"relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm\",\n            className\n          )}\n          {...props}\n        >\n          {children}\n        </code>\n      );\n    },\n    [skipCodeHighlighting]\n  );\n\n  const linkComponent = useCallback(\n    ({\n      href,\n      children,\n      ...props\n    }: {\n      href?: string;\n      children?: React.ReactNode;\n    }) => (\n      <a href={href} rel=\"noopener noreferrer\" target=\"_blank\" {...props}>\n        {children}\n      </a>\n    ),\n    []\n  );\n\n  const components = useMemo(\n    () => ({\n      code: codeComponent,\n      a: linkComponent,\n    }),\n    [codeComponent, linkComponent]\n  );\n\n  return (\n    <div className=\"prose prose-sm dark:prose-invert max-w-none\">\n      <ReactMarkdown components={components} remarkPlugins={[remarkGfm]}>\n        {content}\n      </ReactMarkdown>\n    </div>\n  );\n}\n\ninterface MessageActionsProps {\n  onCopy: () => void;\n  onRegenerate?: () => void;\n  onEdit?: () => void;\n  copied: boolean;\n  messageId?: string;\n}\n\nfunction MessageActions({\n  onCopy,\n  onRegenerate,\n  onEdit,\n  copied,\n  messageId,\n}: MessageActionsProps) {\n  const [isOpen, setIsOpen] = useState(false);\n  const hasActions = onRegenerate || onEdit;\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    if (!(isOpen && hasActions)) return;\n\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (\n        e.target instanceof HTMLInputElement ||\n        e.target instanceof HTMLTextAreaElement\n      ) {\n        return;\n      }\n\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        setIsOpen(false);\n      } else if ((e.metaKey || e.ctrlKey) && e.key === \"c\" && !e.shiftKey) {\n        e.preventDefault();\n        onCopy();\n        setIsOpen(false);\n      }\n    };\n\n    document.addEventListener(\"keydown\", handleKeyDown);\n    return () => {\n      document.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  }, [isOpen, hasActions, onCopy]);\n\n  const handleCopyClick = useCallback(() => {\n    onCopy();\n    if (isOpen) {\n      setIsOpen(false);\n    }\n  }, [onCopy, isOpen]);\n\n  if (!hasActions) {\n    return (\n      <div className=\"flex items-center\" ref={containerRef}>\n        <Button\n          aria-label={copied ? \"Message copied to clipboard\" : \"Copy message\"}\n          aria-live=\"polite\"\n          className=\"min-h-[32px] min-w-[32px] opacity-0 transition-opacity duration-200 focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100\"\n          onClick={handleCopyClick}\n          variant=\"ghost\"\n        >\n          <Copy aria-hidden=\"true\" className=\"size-4\" />\n          <span className=\"sr-only\">{copied ? \"Copied\" : \"Copy\"}</span>\n          <span aria-hidden=\"true\" className=\"text-xs\">\n            {copied ? \"Copied!\" : \"Copy\"}\n          </span>\n        </Button>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex items-center\" ref={containerRef}>\n      <DropdownMenu onOpenChange={setIsOpen} open={isOpen}>\n        <DropdownMenuTrigger asChild>\n          <Button\n            aria-expanded={isOpen}\n            aria-haspopup=\"menu\"\n            aria-label=\"Message actions menu\"\n            className=\"min-h-[32px] min-w-[32px] opacity-0 transition-opacity duration-200 focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100\"\n            size=\"icon\"\n            variant=\"ghost\"\n          >\n            <MoreVertical aria-hidden=\"true\" className=\"size-4\" />\n            <span className=\"sr-only\">Open message actions menu</span>\n          </Button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent\n          align=\"end\"\n          aria-label=\"Message actions\"\n          className=\"font-medium\"\n        >\n          <DropdownMenuItem onClick={handleCopyClick}>\n            <Copy aria-hidden=\"true\" className=\"size-4\" />\n            <span>{copied ? \"Copied!\" : \"Copy\"}</span>\n            <DropdownMenuShortcut>\n              {typeof navigator !== \"undefined\" &&\n              navigator.platform.includes(\"Mac\")\n                ? \"⌘\"\n                : \"Ctrl\"}\n              C\n            </DropdownMenuShortcut>\n          </DropdownMenuItem>\n          {onRegenerate && (\n            <>\n              <DropdownMenuSeparator />\n              <DropdownMenuItem onClick={onRegenerate}>\n                <RotateCcw aria-hidden=\"true\" className=\"size-4\" />\n                <span>Regenerate</span>\n              </DropdownMenuItem>\n            </>\n          )}\n          {onEdit && (\n            <>\n              <DropdownMenuSeparator />\n              <DropdownMenuItem onClick={onEdit}>\n                <Edit aria-hidden=\"true\" className=\"size-4\" />\n                <span>Edit</span>\n              </DropdownMenuItem>\n            </>\n          )}\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </div>\n  );\n}\n\ninterface StreamingIndicatorProps {\n  isStreaming: boolean;\n}\n\nfunction StreamingIndicator({ isStreaming }: StreamingIndicatorProps) {\n  if (!isStreaming) return null;\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      aria-label=\"Streaming content\"\n      className=\"inline-block h-4 w-0.5 bg-foreground motion-safe:animate-pulse\"\n    />\n  );\n}\n\nexport default function AIMessage({\n  content,\n  isStreaming = false,\n  onRegenerate,\n  onEdit,\n  className,\n  skipCodeHighlighting = false,\n}: AIMessageProps) {\n  const [copied, setCopied] = useState(false);\n  const [copyError, setCopyError] = useState(false);\n  const messageRef = useRef<HTMLDivElement>(null);\n  const messageId = useId();\n  const displayedContent = useStreamingText(content, isStreaming);\n\n  const handleCopy = useCallback(async () => {\n    try {\n      await navigator.clipboard.writeText(content);\n      setCopied(true);\n      setCopyError(false);\n      setTimeout(() => {\n        setCopied(false);\n      }, COPY_FEEDBACK_DURATION);\n    } catch {\n      setCopyError(true);\n      setTimeout(() => {\n        setCopyError(false);\n      }, COPY_FEEDBACK_DURATION);\n    }\n  }, [content]);\n\n  useEffect(() => {\n    if (!messageRef.current) return;\n\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (\n        e.target instanceof HTMLInputElement ||\n        e.target instanceof HTMLTextAreaElement\n      ) {\n        return;\n      }\n\n      if ((e.metaKey || e.ctrlKey) && e.key === \"c\" && !e.shiftKey) {\n        const selection = window.getSelection()?.toString();\n        if (\n          !selection &&\n          messageRef.current?.contains(document.activeElement)\n        ) {\n          e.preventDefault();\n          handleCopy();\n        }\n      }\n    };\n\n    const element = messageRef.current;\n    element.addEventListener(\"keydown\", handleKeyDown);\n\n    return () => {\n      element.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  }, [handleCopy]);\n\n  return (\n    <div\n      aria-atomic=\"false\"\n      aria-label=\"AI message\"\n      aria-live={isStreaming ? \"polite\" : \"off\"}\n      className={cn(\"group relative\", className)}\n      ref={messageRef}\n      role=\"article\"\n    >\n      <div aria-label=\"Message actions\" className=\"absolute top-0 right-0 z-10\">\n        <MessageActions\n          copied={copied}\n          messageId={messageId}\n          onCopy={handleCopy}\n          onEdit={onEdit}\n          onRegenerate={onRegenerate}\n        />\n      </div>\n\n      <div className=\"pr-12\">\n        <MarkdownContent\n          content={displayedContent}\n          skipCodeHighlighting={skipCodeHighlighting}\n        />\n        <StreamingIndicator isStreaming={isStreaming} />\n      </div>\n\n      {copyError && (\n        <div aria-live=\"assertive\" className=\"sr-only\" role=\"alert\">\n          Failed to copy message to clipboard\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "block",
    "ai"
  ],
  "type": "registry:ui"
}