mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 05:31:45 +00:00
fix(ui): stop session shortcuts after panel collapse (#103514)
* fix(ui): stop hidden session menu shortcuts * fix(ui): adapt hidden menu dismissal to restored sidebar
This commit is contained in:
committed by
GitHub
parent
f1b75bf5fa
commit
bacc75a358
@@ -49,6 +49,10 @@ type ShellRouteState = {
|
||||
location?: RouteLocation;
|
||||
};
|
||||
|
||||
type AppSidebarElement = HTMLElement & {
|
||||
dismissTransientMenus: () => boolean;
|
||||
};
|
||||
|
||||
// Stable references so the sidebar's enabledRouteIds property does not churn
|
||||
// on every shell render.
|
||||
const ROUTE_IDS_WITHOUT_WORKBOARD = APP_ROUTE_IDS.filter((routeId) => routeId !== "workboard");
|
||||
@@ -530,23 +534,34 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
}
|
||||
if (isMobileNavLayout()) {
|
||||
if (this.navDrawerOpen) {
|
||||
this.closeNavDrawer({ restoreFocus: Boolean(trigger) });
|
||||
this.closeNavDrawer({ restoreFocus: true });
|
||||
return;
|
||||
}
|
||||
this.navDrawerTrigger = trigger ?? null;
|
||||
this.navDrawerTrigger = trigger ?? this.querySelector<HTMLElement>(".topbar-nav-toggle");
|
||||
this.navDrawerOpen = true;
|
||||
return;
|
||||
}
|
||||
// A drawer that survived a breakpoint change is visually expanded even
|
||||
// when the persisted desktop preference says collapsed.
|
||||
const nextNavCollapsed = this.navDrawerOpen || !context.navigation.snapshot.navCollapsed;
|
||||
if (nextNavCollapsed) {
|
||||
this.dismissSidebarTransientMenus();
|
||||
}
|
||||
this.closeNavDrawer();
|
||||
context.navigation.update({
|
||||
navCollapsed: nextNavCollapsed,
|
||||
});
|
||||
if (nextNavCollapsed) {
|
||||
void this.updateComplete.then(() => {
|
||||
this.querySelector<HTMLElement>(".shell-nav-expand")?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private closeNavDrawer(options: { restoreFocus?: boolean } = {}) {
|
||||
if (this.navDrawerOpen) {
|
||||
this.dismissSidebarTransientMenus();
|
||||
}
|
||||
const focusTarget = options.restoreFocus ? this.navDrawerTrigger : null;
|
||||
this.navDrawerOpen = false;
|
||||
this.navDrawerTrigger = null;
|
||||
@@ -573,9 +588,23 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
}
|
||||
|
||||
private readonly handleWindowResize = () => {
|
||||
const dismissedHiddenMenus =
|
||||
isMobileNavLayout() && !this.navDrawerOpen && this.dismissSidebarTransientMenus();
|
||||
this.requestUpdate();
|
||||
if (dismissedHiddenMenus) {
|
||||
void this.updateComplete.then(() => {
|
||||
this.querySelector<HTMLElement>(".topbar-nav-toggle")?.focus();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private dismissSidebarTransientMenus(): boolean {
|
||||
return (
|
||||
this.querySelector<AppSidebarElement>("openclaw-app-sidebar")?.dismissTransientMenus() ??
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
private readonly handleShellKeydown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || event.key !== "Escape" || !this.navDrawerOpen) {
|
||||
return;
|
||||
|
||||
@@ -250,10 +250,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.closeCustomizeMenu();
|
||||
this.closeSessionMenu();
|
||||
this.closeSessionGroupMenu();
|
||||
this.closeSessionSortMenu();
|
||||
this.dismissTransientMenus();
|
||||
this.gatewaySource = null;
|
||||
this.gatewayClient = null;
|
||||
for (const timer of this.routePreloadTimers.values()) {
|
||||
@@ -263,6 +260,22 @@ class AppSidebar extends OpenClawLightDomContentsElement {
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
// The shell calls this before CSS hides the panel or drawer. Mounted menus
|
||||
// keep their document-level shortcuts alive even when an ancestor is hidden.
|
||||
dismissTransientMenus(): boolean {
|
||||
const hadTransientMenu = Boolean(
|
||||
this.customizeMenuPosition ||
|
||||
this.sessionMenu ||
|
||||
this.sessionGroupMenu ||
|
||||
this.sessionSortMenuPosition,
|
||||
);
|
||||
this.closeCustomizeMenu();
|
||||
this.closeSessionMenu();
|
||||
this.closeSessionGroupMenu();
|
||||
this.closeSessionSortMenu();
|
||||
return hadTransientMenu;
|
||||
}
|
||||
|
||||
private readonly updateSessions = (sessions: SessionCapability) => {
|
||||
const snapshot = sessions.state;
|
||||
const gateway = this.context?.gateway;
|
||||
|
||||
@@ -364,6 +364,142 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("dismisses fixed session menus before the sidebar or drawer hides", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsListResponse([
|
||||
sessionRow("agent:main:main", "Main", Date.parse("2026-07-01T16:00:00.000Z")),
|
||||
sessionRow(
|
||||
"agent:main:research",
|
||||
"Research notes",
|
||||
Date.parse("2026-07-01T15:00:00.000Z"),
|
||||
),
|
||||
]),
|
||||
"sessions.patch": {},
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
const dialogs: string[] = [];
|
||||
page.on("dialog", (dialog) => {
|
||||
dialogs.push(dialog.message());
|
||||
void dialog.dismiss();
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
const sidebar = page.locator("openclaw-app-sidebar");
|
||||
const row = sidebar.locator(
|
||||
'.sidebar-recent-session[data-session-key="agent:main:research"]',
|
||||
);
|
||||
const shell = page.locator(".shell");
|
||||
const shellNav = page.locator(".shell-nav");
|
||||
const collapseButton = sidebar
|
||||
.locator(".sidebar-brand")
|
||||
.getByRole("button", { name: "Collapse sidebar" });
|
||||
const expandButton = page.locator(".shell-nav-expand");
|
||||
const drawerToggle = page.locator(".topbar-nav-toggle");
|
||||
const sessionMenu = page.getByRole("menu", { name: "Actions for Research notes" });
|
||||
await row.waitFor({ state: "visible", timeout: 10_000 });
|
||||
|
||||
const openSessionMenu = async () => {
|
||||
await row.hover();
|
||||
await row.getByRole("button", { name: "Open session menu" }).click();
|
||||
await page
|
||||
.getByRole("menu", { name: "Actions for Research notes" })
|
||||
.waitFor({ state: "visible" });
|
||||
};
|
||||
const expectDesktopCollapsed = async () => {
|
||||
await expect.poll(() => sidebar.isVisible()).toBe(false);
|
||||
await expect.poll(() => expandButton.isVisible()).toBe(true);
|
||||
await expect
|
||||
.poll(() => expandButton.evaluate((element) => element === document.activeElement))
|
||||
.toBe(true);
|
||||
};
|
||||
const expectDrawerClosed = async () => {
|
||||
await expect
|
||||
.poll(() => shell.getAttribute("class"))
|
||||
.not.toContain("shell--nav-drawer-open");
|
||||
await expect
|
||||
.poll(() => shellNav.evaluate((element) => element.getBoundingClientRect().right))
|
||||
.toBeLessThanOrEqual(0);
|
||||
};
|
||||
const hiddenActionCounts = async () => ({
|
||||
dialogs: dialogs.length,
|
||||
patches: (await gateway.getRequests("sessions.patch")).length,
|
||||
});
|
||||
const expectHiddenShortcutsInert = async (
|
||||
before: Awaited<ReturnType<typeof hiddenActionCounts>>,
|
||||
) => {
|
||||
for (const shortcut of ["p", "a", "d"] as const) {
|
||||
await page.keyboard.press(shortcut);
|
||||
}
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
expect(await hiddenActionCounts()).toEqual(before);
|
||||
};
|
||||
|
||||
// Keyboard collapse bypasses the menu's outside-pointer handler. The shell
|
||||
// must explicitly unmount it before the sidebar becomes display:none.
|
||||
await openSessionMenu();
|
||||
const beforeKeyboardCollapse = await hiddenActionCounts();
|
||||
await page.keyboard.press("Meta+B");
|
||||
await expectDesktopCollapsed();
|
||||
await expect.poll(() => sessionMenu.count()).toBe(0);
|
||||
await expectHiddenShortcutsInert(beforeKeyboardCollapse);
|
||||
|
||||
await expandButton.click();
|
||||
await expect.poll(() => sidebar.isVisible()).toBe(true);
|
||||
|
||||
// The visible desktop control follows the same focus handoff contract.
|
||||
await openSessionMenu();
|
||||
await collapseButton.click();
|
||||
await expectDesktopCollapsed();
|
||||
await expect.poll(() => sessionMenu.count()).toBe(0);
|
||||
await expandButton.click();
|
||||
await expect.poll(() => sidebar.isVisible()).toBe(true);
|
||||
|
||||
// Crossing into drawer layout hides the desktop sidebar without toggling
|
||||
// persisted collapse state, so resize owns this dismissal and focus move.
|
||||
await openSessionMenu();
|
||||
const beforeNarrowTransition = await hiddenActionCounts();
|
||||
await page.setViewportSize({ height: 900, width: 900 });
|
||||
await expectDrawerClosed();
|
||||
await expect.poll(() => sessionMenu.count()).toBe(0);
|
||||
await expect
|
||||
.poll(() => drawerToggle.evaluate((element) => element === document.activeElement))
|
||||
.toBe(true);
|
||||
await expectHiddenShortcutsInert(beforeNarrowTransition);
|
||||
|
||||
await drawerToggle.click();
|
||||
await expect.poll(() => shell.getAttribute("class")).toContain("shell--nav-drawer-open");
|
||||
await expect
|
||||
.poll(() => shellNav.evaluate((element) => element.getBoundingClientRect().left))
|
||||
.toBe(0);
|
||||
await openSessionMenu();
|
||||
const beforeDrawerCollapse = await hiddenActionCounts();
|
||||
await page.keyboard.press("Meta+B");
|
||||
await expectDrawerClosed();
|
||||
await expect.poll(() => sessionMenu.count()).toBe(0);
|
||||
await expect
|
||||
.poll(() => drawerToggle.evaluate((element) => element === document.activeElement))
|
||||
.toBe(true);
|
||||
await expectHiddenShortcutsInert(beforeDrawerCollapse);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("archives a session from the Sessions page context menu and kebab", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
|
||||
Reference in New Issue
Block a user