fix(ui): use in-app dialog for session rename (#121255)

This commit is contained in:
Patrick Erichsen
2026-08-09 15:24:02 -07:00
committed by GitHub
parent 096b2764e0
commit ce133d4a35
6 changed files with 271 additions and 26 deletions
+84
View File
@@ -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<HTMLInputElement>('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();
});
});
+95
View File
@@ -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<string | null> {
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`
<openclaw-modal-dialog
label=${options.title}
description=${label}
@modal-cancel=${() => finish(null)}
>
<form class="exec-approval-card" @submit=${submit}>
<div class="exec-approval-header">
<div class="exec-approval-title">${options.title}</div>
</div>
<label class="field">
<span>${label}</span>
<input
name="value"
type="text"
autocomplete="off"
.value=${options.defaultValue ?? ""}
autofocus
/>
</label>
<div class="exec-approval-actions">
<button type="submit" class="btn primary">
${options.submitLabel ?? t("common.save")}
</button>
<button type="button" class="btn" @click=${() => finish(null)}>
${options.cancelLabel ?? t("common.cancel")}
</button>
</div>
</form>
</openclaw-modal-dialog>
`,
host,
);
});
}
/** Native prompts block reentrancy; reject a second request instead of stacking it. */
export function showInputDialog(options: InputDialogOptions): Promise<string | null> {
if (inputActive) {
return Promise.resolve(null);
}
inputActive = true;
return presentInputDialog(options).finally(() => {
inputActive = false;
});
}
@@ -392,7 +392,11 @@ export class SessionOrganizerController implements ReactiveController {
}
async renameSession(session: SidebarRecentSession): Promise<void> {
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;
}
@@ -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({
+7 -6
View File
@@ -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);
@@ -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<HTMLButtonElement>('[data-shortcut="r"]')?.click();
const menu = await openSessionMenu(sidebar, "agent:main:a");
menu.querySelector<HTMLButtonElement>('[data-shortcut="r"]')?.click();
await waitForFast(() => {
expect(document.body.querySelector('input[name="value"]')).toBeInstanceOf(HTMLInputElement);
});
document.body.querySelector<HTMLInputElement>('input[name="value"]')!.value = "Rejected rename";
document.body.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('[aria-label="Dismiss error"]')?.click();
await sidebar.updateComplete;
expect(sidebar.querySelector("[data-sidebar-session-error]")).toBeNull();
} finally {
promptSpy.mockRestore();
}
error?.querySelector<HTMLButtonElement>('[aria-label="Dismiss error"]')?.click();
await sidebar.updateComplete;
expect(sidebar.querySelector("[data-sidebar-session-error]")).toBeNull();
});
it("surfaces partial batch-delete errors", async () => {