diff --git a/packages/ui/src/components/code-block-inline.tsx b/packages/ui/src/components/code-block-inline.tsx index 150a9b1b..7e589f1d 100644 --- a/packages/ui/src/components/code-block-inline.tsx +++ b/packages/ui/src/components/code-block-inline.tsx @@ -2,6 +2,7 @@ import { createSignal, onMount, Show, createEffect } from "solid-js" import type { Highlighter } from "shiki/bundle/full" import { useTheme } from "../lib/theme" import { getSharedHighlighter, escapeHtml } from "../lib/markdown" +import { copyToClipboard } from "../lib/clipboard" const inlineLoadedLanguages = new Set() @@ -61,9 +62,11 @@ export function CodeBlockInline(props: CodeBlockInlineProps) { } const copyCode = async () => { - await navigator.clipboard.writeText(props.code) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + const success = await copyToClipboard(props.code) + if (success) { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } } return ( diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 3379bb0b..6317f90c 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -2,6 +2,7 @@ import { createEffect, createSignal, onMount, onCleanup } from "solid-js" import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown" import type { TextPart, RenderCache } from "../types/message" import { getLogger } from "../lib/logger" +import { copyToClipboard } from "../lib/clipboard" const log = getLogger("session") const markdownRenderCache = new Map() @@ -125,13 +126,20 @@ export function Markdown(props: MarkdownProps) { const code = copyButton.getAttribute("data-code") if (code) { const decodedCode = decodeURIComponent(code) - await navigator.clipboard.writeText(decodedCode) + const success = await copyToClipboard(decodedCode) const copyText = copyButton.querySelector(".copy-text") if (copyText) { - copyText.textContent = "Copied!" - setTimeout(() => { - copyText.textContent = "Copy" - }, 2000) + if (success) { + copyText.textContent = "Copied!" + setTimeout(() => { + copyText.textContent = "Copy" + }, 2000) + } else { + copyText.textContent = "Failed" + setTimeout(() => { + copyText.textContent = "Copy" + }, 2000) + } } } } diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 4c24e290..95ce2578 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -3,6 +3,7 @@ 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 @@ -15,9 +16,9 @@ interface MessageItemProps { onFork?: (messageId?: string) => void showAgentMeta?: boolean onContentRendered?: () => void - } +} - export default function MessageItem(props: MessageItemProps) { +export default function MessageItem(props: MessageItemProps) { const [copied, setCopied] = createSignal(false) const isUser = () => props.record.role === "user" @@ -155,8 +156,8 @@ interface MessageItemProps { const handleCopy = async () => { const content = getRawContent() if (!content) return - await navigator.clipboard.writeText(content) - setCopied(true) + const success = await copyToClipboard(content) + setCopied(success) setTimeout(() => setCopied(false), 2000) } diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index 346bb1f6..ed1d4e52 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -10,6 +10,7 @@ import { formatShortcut } from "../lib/keyboard-utils" import { showToastNotification } from "../lib/notifications" import { deleteSession, loading, renameSession } from "../stores/sessions" import { getLogger } from "../lib/logger" +import { copyToClipboard } from "../lib/clipboard" const log = getLogger("session") @@ -72,14 +73,14 @@ const SessionList: Component = (props) => { const copySessionId = async (event: MouseEvent, sessionId: string) => { event.stopPropagation() - + try { - if (typeof navigator === "undefined" || !navigator.clipboard) { - throw new Error("Clipboard API unavailable") + const success = await copyToClipboard(sessionId) + if (success) { + showToastNotification({ message: "Session ID copied", variant: "success" }) + } else { + showToastNotification({ message: "Unable to copy session ID", variant: "error" }) } - - await navigator.clipboard.writeText(sessionId) - showToastNotification({ message: "Session ID copied", variant: "success" }) } catch (error) { log.error(`Failed to copy session ID ${sessionId}:`, error) showToastNotification({ message: "Unable to copy session ID", variant: "error" }) diff --git a/packages/ui/src/lib/clipboard.ts b/packages/ui/src/lib/clipboard.ts new file mode 100644 index 00000000..4fbd4b74 --- /dev/null +++ b/packages/ui/src/lib/clipboard.ts @@ -0,0 +1,61 @@ +/** + * Clipboard utility with fallback for non-secure contexts + * The modern Clipboard API requires HTTPS or localhost, but document.execCommand + * works in HTTP contexts as a fallback. + */ + +import { getLogger } from "./logger" + +const log = getLogger("actions") + +/** + * Copy text to clipboard with fallback for non-secure contexts + * @param text - The text to copy + * @returns Promise - true if successful, false if failed + */ +export async function copyToClipboard(text: string): Promise { + try { + // Try modern Clipboard API first (requires secure context) + if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text) + log.info("Copied text using Clipboard API") + return true + } + } catch (error) { + log.warn("Clipboard API failed, trying fallback:", error) + } + + // Fallback for non-secure contexts (HTTP) using document.execCommand + try { + if (typeof document === "undefined") { + log.error("Document not available for clipboard fallback") + return false + } + + // Create temporary textarea element + const textArea = document.createElement("textarea") + textArea.value = text + textArea.style.position = "fixed" + textArea.style.left = "-9999px" + textArea.style.top = "-9999px" + textArea.style.opacity = "0" + + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + + const success = document.execCommand("copy") + document.body.removeChild(textArea) + + if (success) { + log.info("Copied text using execCommand fallback") + return true + } else { + log.error("execCommand copy failed") + return false + } + } catch (error) { + log.error("Clipboard fallback failed:", error) + return false + } +} \ No newline at end of file