diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index 0d294cec9c98..4c7a182944cd 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -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(".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(".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(".topbar-nav-toggle")?.focus(); + }); + } }; + private dismissSidebarTransientMenus(): boolean { + return ( + this.querySelector("openclaw-app-sidebar")?.dismissTransientMenus() ?? + false + ); + } + private readonly handleShellKeydown = (event: KeyboardEvent) => { if (event.defaultPrevented || event.key !== "Escape" || !this.navDrawerOpen) { return; diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index d9b19b818708..c9ed6351981e 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -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; diff --git a/ui/src/e2e/session-management.e2e.test.ts b/ui/src/e2e/session-management.e2e.test.ts index a3289712f72f..d5765533c313 100644 --- a/ui/src/e2e/session-management.e2e.test.ts +++ b/ui/src/e2e/session-management.e2e.test.ts @@ -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>, + ) => { + for (const shortcut of ["p", "a", "d"] as const) { + await page.keyboard.press(shortcut); + } + await page.evaluate( + () => + new Promise((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",