All components

AI Message

AI Message is a React and Tailwind CSS component from HextaUI, licensed MIT. Copy it and paste it into your project.

HextaUI MIT other

What it is

AI Message is a React and Tailwind CSS component from HextaUI, licensed MIT. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Five free copies a month. No card.

Needs

npm i react-markdown remark-gfm shiki next-themes

Licence

This component comes from HextaUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

AI Message

registry/new-york/blocks/ai/ai-message.tsx
"use client";

import { Copy, Edit, MoreVertical, RotateCcw } from "lucide-react";
import { useTheme } from "next-themes";
import React, {
  startTransition,
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
} from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { type BundledLanguage, codeToHtml } from "shiki";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/new-york/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuTrigger,
} from "@/registry/new-york/ui/dropdown-menu";

const STREAMING_CONFIG = {
  CHAR_DELAY: 20,
  BATCH_SIZE: 3,
} as const;

const COPY_FEEDBACK_DURATION = 2000;

interface AIMessageProps {
  content: string;
  isStreaming?: boolean;
  onRegenerate?: () => void;
  onEdit?: () => void;
  className?: string;
  skipCodeHighlighting?: boolean;
}

function useStreamingText(fullText: string, isStreaming: boolean): string {
  const [displayedText, setDisplayedText] = useState(() =>
    isStreaming ? "" : fullText
  );
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {
    let initialTimeoutId: NodeJS.Timeout | null = null;

    if (!isStreaming) {
      initialTimeoutId = setTimeout(() => {
        setDisplayedText(fullText);
      }, 0);
      return () => {
        if (initialTimeoutId) {
          clearTimeout(initialTimeoutId);
        }
      };
    }

    if (typeof window !== "undefined") {
      const prefersReducedMotion = window.matchMedia(
        "(prefers-reduced-motion: reduce)"
      ).matches;

      if (prefersReducedMotion) {
        initialTimeoutId = setTimeout(() => {
          setDisplayedText(fullText);
        }, 0);
        return () => {
          if (initialTimeoutId) {
            clearTimeout(initialTimeoutId);
          }
        };
      }
    }

    initialTimeoutId = setTimeout(() => {
      setDisplayedText("");
    }, 0);
    let currentIndex = 0;

    const streamNext = () => {
      if (currentIndex >= fullText.length) {
        return;
      }

      const nextIndex = Math.min(
        currentIndex + STREAMING_CONFIG.BATCH_SIZE,
        fullText.length
      );
      setDisplayedText(fullText.slice(0, nextIndex));
      currentIndex = nextIndex;

      timeoutRef.current = setTimeout(streamNext, STREAMING_CONFIG.CHAR_DELAY);
    };

    streamNext();

    return () => {
      if (initialTimeoutId) {
        clearTimeout(initialTimeoutId);
      }
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, [fullText, isStreaming]);

  return displayedText;
}

function parseInlineStyle(styleText: string): React.CSSProperties {
  const styleObject: React.CSSProperties = {};
  for (const declaration of styleText.split(";")) {
    const [prop, value] = declaration.split(":").map((s) => s.trim());
    if (prop && value) {
      const camelProp = prop.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
      (styleObject as Record<string, string>)[camelProp] = value;
    }
  }
  return styleObject;
}

function domNodeToReact(
  node: ChildNode,
  key?: string | number
): React.ReactNode {
  if (node.nodeType === Node.TEXT_NODE) {
    return node.textContent;
  }
  if (node.nodeType !== Node.ELEMENT_NODE) {
    return null;
  }

  const el = node as HTMLElement;
  const tagName = el.tagName.toLowerCase();
  const isCodeBlock = tagName === "pre" || tagName === "code";

  const props: Record<string, any> = { key };

  if (el.className) {
    props.className = isCodeBlock
      ? `${el.className} whitespace-pre-wrap wrap-break-word`.trim()
      : el.className.trim();
  } else if (isCodeBlock) {
    props.className = "whitespace-pre-wrap wrap-break-word";
  }

  const styleAttr = el.getAttribute("style");
  if (styleAttr) {
    props.style = parseInlineStyle(styleAttr);
  }

  for (const attr of el.attributes) {
    if (attr.name.startsWith("data-")) {
      props[attr.name] = attr.value;
    }
  }

  const children = Array.from(el.childNodes).map((child, i) =>
    domNodeToReact(child, i)
  );

  return React.createElement(tagName, props, ...children);
}

interface CodeBlockHeaderProps {
  filename?: string;
}

function CodeBlockHeader({ filename }: CodeBlockHeaderProps) {
  if (!filename) return null;

  return (
    <div className="flex items-center border-border border-b bg-muted/30 px-4 py-2">
      <span className="font-mono text-muted-foreground text-sm">
        {filename}
      </span>
    </div>
  );
}

interface CodeBlockSkeletonProps {
  filename?: string;
}

function CodeBlockSkeleton({ filename }: CodeBlockSkeletonProps) {
  return (
    <div className="rounded-lg border border-border bg-background">
      <CodeBlockHeader filename={filename} />
      <div className="p-4">
        <div className="flex animate-pulse flex-col gap-2">
          <div className="h-4 rounded bg-muted" />
          <div className="h-4 w-3/4 rounded bg-muted" />
        </div>
      </div>
    </div>
  );
}

interface CodeBlockFallbackProps {
  code: string;
  filename?: string;
}

function CodeBlockFallback({ code, filename }: CodeBlockFallbackProps) {
  return (
    <div className="rounded-lg border border-border bg-background">
      <CodeBlockHeader filename={filename} />
      <pre className="overflow-x-auto p-4">
        <code className="wrap-break-word whitespace-pre-wrap font-mono text-sm">
          {code}
        </code>
      </pre>
    </div>
  );
}

interface HighlightedCodeBlockProps {
  code: string;
  language: string;
  filename?: string;
  skipHighlighting?: boolean;
}

function HighlightedCodeBlock({
  code,
  language,
  filename,
  skipHighlighting = false,
}: HighlightedCodeBlockProps) {
  const { theme: websiteTheme } = useTheme();
  const [highlightedCode, setHighlightedCode] = useState<string>("");
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(false);

  const theme = useMemo(
    () => (websiteTheme === "dark" ? "github-dark" : "github-light"),
    [websiteTheme]
  );
  const lang = useMemo(
    () => (language || "text") as BundledLanguage,
    [language]
  );

  useEffect(() => {
    if (skipHighlighting) {
      startTransition(() => {
        setHighlightedCode("");
        setLoading(false);
        setError(false);
      });
      return;
    }

    let mounted = true;
    startTransition(() => {
      setLoading(true);
      setError(false);
    });

    const highlightCode = async () => {
      try {
        const html = await codeToHtml(code, {
          lang,
          theme,
        });
        if (mounted) {
          setHighlightedCode(html);
          setLoading(false);
          setError(false);
        }
      } catch {
        if (mounted) {
          setHighlightedCode("");
          setLoading(false);
          setError(true);
        }
      }
    };

    highlightCode();

    return () => {
      mounted = false;
    };
  }, [code, lang, theme, skipHighlighting]);

  if (skipHighlighting || error) {
    return <CodeBlockFallback code={code} filename={filename} />;
  }

  if (loading) {
    return <CodeBlockSkeleton filename={filename} />;
  }

  if (!highlightedCode) {
    return <CodeBlockFallback code={code} filename={filename} />;
  }

  const parser = new DOMParser();
  const doc = parser.parseFromString(highlightedCode, "text/html");
  const pre = doc.body.querySelector("pre");

  if (!pre) {
    return <CodeBlockFallback code={code} filename={filename} />;
  }

  return (
    <div className="rounded-lg border border-border bg-background">
      <CodeBlockHeader filename={filename} />
      <div className="overflow-x-auto">{domNodeToReact(pre)}</div>
    </div>
  );
}

interface MarkdownContentProps {
  content: string;
  skipCodeHighlighting?: boolean;
}

function MarkdownContent({
  content,
  skipCodeHighlighting = false,
}: MarkdownContentProps) {
  const codeComponent = useCallback(
    ({
      node,
      className,
      children,
      ...props
    }: {
      node?: any;
      className?: string;
      children?: React.ReactNode;
    }) => {
      const match = /language-(\w+)/.exec(className || "");
      const language = match ? match[1] : "";
      const codeString = String(children).replace(/\n$/, "");

      if (language) {
        return (
          <HighlightedCodeBlock
            code={codeString}
            language={language}
            skipHighlighting={skipCodeHighlighting}
          />
        );
      }

      return (
        <code
          className={cn(
            "relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm",
            className
          )}
          {...props}
        >
          {children}
        </code>
      );
    },
    [skipCodeHighlighting]
  );

  const linkComponent = useCallback(
    ({
      href,
      children,
      ...props
    }: {
      href?: string;
      children?: React.ReactNode;
    }) => (
      <a href={href} rel="noopener noreferrer" target="_blank" {...props}>
        {children}
      </a>
    ),
    []
  );

  const components = useMemo(
    () => ({
      code: codeComponent,
      a: linkComponent,
    }),
    [codeComponent, linkComponent]
  );

  return (
    <div className="prose prose-sm dark:prose-invert max-w-none">
      <ReactMarkdown components={components} remarkPlugins={[remarkGfm]}>
        {content}
      </ReactMarkdown>
    </div>
  );
}

interface MessageActionsProps {
  onCopy: () => void;
  onRegenerate?: () => void;
  onEdit?: () => void;
  copied: boolean;
  messageId?: string;
}

function MessageActions({
  onCopy,
  onRegenerate,
  onEdit,
  copied,
  messageId,
}: MessageActionsProps) {
  const [isOpen, setIsOpen] = useState(false);
  const hasActions = onRegenerate || onEdit;
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!(isOpen && hasActions)) return;

    const handleKeyDown = (e: KeyboardEvent) => {
      if (
        e.target instanceof HTMLInputElement ||
        e.target instanceof HTMLTextAreaElement
      ) {
        return;
      }

      if (e.key === "Escape") {
        e.preventDefault();
        setIsOpen(false);
      } else if ((e.metaKey || e.ctrlKey) && e.key === "c" && !e.shiftKey) {
        e.preventDefault();
        onCopy();
        setIsOpen(false);
      }
    };

    document.addEventListener("keydown", handleKeyDown);
    return () => {
      document.removeEventListener("keydown", handleKeyDown);
    };
  }, [isOpen, hasActions, onCopy]);

  const handleCopyClick = useCallback(() => {
    onCopy();
    if (isOpen) {
      setIsOpen(false);
    }
  }, [onCopy, isOpen]);

  if (!hasActions) {
    return (
      <div className="flex items-center" ref={containerRef}>
        <Button
          aria-label={copied ? "Message copied to clipboard" : "Copy message"}
          aria-live="polite"
          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"
          onClick={handleCopyClick}
          variant="ghost"
        >
          <Copy aria-hidden="true" className="size-4" />
          <span className="sr-only">{copied ? "Copied" : "Copy"}</span>
          <span aria-hidden="true" className="text-xs">
            {copied ? "Copied!" : "Copy"}
          </span>
        </Button>
      </div>
    );
  }

  return (
    <div className="flex items-center" ref={containerRef}>
      <DropdownMenu onOpenChange={setIsOpen} open={isOpen}>
        <DropdownMenuTrigger asChild>
          <Button
            aria-expanded={isOpen}
            aria-haspopup="menu"
            aria-label="Message actions menu"
            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"
            size="icon"
            variant="ghost"
          >
            <MoreVertical aria-hidden="true" className="size-4" />
            <span className="sr-only">Open message actions menu</span>
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent
          align="end"
          aria-label="Message actions"
          className="font-medium"
        >
          <DropdownMenuItem onClick={handleCopyClick}>
            <Copy aria-hidden="true" className="size-4" />
            <span>{copied ? "Copied!" : "Copy"}</span>
            <DropdownMenuShortcut>
              {typeof navigator !== "undefined" &&
              navigator.platform.includes("Mac")
                ? "⌘"
                : "Ctrl"}
              C
            </DropdownMenuShortcut>
          </DropdownMenuItem>
          {onRegenerate && (
            <>
              <DropdownMenuSeparator />
              <DropdownMenuItem onClick={onRegenerate}>
                <RotateCcw aria-hidden="true" className="size-4" />
                <span>Regenerate</span>
              </DropdownMenuItem>
            </>
          )}
          {onEdit && (
            <>
              <DropdownMenuSeparator />
              <DropdownMenuItem onClick={onEdit}>
                <Edit aria-hidden="true" className="size-4" />
                <span>Edit</span>
              </DropdownMenuItem>
            </>
          )}
        </DropdownMenuContent>
      </DropdownMenu>
    </div>
  );
}

interface StreamingIndicatorProps {
  isStreaming: boolean;
}

function StreamingIndicator({ isStreaming }: StreamingIndicatorProps) {
  if (!isStreaming) return null;

  return (
    <span
      aria-hidden="true"
      aria-label="Streaming content"
      className="inline-block h-4 w-0.5 bg-foreground motion-safe:animate-pulse"
    />
  );
}

export default function AIMessage({
  content,
  isStreaming = false,
  onRegenerate,
  onEdit,
  className,
  skipCodeHighlighting = false,
}: AIMessageProps) {
  const [copied, setCopied] = useState(false);
  const [copyError, setCopyError] = useState(false);
  const messageRef = useRef<HTMLDivElement>(null);
  const messageId = useId();
  const displayedContent = useStreamingText(content, isStreaming);

  const handleCopy = useCallback(async () => {
    try {
      await navigator.clipboard.writeText(content);
      setCopied(true);
      setCopyError(false);
      setTimeout(() => {
        setCopied(false);
      }, COPY_FEEDBACK_DURATION);
    } catch {
      setCopyError(true);
      setTimeout(() => {
        setCopyError(false);
      }, COPY_FEEDBACK_DURATION);
    }
  }, [content]);

  useEffect(() => {
    if (!messageRef.current) return;

    const handleKeyDown = (e: KeyboardEvent) => {
      if (
        e.target instanceof HTMLInputElement ||
        e.target instanceof HTMLTextAreaElement
      ) {
        return;
      }

      if ((e.metaKey || e.ctrlKey) && e.key === "c" && !e.shiftKey) {
        const selection = window.getSelection()?.toString();
        if (
          !selection &&
          messageRef.current?.contains(document.activeElement)
        ) {
          e.preventDefault();
          handleCopy();
        }
      }
    };

    const element = messageRef.current;
    element.addEventListener("keydown", handleKeyDown);

    return () => {
      element.removeEventListener("keydown", handleKeyDown);
    };
  }, [handleCopy]);

  return (
    <div
      aria-atomic="false"
      aria-label="AI message"
      aria-live={isStreaming ? "polite" : "off"}
      className={cn("group relative", className)}
      ref={messageRef}
      role="article"
    >
      <div aria-label="Message actions" className="absolute top-0 right-0 z-10">
        <MessageActions
          copied={copied}
          messageId={messageId}
          onCopy={handleCopy}
          onEdit={onEdit}
          onRegenerate={onRegenerate}
        />
      </div>

      <div className="pr-12">
        <MarkdownContent
          content={displayedContent}
          skipCodeHighlighting={skipCodeHighlighting}
        />
        <StreamingIndicator isStreaming={isStreaming} />
      </div>

      {copyError && (
        <div aria-live="assertive" className="sr-only" role="alert">
          Failed to copy message to clipboard
        </div>
      )}
    </div>
  );
}

Similar components