import { useCallback, useEffect, useRef, useState, type ReactNode, } 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, preloadMarkdownText } from "@/components/MarkdownText"; import { cn } from "@/lib/utils"; import { formatTurnLatency } from "@/lib/format"; import type { UIImage, UIMediaAttachment, UIMessage } from "@/lib/types"; interface MessageBubbleProps { message: UIMessage; /** When false, hide the assistant reply copy button (mid-turn text before more agent activity). Default true. */ showAssistantCopyAction?: boolean; } /** * 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, showAssistantCopyAction = true, }: 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; const showCopyButton = showAssistantCopyAction && showAssistantActions; const latencyMs = message.latencyMs; const showLatencyFooter = message.role === "assistant" && latencyMs != null && !message.isStreaming && (!empty || hasReasoning || media.length > 0); const showAssistantFooterRow = showCopyButton || showLatencyFooter; return (
{hasReasoning ? ( ) : null} {empty && message.isStreaming && !hasReasoning ? ( ) : empty && message.isStreaming ? null : ( <> {message.content} {media.length > 0 ? : null} {showAssistantFooterRow ? (
{showCopyButton ? ( ) : null} {showLatencyFooter ? ( {formatTurnLatency(latencyMs)} ) : null}
) : null} )}
); } function MessageMedia({ media, align, }: { media: UIMediaAttachment[]; align: "left" | "right"; }) { if (media.length === 0) return null; const images: UIImage[] = []; const nonImages: UIMediaAttachment[] = []; for (const item of media) { if (item.kind === "image") { images.push({ url: item.url, name: item.name }); } else { nonImages.push(item); } } 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; const inner = ( <> {media.name ?? label} ); if (hasUrl) { return ( {inner} ); } return (
{inner}
); } /** * 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 viewableImages: UIImage[] = []; const originalToViewable = new Map(); for (let i = 0; i < images.length; i += 1) { const img = images[i]; if (typeof img.url !== "string" || img.url.length === 0) continue; originalToViewable.set(i, viewableImages.length); viewableImages.push(img); } 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}
); } /** Pre-token-arrival placeholder: three bouncing dots. */ function TypingDots() { const { t } = useTranslation(); return ( ); } function Dot({ delay }: { delay: string }) { return ( ); } /** L→R sheen on the glyphs themselves; inactive labels stay solid muted text. */ export function StreamingLabelSheen({ children, active, className, }: { children: ReactNode; active: boolean; className?: string; }) { const sheenText = typeof children === "string" || typeof children === "number" ? String(children) : undefined; return ( {children} ); } interface ReasoningBubbleProps { text: string; streaming: boolean; hasBodyBelow: boolean; /** When true, skip the slide-in wrapper (used inside ``AgentActivityCluster``). */ embeddedInCluster?: 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 shows a sheen + pulse so * the user sees the model "thinking out loud" in real time. * - Expanded reasoning uses the same Markdown pipeline as assistant replies * (deferred while streaming to reduce parser thrash), so headings and * emphasis render instead of leaking raw ``###`` / ``**``. * - 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. */ export function ReasoningBubble({ text, streaming, hasBodyBelow, embeddedInCluster = false, }: 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)); }; useEffect(() => { if (open && text.length > 0) { preloadMarkdownText(); } }, [open, text.length]); return (
{open && text.length > 0 && (
{text}
)}
); } 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. */ export 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}
  • ))}
)}
); }