2026-02-18 04:48:46 +03:00
|
|
|
import { useState, useEffect, useRef } from "react";
|
2026-02-17 02:57:42 +03:00
|
|
|
import ReactMarkdown from "react-markdown";
|
2026-02-17 05:20:14 +03:00
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
|
import copy from "copy-to-clipboard";
|
|
|
|
|
import { toast } from "sonner";
|
2026-02-18 04:48:46 +03:00
|
|
|
import { FaCopy, FaPencilAlt } from "react-icons/fa";
|
|
|
|
|
import { FaArrowUpLong } from "react-icons/fa6";
|
2026-02-17 05:20:14 +03:00
|
|
|
import { Button } from "@/components/ui/button";
|
2026-02-18 04:48:46 +03:00
|
|
|
import { Textarea } from "@/components/ui/textarea";
|
2026-02-17 05:20:14 +03:00
|
|
|
import {
|
|
|
|
|
Tooltip,
|
|
|
|
|
TooltipContent,
|
|
|
|
|
TooltipTrigger,
|
|
|
|
|
} from "@/components/ui/tooltip";
|
2026-02-17 02:57:42 +03:00
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
|
|
|
|
type MessageBubbleProps = {
|
|
|
|
|
role: "user" | "assistant";
|
|
|
|
|
content: string;
|
2026-02-18 04:48:46 +03:00
|
|
|
messageIndex?: number;
|
|
|
|
|
onEditSubmit?: (messageIndex: number, newContent: string) => void;
|
2026-02-17 02:57:42 +03:00
|
|
|
};
|
|
|
|
|
|
2026-02-18 04:48:46 +03:00
|
|
|
export function MessageBubble({
|
|
|
|
|
role,
|
|
|
|
|
content,
|
|
|
|
|
messageIndex = 0,
|
|
|
|
|
onEditSubmit,
|
|
|
|
|
}: MessageBubbleProps) {
|
2026-02-17 05:20:14 +03:00
|
|
|
const { t } = useTranslation(["views/chat", "common"]);
|
2026-02-17 02:57:42 +03:00
|
|
|
const isUser = role === "user";
|
2026-02-18 04:48:46 +03:00
|
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
|
|
|
const [draftContent, setDraftContent] = useState(content);
|
|
|
|
|
const editInputRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setDraftContent(content);
|
|
|
|
|
}, [content]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isEditing) {
|
|
|
|
|
editInputRef.current?.focus();
|
|
|
|
|
editInputRef.current?.setSelectionRange(
|
|
|
|
|
editInputRef.current.value.length,
|
|
|
|
|
editInputRef.current.value.length,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}, [isEditing]);
|
2026-02-17 02:57:42 +03:00
|
|
|
|
2026-02-17 05:20:14 +03:00
|
|
|
const handleCopy = () => {
|
|
|
|
|
const text = content?.trim() || "";
|
|
|
|
|
if (!text) return;
|
|
|
|
|
if (copy(text)) {
|
|
|
|
|
toast.success(t("button.copiedToClipboard", { ns: "common" }));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-18 04:48:46 +03:00
|
|
|
const handleEditClick = () => {
|
|
|
|
|
setDraftContent(content);
|
|
|
|
|
setIsEditing(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleEditSubmit = () => {
|
|
|
|
|
const trimmed = draftContent.trim();
|
|
|
|
|
if (!trimmed || onEditSubmit == null) return;
|
|
|
|
|
onEditSubmit(messageIndex, trimmed);
|
|
|
|
|
setIsEditing(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleEditCancel = () => {
|
|
|
|
|
setDraftContent(content);
|
|
|
|
|
setIsEditing(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleEditKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
|
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
handleEditSubmit();
|
|
|
|
|
}
|
|
|
|
|
if (e.key === "Escape") {
|
|
|
|
|
handleEditCancel();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (isUser && isEditing) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex w-full max-w-full flex-col gap-2 self-end">
|
|
|
|
|
<Textarea
|
|
|
|
|
ref={editInputRef}
|
|
|
|
|
value={draftContent}
|
|
|
|
|
onChange={(e) => setDraftContent(e.target.value)}
|
|
|
|
|
onKeyDown={handleEditKeyDown}
|
|
|
|
|
className="min-h-[80px] w-full resize-y rounded-lg bg-primary px-3 py-2 text-primary-foreground placeholder:text-primary-foreground/60"
|
|
|
|
|
placeholder={t("placeholder")}
|
|
|
|
|
rows={3}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex items-center gap-2 self-end">
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="text-muted-foreground hover:text-foreground"
|
|
|
|
|
onClick={handleEditCancel}
|
|
|
|
|
>
|
|
|
|
|
{t("button.cancel", { ns: "common" })}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="select"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="size-9 rounded-full"
|
|
|
|
|
disabled={!draftContent.trim()}
|
|
|
|
|
onClick={handleEditSubmit}
|
|
|
|
|
aria-label={t("send")}
|
|
|
|
|
>
|
|
|
|
|
<FaArrowUpLong size="16" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 02:57:42 +03:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-02-17 05:20:14 +03:00
|
|
|
"flex flex-col gap-1",
|
|
|
|
|
isUser ? "items-end self-end" : "items-start self-start",
|
2026-02-17 02:57:42 +03:00
|
|
|
)}
|
|
|
|
|
>
|
2026-02-17 05:20:14 +03:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"rounded-lg px-3 py-2",
|
|
|
|
|
isUser ? "bg-primary text-primary-foreground" : "bg-muted",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{isUser ? content : <ReactMarkdown>{content}</ReactMarkdown>}
|
|
|
|
|
</div>
|
2026-02-18 04:48:46 +03:00
|
|
|
<div className="flex items-center gap-0.5">
|
|
|
|
|
{isUser && onEditSubmit != null && (
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="size-7 text-muted-foreground hover:text-foreground"
|
|
|
|
|
onClick={handleEditClick}
|
|
|
|
|
aria-label={t("button.edit", { ns: "common" })}
|
|
|
|
|
>
|
|
|
|
|
<FaPencilAlt className="size-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent>
|
|
|
|
|
{t("button.edit", { ns: "common" })}
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
)}
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="size-7 text-muted-foreground hover:text-foreground"
|
|
|
|
|
onClick={handleCopy}
|
|
|
|
|
disabled={!content?.trim()}
|
|
|
|
|
aria-label={t("button.copy", { ns: "common" })}
|
|
|
|
|
>
|
|
|
|
|
<FaCopy className="size-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent>{t("button.copy", { ns: "common" })}</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</div>
|
2026-02-17 02:57:42 +03:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|