fix(ui): copy task results through clipboard fallback (#127181)

Amp-Thread-ID: https://ampcode.com/threads/T-01a021f4-b547-7788-a916-d4a94cbd3e3b

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-21 07:31:58 -07:00
committed by GitHub
parent ff83e3efe8
commit 2cbfade48b
6 changed files with 320 additions and 15 deletions
+21
View File
@@ -46,6 +46,27 @@ describe("copyToClipboard", () => {
expect(document.querySelector("textarea")).toBeNull();
});
it("skips fallback when the caller retires a rejected async write", async () => {
let rejectWrite: ((reason?: unknown) => void) | undefined;
const writeText = vi.fn().mockImplementation(
() =>
new Promise<void>((_resolve, reject) => {
rejectWrite = reject;
}),
);
vi.stubGlobal("navigator", { clipboard: { writeText } });
const exec = mockExecCommand(true);
let current = true;
const copy = copyToClipboard("hello", () => current);
current = false;
rejectWrite?.(new Error("denied"));
expect(await copy).toBe(false);
expect(exec).not.toHaveBeenCalled();
expect(document.querySelector("textarea")).toBeNull();
});
it("falls back to execCommand over plain HTTP where navigator.clipboard is undefined", async () => {
vi.stubGlobal("navigator", {});
const exec = mockExecCommand(true);
+9 -1
View File
@@ -5,7 +5,10 @@
// 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.
export async function copyToClipboard(text: string): Promise<boolean> {
export async function copyToClipboard(
text: string,
shouldFallback?: () => boolean,
): Promise<boolean> {
if (!text) {
return false;
}
@@ -18,6 +21,11 @@ export async function copyToClipboard(text: string): Promise<boolean> {
// 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()) {
return false;
}
return copyWithExecCommand(text);
}
+46
View File
@@ -10,8 +10,10 @@ type TasksPageTestElement = HTMLElement & {
context: ApplicationContext;
tasks: TaskSummary[];
error: string | null;
copyResultError: string | null;
cancellingTaskIds: Set<string>;
cancelTask: (taskId: string) => Promise<void>;
copyTaskResult: (taskId: string) => Promise<void>;
recoverTask: (taskId: string, action: "retry" | "dismiss") => Promise<void>;
refreshTasks: () => Promise<void>;
};
@@ -162,6 +164,7 @@ function createContext(
afterEach(() => {
document.body.replaceChildren();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("TasksPage concurrent refresh events", () => {
@@ -447,6 +450,49 @@ describe("TasksPage active pagination", () => {
});
describe("TasksPage cancellation lifecycle", () => {
it("does not clear an unrelated task error when a result copy succeeds", async () => {
const blocked = createTask("task-copy-independent-error", "completed", {
deliveryStatus: "failed",
terminalOutcome: "blocked",
});
const clipboardWrite = deferred<void>();
const writeText = vi.fn(() => clipboardWrite.promise);
const request = vi.fn((method: string) => {
if (method === "tasks.get") {
return Promise.resolve({ task: { ...blocked, result: "Retained result" } });
}
if (method === "tasks.retry") {
return Promise.resolve({
results: [
{
taskId: blocked.taskId,
ok: false,
reason: "Independent recovery failed",
},
],
});
}
return Promise.resolve({ tasks: [blocked] });
});
const source = createGateway({ request } as unknown as GatewayBrowserClient);
const page = document.createElement("openclaw-tasks-page") as TasksPageTestElement;
page.context = createContext(source.gateway);
document.body.append(page);
await waitForFast(() => expect(page.tasks).toHaveLength(1));
vi.stubGlobal("navigator", { clipboard: { writeText } });
const copying = page.copyTaskResult(blocked.taskId);
await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith("Retained result"));
await page.recoverTask(blocked.taskId, "retry");
expect(page.error).toBe("Independent recovery failed");
clipboardWrite.resolve(undefined);
await copying;
expect(page.error).toBe("Independent recovery failed");
expect(page.copyResultError).toBeNull();
});
it("lets a read-only operator copy a retained result without mutation controls", async () => {
const retained = createTask("task-read-only-retained", "completed", {
deliveryStatus: "failed",
+22 -5
View File
@@ -9,6 +9,7 @@ import { hasOperatorReadAccess, hasOperatorWriteAccess } from "../../app/operato
import { renderAgentScopeControl } from "../../components/agent-scope-control.ts";
import { t } from "../../i18n/index.ts";
import { watchAgentScope } from "../../lib/agents/index.ts";
import { copyToClipboard } from "../../lib/clipboard.ts";
import { formatUiError, formatUiExternalText } from "../../lib/format-error.ts";
import {
findUiSessionRow,
@@ -99,14 +100,17 @@ class TasksPage extends OpenClawLightDomElement {
@state() private tasks: TaskSummary[] = [];
@state() private error: string | null = null;
@state() private copyResultError: string | null = null;
@state() private cancellingTaskIds = new Set<string>();
private taskRefreshEvents: TaskRefreshEventBuffer | null = null;
private copyResultAttempt = 0;
private readonly gateway = new GatewayPageController(this, {
getGateway: () => this.context?.gateway,
onIdentityChange: () => {
this.tasks = [];
this.error = null;
this.copyResultError = null;
},
invalidateRequests: () => this.cancelGatewayWork(),
onSnapshot: () => {
@@ -238,6 +242,8 @@ class TasksPage extends OpenClawLightDomElement {
);
override disconnectedCallback() {
this.copyResultAttempt += 1;
this.copyResultError = null;
this.subscriptions.clear();
super.disconnectedCallback();
}
@@ -245,6 +251,8 @@ class TasksPage extends OpenClawLightDomElement {
private cancelGatewayWork() {
// Reconnects may reuse the client object; the epoch keeps pre-disconnect
// cancellation responses from mutating the replacement task snapshot.
this.copyResultAttempt += 1;
this.copyResultError = null;
this.taskRefreshEvents = null;
void this.listTask.run([null, null, null]);
this.cancellingTaskIds = new Set();
@@ -258,6 +266,7 @@ class TasksPage extends OpenClawLightDomElement {
}
const scopeId = this.context.agentSelection.state.scopeId;
this.error = null;
this.copyResultError = null;
return this.listTask.run([gateway, client, scopeId]);
}
@@ -353,6 +362,7 @@ class TasksPage extends OpenClawLightDomElement {
}
private async copyTaskResult(taskId: string) {
const attempt = ++this.copyResultAttempt;
const scope = this.gateway.capture();
const gateway = this.gateway.gateway;
if (!scope || !gateway || this.context.gateway !== gateway) {
@@ -360,18 +370,24 @@ class TasksPage extends OpenClawLightDomElement {
}
try {
const detail = normalizeTasksGetResult(await scope.client.request("tasks.get", { taskId }));
if (!this.gateway.isCurrent(scope)) {
if (!this.gateway.isCurrent(scope) || attempt !== this.copyResultAttempt) {
return;
}
const result = detail?.result ?? detail?.progressSummary;
if (!result) {
this.error = t("tasksPage.recoveryFailed");
this.copyResultError = t("tasksPage.recoveryFailed");
return;
}
await navigator.clipboard.writeText(result);
const copied = await copyToClipboard(
result,
() => this.gateway.isCurrent(scope) && attempt === this.copyResultAttempt,
);
if (this.gateway.isCurrent(scope) && attempt === this.copyResultAttempt) {
this.copyResultError = copied ? null : t("common.copyFailed");
}
} catch (error) {
if (this.gateway.isCurrent(scope)) {
this.error = formatUiError(error, t("tasksPage.recoveryFailed"));
if (this.gateway.isCurrent(scope) && attempt === this.copyResultAttempt) {
this.copyResultError = formatUiError(error, t("tasksPage.recoveryFailed"));
}
}
}
@@ -413,6 +429,7 @@ class TasksPage extends OpenClawLightDomElement {
canCancel: hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null),
loading: this.listTask.status === TaskStatus.PENDING,
error: this.error,
copyResultError: this.copyResultError,
tasks: this.tasks,
cancellingTaskIds: this.cancellingTaskIds,
sessionRow: (sessionKey) => findUiSessionRow(this.context, sessionKey),
+217 -8
View File
@@ -81,6 +81,19 @@ const readOnlyRetainedTask = {
};
const readOnlyRetainedResult = "Synthetic retained result copied by a read-only operator.";
const olderRetainedResult = "Older retained result from the first copy activation.";
const newestRetainedResult = "Newest retained result from the second copy activation.";
type ClipboardFaultState = {
asyncWrites: string[];
execSucceeds: boolean;
legacyWrites: string[];
mode: "defer" | "missing" | "reject";
pending: Array<{
reject: (reason?: unknown) => void;
resolve: () => void;
}>;
};
const retryBlockedTask = {
...readOnlyRetainedTask,
@@ -484,15 +497,50 @@ suite.define(() => {
}
});
it("lets an operator.read-only user copy a retained result without mutations", async () => {
it("copies retained results through fallback and announces total failure", async () => {
await mkdir(artifactDir, { recursive: true });
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { width: 1440, height: 900 },
});
await context.grantPermissions(["clipboard-read", "clipboard-write"], {
origin: new URL(suite.server.baseUrl).origin,
await context.addInitScript(() => {
const state: ClipboardFaultState = {
asyncWrites: [],
execSucceeds: true,
legacyWrites: [],
mode: "reject",
pending: [],
};
Object.defineProperty(window, "tasksClipboardFault", { value: state });
Object.defineProperty(navigator, "clipboard", {
configurable: true,
get: () =>
state.mode === "missing"
? undefined
: {
writeText(text: string) {
state.asyncWrites.push(text);
if (state.mode === "defer") {
return new Promise<void>((resolve, reject) => {
state.pending.push({ reject, resolve });
});
}
return Promise.reject(
new DOMException("Clipboard access denied", "NotAllowedError"),
);
},
},
});
document.execCommand = (command: string) => {
if (command !== "copy") {
return false;
}
state.legacyWrites.push(
document.querySelector<HTMLTextAreaElement>("textarea")?.value ?? "",
);
return state.execSucceeds;
};
});
const page = await context.newPage();
try {
@@ -512,7 +560,13 @@ suite.define(() => {
],
},
"tasks.get": {
task: { ...readOnlyRetainedTask, result: readOnlyRetainedResult },
sequence: [
{ task: { ...readOnlyRetainedTask, result: readOnlyRetainedResult } },
{ task: { ...readOnlyRetainedTask, result: readOnlyRetainedResult } },
{ task: { ...readOnlyRetainedTask, result: readOnlyRetainedResult } },
{ task: { ...readOnlyRetainedTask, result: olderRetainedResult } },
{ task: { ...readOnlyRetainedTask, result: newestRetainedResult } },
],
},
},
});
@@ -533,11 +587,166 @@ suite.define(() => {
const copyButton = task.getByRole("button", { name: "Copy result" });
await copyButton.waitFor({ state: "visible" });
await copyButton.click();
const getRequest = await gateway.waitForRequest("tasks.get");
expect(getRequest.params).toEqual({ taskId: readOnlyRetainedTask.taskId });
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(readOnlyRetainedResult);
.poll(() =>
page.evaluate(
() =>
(window as typeof window & { tasksClipboardFault: ClipboardFaultState })
.tasksClipboardFault,
),
)
.toMatchObject({
asyncWrites: [readOnlyRetainedResult],
legacyWrites: [readOnlyRetainedResult],
});
await page.evaluate(() => {
(
window as typeof window & { tasksClipboardFault: ClipboardFaultState }
).tasksClipboardFault.mode = "missing";
});
await copyButton.click();
await expect
.poll(() =>
page.evaluate(
() =>
(window as typeof window & { tasksClipboardFault: ClipboardFaultState })
.tasksClipboardFault,
),
)
.toMatchObject({
asyncWrites: [readOnlyRetainedResult],
legacyWrites: [readOnlyRetainedResult, readOnlyRetainedResult],
});
await page.evaluate(() => {
const state = (window as typeof window & { tasksClipboardFault: ClipboardFaultState })
.tasksClipboardFault;
state.mode = "reject";
state.execSucceeds = false;
});
await copyButton.click();
await expect.poll(() => page.getByRole("alert").textContent()).toBe("Copy failed");
await page.screenshot({ path: path.join(artifactDir, "05-copy-failed.png") });
expect(
await page.evaluate(
() =>
(window as typeof window & { tasksClipboardFault: ClipboardFaultState })
.tasksClipboardFault,
),
).toMatchObject({
asyncWrites: [readOnlyRetainedResult, readOnlyRetainedResult],
legacyWrites: [readOnlyRetainedResult, readOnlyRetainedResult, readOnlyRetainedResult],
});
await page.evaluate(() => {
const state = (window as typeof window & { tasksClipboardFault: ClipboardFaultState })
.tasksClipboardFault;
state.mode = "defer";
state.execSucceeds = false;
});
await copyButton.click();
await copyButton.click();
await expect
.poll(() =>
page.evaluate(
() =>
(window as typeof window & { tasksClipboardFault: ClipboardFaultState })
.tasksClipboardFault.pending.length,
),
)
.toBe(2);
await page.evaluate(() => {
(
window as typeof window & { tasksClipboardFault: ClipboardFaultState }
).tasksClipboardFault.pending[1]?.reject(
new DOMException("Clipboard access denied", "NotAllowedError"),
);
});
await expect
.poll(() =>
page.evaluate(
() =>
(window as typeof window & { tasksClipboardFault: ClipboardFaultState })
.tasksClipboardFault.legacyWrites.length,
),
)
.toBe(4);
await page.evaluate(() => {
(
window as typeof window & { tasksClipboardFault: ClipboardFaultState }
).tasksClipboardFault.pending[0]?.reject(
new DOMException("Clipboard access denied", "NotAllowedError"),
);
});
await page.evaluate(
() =>
new Promise<void>((resolve) => {
window.setTimeout(resolve, 0);
}),
);
const currentAlert = page.getByRole("alert");
expect(await currentAlert.count()).toBe(1);
expect(await currentAlert.textContent()).toBe("Copy failed");
expect(
await page.evaluate(
() =>
(window as typeof window & { tasksClipboardFault: ClipboardFaultState })
.tasksClipboardFault.legacyWrites,
),
).toEqual([
readOnlyRetainedResult,
readOnlyRetainedResult,
readOnlyRetainedResult,
newestRetainedResult,
]);
await gateway.deferNext("tasks.get", { taskId: readOnlyRetainedTask.taskId });
await copyButton.click();
await expect.poll(() => gateway.getRequests("tasks.get")).toHaveLength(6);
expect(await page.getByRole("alert").textContent()).toBe("Copy failed");
const socketCount = await gateway.getSocketCount();
await gateway.closeLatest(1012, "retire retained-result copy");
await expect.poll(() => gateway.getSocketCount()).toBeGreaterThan(socketCount);
await gateway.resolveDeferred("tasks.get", {
task: { ...readOnlyRetainedTask, result: readOnlyRetainedResult },
});
await page.evaluate(
() =>
new Promise<void>((resolve) => {
window.setTimeout(resolve, 0);
}),
);
expect(await page.getByRole("alert").count()).toBe(0);
expect(
await page.evaluate(
() =>
(window as typeof window & { tasksClipboardFault: ClipboardFaultState })
.tasksClipboardFault,
),
).toMatchObject({
asyncWrites: [
readOnlyRetainedResult,
readOnlyRetainedResult,
olderRetainedResult,
newestRetainedResult,
],
legacyWrites: [
readOnlyRetainedResult,
readOnlyRetainedResult,
readOnlyRetainedResult,
newestRetainedResult,
],
});
expect((await gateway.getRequests("tasks.get")).map((request) => request.params)).toEqual([
{ taskId: readOnlyRetainedTask.taskId },
{ taskId: readOnlyRetainedTask.taskId },
{ taskId: readOnlyRetainedTask.taskId },
{ taskId: readOnlyRetainedTask.taskId },
{ taskId: readOnlyRetainedTask.taskId },
{ taskId: readOnlyRetainedTask.taskId },
]);
expect(await gateway.getRequests("tasks.retry")).toHaveLength(0);
expect(await gateway.getRequests("tasks.dismiss")).toHaveLength(0);
expect(await gateway.getRequests("tasks.cancel")).toHaveLength(0);
+5 -1
View File
@@ -29,6 +29,7 @@ type TasksProps = {
canCancel: boolean;
loading: boolean;
error: string | null;
copyResultError: string | null;
tasks: TaskSummary[];
cancellingTaskIds: ReadonlySet<string>;
sessionRow: (sessionKey: string) => GatewaySessionRow | undefined;
@@ -255,7 +256,10 @@ export function renderTasks(props: TasksProps) {
${!props.connected
? html`<div class="callout warn">${t("tasksPage.disconnected")}</div>`
: nothing}
${props.error ? html`<div class="callout danger">${props.error}</div>` : nothing}
${props.error ? html`<div class="callout danger" role="alert">${props.error}</div>` : nothing}
${props.copyResultError
? html`<div class="callout danger" role="alert">${props.copyResultError}</div>`
: nothing}
${renderSummaryStrip(props.tasks)}
${props.loading && props.tasks.length === 0
? html`<div class="card muted">${t("tasksPage.loading")}</div>`