PDF-Assit_RAG / frontend /src /components /chat /MessageBubble.tsx
KaustubPM's picture
feat: add copy capability to LLM responses
6bcc076
"use client";
import { useState, useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { ChatMsg } from "./ChatPanel";
import { Brain, User, Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
interface Props {
message: ChatMsg;
}
export default function MessageBubble({ message }: Props) {
const isUser = message.role === "user";
const [copied, setCopied] = useState(false);
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCopy = async () => {
if (!message.content) return;
try {
await navigator.clipboard.writeText(message.content);
setCopied(true);
if (copiedTimeoutRef.current) clearTimeout(copiedTimeoutRef.current);
copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
};
return (
<div
className={`flex gap-3 py-3 animate-fade-in-up ${isUser ? "justify-end" : "justify-start"}`}
>
{!isUser && (
<div className="w-8 h-8 rounded-lg bg-primary/15 flex items-center justify-center shrink-0 mt-0.5">
<Brain className="w-4 h-4 text-primary" />
</div>
)}
<div
className={`relative max-w-[80%] rounded-xl px-4 py-3 ${
isUser
? "bg-primary text-primary-foreground rounded-br-sm"
: "group bg-card border border-border/50 rounded-bl-sm"
}`}
>
{isUser ? (
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
) : (
<>
{message.content && (
<Button
type="button"
variant="ghost"
size="icon-xs"
className={`absolute top-2 right-2 text-muted-foreground hover:text-foreground transition-opacity ${
copied
? "opacity-100"
: "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
}`}
onClick={handleCopy}
aria-label={copied ? "Copied" : "Copy response"}
>
{copied ? (
<Check className="w-3.5 h-3.5 text-emerald-400" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</Button>
)}
<div className={`prose-chat text-sm ${message.content ? "pr-7" : ""}`}>
{message.content ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content}
</ReactMarkdown>
) : message.isStreaming ? (
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-primary/60 animate-bounce [animation-delay:0ms]" />
<span className="w-1.5 h-1.5 rounded-full bg-primary/60 animate-bounce [animation-delay:150ms]" />
<span className="w-1.5 h-1.5 rounded-full bg-primary/60 animate-bounce [animation-delay:300ms]" />
</div>
) : null}
{message.isStreaming && message.content && (
<span className="inline-block w-0.5 h-4 bg-primary/60 animate-pulse ml-0.5 align-text-bottom" />
)}
</div>
</>
)}
</div>
{isUser && (
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center shrink-0 mt-0.5">
<User className="w-4 h-4 text-primary-foreground" />
</div>
)}
</div>
);
}