diff --git a/ui/src/components/board/board-view.test.ts b/ui/src/components/board/board-view.test.ts index 805f8a997d6b..96c8e14bdd1c 100644 --- a/ui/src/components/board/board-view.test.ts +++ b/ui/src/components/board/board-view.test.ts @@ -772,20 +772,34 @@ describe("openclaw-board-view", () => { await vi.waitFor(() => expect(allow?.disabled).toBe(false)); }); - it("keeps approval controls after a failed decision and clears the error on refresh", async () => { - const grant = vi.fn(async () => { - throw new Error("approval service unavailable"); + it("toasts failed rejection while keeping controls and leaves successful decisions quiet", async () => { + const toastHost = document.createElement("openclaw-toast-host"); + document.body.append(toastHost); + const grant = vi.fn(async (_name: string, decision: "granted" | "rejected") => { + if (decision === "rejected") { + throw new Error("approval service unavailable"); + } }); const source = snapshot({ widgets: [boardWidget({ grantState: "pending" })] }); const view = await mount({ snapshot: source, callbacks: callbacks({ grant }) }); - view.querySelector('[data-test-id="board-grant-allow"]')?.click(); + const allow = view.querySelector('[data-test-id="board-grant-allow"]'); + const reject = view.querySelector('[data-test-id="board-grant-reject"]'); + allow?.click(); + await vi.waitFor(() => expect(grant).toHaveBeenCalledWith("alpha", "granted")); + await vi.waitFor(() => expect(reject?.disabled).toBe(false)); + expect(toastHost.querySelector(".app-toast")).toBeNull(); + + reject?.click(); await vi.waitFor(() => { expect( view.querySelector('[data-test-id="board-widget-action-error"]')?.textContent, ).toContain("approval service unavailable"); + expect(toastHost.querySelector(".app-toast__message")?.textContent).toContain( + "Could not reject widget access. Try again.", + ); }); - expect(view.querySelector('[data-test-id="board-grant-allow"]')).not.toBeNull(); - expect(view.querySelector('[data-test-id="board-grant-reject"]')).not.toBeNull(); + expect(allow?.disabled).toBe(false); + expect(reject?.disabled).toBe(false); view.snapshot = structuredClone({ ...source, revision: source.revision + 1 }); await settleCells(view); diff --git a/ui/src/components/board/board-widget-cell.ts b/ui/src/components/board/board-widget-cell.ts index 2db63fde2384..01c09651e373 100644 --- a/ui/src/components/board/board-widget-cell.ts +++ b/ui/src/components/board/board-widget-cell.ts @@ -20,6 +20,7 @@ import { type PluginBoardWidgetRenderer, } from "../../lib/board/widgets/index.ts"; import { formatUiError } from "../../lib/format-error.ts"; +import { showToast } from "../../lib/toast.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { renderBoardMcpAppContent } from "./board-mcp-app-content.ts"; import { BoardMcpAppLifecycle } from "./board-mcp-app-lifecycle.ts"; @@ -153,7 +154,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement { super.disconnectedCallback(); } - private async runAction(action: () => Promise): Promise { + private async runAction(action: () => Promise, failureMessage?: string): Promise { if (this.actionPending || this.busy) { return; } @@ -164,11 +165,25 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement { await action(); } catch (error) { this.actionError = formatUiError(error); + if (failureMessage) { + showToast({ message: failureMessage }); + } } finally { this.actionPending = false; } } + private runGrantDecision( + widget: BoardWidget, + callbacks: BoardWidgetCellCallbacks, + decision: BoardGrantDecision, + ): void { + const failureMessage = t( + decision === "granted" ? "board.widget.allowFailed" : "board.widget.rejectFailed", + ); + void this.runAction(() => callbacks.grant(widget.name, decision), failureMessage); + } + private handleMenuSelect( event: CustomEvent<{ item: { value?: string } }>, widget: BoardWidget, @@ -207,8 +222,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement { ? renderBoardWidgetPending({ widget, disabled: this.busy || this.actionPending || !this.canGrant, - onGrant: (decision) => - void this.runAction(() => callbacks.grant(widget.name, decision)), + onGrant: (decision) => this.runGrantDecision(widget, callbacks, decision), ...(this.actionError ? { error: renderBoardWidgetActionError(this.actionError, true) } : {}), @@ -244,7 +258,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement { return renderBoardWidgetPending({ widget, disabled: this.busy || this.actionPending || !this.canGrant, - onGrant: (decision) => void this.runAction(() => callbacks.grant(widget.name, decision)), + onGrant: (decision) => this.runGrantDecision(widget, callbacks, decision), ...(this.actionError ? { error: renderBoardWidgetActionError(this.actionError, true) } : {}), diff --git a/ui/src/e2e/session-dashboard.grant-failure.e2e.test.ts b/ui/src/e2e/session-dashboard.grant-failure.e2e.test.ts new file mode 100644 index 000000000000..d32d6fc5545b --- /dev/null +++ b/ui/src/e2e/session-dashboard.grant-failure.e2e.test.ts @@ -0,0 +1,109 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { + controlUiBundledSettingsStorageKey, + installMockGateway, +} from "../test-helpers/control-ui-e2e.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +const suite = createControlUiE2eSuite({ + name: "Control UI dashboard grant failure", + startServerBeforeBrowser: true, +}); +const sessionKey = "agent:main:dashboard-grant-failure"; +const proofDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/workboard-grant-failure"); + +suite.define(() => { + it("keeps a network-capability decision retryable and toasts when Allow fails", async () => { + const recordProof = process.env.OPENCLAW_UI_E2E_RECORD === "1"; + if (recordProof) { + await mkdir(proofDir, { recursive: true }); + } + const context = await suite.browser.newContext({ + viewport: { height: 900, width: 1280 }, + ...(recordProof + ? { recordVideo: { dir: proofDir, size: { height: 900, width: 1280 } } } + : {}), + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + sessionKey, + featureMethods: ["board.get", "board.widget.grant", "chat.metadata", "chat.startup"], + methodResponses: { + "board.get": { + sessionKey, + revision: 1, + tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }], + widgets: [ + { + name: "status", + tabId: "main", + title: "Status", + contentKind: "html", + sizeW: 6, + sizeH: 4, + position: 0, + grantState: "pending", + declared: { netOrigins: ["https://api.example.com"] }, + revision: 1, + }, + ], + }, + "board.widget.grant": { + __mockError: { + code: "UNAVAILABLE", + message: "internal capability service detail", + }, + }, + }, + }); + const settingsKey = controlUiBundledSettingsStorageKey(suite.server.baseUrl); + await page.addInitScript( + ({ key, storageKey }) => { + const settings = JSON.parse(localStorage.getItem(storageKey) ?? "{}") as Record< + string, + unknown + >; + settings.boardSessionViews = { [key]: { activeTabId: "main" } }; + localStorage.setItem(storageKey, JSON.stringify(settings)); + }, + { key: sessionKey, storageKey: settingsKey }, + ); + + try { + await page.goto(`${suite.server.baseUrl}dashboard`); + const pending = page.locator('[data-test-id="board-pending"]'); + const allow = pending.getByRole("button", { name: "Allow" }); + const reject = pending.getByRole("button", { name: "Reject" }); + await pending.waitFor(); + await gateway.deferNext("board.get", { sessionKey }); + await allow.click(); + + const request = await gateway.waitForRequest("board.widget.grant"); + expect(request.params).toEqual({ + sessionKey, + name: "status", + decision: "granted", + revision: 1, + }); + const toast = page.locator("openclaw-toast-host .app-toast"); + await toast.waitFor(); + expect(await toast.textContent()).toContain("Could not allow widget access. Try again."); + expect(await toast.textContent()).not.toContain("internal capability service detail"); + await expect.poll(() => allow.isEnabled()).toBe(true); + expect(await reject.isEnabled()).toBe(true); + await pending.waitFor(); + await page.locator('[data-test-id="board-widget-action-error"]').waitFor(); + if (recordProof) { + await page.screenshot({ path: path.join(proofDir, "grant-failed.png") }); + } + } finally { + const video = page.video(); + await context.close(); + if (recordProof && video) { + await video.saveAs(path.join(proofDir, "workboard-grant-failure.webm")); + } + } + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 256fcd690b85..bc8b1fb83458 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -3778,7 +3778,9 @@ export const en: TranslationMap = { toolCapability: "Tool: {capability}", granted: "Granted", allow: "Allow", + allowFailed: "Could not allow widget access. Try again.", reject: "Reject", + rejectFailed: "Could not reject widget access. Try again.", rejected: "Access rejected", rejectedDetail: "This widget stays inactive until it is removed or replaced.", appLoading: "Restoring app…",