From 3e98a03188477a154873c6e95f6ca54393d63a74 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 2 Jun 2026 10:52:10 +0800 Subject: [PATCH] fix: support fallback copy for webui replies --- webui/src/components/MessageBubble.tsx | 5 +- webui/src/lib/clipboard.ts | 40 +++++++++++++++ webui/src/tests/message-bubble.test.tsx | 66 +++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 webui/src/lib/clipboard.ts diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index 6fbd29a4d..d3d3b8ac5 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -14,6 +14,7 @@ import { CliAppMentionText } from "@/components/CliAppMentionText"; import { ImageLightbox } from "@/components/ImageLightbox"; import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText"; import { cn } from "@/lib/utils"; +import { copyTextToClipboard } from "@/lib/clipboard"; import { formatTurnLatency } from "@/lib/format"; import { toMediaAttachment } from "@/lib/media"; import type { @@ -71,8 +72,8 @@ export function MessageBubble({ }, []); const onCopyAssistantReply = useCallback(() => { - if (!navigator.clipboard) return; - void navigator.clipboard.writeText(message.content).then(() => { + void copyTextToClipboard(message.content).then((ok) => { + if (!ok) return; setCopied(true); if (copyResetRef.current !== null) { window.clearTimeout(copyResetRef.current); diff --git a/webui/src/lib/clipboard.ts b/webui/src/lib/clipboard.ts new file mode 100644 index 000000000..53ffdf78c --- /dev/null +++ b/webui/src/lib/clipboard.ts @@ -0,0 +1,40 @@ +export async function copyTextToClipboard(text: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // Fall through to the legacy path for browsers/WebViews where the + // Clipboard API exists but rejects outside a secure context. + } + + return copyTextWithTextarea(text); +} + +function copyTextWithTextarea(text: string): boolean { + if (typeof document.execCommand !== "function") { + return false; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "-9999px"; + textarea.style.left = "-9999px"; + textarea.style.opacity = "0"; + + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + try { + return document.execCommand("copy"); + } catch { + return false; + } finally { + document.body.removeChild(textarea); + } +} diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx index 1caaf0194..060f0374a 100644 --- a/webui/src/tests/message-bubble.test.tsx +++ b/webui/src/tests/message-bubble.test.tsx @@ -167,6 +167,72 @@ describe("MessageBubble", () => { ); }); + it("copies completed assistant replies with the textarea fallback", async () => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: undefined, + }); + const execCommand = vi.fn().mockReturnValue(true); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + const message: UIMessage = { + id: "a-copy-fallback", + role: "assistant", + content: "Fallback copy reply.", + createdAt: Date.now(), + }; + + try { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Copy reply" })); + + await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); + await waitFor(() => + expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(), + ); + } finally { + Reflect.deleteProperty(navigator, "clipboard"); + Reflect.deleteProperty(document, "execCommand"); + } + }); + + it("falls back when the Clipboard API rejects assistant reply copy", async () => { + const writeText = vi.fn().mockRejectedValue(new Error("not allowed")); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + const execCommand = vi.fn().mockReturnValue(true); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + const message: UIMessage = { + id: "a-copy-reject", + role: "assistant", + content: "Rejected clipboard copy.", + createdAt: Date.now(), + }; + + try { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Copy reply" })); + + expect(writeText).toHaveBeenCalledWith("Rejected clipboard copy."); + await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); + await waitFor(() => + expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(), + ); + } finally { + Reflect.deleteProperty(navigator, "clipboard"); + Reflect.deleteProperty(document, "execCommand"); + } + }); + it("does not show copy actions for streaming placeholders", () => { const message: UIMessage = { id: "a-streaming",