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:
Peter Steinberger
2026-07-10 11:14:41 +01:00
committed by GitHub
parent f1b75bf5fa
commit bacc75a358
3 changed files with 184 additions and 6 deletions

View File

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

View File

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

View File

@@ -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",