import { Copy, Check, Sparkles, ClipboardList, Pencil, RefreshCw, GitFork, FolderGit2, } from "lucide-react" import { memo, useState, useMemo } from "react" import { useNavigate } from "react-router-dom" import { useCurrentUserQuery } from "@/hooks/useAuth" import { useChatStreamRunner, useEditMessageMutation, useForkMessageMutation, useMessageVersionsQuery, useResendMessageMutation, useSwitchMessageVersionMutation, } from "@/hooks/useAiChatQuery" import { IrRenderer } from "@/lib/ir/renderer" import { parseContentBlocks, extractAnswerText, extractFullText, } from "@/lib/ir/parser" import type { IrContentBlock, IrToolCallNode, IrToolResultNode, } from "@/lib/ir/types" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Reasoning, ReasoningTrigger, ReasoningContent, } from "@/components/ai-elements/reasoning" import type { MessageResponse } from "@/hooks/useAiChatQuery" import { getModelIcon } from "@/lib/icons/modelIcons" import { ToolCallBlock } from "@/components/chat/ToolCallBlock" import { Badge } from "@/components/ui/badge" import { useChatPage } from "./ChatPageContext" import { parseSlashContextMetadata } from "./chatSlashContext" interface ChatMessageBubbleProps { message: MessageResponse conversationId: string onRegenerate?: (newMessageId: string) => void setIsStreaming: (value: boolean) => void } const AVATAR_COLORS = [ "#6366f1", "#8b5cf6", "#d946ef", "#ec4899", "#f43f5e", "#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#06b6d4", "#3b82f6", "#2563eb", "#7c3aed", "#c026d3", ] function hashColor(str: string): string { let hash = 0 for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash) } return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length] } const PROSE_CLASS = "prose prose-sm dark:prose-invert max-w-none [&_p]:leading-[1.55] [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0 [&_h1]:mt-2 [&_h2]:mt-2 [&_h3]:mt-2 [&_pre]:my-1.5 [&_blockquote]:my-1" export const ChatMessageBubble = memo(function ChatMessageBubble({ message, conversationId, onRegenerate, setIsStreaming, }: ChatMessageBubbleProps) { const isUser = message.role === "user" const [copied, setCopied] = useState<"answer" | "full" | false>(false) const [isEditing, setIsEditing] = useState(false) const [editText, setEditText] = useState("") const [showVersions, setShowVersions] = useState(false) const { data: user } = useCurrentUserQuery() const editMutation = useEditMessageMutation() const resendMutation = useResendMessageMutation() const forkMutation = useForkMessageMutation() const switchVersionMutation = useSwitchMessageVersionMutation() const runStream = useChatStreamRunner(setIsStreaming) const navigate = useNavigate() const { scope, projectName } = useChatPage() // Parse content into IrContentBlock[] (handles both old and future formats) const blocks: IrContentBlock[] = useMemo( () => (isUser ? [] : parseContentBlocks(message.content)), [isUser, message.content] ) // User message plain text const userText = typeof message.content === "string" ? message.content : message.content && typeof message.content === "object" ? String((message.content as Record).content ?? "") : "" const plainText = isUser ? userText : extractAnswerText(blocks) const hasThinking = blocks.some((b) => b.role === "thinking") const selectedContexts = useMemo( () => parseSlashContextMetadata(message.metadata), [message.metadata] ) // Fetch versions when showing version switcher const versionsQuery = useMessageVersionsQuery(conversationId, message.id) const handleCopyAnswer = () => { const text = isUser ? plainText : extractAnswerText(blocks) if (text) { navigator.clipboard.writeText(text) setCopied("answer") setTimeout(() => setCopied(false), 2000) } } const handleCopyFull = () => { const text = isUser ? plainText : extractFullText(blocks) if (text) { navigator.clipboard.writeText(text) setCopied("full") setTimeout(() => setCopied(false), 2000) } } const handleStartEdit = () => { setEditText(plainText) setIsEditing(true) } const handleCancelEdit = () => { setIsEditing(false) setEditText("") } const handleSaveEdit = async () => { if (!editText.trim()) return try { const newMsg = await editMutation.mutateAsync({ conversationId, messageId: message.id, content: editText.trim(), }) setIsEditing(false) onRegenerate?.(newMsg.id) await runStream(conversationId, newMsg.id) } catch (err) { console.error("Failed to edit message:", err) } } const handleRegenerate = async () => { try { const newMsg = await resendMutation.mutateAsync({ conversationId, messageId: message.id, }) onRegenerate?.(newMsg.id) await runStream(conversationId, newMsg.id) } catch (err) { console.error("Failed to regenerate:", err) } } const handleSwitchVersion = async (versionNumber: number) => { try { await switchVersionMutation.mutateAsync({ conversationId, messageId: message.id, versionNumber, }) setShowVersions(false) } catch (err) { console.error("Failed to switch version:", err) } } const handleFork = async () => { try { const fork = await forkMutation.mutateAsync({ conversationId, messageId: message.id, }) if (scope === "project" && projectName) { navigate(`/${projectName}/chat/${fork.id}`) } else { navigate(`/me/chat/${fork.id}`) } } catch (err) { console.error("Failed to fork:", err) } } return (
{/* Avatar */}
{isUser ? ( {(user?.display_name || user?.username || "U")[0]?.toUpperCase()} ) : ( )}
{/* Content */}
{/* Sender name */}
{isUser ? user?.display_name || user?.username || "You" : message.model || "Assistant"} {new Date(message.created_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", })} {!isUser && message.version_number > 1 && ( v{message.version_number} )}
{/* Interleaved blocks — thinking (collapsible) + answer (IrRenderer) */}
{isUser && selectedContexts.length > 0 && (
{selectedContexts.map((context) => ( {context.kind === "repo" ? ( ) : ( )} {context.label} {context.kind} ))}
)} {isUser && isEditing ? (