mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix(ui): use in-app dialog for session rename (#121255)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user