diff --git a/docs/web/control-ui/chat.md b/docs/web/control-ui/chat.md index 36e0ba995c82..de642bdb9094 100644 --- a/docs/web/control-ui/chat.md +++ b/docs/web/control-ui/chat.md @@ -156,6 +156,14 @@ Chat error banners, including cloud runner failures, show short messages in full Decoded text artifacts use the same literal preview. **Copy code** preserves the code's leading whitespace and final newline when present. +**Copy URL** in browser tab cards also works on plain HTTP connections where the +browser does not provide its Clipboard API. + +If the browser rejects a text clipboard write, starting another text or image +copy cancels its delayed fallback. Code blocks replaced during streaming and +Mermaid diagrams whose source changes also cancel that fallback. This does not +cancel native clipboard writes that the browser has already accepted. + ### Markdown tables Markdown tables scroll horizontally within the conversation. **Copy table** copies diff --git a/ui/src/components/markdown-clipboard.browser.test.ts b/ui/src/components/markdown-clipboard.browser.test.ts new file mode 100644 index 000000000000..a13f12c9a2cd --- /dev/null +++ b/ui/src/components/markdown-clipboard.browser.test.ts @@ -0,0 +1,353 @@ +import { html, nothing, render } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createDeferred } from "../../../test/helpers/promise.js"; +import { renderMessageImages } from "../pages/chat/components/chat-message-images.ts"; +import { renderMessageMarkdown } from "../pages/chat/components/chat-message-text.ts"; +import { exportWidget } from "../pages/chat/components/widget-export.ts"; +import "../pages/chat/components/browser-tab-card.ts"; +import { renderCopyButton } from "./copy-button.ts"; +import { handleMarkdownCodeBlockClick } from "./markdown-code-blocks.ts"; +import "./markdown-mermaid.ts"; +import { handleMarkdownTableInteraction, releaseMarkdownTables } from "./markdown-tables.ts"; +import { toSanitizedMarkdownHtml } from "./markdown.ts"; + +vi.mock("@openclaw/mermaid-renderer", async (importOriginal) => ({ + ...(await importOriginal()), + renderMermaidSvg: async () => '', +})); + +const owners: HTMLElement[] = []; +const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard"); +let clipboard = "original clipboard"; +const writeText = vi.fn<(text: string) => Promise>(); +const fallbackCopies: string[] = []; + +beforeEach(() => { + clipboard = "original clipboard"; + fallbackCopies.length = 0; + writeText.mockReset().mockImplementation(async (text) => { + clipboard = text; + }); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + // Both transports stay inside this page; these tests never write the OS clipboard. + vi.spyOn(document, "execCommand").mockImplementation(() => { + const input = document.activeElement; + if (!(input instanceof HTMLTextAreaElement)) { + throw new Error("Clipboard fallback did not select its text"); + } + clipboard = input.value; + fallbackCopies.push(clipboard); + return true; + }); +}); + +afterEach(() => { + for (const owner of owners.splice(0)) { + releaseMarkdownTables(owner); + render(nothing, owner); + owner.remove(); + } + vi.restoreAllMocks(); + if (clipboardDescriptor) { + Object.defineProperty(navigator, "clipboard", clipboardDescriptor); + } else { + Reflect.deleteProperty(navigator, "clipboard"); + } +}); + +async function mountCopy(surface: "code" | "table" | "mermaid" | "message") { + const owner = document.body.appendChild(document.createElement("section")); + owners.push(owner); + owner.className = "chat-text"; + let button: HTMLButtonElement | null; + if (surface === "code") { + render( + renderMessageMarkdown( + "```ts\nconst answer = 42;", + "stream", + { role: "assistant", isStreaming: true }, + {}, + ), + owner, + ); + owner.addEventListener("click", handleMarkdownCodeBlockClick); + button = owner.querySelector(".code-block-copy"); + } else if (surface === "table") { + render( + html`${unsafeHTML(toSanitizedMarkdownHtml("| Name |\n| --- |\n| Alpha |", { tableInteractions: "enabled" }))}`, + owner, + ); + owner.addEventListener("click", handleMarkdownTableInteraction); + button = owner.querySelector(".markdown-table__copy"); + } else if (surface === "mermaid") { + const diagram = document.createElement("openclaw-mermaid"); + diagram.source = "flowchart LR\nA --> B"; + owner.append(diagram); + await diagram.updateComplete; + button = diagram.shadowRoot?.querySelector(".copy-button") ?? null; + } else { + render(renderCopyButton("Current message"), owner); + button = owner.querySelector("button"); + } + if (!button) { + throw new Error(`Missing ${surface} copy control`); + } + return { owner, button }; +} + +function delayFirstWrite() { + const pending = createDeferred(); + writeText.mockReturnValueOnce(pending.promise); + return pending; +} + +async function mountBrowserCard() { + const card = document.createElement("openclaw-browser-tab-card"); + card.preview = { + kind: "browser-tab", + target: "host", + profile: "managed", + targetId: "clipboard-tab", + url: "https://example.com/document", + }; + document.body.append(card); + owners.push(card); + await card.updateComplete; + return { + card, + copy: () => + card.shadowRoot + ?.querySelector("wa-dropdown") + ?.dispatchEvent(new CustomEvent("wa-select", { detail: { item: { value: "copy-url" } } })), + }; +} + +async function flushCopy() { + // Clipboard handlers update after the transport's rejection/fallback microtasks. + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +describe("Markdown clipboard operation lifetime", () => { + it.each( + ["managed image", "widget"].flatMap((surface) => + [true, false].map((available) => ({ surface, available })), + ), + )( + "retires text fallback after $surface copy intent (binary API: $available)", + async ({ surface, available }) => { + const pendingText = delayFirstWrite(); + const pngData = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jR7sAAAAASUVORK5CYII="; + const nativeFetch = globalThis.fetch.bind(globalThis); + const png = await (await nativeFetch(pngData)).blob(); + const snapshot = createDeferred(); + const fullImage = createDeferred(); + const write = vi.fn(async (items: ClipboardItem[]) => { + expect((await items[0]!.getType("image/png")).type).toBe("image/png"); + clipboard = "new binary image"; + }); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText, ...(available ? { write } : {}) }, + }); + let copy: () => void; + let widgetResult: Promise | undefined; + if (surface === "managed image") { + vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { + const url = input instanceof Request ? input.url : String(input); + return url.includes("/thumbnail") + ? Promise.resolve(new Response(png)) + : url.includes("/full") + ? fullImage.promise + : nativeFetch(input, init); + }); + const owner = document.body.appendChild(document.createElement("section")); + owners.push(owner); + render( + renderMessageImages([ + { + url: `/api/chat/media/outgoing/agent%3Amain%3Amain/${crypto.randomUUID()}/full`, + alt: "Synthetic image", + }, + ]), + owner, + ); + await vi.waitFor(() => + expect(owner.querySelector('[aria-label="Copy image"]')).not.toBeNull(), + ); + copy = () => owner.querySelector('[aria-label="Copy image"]')!.click(); + } else { + const frame = document.body.appendChild(document.createElement("iframe")); + owners.push(frame); + copy = () => { + widgetResult = exportWidget("copy", frame, "Synthetic widget", { + requestSnapshot: () => snapshot.promise, + }).catch((error: unknown) => error); + }; + } + const code = await mountCopy("code"); + code.button.click(); + copy(); + // Binary ClipboardItem promises must be submitted during the click, before bytes arrive. + expect(write).toHaveBeenCalledTimes(available ? 1 : 0); + pendingText.reject(new Error("Synthetic clipboard rejection")); + await flushCopy(); + snapshot.resolve(pngData); + fullImage.resolve(new Response(png)); + await widgetResult; + if (available) { + await vi.waitFor(() => expect(clipboard).toBe("new binary image")); + } + expect(fallbackCopies).toEqual([]); + }, + ); + + it("copies browser-card URLs when the Clipboard API is absent on plain HTTP", async () => { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); + const browser = await mountBrowserCard(); + browser.copy(); + await flushCopy(); + expect(clipboard).toBe("https://example.com/document"); + expect(fallbackCopies).toEqual(["https://example.com/document"]); + }); + + it("keeps a newer browser URL copy when older code copying rejects", async () => { + const pending = delayFirstWrite(); + const code = await mountCopy("code"); + const browser = await mountBrowserCard(); + code.button.click(); + browser.copy(); + await flushCopy(); + expect(clipboard).toBe("https://example.com/document"); + pending.reject(new Error("Synthetic clipboard rejection")); + await flushCopy(); + expect(clipboard).toBe("https://example.com/document"); + expect(fallbackCopies).toEqual([]); + }); + + it.each(["removed", "URL changed"])( + "retires browser URL copying after its card is %s", + async (change) => { + const pending = delayFirstWrite(); + const browser = await mountBrowserCard(); + browser.copy(); + if (change === "removed") { + browser.card.remove(); + } else { + browser.card.preview = { ...browser.card.preview!, url: "https://example.com/new" }; + await browser.card.updateComplete; + } + pending.reject(new Error("Synthetic clipboard rejection")); + await flushCopy(); + expect(fallbackCopies).toEqual([]); + }, + ); + + it.each(["code", "table", "mermaid", "message"] as const)( + "does not start fallback after the %s control is retired", + async (surface) => { + const pending = delayFirstWrite(); + const { owner, button } = await mountCopy(surface); + button.click(); + expect(writeText).toHaveBeenCalledOnce(); + owner.remove(); + pending.reject(new Error("Synthetic clipboard rejection")); + await flushCopy(); + expect(fallbackCopies).toEqual([]); + expect(clipboard).toBe("original clipboard"); + }, + ); + + it("preserves the newer copy when an older connected code control rejects", async () => { + const pending = delayFirstWrite(); + const older = await mountCopy("code"); + const newer = await mountCopy("message"); + older.button.click(); + newer.button.click(); + await flushCopy(); + expect(clipboard).toBe("Current message"); + expect(older.button.isConnected).toBe(true); + pending.reject(new Error("Synthetic clipboard rejection")); + await flushCopy(); + expect(fallbackCopies).toEqual([]); + expect(clipboard).toBe("Current message"); + }); + + it("retires code replaced by the next streaming update", async () => { + const pending = delayFirstWrite(); + const { owner, button } = await mountCopy("code"); + button.click(); + render( + renderMessageMarkdown( + "```ts\nconst answer = 42;\nconst next = 43;", + "stream", + { role: "assistant", isStreaming: true }, + {}, + ), + owner, + ); + expect(button.isConnected).toBe(false); + expect(owner.querySelector("code")?.textContent).toContain("const next = 43;"); + pending.reject(new Error("Synthetic clipboard rejection")); + await flushCopy(); + expect(fallbackCopies).toEqual([]); + }); + + it("retires an older fallback when the newer code-copy payload is empty", async () => { + const pending = delayFirstWrite(); + const older = await mountCopy("code"); + const newer = await mountCopy("code"); + render( + renderMessageMarkdown("```ts\n```", "empty", { role: "assistant", isStreaming: false }, {}), + newer.owner, + ); + older.button.click(); + newer.owner.querySelector(".code-block-copy")!.click(); + expect(writeText).toHaveBeenCalledOnce(); + pending.reject(new Error("Synthetic clipboard rejection")); + await flushCopy(); + expect(fallbackCopies).toEqual([]); + expect(clipboard).toBe("original clipboard"); + }); + + it("retires a Mermaid copy when its source changes on the connected element", async () => { + const pending = delayFirstWrite(); + const { owner, button } = await mountCopy("mermaid"); + const diagram = owner.querySelector("openclaw-mermaid")!; + button.click(); + diagram.source = "flowchart LR\nC --> D"; + await diagram.updateComplete; + expect(diagram.isConnected).toBe(true); + pending.reject(new Error("Synthetic clipboard rejection")); + await flushCopy(); + expect(fallbackCopies).toEqual([]); + }); + + it("keeps the newer table-copy feedback when an older request rejects", async () => { + const pending = delayFirstWrite(); + const { button } = await mountCopy("table"); + button.click(); + button.click(); + await flushCopy(); + expect(button.getAttribute("aria-label")).toBe("Copied!"); + pending.reject(new Error("Synthetic clipboard rejection")); + await flushCopy(); + expect(fallbackCopies).toEqual([]); + expect(button.getAttribute("aria-label")).toBe("Copied!"); + }); + + it.each(["code", "table", "mermaid", "message"] as const)( + "keeps fallback available for the current %s control", + async (surface) => { + writeText.mockRejectedValueOnce(new Error("Synthetic clipboard rejection")); + const { button } = await mountCopy(surface); + button.click(); + await flushCopy(); + expect(fallbackCopies).toEqual([writeText.mock.calls[0]![0]]); + expect(button.getAttribute("aria-label")).toBe("Copied!"); + }, + ); +}); diff --git a/ui/src/components/markdown-code-blocks.ts b/ui/src/components/markdown-code-blocks.ts index a74c9d47196d..08c60c787a50 100644 --- a/ui/src/components/markdown-code-blocks.ts +++ b/ui/src/components/markdown-code-blocks.ts @@ -95,9 +95,10 @@ export function handleMarkdownCodeBlockClick(event: Event): void { const code = decodeCodeBlockCopyPayload(button.dataset.code ?? "", button.dataset.codeEncoding); const attempt = (codeBlockCopyAttempts.get(button) ?? 0) + 1; codeBlockCopyAttempts.set(button, attempt); - void copyToClipboard(code).then((copied) => { + const isCurrent = () => button.isConnected && codeBlockCopyAttempts.get(button) === attempt; + void copyToClipboard(code, isCurrent).then((copied) => { // Clipboard writes can finish out of click order; older attempts must not own feedback. - if (codeBlockCopyAttempts.get(button) !== attempt) { + if (!isCurrent()) { return; } button.classList.toggle("copied", copied); diff --git a/ui/src/components/markdown-mermaid.test.ts b/ui/src/components/markdown-mermaid.test.ts index c607f8b6801d..7f3118b9680f 100644 --- a/ui/src/components/markdown-mermaid.test.ts +++ b/ui/src/components/markdown-mermaid.test.ts @@ -126,7 +126,7 @@ describe("Mermaid Markdown presentation", () => { await vi.waitFor(() => expect(action(element!, copied ? "Copied!" : "Copy failed")).toBeDefined(), ); - expect(copySource).toHaveBeenCalledExactlyOnceWith(original); + expect(copySource.mock.calls.map(([text]) => text)).toEqual([original]); action(element!, "Show diagram").click(); await element!.updateComplete; @@ -161,7 +161,7 @@ describe("Mermaid Markdown presentation", () => { expect(element!.shadowRoot?.textContent).not.toContain("internal parser detail"); expect(action(element!, "Expand diagram").disabled).toBe(true); action(element!, "Copy source").click(); - await vi.waitFor(() => expect(copySource).toHaveBeenCalledExactlyOnceWith(original)); + await vi.waitFor(() => expect(copySource.mock.calls.map(([text]) => text)).toEqual([original])); if (failure === "image") { expect(revokeObjectURL).toHaveBeenCalledExactlyOnceWith("blob:mermaid-1"); } else { diff --git a/ui/src/components/markdown-mermaid.ts b/ui/src/components/markdown-mermaid.ts index 69579ba1de37..5056e236f0e9 100644 --- a/ui/src/components/markdown-mermaid.ts +++ b/ui/src/components/markdown-mermaid.ts @@ -255,8 +255,9 @@ class OpenClawMermaid extends OpenClawLitElement { private async copySource() { const attempt = ++this.copyAttempt; - const copied = await copyToClipboard(this.source); - if (this.isConnected && attempt === this.copyAttempt) { + const isCurrent = () => this.isConnected && attempt === this.copyAttempt; + const copied = await copyToClipboard(this.source, isCurrent); + if (isCurrent()) { this.copyResult = copied; } } diff --git a/ui/src/components/markdown-tables.ts b/ui/src/components/markdown-tables.ts index db4ec03b6a93..387804a45f29 100644 --- a/ui/src/components/markdown-tables.ts +++ b/ui/src/components/markdown-tables.ts @@ -12,6 +12,7 @@ const tableViewportSelector = ".markdown-table__viewport"; const enhancedTableShells = new WeakSet(); const tableOwnerStates = new WeakMap(); const tableCopyResetTimers = new WeakMap>(); +const tableCopyAttempts = new WeakMap(); type TableOwnerState = { release: () => void; @@ -235,7 +236,13 @@ export function handleMarkdownTableInteraction(event: Event): void { } const copy = target.closest(".markdown-table__copy"); if (copy) { - void copyToClipboard(tableText(table)).then((copied) => { + const attempt = (tableCopyAttempts.get(copy) ?? 0) + 1; + tableCopyAttempts.set(copy, attempt); + const isCurrent = () => copy.isConnected && tableCopyAttempts.get(copy) === attempt; + void copyToClipboard(tableText(table), isCurrent).then((copied) => { + if (!isCurrent()) { + return; + } copy.setAttribute("aria-label", t(copied ? "common.copied" : "common.copyFailed")); if (copied) { render(icons.check, copy); diff --git a/ui/src/components/markdown.test.ts b/ui/src/components/markdown.test.ts index 3426a858b663..480ecb694df7 100644 --- a/ui/src/components/markdown.test.ts +++ b/ui/src/components/markdown.test.ts @@ -299,12 +299,14 @@ describe("toSanitizedMarkdownHtml", () => { }); fragment.addEventListener("click", handleMarkdownCodeBlockClick); try { + document.body.append(fragment); const button = fragment.querySelector(".code-block-copy"); expect(button).toBeInstanceOf(HTMLButtonElement); button!.click(); await vi.waitFor(() => expect(button!.getAttribute("aria-label")).toBe("Copied!")); expect(writeText).toHaveBeenCalledWith(text); } finally { + fragment.remove(); fragment.removeEventListener("click", handleMarkdownCodeBlockClick); for (const [index, [, delay]] of schedule.mock.calls.entries()) { if (delay === 1_500) { diff --git a/ui/src/lib/clipboard.ts b/ui/src/lib/clipboard.ts index ea0e170b59ad..741bd0c6d9a3 100644 --- a/ui/src/lib/clipboard.ts +++ b/ui/src/lib/clipboard.ts @@ -5,10 +5,17 @@ // is undefined, so calling it throws synchronously rather than rejecting. Guard // the secure-context path and fall back to the legacy execCommand copy so the // copy buttons keep working over HTTP. Returns whether the copy succeeded. +let clipboardWriteAttempt = 0; + +export function beginClipboardCopy(): number { + return ++clipboardWriteAttempt; +} + export async function copyToClipboard( text: string, shouldFallback?: () => boolean, ): Promise { + const attempt = beginClipboardCopy(); if (!text) { return false; } @@ -21,9 +28,9 @@ export async function copyToClipboard( // fall through to the execCommand path before giving up. } } - // A rejected async write can settle after newer caller-owned work. Let that - // owner retire this second transport attempt before it mutates the clipboard. - if (shouldFallback && !shouldFallback()) { + // The clipboard is shared across controls. A newer copy or a retired caller + // cancels this second transport before it can overwrite the user's selection. + if (attempt !== clipboardWriteAttempt || (shouldFallback && !shouldFallback())) { return false; } return copyWithExecCommand(text); diff --git a/ui/src/pages/chat/components/browser-tab-card.ts b/ui/src/pages/chat/components/browser-tab-card.ts index 9a2b54690eb0..cf1925aed693 100644 --- a/ui/src/pages/chat/components/browser-tab-card.ts +++ b/ui/src/pages/chat/components/browser-tab-card.ts @@ -12,6 +12,7 @@ import { BROWSER_PANEL_TOGGLE_EVENT } from "../../../components/panel-toggle-con import { t } from "../../../i18n/index.ts"; import { loadBrowserTabThumbnail } from "../../../lib/chat/browser-tab-preview.ts"; import type { ToolPreview } from "../../../lib/chat/tool-cards.ts"; +import { copyToClipboard } from "../../../lib/clipboard.ts"; import { openExternalUrlSafe } from "../../../lib/open-external-url.ts"; import { OpenClawLitElement } from "../../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../../lit/subscriptions-controller.ts"; @@ -209,9 +210,7 @@ class OpenClawBrowserTabCard extends OpenClawLitElement { return; } if (event.detail.item.value === "copy-url") { - navigator.clipboard.writeText(url).catch(() => { - // Clipboard access can be denied; the URL stays visible on the card. - }); + void copyToClipboard(url, () => this.isConnected && this.preview?.url === url); } else if (event.detail.item.value === "open-new-tab") { openExternalUrlSafe(url); } diff --git a/ui/src/pages/chat/components/chat-message-images.ts b/ui/src/pages/chat/components/chat-message-images.ts index 5d59e51d782f..eac7f2638b65 100644 --- a/ui/src/pages/chat/components/chat-message-images.ts +++ b/ui/src/pages/chat/components/chat-message-images.ts @@ -6,6 +6,7 @@ import { repeat } from "lit/directives/repeat.js"; import { normalizeBasePath } from "../../../app-route-paths.ts"; import { icons } from "../../../components/icons.ts"; import { t } from "../../../i18n/index.ts"; +import { beginClipboardCopy } from "../../../lib/clipboard.ts"; import { reserveExternalWindowForDeferredNavigation, resolveSafeExternalUrl, @@ -656,6 +657,7 @@ function renderManagedImageActions(image: ImageBlock, opts: ImageRenderOptions | } }; const copy = async () => { + beginClipboardCopy(); try { if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") { throw new Error("image clipboard is unavailable"); diff --git a/ui/src/pages/chat/components/widget-export.ts b/ui/src/pages/chat/components/widget-export.ts index 123f2f34706b..818802b70097 100644 --- a/ui/src/pages/chat/components/widget-export.ts +++ b/ui/src/pages/chat/components/widget-export.ts @@ -1,3 +1,5 @@ +import { beginClipboardCopy } from "../../../lib/clipboard.ts"; + const WIDGET_SNAPSHOT_REQUEST_TYPE = "openclaw:widget-snapshot-request"; const WIDGET_SNAPSHOT_REPLY_TYPE = "openclaw:widget-snapshot"; const WIDGET_SNAPSHOT_TIMEOUT_MS = 5_000; @@ -106,6 +108,7 @@ export async function exportWidget( ); if (action === "copy") { + beginClipboardCopy(); const copyImage = runtime.copyImage ?? ((dataUrl: Promise) => { diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index ea00cd6c3f69..a959382e0e08 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -160,8 +160,12 @@ export function createUiBrowserVitestConfig(env = process.env): ViteUserConfig { plugins: [controlUiLocaleModulesPlugin()], optimizeDeps: { include: [ + "@lit/context", + "@noble/ed25519", + "@noble/hashes/sha2.js", "@openclaw/uirouter", "dompurify", + "file-type", "highlight.js/lib/core", "highlight.js/lib/languages/{bash,cpp,css,diff,java,javascript,json,markdown,python,rust,typescript,xml,yaml}", "lit/async-directive.js",