From e5a5ea10722e51f6dfa096b4d0d057106f49f3a9 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Wed, 29 Apr 2026 05:46:50 -0500 Subject: [PATCH] fix(ui): make control prompts real modals Introduce a native dialog-backed Control UI modal primitive and migrate the exec approval, gateway URL confirmation, and dreaming restart confirmation prompts to it. The modal primitive provides aria-modal semantics, shadow-root-local labels/descriptions, focus trapping, safe initial focus, Escape cancellation, and focus restoration while preserving the existing prompt content and decision semantics. Validation: - pnpm lint --threads=8 - pnpm --dir ui test src/ui/components/modal-dialog.test.ts src/ui/views/exec-approval.test.ts src/ui/navigation.browser.test.ts - pnpm test:ui - pnpm exec oxfmt --check --threads=1 ui/src/ui/components/modal-dialog.ts ui/src/styles/config-quick.test.ts - git diff --check CI note: checks-node-core-support-boundary is failing in test/scripts/docker-build-helper.test.ts on an unrelated package-acceptance assertion; the failing files are identical to origin/main and outside this UI-only PR. --- ui/src/styles/components.css | 22 +- ui/src/styles/components.test.ts | 15 +- ui/src/styles/config-quick.test.ts | 13 +- ui/src/styles/layout.mobile.test.ts | 15 +- ui/src/styles/markdown-preview.test.ts | 18 +- ui/src/ui/components/modal-dialog.test.ts | 175 +++++++++++ ui/src/ui/components/modal-dialog.ts | 279 ++++++++++++++++++ .../ui/views/dreaming-restart-confirmation.ts | 18 +- ui/src/ui/views/exec-approval.test.ts | 221 ++++++++++++++ ui/src/ui/views/exec-approval.ts | 16 +- ui/src/ui/views/gateway-url-confirmation.ts | 17 +- 11 files changed, 764 insertions(+), 45 deletions(-) create mode 100644 ui/src/ui/components/modal-dialog.test.ts create mode 100644 ui/src/ui/components/modal-dialog.ts create mode 100644 ui/src/ui/views/exec-approval.test.ts diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index fdb44d1e39a..bc598c1b633 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -3285,21 +3285,9 @@ td.data-table-key-col { Exec Approval Modal =========================================== */ -.exec-approval-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.8); - backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - padding: 24px; - z-index: 200; -} - .exec-approval-card { - width: min(540px, 100%); - max-height: calc(100dvh - 48px); + width: 100%; + max-height: inherit; display: flex; flex-direction: column; background: var(--card); @@ -5415,14 +5403,8 @@ details[open] > .ov-expandable-toggle::after { gap: 8px; } - .exec-approval-overlay { - padding: 12px; - padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); - } - .exec-approval-card { padding: 16px; - max-height: 90dvh; display: flex; flex-direction: column; } diff --git a/ui/src/styles/components.test.ts b/ui/src/styles/components.test.ts index fb1e556bd58..599c5ed64a9 100644 --- a/ui/src/styles/components.test.ts +++ b/ui/src/styles/components.test.ts @@ -1,10 +1,19 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; +function readComponentsCss(): string { + const cssPath = [ + resolve(process.cwd(), "ui/src/styles/components.css"), + resolve(process.cwd(), "..", "ui/src/styles/components.css"), + ].find((candidate) => existsSync(candidate)); + expect(cssPath).toBeTruthy(); + return readFileSync(cssPath!, "utf8"); +} + describe("agent fallback chip styles", () => { it("styles the chip remove control inside the agent model input", () => { - const css = readFileSync(path.join(process.cwd(), "ui/src/styles/components.css"), "utf8"); + const css = readComponentsCss(); expect(css).toContain(".agent-chip-input .chip {"); expect(css).toContain(".agent-chip-input .chip-remove {"); diff --git a/ui/src/styles/config-quick.test.ts b/ui/src/styles/config-quick.test.ts index 90300ebb48c..971d8c7068c 100644 --- a/ui/src/styles/config-quick.test.ts +++ b/ui/src/styles/config-quick.test.ts @@ -1,8 +1,15 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; -const css = readFileSync(path.join(process.cwd(), "ui/src/styles/config-quick.css"), "utf8"); +const cssPath = [ + resolve(process.cwd(), "ui/src/styles/config-quick.css"), + resolve(process.cwd(), "..", "ui/src/styles/config-quick.css"), +].find((candidate) => existsSync(candidate)); +if (!cssPath) { + throw new Error(`config-quick.css not found from cwd: ${process.cwd()}`); +} +const css = readFileSync(cssPath, "utf8"); describe("config-quick styles", () => { it("includes the local user identity quick-settings styles", () => { diff --git a/ui/src/styles/layout.mobile.test.ts b/ui/src/styles/layout.mobile.test.ts index 5a1fe3b0e13..c333143e30f 100644 --- a/ui/src/styles/layout.mobile.test.ts +++ b/ui/src/styles/layout.mobile.test.ts @@ -1,10 +1,19 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; +function readMobileCss(): string { + const cssPath = [ + resolve(process.cwd(), "ui/src/styles/layout.mobile.css"), + resolve(process.cwd(), "..", "ui/src/styles/layout.mobile.css"), + ].find((candidate) => existsSync(candidate)); + expect(cssPath).toBeTruthy(); + return readFileSync(cssPath!, "utf8"); +} + describe("chat header responsive mobile styles", () => { it("keeps the chat header and session controls from clipping on narrow widths", () => { - const css = readFileSync(path.join(process.cwd(), "ui/src/styles/layout.mobile.css"), "utf8"); + const css = readMobileCss(); expect(css).toContain("@media (max-width: 1320px)"); expect(css).toContain(".content--chat .content-header"); diff --git a/ui/src/styles/markdown-preview.test.ts b/ui/src/styles/markdown-preview.test.ts index 183744f3d14..e17775da0e3 100644 --- a/ui/src/styles/markdown-preview.test.ts +++ b/ui/src/styles/markdown-preview.test.ts @@ -1,9 +1,19 @@ +import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; +function stylePath(path: string): string { + const cssPath = [resolve(process.cwd(), path), resolve(process.cwd(), "..", path)].find( + (candidate) => existsSync(candidate), + ); + expect(cssPath).toBeTruthy(); + return cssPath!; +} + describe("markdown preview styles", () => { it("keeps the preview dialog canvas unified", async () => { - const css = await readFile("ui/src/styles/components.css", "utf8"); + const css = await readFile(stylePath("ui/src/styles/components.css"), "utf8"); expect(css).toContain(".md-preview-dialog__header-main"); expect(css).toContain(".md-preview-dialog__meta"); @@ -15,7 +25,7 @@ describe("markdown preview styles", () => { }); it("keeps expanded previews focused on header controls and reading space", async () => { - const css = await readFile("ui/src/styles/components.css", "utf8"); + const css = await readFile(stylePath("ui/src/styles/components.css"), "utf8"); expect(css).toContain(".md-preview-dialog__panel.fullscreen .md-preview-dialog__header-main"); expect(css).toContain("clip-path: inset(50%);"); @@ -27,7 +37,7 @@ describe("markdown preview styles", () => { }); it("styles preview header controls as compact icon buttons", async () => { - const css = await readFile("ui/src/styles/components.css", "utf8"); + const css = await readFile(stylePath("ui/src/styles/components.css"), "utf8"); expect(css).toContain(".md-preview-icon-btn"); expect(css).toContain("width: 36px;"); @@ -36,7 +46,7 @@ describe("markdown preview styles", () => { }); it("keeps the sidebar reader shell in sidebar.css", async () => { - const css = await readFile("ui/src/styles/chat/sidebar.css", "utf8"); + const css = await readFile(stylePath("ui/src/styles/chat/sidebar.css"), "utf8"); expect(css).toContain(".sidebar-markdown-shell__toolbar"); expect(css).toContain(".sidebar-markdown-reader"); diff --git a/ui/src/ui/components/modal-dialog.test.ts b/ui/src/ui/components/modal-dialog.test.ts new file mode 100644 index 00000000000..db88558ac86 --- /dev/null +++ b/ui/src/ui/components/modal-dialog.test.ts @@ -0,0 +1,175 @@ +/* @vitest-environment jsdom */ + +import { html, nothing, render } from "lit"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { type OpenClawModalDialog } from "./modal-dialog.ts"; +import "./modal-dialog.ts"; + +let container: HTMLDivElement; + +const showModalDescriptor = Object.getOwnPropertyDescriptor( + HTMLDialogElement.prototype, + "showModal", +); +const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close"); + +function nextFrame() { + return new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); +} + +function installDialogPolyfill() { + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { + configurable: true, + value(this: HTMLDialogElement) { + this.setAttribute("open", ""); + }, + }); + Object.defineProperty(HTMLDialogElement.prototype, "close", { + configurable: true, + value(this: HTMLDialogElement) { + this.removeAttribute("open"); + }, + }); +} + +function restoreDescriptor(name: "showModal" | "close", descriptor?: PropertyDescriptor) { + if (descriptor) { + Object.defineProperty(HTMLDialogElement.prototype, name, descriptor); + return; + } + delete (HTMLDialogElement.prototype as Partial)[name]; +} + +async function renderModal() { + render( + html` + +
+ + + + +
+
+ `, + container, + ); + const modal = container.querySelector("openclaw-modal-dialog"); + expect(modal).not.toBeNull(); + await modal!.updateComplete; + await nextFrame(); + const dialog = modal!.shadowRoot?.querySelector("dialog"); + expect(dialog).not.toBeNull(); + return { modal: modal!, dialog: dialog! }; +} + +describe("openclaw-modal-dialog", () => { + beforeEach(() => { + installDialogPolyfill(); + container = document.createElement("div"); + document.body.append(container); + }); + + afterEach(() => { + render(nothing, container); + container.remove(); + restoreDescriptor("showModal", showModalDescriptor); + restoreDescriptor("close", closeDescriptor); + vi.restoreAllMocks(); + }); + + it("opens a labelled modal dialog with an optional description", async () => { + const { modal, dialog } = await renderModal(); + + expect(dialog.open).toBe(true); + expect(dialog.getAttribute("role")).toBe("dialog"); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + const labelId = dialog.getAttribute("aria-labelledby"); + const descriptionId = dialog.getAttribute("aria-describedby"); + expect(labelId).toBe("openclaw-modal-dialog-label"); + expect(descriptionId).toBe("openclaw-modal-dialog-description"); + expect(dialog.getRootNode()).toBe(modal.shadowRoot); + expect(dialog.ownerDocument.querySelector(`#${labelId}`)).toBeNull(); + expect(modal.shadowRoot?.getElementById(labelId!)?.textContent).toBe("Confirm action"); + expect(modal.shadowRoot?.getElementById(descriptionId!)?.textContent).toBe( + "Review the operation before continuing.", + ); + }); + + it("focuses the dialog container first", async () => { + const { modal, dialog } = await renderModal(); + + expect(modal.shadowRoot?.activeElement).toBe(dialog); + expect(document.activeElement).not.toBe(container.querySelector("#first-action")); + }); + + it("cycles Tab and Shift+Tab inside focusable dialog content", async () => { + const { dialog } = await renderModal(); + const first = container.querySelector("#first-action"); + const last = container.querySelector("#last-action"); + expect(first).not.toBeNull(); + expect(last).not.toBeNull(); + + last!.focus(); + const tab = new KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + composed: true, + }); + last!.dispatchEvent(tab); + expect(tab.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(first); + + first!.focus(); + const shiftTab = new KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, + bubbles: true, + cancelable: true, + composed: true, + }); + first!.dispatchEvent(shiftTab); + expect(shiftTab.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(last); + expect(dialog.open).toBe(true); + }); + + it("emits modal-cancel on Escape", async () => { + const { modal, dialog } = await renderModal(); + const onCancel = vi.fn(); + modal.addEventListener("modal-cancel", onCancel); + + dialog.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + composed: true, + }), + ); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("restores focus when closed and removed", async () => { + const returnTarget = document.createElement("button"); + returnTarget.textContent = "Return"; + document.body.append(returnTarget); + returnTarget.focus(); + + await renderModal(); + expect(document.activeElement).not.toBe(returnTarget); + + render(nothing, container); + await nextFrame(); + + expect(document.activeElement).toBe(returnTarget); + returnTarget.remove(); + }); +}); diff --git a/ui/src/ui/components/modal-dialog.ts b/ui/src/ui/components/modal-dialog.ts new file mode 100644 index 00000000000..806fc316d56 --- /dev/null +++ b/ui/src/ui/components/modal-dialog.ts @@ -0,0 +1,279 @@ +import { LitElement, css, html, nothing } from "lit"; +import { property, query } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "summary", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +export class OpenClawModalDialog extends LitElement { + @property() label = ""; + @property() description = ""; + + @query("dialog") private dialogElement?: HTMLDialogElement; + @query("slot") private slotElement?: HTMLSlotElement; + + private previouslyFocused: Element | null = null; + private opened = false; + + static styles = css` + :host { + position: fixed; + inset: 0; + z-index: 200; + display: block; + padding: 24px; + box-sizing: border-box; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + } + + dialog { + position: fixed; + top: 50%; + left: 50%; + width: min(540px, calc(100vw - 48px)); + max-height: calc(100dvh - 48px); + margin: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--text); + transform: translate(-50%, -50%); + overflow: visible; + outline: none; + } + + dialog::backdrop { + background: transparent; + } + + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + } + + @media (max-width: 640px) { + :host { + padding: 12px; + padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); + } + + dialog { + width: calc(100vw - 24px); + max-height: 90dvh; + } + } + `; + + override connectedCallback() { + super.connectedCallback(); + this.previouslyFocused = this.ownerDocument.activeElement; + } + + override firstUpdated() { + this.openDialog(); + } + + override disconnectedCallback() { + this.closeDialog(); + this.restoreFocus(); + super.disconnectedCallback(); + } + + override render() { + const labelId = this.label ? "openclaw-modal-dialog-label" : ""; + const descriptionId = this.description ? "openclaw-modal-dialog-description" : ""; + return html` + + ${this.label + ? html`${this.label}` + : nothing} + ${this.description + ? html`${this.description}` + : nothing} + + + `; + } + + private openDialog() { + if (this.opened) { + return; + } + const dialog = this.dialogElement; + if (!dialog) { + return; + } + this.opened = true; + if (typeof dialog.showModal === "function") { + try { + if (!dialog.open) { + dialog.showModal(); + } + } catch { + if (!dialog.open) { + dialog.setAttribute("open", ""); + } + } + } else if (!dialog.open) { + dialog.setAttribute("open", ""); + } + requestAnimationFrame(() => { + if (!this.isConnected || !this.dialogElement?.open) { + return; + } + this.focusDialog(); + }); + } + + private closeDialog() { + const dialog = this.dialogElement; + if (!dialog?.open) { + return; + } + if (typeof dialog.close === "function") { + dialog.close(); + return; + } + dialog.removeAttribute("open"); + } + + private restoreFocus() { + const target = this.previouslyFocused; + this.previouslyFocused = null; + if (!(target instanceof HTMLElement) || !target.isConnected) { + return; + } + requestAnimationFrame(() => { + if (target.isConnected) { + target.focus(); + } + }); + } + + private focusDialog() { + const dialog = this.dialogElement; + if (!dialog) { + return; + } + try { + dialog.focus({ preventScroll: true }); + } catch { + dialog.focus(); + } + } + + private handleCancel = (event: Event) => { + event.preventDefault(); + this.dispatchCancel(); + }; + + private handleKeydown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + this.dispatchCancel(); + return; + } + if (event.key === "Tab") { + this.trapFocus(event); + } + }; + + private trapFocus(event: KeyboardEvent) { + const focusable = this.getFocusableElements(); + if (focusable.length === 0) { + event.preventDefault(); + this.focusDialog(); + return; + } + const active = this.getActiveElement(); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const focusInside = active ? focusable.includes(active) : false; + + if (event.shiftKey && (!focusInside || active === first || active === this.dialogElement)) { + event.preventDefault(); + last.focus(); + return; + } + if (!event.shiftKey && (!focusInside || active === last || active === this.dialogElement)) { + event.preventDefault(); + first.focus(); + } + } + + private getActiveElement(): HTMLElement | null { + const active = this.ownerDocument.activeElement; + if (active === this && this.shadowRoot?.activeElement instanceof HTMLElement) { + return this.shadowRoot.activeElement; + } + return active instanceof HTMLElement ? active : null; + } + + private getFocusableElements(): HTMLElement[] { + const assigned = this.slotElement?.assignedElements({ flatten: true }) ?? []; + const focusable: HTMLElement[] = []; + for (const element of assigned) { + this.collectFocusable(element, focusable); + } + return focusable.filter((element) => this.isFocusable(element)); + } + + private collectFocusable(element: Element, output: HTMLElement[]) { + if (element instanceof HTMLElement && element.matches(FOCUSABLE_SELECTOR)) { + output.push(element); + } + for (const child of element.querySelectorAll(FOCUSABLE_SELECTOR)) { + output.push(child); + } + } + + private isFocusable(element: HTMLElement): boolean { + if (element.closest("[hidden], [inert]")) { + return false; + } + if (element.tabIndex < 0) { + return false; + } + return element.isConnected; + } + + private dispatchCancel() { + this.dispatchEvent(new CustomEvent("modal-cancel", { bubbles: true, composed: true })); + } +} + +if (!customElements.get("openclaw-modal-dialog")) { + customElements.define("openclaw-modal-dialog", OpenClawModalDialog); +} + +declare global { + interface HTMLElementTagNameMap { + "openclaw-modal-dialog": OpenClawModalDialog; + } +} diff --git a/ui/src/ui/views/dreaming-restart-confirmation.ts b/ui/src/ui/views/dreaming-restart-confirmation.ts index ec1da319a18..19505ebad19 100644 --- a/ui/src/ui/views/dreaming-restart-confirmation.ts +++ b/ui/src/ui/views/dreaming-restart-confirmation.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; import { t } from "../../i18n/index.ts"; +import "../components/modal-dialog.ts"; type DreamingRestartConfirmationProps = { open: boolean; @@ -13,14 +14,23 @@ export function renderDreamingRestartConfirmation(props: DreamingRestartConfirma if (!props.open) { return nothing; } + const titleId = "dreaming-restart-confirmation-title"; + const descriptionId = "dreaming-restart-confirmation-description"; + const title = t("dreaming.restartConfirmation.title"); + const description = t("dreaming.restartConfirmation.subtitle"); + const handleCancel = () => { + if (!props.loading) { + props.onCancel(); + } + }; return html` - + `; } diff --git a/ui/src/ui/views/exec-approval.test.ts b/ui/src/ui/views/exec-approval.test.ts new file mode 100644 index 00000000000..a21fda1dd2d --- /dev/null +++ b/ui/src/ui/views/exec-approval.test.ts @@ -0,0 +1,221 @@ +/* @vitest-environment jsdom */ + +import { nothing, render } from "lit"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AppViewState } from "../app-view-state.ts"; +import { type OpenClawModalDialog } from "../components/modal-dialog.ts"; +import type { ExecApprovalRequest } from "../controllers/exec-approval.ts"; +import { renderDreamingRestartConfirmation } from "./dreaming-restart-confirmation.ts"; +import { renderExecApprovalPrompt } from "./exec-approval.ts"; +import { renderGatewayUrlConfirmation } from "./gateway-url-confirmation.ts"; + +let container: HTMLDivElement; + +const showModalDescriptor = Object.getOwnPropertyDescriptor( + HTMLDialogElement.prototype, + "showModal", +); +const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close"); + +function nextFrame() { + return new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); +} + +function installDialogPolyfill() { + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { + configurable: true, + value(this: HTMLDialogElement) { + this.setAttribute("open", ""); + }, + }); + Object.defineProperty(HTMLDialogElement.prototype, "close", { + configurable: true, + value(this: HTMLDialogElement) { + this.removeAttribute("open"); + }, + }); +} + +function restoreDescriptor(name: "showModal" | "close", descriptor?: PropertyDescriptor) { + if (descriptor) { + Object.defineProperty(HTMLDialogElement.prototype, name, descriptor); + return; + } + delete (HTMLDialogElement.prototype as Partial)[name]; +} + +async function getRenderedDialog() { + const modal = container.querySelector("openclaw-modal-dialog"); + expect(modal).not.toBeNull(); + await modal!.updateComplete; + await nextFrame(); + const dialog = modal!.shadowRoot?.querySelector("dialog"); + expect(dialog).not.toBeNull(); + return { modal: modal!, dialog: dialog! }; +} + +function dispatchEscape(target: EventTarget) { + target.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + composed: true, + }), + ); +} + +function createExecRequest(): ExecApprovalRequest { + return { + id: "approval-1", + kind: "exec", + request: { + command: "echo hello", + host: "gateway", + cwd: "/tmp/openclaw", + security: "workspace-write", + ask: "on-request", + }, + createdAtMs: Date.now() - 1_000, + expiresAtMs: Date.now() + 60_000, + }; +} + +function createExecState( + overrides: Partial< + Pick< + AppViewState, + "execApprovalBusy" | "execApprovalError" | "execApprovalQueue" | "handleExecApprovalDecision" + > + > = {}, +): AppViewState { + return { + execApprovalQueue: [createExecRequest()], + execApprovalBusy: false, + execApprovalError: null, + handleExecApprovalDecision: vi.fn(async () => undefined), + ...overrides, + } as unknown as AppViewState; +} + +describe("approval and confirmation modals", () => { + beforeEach(() => { + installDialogPolyfill(); + container = document.createElement("div"); + document.body.append(container); + }); + + afterEach(() => { + render(nothing, container); + container.remove(); + restoreDescriptor("showModal", showModalDescriptor); + restoreDescriptor("close", closeDescriptor); + vi.restoreAllMocks(); + }); + + it("renders exec approval as a labelled modal", async () => { + render(renderExecApprovalPrompt(createExecState()), container); + + const { modal, dialog } = await getRenderedDialog(); + + expect(dialog.getAttribute("aria-modal")).toBe("true"); + expect(dialog.getAttribute("aria-labelledby")).toBe("openclaw-modal-dialog-label"); + expect(dialog.getAttribute("aria-describedby")).toBe("openclaw-modal-dialog-description"); + expect(modal.shadowRoot?.querySelector("#openclaw-modal-dialog-label")?.textContent).toBe( + "Exec approval needed", + ); + expect( + modal.shadowRoot?.querySelector("#openclaw-modal-dialog-description")?.textContent, + ).toContain("expires in"); + expect(container.querySelector("#exec-approval-title")?.textContent).toContain( + "Exec approval needed", + ); + }); + + it("maps Escape to exec denial when approval is idle", async () => { + const handleExecApprovalDecision = vi.fn(async () => undefined); + render(renderExecApprovalPrompt(createExecState({ handleExecApprovalDecision })), container); + + const { dialog } = await getRenderedDialog(); + dispatchEscape(dialog); + + expect(handleExecApprovalDecision).toHaveBeenCalledTimes(1); + expect(handleExecApprovalDecision).toHaveBeenCalledWith("deny"); + }); + + it("does not dispatch an extra exec decision from Escape while busy", async () => { + const handleExecApprovalDecision = vi.fn(async () => undefined); + render( + renderExecApprovalPrompt( + createExecState({ execApprovalBusy: true, handleExecApprovalDecision }), + ), + container, + ); + + const { dialog } = await getRenderedDialog(); + dispatchEscape(dialog); + + expect(handleExecApprovalDecision).not.toHaveBeenCalled(); + }); + + it("uses the shared modal primitive for gateway URL confirmation and cancels on Escape", async () => { + const handleGatewayUrlCancel = vi.fn(); + render( + renderGatewayUrlConfirmation({ + pendingGatewayUrl: "wss://gateway.example/openclaw", + handleGatewayUrlConfirm: vi.fn(), + handleGatewayUrlCancel, + } as unknown as AppViewState), + container, + ); + + const { dialog } = await getRenderedDialog(); + expect(container.querySelector("openclaw-modal-dialog")).not.toBeNull(); + + dispatchEscape(dialog); + + expect(handleGatewayUrlCancel).toHaveBeenCalledTimes(1); + }); + + it("uses the shared modal primitive for dreaming restart confirmation and cancels on Escape", async () => { + const onCancel = vi.fn(); + render( + renderDreamingRestartConfirmation({ + open: true, + loading: false, + onConfirm: vi.fn(), + onCancel, + hasError: false, + }), + container, + ); + + const { dialog } = await getRenderedDialog(); + expect(container.querySelector("openclaw-modal-dialog")).not.toBeNull(); + + dispatchEscape(dialog); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("does not cancel dreaming restart from Escape while loading", async () => { + const onCancel = vi.fn(); + render( + renderDreamingRestartConfirmation({ + open: true, + loading: true, + onConfirm: vi.fn(), + onCancel, + hasError: false, + }), + container, + ); + + const { dialog } = await getRenderedDialog(); + dispatchEscape(dialog); + + expect(onCancel).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/ui/views/exec-approval.ts b/ui/src/ui/views/exec-approval.ts index 4c8aaea5fe9..6c640d12ebf 100644 --- a/ui/src/ui/views/exec-approval.ts +++ b/ui/src/ui/views/exec-approval.ts @@ -1,6 +1,7 @@ import { html, nothing } from "lit"; import { formatApprovalDisplayPath } from "../../../../src/infra/approval-display-paths.ts"; import type { AppViewState } from "../app-view-state.ts"; +import "../components/modal-dialog.ts"; import type { ExecApprovalRequest, ExecApprovalRequestPayload, @@ -73,13 +74,20 @@ export function renderExecApprovalPrompt(state: AppViewState) { const title = isPlugin ? (active.pluginTitle ?? "Plugin approval needed") : "Exec approval needed"; + const titleId = "exec-approval-title"; + const descriptionId = "exec-approval-description"; + const handleCancel = () => { + if (!state.execApprovalBusy) { + void state.handleExecApprovalDecision("deny"); + } + }; return html` - + `; } diff --git a/ui/src/ui/views/gateway-url-confirmation.ts b/ui/src/ui/views/gateway-url-confirmation.ts index af43a098623..c677e5b8939 100644 --- a/ui/src/ui/views/gateway-url-confirmation.ts +++ b/ui/src/ui/views/gateway-url-confirmation.ts @@ -1,20 +1,29 @@ import { html, nothing } from "lit"; import { t } from "../../i18n/index.ts"; import type { AppViewState } from "../app-view-state.ts"; +import "../components/modal-dialog.ts"; export function renderGatewayUrlConfirmation(state: AppViewState) { const { pendingGatewayUrl } = state; if (!pendingGatewayUrl) { return nothing; } + const titleId = "gateway-url-confirmation-title"; + const descriptionId = "gateway-url-confirmation-description"; + const title = t("channels.gatewayUrlConfirmation.title"); + const description = t("channels.gatewayUrlConfirmation.subtitle"); return html` - - + `; }