diff --git a/ui/src/components/input-dialog.test.ts b/ui/src/components/input-dialog.test.ts new file mode 100644 index 000000000000..4841273d1206 --- /dev/null +++ b/ui/src/components/input-dialog.test.ts @@ -0,0 +1,84 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getRenderedModalDialog, installDialogPolyfill } from "../test-helpers/modal-dialog.ts"; +import { showInputDialog } from "./input-dialog.ts"; + +let restoreDialogPolyfill: () => void; + +function findButton(label: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === label, + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected ${label} button`); + } + return button; +} + +describe("showInputDialog", () => { + beforeEach(() => { + restoreDialogPolyfill = installDialogPolyfill(); + }); + + afterEach(() => { + document.body.replaceChildren(); + restoreDialogPolyfill(); + }); + + it("renders accessible copy and resolves the submitted value", async () => { + const result = showInputDialog({ + title: "Rename session", + label: "Session name", + defaultValue: "Original name", + submitLabel: "Rename", + }); + const { modal, dialog } = await getRenderedModalDialog(document.body); + const input = modal.querySelector('input[name="value"]'); + + expect(dialog.getAttribute("aria-label")).toBe("Rename session"); + expect(dialog.getAttribute("aria-description")).toBe("Session name"); + expect(input?.value).toBe("Original name"); + + if (!input) { + throw new Error("Expected text input"); + } + input.value = "Renamed session"; + findButton("Rename").click(); + + await expect(result).resolves.toBe("Renamed session"); + expect(document.body.querySelector("openclaw-modal-dialog")).toBeNull(); + }); + + it("treats modal dismissal as cancellation", async () => { + const result = showInputDialog({ title: "Rename session" }); + const { modal } = await getRenderedModalDialog(document.body); + + modal.dispatchEvent(new CustomEvent("modal-cancel")); + + await expect(result).resolves.toBeNull(); + }); + + it("removes the dialog and cancels when its owner aborts", async () => { + const controller = new AbortController(); + const result = showInputDialog({ title: "Rename session", signal: controller.signal }); + await getRenderedModalDialog(document.body); + + controller.abort(); + + await expect(result).resolves.toBeNull(); + expect(document.body.querySelector("openclaw-modal-dialog")).toBeNull(); + }); + + it("rejects a reentrant input request instead of stacking or replaying it", async () => { + const first = showInputDialog({ title: "First" }); + const second = showInputDialog({ title: "Second" }); + await getRenderedModalDialog(document.body); + + expect(document.body.textContent).toContain("First"); + expect(document.body.textContent).not.toContain("Second"); + await expect(second).resolves.toBeNull(); + findButton("Cancel").click(); + await expect(first).resolves.toBeNull(); + }); +}); diff --git a/ui/src/components/input-dialog.ts b/ui/src/components/input-dialog.ts new file mode 100644 index 000000000000..6500b242eaf4 --- /dev/null +++ b/ui/src/components/input-dialog.ts @@ -0,0 +1,95 @@ +// Control UI helper presents Promise-based text input without relying on a native prompt bridge. +import { html, nothing, render } from "lit"; +import { t } from "../i18n/index.ts"; +import "./modal-dialog.ts"; + +type InputDialogOptions = { + title: string; + label?: string; + defaultValue?: string; + submitLabel?: string; + cancelLabel?: string; + signal?: AbortSignal; +}; + +let inputActive = false; + +function presentInputDialog(options: InputDialogOptions): Promise { + if (options.signal?.aborted) { + return Promise.resolve(null); + } + const host = document.createElement("div"); + document.body.append(host); + return new Promise((resolve) => { + let settled = false; + const finish = (value: string | null) => { + if (settled) { + return; + } + settled = true; + options.signal?.removeEventListener("abort", handleAbort); + render(nothing, host); + host.remove(); + resolve(value); + }; + const handleAbort = () => finish(null); + const submit = (event: SubmitEvent) => { + event.preventDefault(); + const form = event.currentTarget; + if (!(form instanceof HTMLFormElement)) { + return; + } + const input = form.elements.namedItem("value"); + if (input instanceof HTMLInputElement) { + finish(input.value); + } + }; + options.signal?.addEventListener("abort", handleAbort, { once: true }); + const label = options.label ?? options.title; + render( + html` + finish(null)} + > +
+
+
${options.title}
+
+ +
+ + +
+
+
+ `, + host, + ); + }); +} + +/** Native prompts block reentrancy; reject a second request instead of stacking it. */ +export function showInputDialog(options: InputDialogOptions): Promise { + if (inputActive) { + return Promise.resolve(null); + } + inputActive = true; + return presentInputDialog(options).finally(() => { + inputActive = false; + }); +} diff --git a/ui/src/components/session-organizer-controller.ts b/ui/src/components/session-organizer-controller.ts index e69a2e947e10..724027302da4 100644 --- a/ui/src/components/session-organizer-controller.ts +++ b/ui/src/components/session-organizer-controller.ts @@ -392,7 +392,11 @@ export class SessionOrganizerController implements ReactiveController { } async renameSession(session: SidebarRecentSession): Promise { - const nextLabel = window.prompt(t("sessionsView.renameSessionPrompt"), session.label); + const { showInputDialog } = await import("./input-dialog.ts"); + const nextLabel = await showInputDialog({ + title: t("sessionsView.renameSessionPrompt"), + defaultValue: session.label, + }); if (nextLabel === null) { return; } diff --git a/ui/src/e2e/session-management.groups.e2e.test.ts b/ui/src/e2e/session-management.groups.e2e.test.ts index 71e9f1ad3b02..055777810ca2 100644 --- a/ui/src/e2e/session-management.groups.e2e.test.ts +++ b/ui/src/e2e/session-management.groups.e2e.test.ts @@ -1,8 +1,10 @@ +import path from "node:path"; import { expect, it } from "vitest"; import { actionOpacity, activateMenuItem, captureUiProof, + captureUiProofEnabled, collapsedSessionSectionsStorageKey, controlUiSessionPath, createSessionManagementE2eSuite, @@ -10,6 +12,7 @@ import { requireRecord, sessionRow, sessionsListResponse, + uiProofArtifactDir, waitForPatch, } from "./session-management.test-support.ts"; @@ -77,8 +80,10 @@ suite.define(() => { await row.waitFor({ state: "visible", timeout: 10_000 }); await row.hover(); await row.getByRole("button", { name: "Open session menu" }).click(); - page.once("dialog", (dialog) => void dialog.accept("Rejected rename")); await page.getByRole("menuitem", { name: "Rename…" }).click(); + const dialog = page.locator('openclaw-modal-dialog[label="Rename session"]'); + await dialog.getByRole("textbox", { name: "Rename session" }).fill("Rejected rename"); + await dialog.getByRole("button", { name: "Save" }).click(); await gateway.waitForRequest("sessions.patch"); await gateway.rejectDeferred("sessions.patch", { code: "INVALID_REQUEST", @@ -101,6 +106,62 @@ suite.define(() => { } }); + it("renames a sidebar session through an in-app dialog", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + recordVideo: captureUiProofEnabled + ? { dir: uiProofArtifactDir, size: { height: 900, width: 1280 } } + : undefined, + }); + const page = await context.newPage(); + const proofVideo = page.video(); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.list": sessionsListResponse([ + sessionRow("agent:main:rename-me", "Original name", Date.now()), + ]), + "sessions.patch": {}, + }, + sessionKey: "agent:main:rename-me", + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const row = page.locator('[data-session-key="agent:main:rename-me"]'); + await row.waitFor({ state: "visible", timeout: 10_000 }); + await row.hover(); + await row.getByRole("button", { name: "Open session menu" }).click(); + await page.getByRole("menuitem", { name: "Rename…" }).click(); + + await page.getByRole("dialog", { name: "Rename session" }).waitFor({ state: "visible" }); + const dialog = page.locator('openclaw-modal-dialog[label="Rename session"]'); + const name = dialog.getByRole("textbox", { name: "Rename session" }); + await name.waitFor({ state: "visible" }); + await expect.poll(() => name.inputValue()).toBe("Original name"); + await captureUiProof(page, "sidebar-session-rename-dialog.png"); + await name.fill("Renamed session"); + await dialog.getByRole("button", { name: "Save" }).click(); + + const patch = await waitForPatch( + gateway, + (params) => params.key === "agent:main:rename-me" && params.label === "Renamed session", + ); + expect(patch.params).toMatchObject({ + key: "agent:main:rename-me", + label: "Renamed session", + }); + await expect.poll(() => row.textContent()).toContain("Renamed session"); + await captureUiProof(page, "sidebar-session-renamed.png"); + } finally { + await context.close(); + if (proofVideo) { + await proofVideo.saveAs(path.join(uiProofArtifactDir, "sidebar-session-rename.webm")); + } + } + }); + it("manages sessions through the sidebar groups and command palette", async () => { const baseTime = Date.parse("2026-07-01T16:00:00.000Z"); const context = await suite.browser.newContext({ diff --git a/ui/src/pages/sessions/sessions-page.ts b/ui/src/pages/sessions/sessions-page.ts index 171ba5ff1138..90faf25439c7 100644 --- a/ui/src/pages/sessions/sessions-page.ts +++ b/ui/src/pages/sessions/sessions-page.ts @@ -18,6 +18,7 @@ import { resolveCloudWorkerStopAction, } from "../../components/cloud-worker-stop.ts"; import { showConfirmDialog } from "../../components/confirm-dialog.ts"; +import { showInputDialog } from "../../components/input-dialog.ts"; import { fetchSessionMenuWork } from "../../components/session-menu-work.ts"; import type { SessionMenuAction, @@ -1078,11 +1079,11 @@ class SessionsPage extends OpenClawLightDomElement { } } - private renameSession(row: GatewaySessionRow) { - const value = window.prompt( - t("sessionsView.renameSessionPrompt"), - normalizeOptionalString(row.label) ?? "", - ); + private async renameSession(row: GatewaySessionRow) { + const value = await showInputDialog({ + title: t("sessionsView.renameSessionPrompt"), + defaultValue: normalizeOptionalString(row.label) ?? "", + }); if (value === null) { return; } @@ -1472,7 +1473,7 @@ class SessionsPage extends OpenClawLightDomElement { void this.patchSession(row.key, { unread: row.unread !== true }); break; case "rename": - this.renameSession(row); + void this.renameSession(row); break; case "fork": void this.forkSession(row.key); diff --git a/ui/src/test-helpers/app-sidebar-cases/sessions.ts b/ui/src/test-helpers/app-sidebar-cases/sessions.ts index 361ea35c5ba1..d916bf728cf1 100644 --- a/ui/src/test-helpers/app-sidebar-cases/sessions.ts +++ b/ui/src/test-helpers/app-sidebar-cases/sessions.ts @@ -573,26 +573,26 @@ describe("AppSidebar session mutation feedback", () => { it("shows and dismisses a fixed sidebar error when a session patch is rejected", async () => { const { harness, sidebar } = await mountMutationHarness(); harness.patch.mockRejectedValueOnce(new Error("rename rejected by Gateway")); - const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("Rejected rename"); - try { - const menu = await openSessionMenu(sidebar, "agent:main:a"); - menu.querySelector('[data-shortcut="r"]')?.click(); + const menu = await openSessionMenu(sidebar, "agent:main:a"); + menu.querySelector('[data-shortcut="r"]')?.click(); + await waitForFast(() => { + expect(document.body.querySelector('input[name="value"]')).toBeInstanceOf(HTMLInputElement); + }); + document.body.querySelector('input[name="value"]')!.value = "Rejected rename"; + document.body.querySelector('button[type="submit"]')?.click(); - await waitForFast(() => { - expect(sidebar.querySelector("[data-sidebar-session-error]")?.textContent).toContain( - "rename rejected by Gateway", - ); - }); - const error = sidebar.querySelector("[data-sidebar-session-error]"); - expect(error?.parentElement?.classList.contains("sidebar-sessions")).toBe(true); - expect(error?.closest(".sidebar-recent-sessions")).toBeNull(); + await waitForFast(() => { + expect(sidebar.querySelector("[data-sidebar-session-error]")?.textContent).toContain( + "rename rejected by Gateway", + ); + }); + const error = sidebar.querySelector("[data-sidebar-session-error]"); + expect(error?.parentElement?.classList.contains("sidebar-sessions")).toBe(true); + expect(error?.closest(".sidebar-recent-sessions")).toBeNull(); - error?.querySelector('[aria-label="Dismiss error"]')?.click(); - await sidebar.updateComplete; - expect(sidebar.querySelector("[data-sidebar-session-error]")).toBeNull(); - } finally { - promptSpy.mockRestore(); - } + error?.querySelector('[aria-label="Dismiss error"]')?.click(); + await sidebar.updateComplete; + expect(sidebar.querySelector("[data-sidebar-session-error]")).toBeNull(); }); it("surfaces partial batch-delete errors", async () => {