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.
This commit is contained in:
Peter Steinberger 2026-09-07 00:10:55 -07:00 • committed by GitHub
parent c69965b2cf
commit 1b0186412e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 400 additions and 13 deletions

View file

@ -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

View file

@ -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<typeof import("@openclaw/mermaid-renderer")>()),
renderMermaidSvg: async () => '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>',
}));
const owners: HTMLElement[] = [];
const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard");
let clipboard = "original clipboard";
const writeText = vi.fn<(text: string) => Promise<void>>();
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<void>((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<string>();
const fullImage = createDeferred<Response>();
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<unknown> | 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<HTMLButtonElement>('[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<HTMLButtonElement>(".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!");
},
);
});

View file

@ -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);

View file

@ -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 {

View file

@ -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;
}
}

View file

@ -12,6 +12,7 @@ const tableViewportSelector = ".markdown-table__viewport";
const enhancedTableShells = new WeakSet<HTMLElement>();
const tableOwnerStates = new WeakMap<HTMLElement, TableOwnerState>();
const tableCopyResetTimers = new WeakMap<HTMLElement, ReturnType<typeof setTimeout>>();
const tableCopyAttempts = new WeakMap<HTMLElement, number>();
type TableOwnerState = {
release: () => void;
@ -235,7 +236,13 @@ export function handleMarkdownTableInteraction(event: Event): void {
}
const copy = target.closest<HTMLElement>(".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);

View file

@ -299,12 +299,14 @@ describe("toSanitizedMarkdownHtml", () => {
});
fragment.addEventListener("click", handleMarkdownCodeBlockClick);
try {
document.body.append(fragment);
const button = fragment.querySelector<HTMLButtonElement>(".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) {

View file

@ -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<boolean> {
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);

View file

@ -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);
}

View file

@ -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");

View file

@ -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<string>) => {

View file

@ -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",