diff --git a/ui/src/lib/clipboard.test.ts b/ui/src/lib/clipboard.test.ts index e3424ae2ff75..82bacd9b4c76 100644 --- a/ui/src/lib/clipboard.test.ts +++ b/ui/src/lib/clipboard.test.ts @@ -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((_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); diff --git a/ui/src/lib/clipboard.ts b/ui/src/lib/clipboard.ts index 57784c85c6c3..8e2a2b983979 100644 --- a/ui/src/lib/clipboard.ts +++ b/ui/src/lib/clipboard.ts @@ -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 { +export async function copyToClipboard( + text: string, + shouldFallback?: () => boolean, +): Promise { if (!text) { return false; } @@ -18,6 +21,11 @@ export async function copyToClipboard(text: string): Promise { // 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); } diff --git a/ui/src/pages/tasks/tasks-page.test.ts b/ui/src/pages/tasks/tasks-page.test.ts index 3054dd085365..9d1f626df0a7 100644 --- a/ui/src/pages/tasks/tasks-page.test.ts +++ b/ui/src/pages/tasks/tasks-page.test.ts @@ -10,8 +10,10 @@ type TasksPageTestElement = HTMLElement & { context: ApplicationContext; tasks: TaskSummary[]; error: string | null; + copyResultError: string | null; cancellingTaskIds: Set; cancelTask: (taskId: string) => Promise; + copyTaskResult: (taskId: string) => Promise; recoverTask: (taskId: string, action: "retry" | "dismiss") => Promise; refreshTasks: () => Promise; }; @@ -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(); + 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", diff --git a/ui/src/pages/tasks/tasks-page.ts b/ui/src/pages/tasks/tasks-page.ts index 8b9ead6fd442..d60377df8f22 100644 --- a/ui/src/pages/tasks/tasks-page.ts +++ b/ui/src/pages/tasks/tasks-page.ts @@ -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(); 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), diff --git a/ui/src/pages/tasks/tasks.e2e.test.ts b/ui/src/pages/tasks/tasks.e2e.test.ts index 2bf2917aac19..169e9467773f 100644 --- a/ui/src/pages/tasks/tasks.e2e.test.ts +++ b/ui/src/pages/tasks/tasks.e2e.test.ts @@ -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((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("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((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((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); diff --git a/ui/src/pages/tasks/view.ts b/ui/src/pages/tasks/view.ts index 2e5c01e91c3c..d649ae9d1ddb 100644 --- a/ui/src/pages/tasks/view.ts +++ b/ui/src/pages/tasks/view.ts @@ -29,6 +29,7 @@ type TasksProps = { canCancel: boolean; loading: boolean; error: string | null; + copyResultError: string | null; tasks: TaskSummary[]; cancellingTaskIds: ReadonlySet; sessionRow: (sessionKey: string) => GatewaySessionRow | undefined; @@ -255,7 +256,10 @@ export function renderTasks(props: TasksProps) { ${!props.connected ? html`
${t("tasksPage.disconnected")}
` : nothing} - ${props.error ? html`
${props.error}
` : nothing} + ${props.error ? html`` : nothing} + ${props.copyResultError + ? html`` + : nothing} ${renderSummaryStrip(props.tasks)} ${props.loading && props.tasks.length === 0 ? html`
${t("tasksPage.loading")}
`