fix(ui): surface failed widget access decisions (#125934)

This commit is contained in:
Peter Steinberger
2026-08-18 11:19:47 -07:00
committed by GitHub
parent 2ffbff0547
commit 8bd49e1f4d
4 changed files with 149 additions and 10 deletions
+20 -6
View File
@@ -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<HTMLButtonElement>('[data-test-id="board-grant-allow"]')?.click();
const allow = view.querySelector<HTMLButtonElement>('[data-test-id="board-grant-allow"]');
const reject = view.querySelector<HTMLButtonElement>('[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);
+18 -4
View File
@@ -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<void>): Promise<void> {
private async runAction(action: () => Promise<void>, failureMessage?: string): Promise<void> {
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) }
: {}),
@@ -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"));
}
}
});
});
+2
View File
@@ -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…",