import { useState } from "react"; import { ChevronRight, ImageIcon, 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, 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 baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300"; if (message.kind === "trace") { return ; } if (message.role === "user") { const images = message.images ?? []; const hasImages = images.length > 0; const hasText = message.content.trim().length > 0; return (
{hasImages ? : null} {hasText ? (

{message.content}

) : null}
); } const empty = message.content.trim().length === 0; return (
{empty && message.isStreaming ? ( ) : ( <> {message.content} {message.isStreaming && } )}
); } /** * 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 }: { images: UIImage[] }) { 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, placeholderLabel, openLabel, onOpen, }: { image: UIImage; placeholderLabel: string; openLabel: string; onOpen?: () => void; }) { const hasUrl = typeof image.url === "string" && image.url.length > 0; const tileClasses = cn( "relative h-24 w-24 overflow-hidden rounded-[14px] border border-border/60 bg-muted/40", "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 * expanded for discoverability; a single click on the header folds the * group down to a one-line summary so it never dominates the thread. */ function TraceGroup({ message, animClass }: TraceGroupProps) { const { t } = useTranslation(); const lines = message.traces ?? [message.content]; const count = lines.length; const [open, setOpen] = useState(true); return (
{open && (
    {lines.map((line, i) => (
  • {line}
  • ))}
)}
); }