/** * MessageBubble — renders a single user or assistant message in the chat thread. * * - User bubbles: violet gradient background, white text * - Assistant bubbles: white card, rounded, with shadow; Markdown content; * citation chips, save/export buttons, and metadata bar below completed messages * * Requirements: 4.4, 4.7, 4.8, 5.1, 7.1, 7.6, 11.2, 12.3, 12.4 */ import { useState, useEffect, useRef } from "react"; import ReactMarkdown from "react-markdown"; import { remarkPlugins, rehypePlugins, components } from "../lib/markdown"; import { CitationChips } from "./CitationChips"; import type { Message, QueryResult } from "../types"; // Messages shown while waiting for the pipeline to return tokens. // The last entry appears once the first token arrives. const RETRIEVAL_MESSAGES = [ "Looking at the legislation", "Reviewing relevant regulations", "Assessing requirements", "Scanning specific guidelines", "Understanding policy recommendations", ]; const GENERATING_MESSAGE = "Generating tailored response"; const CYCLE_INTERVAL_MS = 2500; export interface MessageBubbleProps { message: Message; index: number; conversationId: string; result?: QueryResult; isStreaming?: boolean; isSaved?: boolean; onSave?: (index: number) => void; onExport?: (index: number) => void; } /** * Renders a single chat message bubble. * * User messages use a violet gradient background with white text. * Assistant messages use a full-width white card with rounded corners and shadow, * with Markdown rendering, citation chips, a collapsible detail panel, * and a metadata bar with timing info + Save Response pill button. */ export function MessageBubble({ message, index, conversationId: _conversationId, result, isStreaming = false, isSaved = false, onSave, onExport, }: MessageBubbleProps) { const isUser = message.role === "user"; const isCompleted = !isStreaming && message.role === "assistant"; const hasContent = message.content.trim().length > 0; // Cycle through retrieval messages while waiting for first tokens const [msgIndex, setMsgIndex] = useState(0); const intervalRef = useRef(null); useEffect(() => { if (isStreaming && !hasContent) { // Start cycling intervalRef.current = setInterval(() => { setMsgIndex((i) => (i + 1) % RETRIEVAL_MESSAGES.length); }, CYCLE_INTERVAL_MS); } else { // Stop cycling when tokens arrive or streaming ends if (intervalRef.current !== null) { clearInterval(intervalRef.current); intervalRef.current = null; } } return () => { if (intervalRef.current !== null) clearInterval(intervalRef.current); }; }, [isStreaming, hasContent]); if (isUser) { return (
{message.content}
); } return (
{/* White card */}
{/* Retrieval phase — cycling status messages before first token */} {isStreaming && !hasContent && (
{RETRIEVAL_MESSAGES[msgIndex]}
)} {/* Generation phase label — shown while tokens are streaming in */} {isStreaming && hasContent && (
{GENERATING_MESSAGE}
)} {/* Markdown-rendered assistant content */} {hasContent && (
{message.content}
)} {/* Citation chips + collapsible detail — only for completed messages */} {isCompleted && (
{result && result.citations.length > 0 && ( )} {/* Collapsible "Full citation detail" card */} {result && result.citations.length > 0 && ( )}
)}
{/* Bottom metadata bar — timing, sections, domains, tokens + Save Response */} {isCompleted && result && (
{onExport && ( )} {onSave && ( )}
)}
); } // --------------------------------------------------------------------------- // Internal sub-components // --------------------------------------------------------------------------- /** * Collapsible full citation detail panel with chevron and card styling. */ function CitationDetailPanel({ citations, }: { citations: QueryResult["citations"]; }) { const [open, setOpen] = useState(false); return (
{open && (
{citations.map((c) => (
[{c.index}] {c.domain}
{c.title}
{c.source_url && (
{c.source_url}
)} {c.line_num !== undefined && (
Line {c.line_num}
)}
))}
)}
); } /** * Metadata bar showing timing · sections · domains · tokens. */ function MetadataBar({ result }: { result: QueryResult }) { const parts: string[] = []; if (result.timing?.total !== undefined) { parts.push(`${result.timing.total.toFixed(0)}s`); } if (result.sections_retrieved !== undefined && result.sections_retrieved > 0) { parts.push(`${result.sections_retrieved} section${result.sections_retrieved > 1 ? "s" : ""}`); } if (result.domains_searched && result.domains_searched.length > 0) { parts.push( result.domains_searched .map((d) => d .replace(/_/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()) ) .join(", ") ); } if (result.token_usage?.total_tokens) { parts.push(`${result.token_usage.total_tokens.toLocaleString()} tokens`); } if (parts.length === 0) return null; return (

{parts.join(" · ")}

); } // --------------------------------------------------------------------------- // Icon components // --------------------------------------------------------------------------- function ChevronIcon({ open }: { open: boolean }) { return ( ); } function DownloadIcon() { return ( ); } export default MessageBubble;