import { For, Show, createEffect, createSignal, onCleanup } from "solid-js" import { Portal } from "solid-js/web" import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid" import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message" import { partHasRenderableText } from "../types/message" import type { MessageRecord } from "../stores/message-v2/types" import MessagePart from "./message-part" import { copyToClipboard } from "../lib/clipboard" import { useI18n } from "../lib/i18n" import { showAlertDialog } from "../stores/alerts" import { deleteMessage } from "../stores/session-actions" import { isTauriHost } from "../lib/runtime-env" import type { DeleteHoverState } from "../types/delete-hover" function DeleteUpToIcon() { return ( ) } interface MessageItemProps { record: MessageRecord messageInfo?: MessageInfo instanceId: string sessionId: string isQueued?: boolean parts: ClientPart[] onRevert?: (messageId: string) => void selectedMessageIds?: () => Set onToggleSelectedMessage?: (messageId: string, selected: boolean) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise onFork?: (messageId?: string) => void showAgentMeta?: boolean onContentRendered?: () => void showDeleteMessage?: boolean onDeleteHoverChange?: (state: DeleteHoverState) => void } export default function MessageItem(props: MessageItemProps) { const { t } = useI18n() const [copied, setCopied] = createSignal(false) const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingUpTo, setDeletingUpTo] = createSignal(false) type ImagePreviewState = { url: string name: string anchor: HTMLElement } const [imagePreview, setImagePreview] = createSignal(null) const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) const getImagePreviewPosition = () => { const state = imagePreview() if (!state) return null const rect = state.anchor.getBoundingClientRect() // Outer box: 320px image + 8px padding on each side. const padding = 8 const maxImage = 320 const gap = 8 const chrome = padding * 2 const outerWidth = maxImage + chrome const outerHeight = maxImage + chrome const viewportW = window.innerWidth const viewportH = window.innerHeight const left = clamp(rect.left, 8, Math.max(8, viewportW - outerWidth - 8)) const fitsAbove = rect.top >= outerHeight + gap + 8 const preferredTop = fitsAbove ? rect.top - outerHeight - gap : rect.bottom + gap const top = clamp(preferredTop, 8, Math.max(8, viewportH - outerHeight - 8)) return { left, top } } createEffect(() => { const active = imagePreview() if (!active) return // If the user scrolls (message stream scroll container) or resizes, the anchor moves. // Hide the popover to avoid showing it in the wrong place. const hide = () => setImagePreview(null) window.addEventListener("scroll", hide, true) window.addEventListener("resize", hide) onCleanup(() => { window.removeEventListener("scroll", hide, true) window.removeEventListener("resize", hide) }) }) const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.record.id)) let topRowEl: HTMLDivElement | undefined let actionsEl: HTMLDivElement | undefined let speakerPrimaryEl: HTMLDivElement | undefined let metaMeasureEl: HTMLSpanElement | undefined const [showMetaInline, setShowMetaInline] = createSignal(true) const metaText = () => agentMeta() const updateMetaLayout = () => { const text = metaText() if (!text) return if (!topRowEl || !actionsEl || !speakerPrimaryEl || !metaMeasureEl) return const rowWidth = topRowEl.getBoundingClientRect().width const actionsWidth = actionsEl.getBoundingClientRect().width const primaryWidth = speakerPrimaryEl.getBoundingClientRect().width const metaWidth = metaMeasureEl.getBoundingClientRect().width // Allow for the flex gap between left and actions. const availableLeft = Math.max(0, rowWidth - actionsWidth - 12) setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft) } createEffect(() => { const text = metaText() if (!text || typeof ResizeObserver === "undefined") { setShowMetaInline(true) return } updateMetaLayout() const observer = new ResizeObserver(() => updateMetaLayout()) if (topRowEl) observer.observe(topRowEl) if (actionsEl) observer.observe(actionsEl) if (speakerPrimaryEl) observer.observe(speakerPrimaryEl) onCleanup(() => observer.disconnect()) }) const isUser = () => props.record.role === "user" const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt const timestamp = () => { const date = new Date(createdTimestamp()) return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) } const timestampIso = () => new Date(createdTimestamp()).toISOString() type FilePart = Extract & { url?: string mime?: string filename?: string } const messageParts = () => props.parts // User messages can temporarily include synthetic helper parts (e.g. tool traces / file reads). // We only want to display the primary prompt text for the user message; other synthetic text // parts should be hidden. const primaryUserTextPartId = () => { if (!isUser()) return null const firstText = messageParts().find((part) => part?.type === "text") as { id?: string } | undefined return typeof firstText?.id === "string" ? firstText.id : null } const fileAttachments = () => messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string") const getAttachmentName = (part: FilePart) => { if (part.filename && part.filename.trim().length > 0) { return part.filename } const url = part.url || "" if (url.startsWith("data:")) { return t("messageItem.attachment.defaultName") } try { const parsed = new URL(url) const segments = parsed.pathname.split("/") return segments.pop() || t("messageItem.attachment.defaultName") } catch (error) { const fallback = url.split("/").pop() return fallback && fallback.length > 0 ? fallback : t("messageItem.attachment.defaultName") } } const isImageAttachment = (part: FilePart) => { if (part.mime && typeof part.mime === "string" && part.mime.startsWith("image/")) { return true } return typeof part.url === "string" && part.url.startsWith("data:image/") } const handleAttachmentDownload = async (part: FilePart) => { const url = part.url if (!url) return const filename = getAttachmentName(part) const directDownload = (href: string) => { const anchor = document.createElement("a") anchor.href = href anchor.download = filename anchor.target = "_blank" anchor.rel = "noopener" document.body.appendChild(anchor) anchor.click() document.body.removeChild(anchor) } if (url.startsWith("data:")) { directDownload(url) return } if (url.startsWith("file://")) { // Local filesystem URLs are not reliably downloadable from the message stream. // We hide the download action for these chips. return } try { const response = await fetch(url) if (!response.ok) throw new Error(`Failed to fetch attachment: ${response.status}`) const blob = await response.blob() const objectUrl = URL.createObjectURL(blob) directDownload(objectUrl) URL.revokeObjectURL(objectUrl) } catch (error) { directDownload(url) } } const showImagePreview = (anchor: HTMLElement, url: string, name: string) => { if (!url) return setImagePreview({ anchor, url, name }) } const errorMessage = () => { const info = props.messageInfo if (!info || info.role !== "assistant" || !info.error) return null const error = info.error if (error.name === "ProviderAuthError") { return error.data?.message || t("messageItem.errors.authenticationFallback") } if (error.name === "MessageOutputLengthError") { return t("messageItem.errors.outputLengthExceeded") } if (error.name === "MessageAbortedError") { return t("messageItem.errors.requestAborted") } if (error.name === "UnknownError") { return error.data?.message || t("messageItem.errors.unknownFallback") } return null } const hasContent = () => { if (errorMessage() !== null) { return true } return messageParts().some((part) => partHasRenderableText(part)) } const isGenerating = () => { if (hasContent()) { return false } // Prefer the local record status for streaming placeholders. if (!isUser() && props.record.status === "streaming") { return true } const info = props.messageInfo const timeInfo = info?.time as { created: number; end?: number } | undefined return Boolean(info && info.role === "assistant" && (timeInfo?.end === undefined || timeInfo?.end === 0)) } const handleRevert = () => { if (props.onRevert && isUser()) { props.onRevert(props.record.id) } } const copyLabel = () => (copied() ? t("messageItem.actions.copied") : t("messageItem.actions.copy")) const getRawContent = () => { return props.parts .filter(part => part.type === "text") .map(part => (part as { text?: string }).text || "") .filter(text => text.trim().length > 0) .join("\n\n") } const handleCopy = async () => { const content = getRawContent() if (!content) return const success = await copyToClipboard(content) setCopied(success) setTimeout(() => setCopied(false), 2000) } const handleDeleteMessage = async () => { if (deletingMessage()) return setDeletingMessage(true) try { await deleteMessage(props.instanceId, props.sessionId, props.record.id) } catch (error) { showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), { title: t("messageItem.actions.deleteMessageFailedTitle"), detail: error instanceof Error ? error.message : String(error), variant: "error", }) } finally { setDeletingMessage(false) } } const handleDeleteUpTo = async () => { if (!props.onDeleteMessagesUpTo) return if (deletingUpTo()) return setDeletingUpTo(true) try { await props.onDeleteMessagesUpTo(props.record.id) } finally { setDeletingUpTo(false) } } if (!isUser() && !hasContent() && !isGenerating()) { return null } const containerClass = () => isUser() ? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]" : "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]" const speakerLabel = () => (isUser() ? t("messageItem.speaker.you") : t("messageItem.speaker.assistant")) const agentIdentifier = () => { if (isUser()) return "" const info = props.messageInfo if (!info || info.role !== "assistant") return "" return info.mode || "" } const modelIdentifier = () => { if (isUser()) return "" const info = props.messageInfo if (!info || info.role !== "assistant") return "" const modelID = info.modelID || "" const providerID = info.providerID || "" const base = modelID && providerID ? `${providerID}/${modelID}` : modelID if (!base) return "" const variant = (info as SDKAssistantMessageV2).variant if (typeof variant === "string" && variant.trim().length > 0) { return `${base} (${variant.trim()})` } return base } const agentMeta = () => { if (isUser() || !props.showAgentMeta) return "" const segments: string[] = [] const agent = agentIdentifier() const model = modelIdentifier() if (agent) { segments.push(t("messageItem.agentMeta.agentLabel", { agent })) } if (model) { segments.push(t("messageItem.agentMeta.modelLabel", { model })) } return segments.join(" • ") } return (
(topRowEl = el)}>
(speakerPrimaryEl = el)}> { event.stopPropagation() }} onChange={(event) => { event.stopPropagation() const next = Boolean((event.currentTarget as HTMLInputElement).checked) props.onToggleSelectedMessage?.(props.record.id, next) }} aria-label={t("messageItem.selection.checkboxAriaLabel")} title={t("messageItem.selection.checkboxAriaLabel")} /> {speakerLabel()}
{metaText()} (metaMeasureEl = el)} class="message-agent-meta-inline message-agent-meta-inline--measure" > {metaText()}
(actionsEl = el)}>
{metaText()}
{t("messageItem.status.queued")}
⚠️ {errorMessage()}
{t("messageItem.status.generating")}
{(part) => { return (
) }}
0}>
{(attachment) => { const name = getAttachmentName(attachment) const isImage = isImageAttachment(attachment) return (
{ if (!isImage) return const el = e.currentTarget as HTMLElement showImagePreview(el, attachment.url || "", name) }} onMouseLeave={() => setImagePreview(null)} > }> {name} {name}
) }}
{(stateAccessor) => { const state = stateAccessor() const pos = () => getImagePreviewPosition() return ( {(posAccessor) => { const coords = posAccessor() return ( ) }} ) }}
{t("messageItem.status.sending")}
⚠ {t("messageItem.status.failedToSend")}
) }