import { useCallback, useEffect, useRef, useState } from "react"; import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react"; import { useTranslation } from "react-i18next"; import { ImageLightbox } from "@/components/ImageLightbox"; import { MarkdownText } from "@/components/MarkdownText"; import { cn } from "@/lib/utils"; import type { UIImage, UIMediaAttachment, UIMessage } from "@/lib/types"; interface MessageBubbleProps { message: UIMessage; } /** * Render a single message. Following agent-chat-ui: user turns are a rounded * "pill" right-aligned with a muted fill; assistant turns render as bare * markdown so prose/code read like a document rather than a chat bubble. * Each turn fades+slides in for a touch of motion polish. * * Trace rows (tool-call hints, progress breadcrumbs) render as a subdued * collapsible group so intermediate steps never masquerade as replies. */ export function MessageBubble({ message }: MessageBubbleProps) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); const copyResetRef = useRef(null); const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300"; useEffect(() => { return () => { if (copyResetRef.current !== null) { window.clearTimeout(copyResetRef.current); } }; }, []); const onCopyAssistantReply = useCallback(() => { if (!navigator.clipboard) return; void navigator.clipboard.writeText(message.content).then(() => { setCopied(true); if (copyResetRef.current !== null) { window.clearTimeout(copyResetRef.current); } copyResetRef.current = window.setTimeout(() => { setCopied(false); copyResetRef.current = null; }, 1_500); }); }, [message.content]); if (message.kind === "trace") { return ; } if (message.role === "user") { const images = message.images ?? []; const media = message.media ?? []; const hasImages = images.length > 0; const hasMedia = media.length > 0; const hasText = message.content.trim().length > 0; return (
{hasImages ? : null} {!hasImages && hasMedia ? ( ) : null} {hasText ? (

{message.content}

) : null}
); } const empty = message.content.trim().length === 0; const media = message.media ?? []; const reasoning = message.role === "assistant" ? message.reasoning ?? "" : ""; const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming); const hasReasoning = reasoning.length > 0 || reasoningStreaming; const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty; return (
{hasReasoning ? ( ) : null} {empty && message.isStreaming && !hasReasoning ? ( ) : empty && message.isStreaming ? null : ( <> {message.content} {message.isStreaming && } {media.length > 0 ? : null} {showAssistantActions ? (
) : null} )}
); } function MessageMedia({ media, align, }: { media: UIMediaAttachment[]; align: "left" | "right"; }) { if (media.length === 0) return null; const images = media .filter((item) => item.kind === "image") .map(({ url, name }) => ({ url, name })); const nonImages = media.filter((item) => item.kind !== "image"); return (
{images.length > 0 ? ( ) : null} {nonImages.map((item, i) => ( ))}
); } function MediaCell({ media }: { media: UIMediaAttachment }) { const { t } = useTranslation(); const hasUrl = typeof media.url === "string" && media.url.length > 0; if (media.kind === "video" && hasUrl) { return (
); } const label = media.kind === "video" ? t("message.videoAttachment", { defaultValue: "Video attachment" }) : t("message.fileAttachment", { defaultValue: "File attachment" }); const Icon = media.kind === "video" ? PlaySquare : FileIcon; return (
{media.name ?? label}
); } /** * Right-aligned preview row for images attached to a user turn. * * Visual follows agent-chat-ui: a single wrapping row of fixed-size square * thumbnails that stay modest next to the text pill regardless of how many * images are attached. * * The URL is expected to be a self-contained ``data:`` URL (the Composer * hands the normalized base64 payload to the optimistic bubble so that the * preview survives React StrictMode double-mount — blob URLs would be * revoked by the Composer's cleanup before remount). Historical replays * have no URL (the backend strips data URLs before persisting), so we * render a labelled placeholder tile instead of a broken ````. */ function UserImages({ images, align = "right", size = "compact", }: { images: UIImage[]; align?: "left" | "right"; size?: "compact" | "large"; }) { const { t } = useTranslation(); // Only real-URL images can open in the lightbox; historical-replay // placeholders (no URL) have nothing to zoom into. const viewable = images .map((img, i) => ({ img, i })) .filter(({ img }) => typeof img.url === "string" && img.url.length > 0); const viewableImages = viewable.map(({ img }) => img); const originalToViewable = new Map( viewable.map(({ i }, v) => [i, v]), ); const [lightboxIndex, setLightboxIndex] = useState(null); return ( <>
{images.map((img, i) => ( setLightboxIndex(originalToViewable.get(i)!) : undefined } /> ))}
{ if (!open) setLightboxIndex(null); }} /> ); } function UserImageCell({ image, size, placeholderLabel, openLabel, onOpen, }: { image: UIImage; size: "compact" | "large"; placeholderLabel: string; openLabel: string; onOpen?: () => void; }) { const hasUrl = typeof image.url === "string" && image.url.length > 0; const tileClasses = cn( "relative overflow-hidden border border-border/60 bg-muted/40", size === "large" ? "w-[min(100%,34rem)] rounded-[20px] bg-transparent" : "h-24 w-24 rounded-[14px]", "shadow-[0_6px_18px_-14px_rgba(0,0,0,0.45)]", ); if (hasUrl && onOpen) { return ( ); } return (
{image.name ?? placeholderLabel}
); } /** Blinking cursor appended at the end of streaming text. */ function StreamCursor() { const { t } = useTranslation(); return ( ); } /** Pre-token-arrival placeholder: three bouncing dots. */ function TypingDots() { const { t } = useTranslation(); return ( ); } function Dot({ delay }: { delay: string }) { return ( ); } interface TraceGroupProps { message: UIMessage; animClass: string; } /** * Collapsible group of tool-call / progress breadcrumbs. Defaults to * collapsed because tool traces are supporting evidence, not the answer. * A single click expands the exact calls when the user wants details. */ function TraceGroup({ message, animClass }: TraceGroupProps) { const { t } = useTranslation(); const lines = message.traces ?? [message.content]; const count = lines.length; const [open, setOpen] = useState(false); return (
{open && (
    {lines.map((line, i) => (
  • {line}
  • ))}
)}
); } interface ReasoningBubbleProps { text: string; streaming: boolean; } /** * Subordinate "thinking" trace shown above an assistant turn. * * Lifecycle: * - While ``streaming`` is true (``reasoning_delta`` frames still arriving), * the bubble defaults to open and the header runs a shimmer + pulse so * the user sees the model "thinking out loud" in real time. * - On ``reasoning_end`` the bubble auto-collapses for prose density — * the user can re-expand to inspect the chain of thought. The local * toggle persists once the user interacts. */ function ReasoningBubble({ text, streaming }: ReasoningBubbleProps) { const { t } = useTranslation(); const [userToggled, setUserToggled] = useState(false); const [openLocal, setOpenLocal] = useState(true); const open = userToggled ? openLocal : streaming; const onToggle = () => { setUserToggled(true); setOpenLocal((v) => (userToggled ? !v : !open)); }; return (
{open && text.length > 0 && (
{text}
)}
); }