web / frontend /src /components /chat /ChatBubble.tsx
Chandima Prabhath
update
978caa8
import { useState } from "react";
import { ArrowRight, RefreshCcw, Copy, Check, Trash2, RotateCcw, ListFilter, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { format } from "date-fns";
import { Avatar, AvatarFallback } from "../ui/avatar";
import { ChatMessage } from "./ChatMessage";
import { ThinkingAnimation } from "./ThinkingAnimation";
import { toast } from '../ui/sonner';
import { MessageActions } from "./MessageActions";
import { MessageVariationControls } from "./MessageVariationControls";
import { DeleteMessageDialog } from "./DeleteMessageDialog";
import { MessageVariation, Message } from "@/types/chat";
interface ChatBubbleProps {
message: Message;
onViewSearchResults?: (messageId: string) => void;
onRetry?: (messageId: string) => void;
onRegenerate?: (messageId: string) => void;
onDelete?: (messageId: string) => void;
onSelectVariation?: (messageId: string, variationId: string) => void;
}
export const ChatBubble = ({
message,
onViewSearchResults,
onRetry,
onRegenerate,
onDelete,
onSelectVariation
}: ChatBubbleProps) => {
const [copied, setCopied] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const isWelcomeMessage = message.id === "welcomems";
const isSystem = message.sender === "system";
const hasVariations = isSystem && message.variations && message.variations.length > 0;
// If the message has variations, display the active one or the first one
const displayContent = isSystem && hasVariations && message.activeVariation
? message.variations.find(v => v.id === message.activeVariation)?.content || message.content
: message.content;
const copyToClipboard = () => {
// Strip thinking content before copying
const cleanContent = displayContent.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
navigator.clipboard.writeText(cleanContent);
setCopied(true);
toast.success('Copied to clipboard!');
setTimeout(() => setCopied(false), 2000);
};
const handleDelete = () => {
setDeleteDialogOpen(false);
if (onDelete) {
onDelete(message.id);
}
};
return (
<>
<div
className={cn(
"group flex w-full animate-slide-in mb-2",
isSystem ? "justify-start" : "justify-end"
)}
>
<div className={cn(
"flex gap-3 max-w-[80%]",
isSystem ? "flex-row" : "flex-row-reverse"
)}>
<Avatar className={cn(
"h-8 w-8 border",
isSystem
? "bg-financial-accent dark:text-white shadow-lg"
: "bg-muted"
)}>
<AvatarFallback className="text-xs font-semibold">
{isSystem ? "AI" : "You"}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<div className={cn(
"rounded-2xl shadow-lg message-bubble backdrop-blur-sm",
isSystem
? "bg-white/90 dark:bg-card/90 border border-border text-foreground message-bubble-ai"
: "bg-financial-accent/30 border border-financial-accent/30 dark:text-white message-bubble-user",
message.error && "border-destructive dark:border-red-500"
)}>
{message.isLoading ? (
<ThinkingAnimation />
) : (
<ChatMessage
content={displayContent}
/>
)}
</div>
{/* Chat bubble footer */}
<div className="flex flex-row chat-bubble-footer justify-between items-center mt-1">
{/* Time */}
<div className={cn(
"text-xs text-muted-foreground",
isSystem ? "text-left" : "text-right"
)}>
{format(message.timestamp, "h:mm a")}
</div>
{/* Controls */}
{!isWelcomeMessage && (
<div className="controls flex gap-1 items-center">
{/* Variation controls */}
{hasVariations && message.variations && message.variations.length > 1 && (
<MessageVariationControls
message={message}
onSelectVariation={onSelectVariation}
/>
)}
<MessageActions
message={message}
onRetry={onRetry}
onRegenerate={onRegenerate}
onDelete={() => setDeleteDialogOpen(true)}
onCopy={copyToClipboard}
copied={copied}
/>
</div>
)}
</div>
</div>
</div>
</div>
<DeleteMessageDialog
isOpen={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onDelete={handleDelete}
/>
</>
);
};