hmc-rag / frontend /src /components /MessageBubble.tsx
webmuppet
chat: cycle loading messages through pipeline phases
c812b81
Raw
History Blame Contribute Delete
12 kB
/**
* 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<number | null>(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 (
<div className="flex justify-end mb-4">
<div
className="max-w-[80%] px-4 py-3 text-white"
style={{
background: "linear-gradient(135deg, #542FE8 0%, #1E3798 100%)",
borderRadius: "14px",
fontSize: "0.95rem",
fontWeight: 400,
lineHeight: 1.55,
}}
>
{message.content}
</div>
</div>
);
}
return (
<div className="mb-6">
<div className="w-full">
{/* White card */}
<div
className="px-6 py-5"
style={{
backgroundColor: "#FFFFFF",
borderRadius: "16px",
boxShadow: "0 4px 24px rgba(0,0,0,0.12)",
minHeight: isStreaming ? "80px" : undefined,
}}
>
{/* Retrieval phase — cycling status messages before first token */}
{isStreaming && !hasContent && (
<div className="flex items-center gap-2 py-2">
<span className="text-sm text-gray-400">{RETRIEVAL_MESSAGES[msgIndex]}</span>
<div className="flex gap-1">
<span
className="w-2 h-2 rounded-full bg-gray-300"
style={{ animation: "pulse 1.4s ease-in-out 0s infinite" }}
/>
<span
className="w-2 h-2 rounded-full bg-gray-300"
style={{ animation: "pulse 1.4s ease-in-out 0.2s infinite" }}
/>
<span
className="w-2 h-2 rounded-full bg-gray-300"
style={{ animation: "pulse 1.4s ease-in-out 0.4s infinite" }}
/>
</div>
</div>
)}
{/* Generation phase label — shown while tokens are streaming in */}
{isStreaming && hasContent && (
<div className="flex items-center gap-2 pb-3">
<span className="text-xs text-gray-400">{GENERATING_MESSAGE}</span>
<div className="flex gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-gray-300" style={{ animation: "pulse 1.4s ease-in-out 0s infinite" }} />
<span className="w-1.5 h-1.5 rounded-full bg-gray-300" style={{ animation: "pulse 1.4s ease-in-out 0.2s infinite" }} />
<span className="w-1.5 h-1.5 rounded-full bg-gray-300" style={{ animation: "pulse 1.4s ease-in-out 0.4s infinite" }} />
</div>
</div>
)}
{/* Markdown-rendered assistant content */}
{hasContent && (
<div className="max-w-none" style={{ color: "#1C2942" }}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
>
{message.content}
</ReactMarkdown>
</div>
)}
{/* Citation chips + collapsible detail — only for completed messages */}
{isCompleted && (
<div className="mt-4">
{result && result.citations.length > 0 && (
<CitationChips citations={result.citations} />
)}
{/* Collapsible "Full citation detail" card */}
{result && result.citations.length > 0 && (
<CitationDetailPanel citations={result.citations} />
)}
</div>
)}
</div>
{/* Bottom metadata bar — timing, sections, domains, tokens + Save Response */}
{isCompleted && result && (
<div className="flex items-center justify-between mt-3 px-1">
<MetadataBar result={result} />
<div className="flex items-center gap-2">
{onExport && (
<button
type="button"
onClick={() => onExport(index)}
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600 transition-colors"
title="Download response"
aria-label="Download response"
>
<DownloadIcon />
</button>
)}
{onSave && (
<button
type="button"
onClick={() => onSave(index)}
className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium text-white transition-all active:scale-95"
style={{
backgroundColor: isSaved ? "#3B2498" : "#4A2DB8",
}}
title={isSaved ? "Response saved" : "Save response"}
aria-label={isSaved ? "Response saved" : "Save response"}
>
<span>{isSaved ? "Saved" : "Save Response"}</span>
</button>
)}
</div>
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="mt-3">
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className="w-full flex items-center gap-2 px-4 py-3 text-sm text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
aria-expanded={open}
>
<ChevronIcon open={open} />
<span>Full citation detail</span>
</button>
{open && (
<div className="mt-2 space-y-2">
{citations.map((c) => (
<div
key={c.index}
className="text-xs text-gray-600 border border-gray-200 rounded-md p-3 bg-gray-50"
>
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-700">[{c.index}]</span>
<span className="font-medium">{c.domain}</span>
</div>
<div className="mt-1 text-gray-800">{c.title}</div>
{c.source_url && (
<div className="mt-1 truncate">
<a
href={c.source_url}
target="_blank"
rel="noopener"
className="text-blue-600 hover:underline"
>
{c.source_url}
</a>
</div>
)}
{c.line_num !== undefined && (
<div className="mt-1 text-gray-500">Line {c.line_num}</div>
)}
</div>
))}
</div>
)}
</div>
);
}
/**
* 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 (
<p className="text-xs text-gray-500 leading-relaxed">
{parts.join(" · ")}
</p>
);
}
// ---------------------------------------------------------------------------
// Icon components
// ---------------------------------------------------------------------------
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="w-4 h-4 text-gray-400 transition-transform"
style={{ transform: open ? "rotate(90deg)" : "rotate(0deg)" }}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
);
}
function DownloadIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-4 h-4"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
);
}
export default MessageBubble;