From 1b0186412e20ff7e248b3228fc8d4c2f7a287e5e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 7 Sep 2026 00:10:55 -0700 Subject: [PATCH] fix(ui): preserve newer copies and restore browser URL copying (#140893) * fix(ui): preserve newer copies and restore browser URL copying Record shared copy intent before text fallback or binary preparation so older rejected writes cannot replace a newer selection. Bind code, table, Mermaid and browser URL fallback to the current source owner, and restore Copy URL on plain HTTP. Keep native ClipboardItem promises and write invocation order intact. Verified with 78 focused tests, synthetic Chromium ordering and insecure-origin proof, owning type graphs, lint/style/format gates, ratchets, and scoped-clean P2 review. * test(ui): mount copied Markdown fixtures during interaction Connect the shared code-copy fixture before clicking and remove it in finally so its live feedback assertion matches the preserved retirement guard. Keep every assertion and genuine detached-control regression unchanged. Validation: 149 Markdown and clipboard lifecycle tests, owning UI test types, typed lint, formatting and scoped-clean managed P2 review. --- docs/web/control-ui/chat.md | 8 + .../markdown-clipboard.browser.test.ts | 353 ++++++++++++++++++ ui/src/components/markdown-code-blocks.ts | 5 +- ui/src/components/markdown-mermaid.test.ts | 4 +- ui/src/components/markdown-mermaid.ts | 5 +- ui/src/components/markdown-tables.ts | 9 +- ui/src/components/markdown.test.ts | 2 + ui/src/lib/clipboard.ts | 13 +- .../pages/chat/components/browser-tab-card.ts | 5 +- .../chat/components/chat-message-images.ts | 2 + ui/src/pages/chat/components/widget-export.ts | 3 + ui/vitest.config.ts | 4 + 12 files changed, 400 insertions(+), 13 deletions(-) create mode 100644 ui/src/components/markdown-clipboard.browser.test.ts 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",