Files
CodeNomad/packages/ui/src/components/message-item.tsx
bizzkoot 3c450c076a fix: copy button functionality in web browsers
- Add clipboard utility with fallback for non-secure contexts
- Implement modern Clipboard API with document.execCommand fallback
- Update copy buttons in code blocks, markdown, messages, and session list
- Add proper error handling and user feedback for copy operations

Fixes issue where copy buttons did not work in web browsers served over HTTP or without Clipboard API support
2026-01-04 20:00:22 +08:00

354 lines
11 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, createSignal } from "solid-js"
import type { MessageInfo, ClientPart } 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"
interface MessageItemProps {
record: MessageRecord
messageInfo?: MessageInfo
instanceId: string
sessionId: string
isQueued?: boolean
parts: ClientPart[]
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
showAgentMeta?: boolean
onContentRendered?: () => void
}
export default function MessageItem(props: MessageItemProps) {
const [copied, setCopied] = createSignal(false)
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
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 "attachment"
}
try {
const parsed = new URL(url)
const segments = parsed.pathname.split("/")
return segments.pop() || "attachment"
} catch (error) {
const fallback = url.split("/").pop()
return fallback && fallback.length > 0 ? fallback : "attachment"
}
}
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://")) {
window.open(url, "_blank", "noopener")
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 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 || "Authentication error"
}
if (error.name === "MessageOutputLengthError") {
return "Message output length exceeded"
}
if (error.name === "MessageAbortedError") {
return "Request was aborted"
}
if (error.name === "UnknownError") {
return error.data?.message || "Unknown error occurred"
}
return null
}
const hasContent = () => {
if (errorMessage() !== null) {
return true
}
return messageParts().some((part) => partHasRenderableText(part))
}
const isGenerating = () => {
const info = props.messageInfo
return !hasContent() && info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0
}
const handleRevert = () => {
if (props.onRevert && isUser()) {
props.onRevert(props.record.id)
}
}
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)
}
if (!isUser() && !hasContent()) {
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() ? "You" : "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 || ""
if (modelID && providerID) return `${providerID}/${modelID}`
return modelID
}
const agentMeta = () => {
if (isUser() || !props.showAgentMeta) return ""
const segments: string[] = []
const agent = agentIdentifier()
const model = modelIdentifier()
if (agent) {
segments.push(`Agent: ${agent}`)
}
if (model) {
segments.push(`Model: ${model}`)
}
return segments.join(" • ")
}
return (
<div class={containerClass()}>
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
<div class="message-speaker">
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
{speakerLabel()}
</span>
<Show when={agentMeta()}>{(meta) => <span class="message-agent-meta">{meta()}</span>}</Show>
</div>
<div class="message-item-actions">
<Show when={isUser()}>
<div class="message-action-group">
<Show when={props.onRevert}>
<button
class="message-action-button"
onClick={handleRevert}
title="Revert to this message"
aria-label="Revert to this message"
>
Revert
</button>
</Show>
<Show when={props.onFork}>
<button
class="message-action-button"
onClick={() => props.onFork?.(props.record.id)}
title="Fork from this message"
aria-label="Fork from this message"
>
Fork
</button>
</Show>
<button
class="message-action-button"
onClick={handleCopy}
title="Copy message"
aria-label="Copy message"
>
<Show when={copied()} fallback="Copy">
Copied!
</Show>
</button>
</div>
</Show>
<Show when={!isUser()}>
<button
class="message-action-button"
onClick={handleCopy}
title="Copy message"
aria-label="Copy message"
>
<Show when={copied()} fallback="Copy">
Copied!
</Show>
</button>
</Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div>
</header>
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
<Show when={props.isQueued && isUser()}>
<div class="message-queued-badge">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> Generating...
</div>
</Show>
<For each={messageParts()}>
{(part) => (
<MessagePart
part={part}
messageType={props.record.role}
instanceId={props.instanceId}
sessionId={props.sessionId}
onRendered={props.onContentRendered}
/>
)}
</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}>
<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>
<button
type="button"
onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download"
aria-label={`Download ${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 when={isImage}>
<div class="attachment-chip-preview">
<img src={attachment.url} alt={name} />
</div>
</Show>
</div>
)
}}
</For>
</div>
</Show>
<Show when={props.record.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>
</Show>
<Show when={props.record.status === "error"}>
<div class="message-error"> Message failed to send</div>
</Show>
</div>
</div>
)
}