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.
This commit is contained in:
Val Alexander
2026-04-29 05:46:50 -05:00
committed by GitHub
parent 1dac6ac4c6
commit e5a5ea1072
11 changed files with 764 additions and 45 deletions

View File

@@ -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;
}

View File

@@ -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 {");

View File

@@ -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", () => {

View File

@@ -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");

View File

@@ -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");

View File

@@ -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<void>((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<HTMLDialogElement>)[name];
}
async function renderModal() {
render(
html`
<openclaw-modal-dialog
label="Confirm action"
description="Review the operation before continuing."
>
<section>
<h2 id="modal-title">Confirm action</h2>
<p id="modal-description">Review the operation before continuing.</p>
<button id="first-action">First</button>
<button id="last-action">Last</button>
</section>
</openclaw-modal-dialog>
`,
container,
);
const modal = container.querySelector<OpenClawModalDialog>("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<HTMLButtonElement>("#first-action");
const last = container.querySelector<HTMLButtonElement>("#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();
});
});

View File

@@ -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`
<dialog
role="dialog"
aria-modal="true"
aria-labelledby=${ifDefined(labelId || undefined)}
aria-describedby=${ifDefined(descriptionId || undefined)}
tabindex="-1"
@cancel=${this.handleCancel}
@keydown=${this.handleKeydown}
>
${this.label
? html`<span id=${labelId} class="visually-hidden">${this.label}</span>`
: nothing}
${this.description
? html`<span id=${descriptionId} class="visually-hidden">${this.description}</span>`
: nothing}
<slot></slot>
</dialog>
`;
}
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<HTMLElement>(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;
}
}

View File

@@ -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`
<div class="exec-approval-overlay" role="dialog" aria-modal="true" aria-live="polite">
<openclaw-modal-dialog label=${title} description=${description} @modal-cancel=${handleCancel}>
<div class="exec-approval-card">
<div class="exec-approval-header">
<div>
<div class="exec-approval-title">${t("dreaming.restartConfirmation.title")}</div>
<div class="exec-approval-sub">${t("dreaming.restartConfirmation.subtitle")}</div>
<div id=${titleId} class="exec-approval-title">${title}</div>
<div id=${descriptionId} class="exec-approval-sub">${description}</div>
</div>
</div>
<div class="callout danger" style="margin-top: 12px;">
@@ -40,6 +50,6 @@ export function renderDreamingRestartConfirmation(props: DreamingRestartConfirma
</button>
</div>
</div>
</div>
</openclaw-modal-dialog>
`;
}

View File

@@ -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<void>((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<HTMLDialogElement>)[name];
}
async function getRenderedDialog() {
const modal = container.querySelector<OpenClawModalDialog>("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();
});
});

View File

@@ -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`
<div class="exec-approval-overlay" role="dialog" aria-live="polite">
<openclaw-modal-dialog label=${title} description=${remaining} @modal-cancel=${handleCancel}>
<div class="exec-approval-card">
<div class="exec-approval-header">
<div>
<div class="exec-approval-title">${title}</div>
<div class="exec-approval-sub">${remaining}</div>
<div id=${titleId} class="exec-approval-title">${title}</div>
<div id=${descriptionId} class="exec-approval-sub">${remaining}</div>
</div>
${queueCount > 1
? html`<div class="exec-approval-queue">${queueCount} pending</div>`
@@ -113,6 +121,6 @@ export function renderExecApprovalPrompt(state: AppViewState) {
</button>
</div>
</div>
</div>
</openclaw-modal-dialog>
`;
}

View File

@@ -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`
<div class="exec-approval-overlay" role="dialog" aria-modal="true" aria-live="polite">
<openclaw-modal-dialog
label=${title}
description=${description}
@modal-cancel=${() => state.handleGatewayUrlCancel()}
>
<div class="exec-approval-card">
<div class="exec-approval-header">
<div>
<div class="exec-approval-title">${t("channels.gatewayUrlConfirmation.title")}</div>
<div class="exec-approval-sub">${t("channels.gatewayUrlConfirmation.subtitle")}</div>
<div id=${titleId} class="exec-approval-title">${title}</div>
<div id=${descriptionId} class="exec-approval-sub">${description}</div>
</div>
</div>
<div class="exec-approval-command mono">${pendingGatewayUrl}</div>
@@ -30,6 +39,6 @@ export function renderGatewayUrlConfirmation(state: AppViewState) {
</button>
</div>
</div>
</div>
</openclaw-modal-dialog>
`;
}