Spaces:
Running
Running
| "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> | |
| ); | |
| } | |