/* * MessageBubble.tsx * Zyquo Cloud Web * * Author: Simon-Pierre Boucher * Mail: contact@spboucher.ai * * One chat turn: user right (accent-subtle) / assistant left (surface + * hairline + provider avatar). Markdown body, collapsible reasoning, * citation chips, attachments, error note, hover metadata + actions * (copy, edit & resend, regenerate, quote, branch, read-aloud, delete), * variant switcher, streaming caret. */ import { memo, useState } from 'react' import { costLabel } from '../features/catalogHelpers' import { useStore } from '../state/store' import type { Message } from '../types' import { Markdown } from './Markdown' import { IconBranch, IconCheck, IconChevronRight, IconCopy, IconEdit, IconQuote, IconRefresh, IconSpeaker, IconTrash, IconX, ProviderGlyph, } from './icons' function timeLabel(timestamp: number): string { return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } export const MessageBubble = memo(function MessageBubble({ conversationID, message, streaming, isLast = false, onQuote, }: { conversationID: string message: Message streaming: boolean isLast?: boolean onQuote: (text: string) => void }) { const continueGeneration = useStore((s) => s.continueGeneration) const regenerate = useStore((s) => s.regenerate) const editAndResend = useStore((s) => s.editAndResend) const deleteMessage = useStore((s) => s.deleteMessage) const branchFrom = useStore((s) => s.branchFrom) const setActiveVariant = useStore((s) => s.setActiveVariant) const toast = useStore((s) => s.toast) const [reasoningOpen, setReasoningOpen] = useState(false) const [editing, setEditing] = useState(false) const [draft, setDraft] = useState(message.text) const [copied, setCopied] = useState(false) const isUser = message.role === 'user' const variants = message.variants ?? [] const activeVariant = message.activeVariant !== undefined ? variants[message.activeVariant] : undefined const shownText = activeVariant?.text ?? message.text const shownReasoning = activeVariant?.reasoning ?? message.reasoning const shownCitations = activeVariant?.citations ?? message.citations const shownUsage = activeVariant?.usage ?? message.usage const shownCost = activeVariant?.estimatedCost ?? message.estimatedCost const copy = () => { void navigator.clipboard.writeText(shownText) setCopied(true) setTimeout(() => setCopied(false), 1200) } const readAloud = () => { if (!('speechSynthesis' in window)) { toast('Speech synthesis not available in this browser', 'error') return } speechSynthesis.cancel() speechSynthesis.speak(new SpeechSynthesisUtterance(shownText)) } return (
{!isUser && (
)}
{(message.attachments ?? []).filter((a) => a.kind === 'image').length > 0 && (
{(message.attachments ?? []) .filter((a) => a.kind === 'image') .map((a) => ( {a.fileName} ))}
)} {(message.attachments ?? []).filter((a) => a.kind === 'textFile').length > 0 && (
{(message.attachments ?? []) .filter((a) => a.kind === 'textFile') .map((a) => ( {a.fileName} ))}
)} {shownReasoning !== undefined && shownReasoning !== '' && (
{reasoningOpen &&
{shownReasoning}
}
)} {editing ? (