Files
CodeNomad/packages/ui/src/components/message-item.tsx
Shantur Rathore ca2b3c232f perf(ui): drop virtualized DOM in hidden panes
Add DOM instrumentation tags and harden VirtualItem visibility for hidden/zero-sized roots to prevent inactive instances from keeping heavy tool-call markup mounted; restore message stream virtualization margin.
2026-02-28 14:13:42 +00:00

668 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<span class="relative inline-block w-3.5 h-3.5" aria-hidden="true">
<ListStart class="absolute inset-0 w-3.5 h-3.5" aria-hidden="true" />
</span>
)
}
interface MessageItemProps {
record: MessageRecord
messageInfo?: MessageInfo
instanceId: string
sessionId: string
isQueued?: boolean
parts: ClientPart[]
onRevert?: (messageId: string) => void
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
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<ImagePreviewState | null>(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<ClientPart, { type: "file" }> & {
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 (
<div
class={containerClass()}
data-view="message-item"
data-instance-id={props.instanceId}
data-session-id={props.sessionId}
data-message-id={props.record.id}
data-message-role={isUser() ? "user" : "assistant"}
data-message-status={props.record.status}
>
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
<div class="message-item-header-row message-item-header-row--top" ref={(el) => (topRowEl = el)}>
<div class="message-header-left">
<div class="message-speaker-primary" ref={(el) => (speakerPrimaryEl = el)}>
<Show when={props.showDeleteMessage}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
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")}
/>
</Show>
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
{speakerLabel()}
</span>
</div>
<Show when={metaText() && showMetaInline()}>
<span class="message-agent-meta-inline">{metaText()}</span>
</Show>
<Show when={metaText()}>
<span
ref={(el) => (metaMeasureEl = el)}
class="message-agent-meta-inline message-agent-meta-inline--measure"
>
{metaText()}
</span>
</Show>
</div>
<div class="message-item-actions" ref={(el) => (actionsEl = el)}>
<Show when={isUser()}>
<div class="message-action-group">
<button
class="message-action-button"
onClick={handleCopy}
title={copyLabel()}
aria-label={copyLabel()}
>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<Show when={props.onFork}>
<button
class="message-action-button"
onClick={() => props.onFork?.(props.record.id)}
title={t("messageItem.actions.fork")}
aria-label={t("messageItem.actions.fork")}
>
<Split class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<Show when={props.onRevert}>
<button
class="message-action-button"
onClick={handleRevert}
title={t("messageItem.actions.revertTitle")}
aria-label={t("messageItem.actions.revertTitle")}
>
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<Show when={props.showDeleteMessage}>
<button
class="message-action-button"
onClick={() => void handleDeleteUpTo()}
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
class="message-action-button"
onClick={handleDeleteMessage}
disabled={deletingMessage()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div>
</Show>
<Show when={!isUser()}>
<div class="message-action-group">
<button
class="message-action-button"
onClick={handleCopy}
title={copyLabel()}
aria-label={copyLabel()}
>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<Show when={props.showDeleteMessage}>
<button
class="message-action-button"
onClick={() => void handleDeleteUpTo()}
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
class="message-action-button"
onClick={handleDeleteMessage}
disabled={deletingMessage()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div>
</Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div>
</div>
<Show when={metaText() && !showMetaInline()}>
<div class="message-item-header-row message-item-header-row--meta">
<span class="message-agent-meta-block">{metaText()}</span>
</div>
</Show>
</header>
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
<Show when={props.isQueued && isUser()}>
<div class="message-queued-badge">{t("messageItem.status.queued")}</div>
</Show>
<Show when={errorMessage()}>
<div class="message-error-block"> {errorMessage()}</div>
</Show>
<Show when={isGenerating()}>
<div class="message-generating">
<span class="generating-spinner"></span> {t("messageItem.status.generating")}
</div>
</Show>
<For each={messageParts()}>
{(part) => {
return (
<div class="message-part-shell">
<MessagePart
part={part}
messageType={props.record.role}
instanceId={props.instanceId}
sessionId={props.sessionId}
primaryUserTextPartId={primaryUserTextPartId()}
onRendered={props.onContentRendered}
/>
</div>
)
}}
</For>
<Show when={fileAttachments().length > 0}>
<div class="message-attachments mt-1">
<For each={fileAttachments()}>
{(attachment) => {
const name = getAttachmentName(attachment)
const isImage = isImageAttachment(attachment)
return (
<div
class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}
title={name}
onMouseEnter={(e) => {
if (!isImage) return
const el = e.currentTarget as HTMLElement
showImagePreview(el, attachment.url || "", name)
}}
onMouseLeave={() => setImagePreview(null)}
>
<Show when={isImage} fallback={
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
}>
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
</Show>
<span class="truncate max-w-[180px]">{name}</span>
<Show when={!attachment.url?.startsWith("file://")}>
<button
type="button"
onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download"
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
title={t("messageItem.attachment.downloadAriaLabel", { name })}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
</Show>
</div>
)
}}
</For>
</div>
</Show>
<Show when={imagePreview()}>
{(stateAccessor) => {
const state = stateAccessor()
const pos = () => getImagePreviewPosition()
return (
<Portal>
<Show when={pos()}>
{(posAccessor) => {
const coords = posAccessor()
return (
<div
class="attachment-image-popover"
style={{ left: `${coords.left}px`, top: `${coords.top}px` }}
aria-hidden="true"
>
<img src={state.url} alt={state.name} />
</div>
)
}}
</Show>
</Portal>
)
}}
</Show>
<Show when={props.record.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> {t("messageItem.status.sending")}
</div>
</Show>
<Show when={props.record.status === "error"}>
<div class="message-error"> {t("messageItem.status.failedToSend")}</div>
</Show>
</div>
</div>
)
}