web / frontend /src /components /chat /ChatBubble.tsx
Chandima Prabhath
feat: Add chat components and modals for enhanced user interaction
1904e4c
raw
history blame
10.9 kB
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
interface MessageVariation {
id: string;
content: string;
timestamp: Date;
}
interface Message {
id: string;
content: string;
sender: "user" | "system";
timestamp: Date;
isLoading?: boolean;
error?: boolean;
result?: any;
variations?: MessageVariation[];
activeVariation?: string;
}
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 showCopyButton = true;
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 = () => {
navigator.clipboard.writeText(displayContent);
setCopied(true);
toast.success('Copied to clipboard!');
setTimeout(() => setCopied(false), 2000);
};
const handleDelete = () => {
setDeleteDialogOpen(false);
if (onDelete) {
onDelete(message.id);
}
};
const handleSelectVariation = (variationId: string) => {
if (onSelectVariation) {
onSelectVariation(message.id, variationId);
}
};
// Function to get the current variation index and navigate through variations
const navigateVariations = (direction: 'prev' | 'next') => {
if (!hasVariations || !message.variations || message.variations.length <= 1) return;
const currentIndex = message.activeVariation
? message.variations.findIndex(v => v.id === message.activeVariation)
: 0;
let newIndex;
if (direction === 'prev') {
newIndex = (currentIndex - 1 + message.variations.length) % message.variations.length;
} else {
newIndex = (currentIndex + 1) % message.variations.length;
}
handleSelectVariation(message.variations[newIndex].id);
};
// Get current variation index for display
const getCurrentVariationIndex = () => {
if (!hasVariations || !message.variations) return 0;
return message.activeVariation
? message.variations.findIndex(v => v.id === message.activeVariation) + 1
: 1;
};
return (
<>
<div
className={cn(
"group flex w-full animate-slide-in mb-4",
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">
{/* Time */}
<div className={cn(
"text-xs text-muted-foreground mt-1",
isSystem ? "text-left" : "text-right"
)}>
{format(message.timestamp, "h:mm a")}
</div>
{/* Controls */}
{!isWelcomeMessage && <div className="controls flex gap-1">
{/* Variation Navigation */}
{hasVariations && message.variations && message.variations.length > 1 && (
<div className="flex items-center mt-1 opacity-0 group-hover:opacity-100 transition-opacity bg-background/50 rounded-md border">
<Button
variant="ghost"
size="sm"
onClick={() => navigateVariations('prev')}
disabled={message.variations.length <= 1}
className="h-6 px-1"
>
<ChevronLeft className="h-3 w-3" />
</Button>
<span className="text-xs px-1">
{getCurrentVariationIndex()}/{message.variations.length}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => navigateVariations('next')}
disabled={message.variations.length <= 1}
className="h-6 px-1"
>
<ChevronRight className="h-3 w-3" />
</Button>
</div>
)}
{/* Retry button for failed messages */}
{message.error && onRetry && (
<div className="flex mt-1">
<Button
variant="secondary"
size="sm"
onClick={() => onRetry(message.id)}
className="text-xs flex items-center gap-1.5 text-muted-foreground hover:border border-financial-accent/30 bg-background/50 backdrop-blur-sm"
>
<RefreshCcw className="h-3 w-3" />
Retry
</Button>
</div>
)}
{/* Regenerate button for system messages */}
{isSystem && !message.isLoading && onRegenerate && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="link"
size="sm"
onClick={() => onRegenerate(message.id)}
>
<RotateCcw className="h-3 w-3" />
</Button>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Generate variation</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Delete button */}
{onDelete && !message.isLoading && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex mt-1 opacity-0 group-hover:opacity-100">
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteDialogOpen(true)}
className="text-xs flex items-center gap-1.5 text-muted-foreground hover:text-destructive hover:bg-background/50 backdrop-blur-sm"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Delete message</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Copy */}
{showCopyButton && !message.isLoading && (
<div className="relative top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="link" size="sm" onClick={copyToClipboard}>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
)}
</div>}
</div>
</div>
</div>
</div>
{/* Delete confirmation dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Message</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this message? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};