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 (
);
}
/**
* 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 (
<>
);
}
/** 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 (