"use client"; import * as React from "react"; import { AlertTriangle, BookmarkPlus, Brain, Check, ChevronDown, ChevronRight, Columns3, Copy, ExternalLink, FileText, FileDown, GitBranch, Globe, Loader2, MoreHorizontal, Pencil, Quote, RefreshCw, Shuffle, StepForward, Trash2, Wrench, X } from "lucide-react"; import type { PublicMessage, StoredPart, PolyModel, StoredMessageError } from "@/lib/client/types"; import { Markdown } from "@/components/markdown/markdown"; import { ProviderIcon } from "@/components/brand/provider-icon"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/input"; import { Tooltip } from "@/components/ui/tooltip"; import { Badge } from "@/components/ui/badge"; import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { useIsMobile, useLongPress } from "@/lib/client/hooks"; import { deprecationNotice } from "@/lib/chat/deprecation"; import { MessageError } from "./message-error"; import { cn, formatMs, formatTokens, formatUsd } from "@/lib/utils"; import { PROVIDERS } from "@/lib/client/providers"; export interface LiveState { text: string; reasoning: string; tools: { id: string; name: string; args: string; result?: unknown; isError?: boolean; durationMs?: number }[]; serverTools: { name: string; status: string }[]; citations: { url?: string; title?: string; snippet?: string }[]; startedAt: number; firstTokenAt?: number; } export interface MessageActions { onCopy?: (text: string) => void; onEdit?: (m: PublicMessage, text: string) => void; /** Retry with the same model. */ onRegenerate?: (m: PublicMessage) => void; /** Opens the model picker, then regenerates with the chosen model. */ onRegenerateWith?: (m: PublicMessage) => void; onRetry?: (m: PublicMessage) => void; onContinue?: (m: PublicMessage) => void; onBranch?: (m: PublicMessage) => void; onDelete?: (m: PublicMessage) => void; onCompare?: (m: PublicMessage) => void; onQuote?: (m: PublicMessage) => void; onSaveAsPrompt?: (m: PublicMessage) => void; onExport?: (m: PublicMessage) => void; /** "Switch to " from the deprecated-model notice. */ onSwitchModel?: (key: string) => void; } interface Props { message: PublicMessage; model?: PolyModel; /** Suggested replacement when `model` is deprecated / retiring (computed by the parent). */ replacement?: PolyModel | null; live?: LiveState | null; isLast?: boolean; wrapCode?: boolean; showReasoning?: boolean; showCosts?: boolean; actions?: MessageActions; busy?: boolean; /** Request id of the turn that produced this message (for the error details sheet). */ requestId?: string | null; /** Epoch ms when the error was received (drives the retry countdown). */ errorAt?: number; } type Item = { key: string; label: string; icon: React.ReactNode; onSelect: () => void; destructive?: boolean; hidden?: boolean }; export const MessageItem = React.memo(function MessageItem({ message: m, model, replacement, live, isLast, wrapCode, showReasoning = true, showCosts = true, actions, busy, requestId, errorAt }: Props) { const isUser = m.role === "user"; const streaming = m.status === "streaming" && Boolean(live); const isMobile = useIsMobile(); const [editing, setEditing] = React.useState(false); const [draft, setDraft] = React.useState(m.content); const [copied, setCopied] = React.useState(false); const [sheetOpen, setSheetOpen] = React.useState(false); const [detailsOpen, setDetailsOpen] = React.useState(false); const [now] = React.useState(() => Date.now()); const text = streaming ? live!.text : m.content; const parts = m.parts as StoredPart[]; const reasoningParts = parts.filter((p): p is Extract => p.type === "reasoning"); const reasoning = streaming ? live!.reasoning : reasoningParts.map((p) => p.text).join("\n\n"); const attachments = parts.filter((p): p is Extract => p.type === "attachment"); const toolCalls = streaming ? live!.tools : parts.filter((p): p is Extract => p.type === "tool-call").map((p) => ({ id: p.id, name: p.name, args: p.argumentsText ?? JSON.stringify(p.arguments), result: p.result, isError: p.isError, durationMs: p.durationMs })); const citations = streaming ? live!.citations : parts.filter((p): p is Extract => p.type === "citation"); const serverTools = streaming ? live!.serverTools : parts.filter((p): p is Extract => p.type === "server-tool"); const refusal = parts.find((p): p is Extract => p.type === "refusal"); const usage = m.usage as { inputTokens?: number; outputTokens?: number; reasoningTokens?: number; cachedInputTokens?: number } | null; const settings = (m as { settings?: Record | null }).settings ?? null; const tps = m.latencyMs && usage?.outputTokens ? Math.round((usage.outputTokens / Math.max(1, m.latencyMs - (m.ttftMs ?? 0))) * 1000) : null; const notice = deprecationNotice(model, now); const copy = React.useCallback(async () => { await navigator.clipboard.writeText(text).catch(() => {}); actions?.onCopy?.(text); setCopied(true); setTimeout(() => setCopied(false), 1400); }, [text, actions]); // --- action list (shared by the hover toolbar, the "more" menu and the long-press sheet) ---------- const items: Item[] = isUser ? [ { key: "copy", label: copied ? "Copied" : "Copy", icon: copied ? : , onSelect: copy }, { key: "edit", label: "Edit & resend", icon: , onSelect: () => setEditing(true), hidden: !actions?.onEdit || busy }, { key: "quote", label: "Quote", icon: , onSelect: () => actions?.onQuote?.(m), hidden: !actions?.onQuote }, { key: "branch", label: "Branch from here", icon: , onSelect: () => actions?.onBranch?.(m), hidden: !actions?.onBranch }, { key: "prompt", label: "Save as prompt", icon: , onSelect: () => actions?.onSaveAsPrompt?.(m), hidden: !actions?.onSaveAsPrompt }, { key: "export", label: "Export message", icon: , onSelect: () => actions?.onExport?.(m), hidden: !actions?.onExport }, { key: "delete", label: "Delete", icon: , onSelect: () => actions?.onDelete?.(m), destructive: true, hidden: !actions?.onDelete || busy }, ] : [ { key: "copy", label: copied ? "Copied" : "Copy", icon: copied ? : , onSelect: copy }, { key: "retry", label: "Retry", icon: , onSelect: () => actions?.onRegenerate?.(m), hidden: !actions?.onRegenerate || busy }, { key: "retry-with", label: "Retry with another model", icon: , onSelect: () => actions?.onRegenerateWith?.(m), hidden: !actions?.onRegenerateWith || busy }, { key: "continue", label: "Continue", icon: , onSelect: () => actions?.onContinue?.(m), hidden: !actions?.onContinue || !isLast || busy || !(m.finishReason === "length" || m.status === "stopped") }, { key: "compare", label: "Compare this response", icon: , onSelect: () => actions?.onCompare?.(m), hidden: !actions?.onCompare || busy }, { key: "branch", label: "Branch from here", icon: , onSelect: () => actions?.onBranch?.(m), hidden: !actions?.onBranch }, { key: "quote", label: "Quote", icon: , onSelect: () => actions?.onQuote?.(m), hidden: !actions?.onQuote }, { key: "prompt", label: "Save as prompt", icon: , onSelect: () => actions?.onSaveAsPrompt?.(m), hidden: !actions?.onSaveAsPrompt }, { key: "export", label: "Export message", icon: , onSelect: () => actions?.onExport?.(m), hidden: !actions?.onExport }, { key: "delete", label: "Delete", icon: , onSelect: () => actions?.onDelete?.(m), destructive: true, hidden: !actions?.onDelete || busy }, ]; const visible = items.filter((i) => !i.hidden); const primaryKeys = isUser ? ["copy", "edit", "branch"] : ["copy", "retry", "continue", "compare", "branch"]; const primary = visible.filter((i) => primaryKeys.includes(i.key)); const more = visible.filter((i) => !primaryKeys.includes(i.key)); const longPress = useLongPress({ onLongPress: () => setSheetOpen(true), disabled: !isMobile || streaming || editing || visible.length === 0 }); const toolbar = !streaming ? (
{primary.map((i) => ( {i.icon} ))} {more.length ? ( isMobile ? ( setSheetOpen(true)}> ) : ( {more.map((i, idx) => ( {i.destructive && idx > 0 ? : null} setTimeout(i.onSelect, 10)}> {i.icon} {i.label} ))} ) ) : null}
) : null; const sheet = isMobile ? (

{text || "(no text)"}

) : null; if (isUser) { return (
{attachments.length ? (
{attachments.map((a) => ( ))}
) : null} {editing ? (