From f15b825f64163ff25d2f331bb85e2f213d9b7d7b Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Thu, 27 Aug 2026 07:05:58 -0300 Subject: [PATCH] feat(ui): make mobile navigation a draggable drawer (#130319) * feat(ui): make mobile navigation a draggable drawer * fix(ui): align drawer gesture with mobile layout * refactor(ui): isolate drawer startup owners * perf(ui): keep drawer opening in lazy chunk * fix(ui): harden drawer modal lifecycle * fix(ui): complete drawer modal lifecycle * fix(ui): preserve drawer modal ownership * fix(ui): keep drawer modal controls reachable * fix(ui): own drawer shortcuts in capture phase * fix(ui): defer drawer escape to nested controls * fix(ui): suppress shell shortcuts under modals * refactor(ui): isolate drawer key handling * fix(ui): preserve drawer modal ownership * fix(ui): defer picker escape to drawer * fix(ui): scope inbox modal semantics * fix(ui): dismiss inbox across breakpoints * fix(ui): preserve overlay escape ownership * fix(ui): mark drawer swipe runtime boundary * fix(ui): separate shell key phases * chore(ui): shrink assertion baseline --- config/assertion-safety-baseline.txt | 2 +- .../control-ui-startup-budget-baseline.json | 4 +- ui/src/app/app-host.test.ts | 21 +- ui/src/app/app-host.ts | 8 +- ui/src/app/app-root.ts | 2 +- ui/src/app/app-shell-chrome.ts | 202 ++++++++--------- ui/src/app/app-shell-view.ts | 44 ++-- ui/src/app/bootstrap.ts | 2 +- ui/src/app/nav-drawer-swipe-loader.ts | 51 +++++ ui/src/app/nav-drawer-swipe.runtime.ts | 211 ++++++++++++++++++ ui/src/app/navigation-surface.ts | 103 +++++++++ ui/src/app/panel-availability.ts | 21 ++ ui/src/components/command-palette-contract.ts | 43 ++++ .../file-preview-modal.browser.test.ts | 12 +- ui/src/components/modal-dialog.test.ts | 13 +- ui/src/components/modal-dialog.ts | 24 -- .../sidebar-attention-panel.runtime.ts | 2 + ui/src/components/sidebar-attention.ts | 6 + ui/src/e2e/model-setup.e2e.test.ts | 18 +- .../e2e/native-nav-sidebar-toggle.e2e.test.ts | 130 +++++++++-- ui/src/lib/toast.ts | 10 +- ui/src/pages/chat/chat-pane-header.ts | 2 +- ui/src/pages/chat/chat-pane-layout-render.ts | 2 +- ui/src/pages/chat/chat-pane-lifecycle.ts | 3 + ui/src/pages/chat/chat-pane-rails.ts | 2 +- .../chat/components/chat-picker-overlay.ts | 2 +- ui/src/pages/config/config-page.ts | 2 +- .../pages/new-session/new-session-runtime.ts | 3 + ui/src/pages/plugins/plugins-page.ts | 3 + ui/src/styles/layout.css | 4 + ui/src/styles/layout.mobile.css | 83 ++++++- 31 files changed, 800 insertions(+), 235 deletions(-) create mode 100644 ui/src/app/nav-drawer-swipe-loader.ts create mode 100644 ui/src/app/nav-drawer-swipe.runtime.ts create mode 100644 ui/src/app/panel-availability.ts diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 0f0df564b67a..1f4c10a45271 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -3916,7 +3916,7 @@ ui/src/app-route-paths.ts 1 ui/src/app-routes.ts 1 ui/src/app/app-host-route-state.ts 1 ui/src/app/app-host.ts 1 -ui/src/app/app-shell-chrome.ts 4 +ui/src/app/app-shell-chrome.ts 2 ui/src/app/app-shell-gateway.ts 1 ui/src/app/assistant-identity.ts 2 ui/src/app/browser.ts 1 diff --git a/config/control-ui-startup-budget-baseline.json b/config/control-ui-startup-budget-baseline.json index 2ee07efa9b21..44052b279440 100644 --- a/config/control-ui-startup-budget-baseline.json +++ b/config/control-ui-startup-budget-baseline.json @@ -1,5 +1,5 @@ { - "startupJsGzipBytes": 343529, - "reason": "compact chat topbar and route-aware native chrome on the boot path; measured 343529 B with compact layout regression coverage", + "startupJsGzipBytes": 344044, + "reason": "mobile drawer modal ownership and breakpoint-gated swipe loader; measured 344044 B with desktop request and modal regression coverage", "updatedAt": "2026-08-27" } diff --git a/ui/src/app/app-host.test.ts b/ui/src/app/app-host.test.ts index 4660e2c2a34a..09bba1993827 100644 --- a/ui/src/app/app-host.test.ts +++ b/ui/src/app/app-host.test.ts @@ -782,21 +782,19 @@ describe("OpenClaw shell keyboard shortcuts", () => { } }); - it("suppresses modal focus restoration when the navigation drawer closes without restoring focus", () => { + it("keeps focus in place when the navigation drawer closes without restoration", () => { const shell = document.createElement("openclaw-app-shell") as ShellNavDrawerCloseState; - const modal = document.createElement("openclaw-modal-dialog"); - const setReturnFocusTarget = vi.fn(); - modal.className = "drawer nav-drawer"; - Object.defineProperty(modal, "setReturnFocusTarget", { value: setReturnFocusTarget }); - shell.append(modal); + const trigger = document.body.appendChild(document.createElement("button")); + const restoreTriggerFocus = vi.spyOn(trigger, "focus"); shell.navDrawerOpen = true; - shell.navDrawerTrigger = document.createElement("button"); + shell.navDrawerTrigger = trigger; shell.closeNavDrawer(); - expect(setReturnFocusTarget).toHaveBeenCalledExactlyOnceWith(null); + expect(restoreTriggerFocus).not.toHaveBeenCalled(); expect(shell.navDrawerOpen).toBe(false); expect(shell.navDrawerTrigger).toBeNull(); + trigger.remove(); }); it("closes an open navigation drawer before moving its sidebar into desktop layout", () => { @@ -814,11 +812,7 @@ describe("OpenClaw shell keyboard shortcuts", () => { const sidebar = document.createElement("openclaw-app-sidebar"); const dismissTransientMenus = vi.fn(() => true); Object.defineProperty(sidebar, "dismissTransientMenus", { value: dismissTransientMenus }); - const modal = document.createElement("openclaw-modal-dialog"); - const setReturnFocusTarget = vi.fn(); - modal.className = "drawer nav-drawer"; - Object.defineProperty(modal, "setReturnFocusTarget", { value: setReturnFocusTarget }); - shell.append(sidebar, modal); + shell.append(sidebar); const trigger = document.body.appendChild(document.createElement("button")); const restoreTriggerFocus = vi.spyOn(trigger, "focus"); const closeNavDrawer = vi.spyOn(shell, "closeNavDrawer"); @@ -829,7 +823,6 @@ describe("OpenClaw shell keyboard shortcuts", () => { expect(closeNavDrawer).toHaveBeenCalledExactlyOnceWith({ restoreFocus: false }); expect(dismissTransientMenus).toHaveBeenCalledOnce(); - expect(setReturnFocusTarget).toHaveBeenCalledExactlyOnceWith(null); expect(restoreTriggerFocus).not.toHaveBeenCalled(); expect(shell.navDrawerOpen).toBe(false); expect(shell.navDrawerTrigger).toBeNull(); diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index 6789d3b71f46..490c107a89ae 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -47,12 +47,7 @@ import type { ChatPage } from "../pages/chat/chat-page.ts"; import type { NewSessionTarget } from "../pages/new-session/location.ts"; import { selectShellRouteState, type ShellRouteState } from "./app-host-route-state.ts"; import { OpenClawApp } from "./app-root.ts"; -import { - isBrowserPanelAvailable, - isDesktopPanelAvailable, - ShellChromeOwner, - type ShellChromeHost, -} from "./app-shell-chrome.ts"; +import { ShellChromeOwner, type ShellChromeHost } from "./app-shell-chrome.ts"; import { ShellGatewayOwner, type OutboxStoreRuntime, @@ -79,6 +74,7 @@ import { hasStoredLazyShellAction } from "./lazy-shell-action.ts"; import { postNativeNavState, type NativeNavState } from "./native-nav-state.ts"; import { readNativeHistoryState, type NativeHistoryState } from "./native-web-chrome.ts"; import { resolveOnboardingMode } from "./onboarding-mode.ts"; +import { isBrowserPanelAvailable, isDesktopPanelAvailable } from "./panel-availability.ts"; import { changedServerUiPrefs, isApplyingServerUiPrefs, diff --git a/ui/src/app/app-root.ts b/ui/src/app/app-root.ts index 9482165d6702..e54713d7707a 100644 --- a/ui/src/app/app-root.ts +++ b/ui/src/app/app-root.ts @@ -18,7 +18,6 @@ import { isTerminalAvailable } from "../lib/terminal-availability.ts"; import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; import { SubscriptionsController } from "../lit/subscriptions-controller.ts"; import type { ChatRouteData } from "../pages/chat/route-loader.ts"; -import { isDesktopPanelAvailable } from "./app-shell-chrome.ts"; import { bootstrapApplication, type ApplicationRuntime } from "./bootstrap.ts"; import { applicationContext, type ApplicationContext } from "./context.ts"; import { @@ -32,6 +31,7 @@ import { TERMINAL_PANEL_ELEMENT, } from "./lazy-custom-element.ts"; import { resolveOnboardingMode } from "./onboarding-mode.ts"; +import { isDesktopPanelAvailable } from "./panel-availability.ts"; type FocusDashboardRouteState = | { kind: "loading" } diff --git a/ui/src/app/app-shell-chrome.ts b/ui/src/app/app-shell-chrome.ts index 8473994da10d..eaef4a503885 100644 --- a/ui/src/app/app-shell-chrome.ts +++ b/ui/src/app/app-shell-chrome.ts @@ -1,15 +1,15 @@ import { isSettingsNavigationRoute } from "../app-navigation.ts"; import { isSessionRouteId, routeIdFromPath, type RouteId } from "../app-route-paths.ts"; import { + applyCommandPaletteTargetEvent, COMMAND_PALETTE_OPEN_EVENT, COMMAND_PALETTE_TARGET_EVENT, isCommandPaletteShortcut, SHELL_NAV_DRAWER_TOGGLE_EVENT, + shellNavDrawerTriggerFromEvent, type CommandPaletteElement, type CommandPaletteTargetDetail, - type ShellNavDrawerToggleDetail, } from "../components/command-palette-contract.ts"; -import type { OpenClawModalDialog } from "../components/modal-dialog.ts"; import { BROWSER_PANEL_TOGGLE_EVENT, CUSTODIAN_PANEL_TOGGLE_EVENT, @@ -21,7 +21,7 @@ import { } from "../components/panel-toggle-contract.ts"; import { rememberSessionPanelToggle } from "../components/session-panel-toggle-buffer.ts"; import type { BoardFace } from "../lib/board/settings.ts"; -import { canCallGatewayMethod, isGatewayMethodAdvertised } from "../lib/gateway-methods.ts"; +import { canCallGatewayMethod } from "../lib/gateway-methods.ts"; import { KEYBOARD_SHORTCUT_COMBOS, matchesShortcutCombo, @@ -51,13 +51,17 @@ import { readNativeHistoryState, type NativeHistoryState, } from "./native-web-chrome.ts"; -import { hasOperatorAdminAccess } from "./operator-access.ts"; +import { NavDrawerSwipeLoader } from "./nav-drawer-swipe-loader.ts"; +import { + dismissNavigationTransientSurfaces, + handleNavDrawerKeydown, + moveToastToNavDrawer, + restoreToastFromNavDrawer, + visibleNavDrawerToggle, +} from "./navigation-surface.ts"; +import { isBrowserPanelAvailable, isDesktopPanelAvailable } from "./panel-availability.ts"; import { NAV_WIDTH_MAX, NAV_WIDTH_MIN } from "./settings.ts"; -type AppSidebarElement = HTMLElement & { - dismissTransientMenus: () => boolean; -}; - type DebugOverlayElement = HTMLElement & { toggle: () => void; }; @@ -67,24 +71,8 @@ type KeyboardShortcutsDialogElement = HTMLElement & { toggle: () => void; }; -export function isBrowserPanelAvailable( - snapshot: ApplicationContext["gateway"]["snapshot"], -): boolean { - return ( - snapshot.phase === "connected" && - hasOperatorAdminAccess(snapshot.hello?.auth ?? null) && - isGatewayMethodAdvertised(snapshot, "browser.request") === true - ); -} - -export function isDesktopPanelAvailable( - snapshot: ApplicationContext["gateway"]["snapshot"], -): boolean { - return ( - snapshot.phase === "connected" && - hasOperatorAdminAccess(snapshot.hello?.auth ?? null) && - isGatewayMethodAdvertised(snapshot, "desktop.observe") === true - ); +function isSettingsTakeover(routeId: RouteId | undefined): boolean { + return routeId !== undefined && isSettingsNavigationRoute(routeId); } export interface ShellChromeHost extends HTMLElement { @@ -122,8 +110,10 @@ export interface ShellChromeHost extends HTMLElement { export class ShellChromeOwner { private pendingLazyAction = readLazyShellAction(); private listeners: AbortController | undefined; - - constructor(private readonly host: ShellChromeHost) {} + private readonly navDrawerSwipe: NavDrawerSwipeLoader; + constructor(private readonly host: ShellChromeHost) { + this.navDrawerSwipe = new NavDrawerSwipeLoader(host, () => this.toggleNavigationSurface()); + } private isSessionRoute(): boolean { const locationRouteId = routeIdFromPath( @@ -153,7 +143,11 @@ export class ShellChromeOwner { this.handleKeyboardShortcutsRequest, options, ); - document.addEventListener("keydown", this.handleDocumentKeydown, options); + document.addEventListener("keydown", this.handleDocumentKeydown, { + capture: true, + signal: this.listeners.signal, + }); + document.addEventListener("keydown", this.handleDocumentKeydownBubble, options); window.addEventListener("resize", this.handleWindowResize, options); window.addEventListener("dragover", this.handleUnhandledFileDrag, options); window.addEventListener("drop", this.handleUnhandledFileDrag, options); @@ -185,27 +179,45 @@ export class ShellChromeOwner { options, ); window.addEventListener(SHELL_APPROVALS_OPEN_EVENT, this.handleApprovalsOpen, options); + this.navDrawerSwipe.connect(); + if (isMobileNavLayout()) { + this.navDrawerSwipe.load(); + } } disconnect(): void { this.listeners?.abort(); this.listeners = undefined; + this.navDrawerSwipe.disconnect(); } toggleNavigationSurface(trigger?: HTMLElement): void { const host = this.host; const context = host.context; // Desktop settings takeover has no app nav; its mobile drawer still owns navigation. - if (!context || host.onboardingMode || (this.isSettingsTakeover() && !isMobileNavLayout())) { + if ( + !context || + host.onboardingMode || + (isSettingsTakeover(host.routeState.routeId) && !isMobileNavLayout()) + ) { return; } if (isMobileNavLayout()) { + this.navDrawerSwipe.load(); if (host.navDrawerOpen) { host.closeNavDrawer({ restoreFocus: true }); return; } - host.navDrawerTrigger = trigger ?? host.querySelector(".topbar-nav-toggle"); + host.navDrawerTrigger = trigger ?? visibleNavDrawerToggle(host) ?? null; host.navDrawerOpen = true; + moveToastToNavDrawer(host); + if (!this.navDrawerSwipe.opened()) { + void host.updateComplete.then(() => { + if (host.isConnected && host.navDrawerOpen) { + host.querySelector(".shell-nav")?.focus({ preventScroll: true }); + } + }); + } return; } // A responsive handoff expands this shell without overwriting the desktop preference. @@ -226,41 +238,24 @@ export class ShellChromeOwner { } /** Native Mac chrome hides in-page toggles, so restoration falls back to content. */ - restoreFocusTo(target: HTMLElement | null | undefined): void { - const resolved = - target?.isConnected && target.checkVisibility() - ? target - : this.host.querySelector(".content"); - resolved?.focus(); - } - - visibleNavDrawerToggle(): HTMLElement | undefined { - return [ - ...this.host.querySelectorAll(".topbar-nav-toggle, .chat-pane__nav-toggle"), - ].find((candidate) => candidate.checkVisibility()); - } + restoreFocusTo = (target: HTMLElement | null | undefined): void => + (target?.isConnected && target.checkVisibility() + ? target + : this.host.querySelector(".content") + )?.focus(); closeNavDrawer(options: { restoreFocus?: boolean } = {}): void { const host = this.host; if (host.navDrawerOpen) { this.dismissSidebarTransientMenus(); + this.navDrawerSwipe.closed(); } + restoreToastFromNavDrawer(host); const trigger = options.restoreFocus ? host.navDrawerTrigger : null; - const returnFocusTarget = - options.restoreFocus && trigger?.isConnected && trigger.checkVisibility() - ? trigger - : options.restoreFocus - ? host.querySelector(".content") - : null; - host - .querySelector("openclaw-modal-dialog.nav-drawer") - ?.setReturnFocusTarget(returnFocusTarget ?? null); host.navDrawerOpen = false; host.navDrawerTrigger = null; if (options.restoreFocus) { - requestAnimationFrame(() => { - this.restoreFocusTo(trigger instanceof HTMLElement ? trigger : null); - }); + requestAnimationFrame(() => this.restoreFocusTo(trigger)); } } @@ -277,17 +272,11 @@ export class ShellChromeOwner { context.navigation.update({ navWidth }); } - readonly handleNativeToggleSidebar = (): void => { - this.toggleNavigationSurface(); - }; - - readonly handleNativeOpenSearch = (): void => { - this.openPalette(); - }; + readonly handleNativeToggleSidebar = (): void => this.toggleNavigationSurface(); + readonly handleNativeOpenSearch = (): void => this.openPalette(); readonly handleNativeToggleSearch = (event: Event): void => { - // Native menu dispatch falls back to open-only search unless the toggle acknowledges it. - event.preventDefault(); + event.preventDefault(); // Acknowledges toggle so native does not fall back to open-only search. this.togglePalette(); }; @@ -356,6 +345,7 @@ export class ShellChromeOwner { const dismissedSidebarMenus = mobileNavLayout && !host.navDrawerOpen && this.dismissSidebarTransientMenus(); if (mobileNavLayout) { + this.navDrawerSwipe.load(); host.desktopNavigationExpanded = false; } else if (host.navDrawerOpen) { host.closeNavDrawer({ restoreFocus: false }); @@ -366,7 +356,7 @@ export class ShellChromeOwner { void host.updateComplete.then(() => { if (isMobileNavLayout() && !host.navDrawerOpen && dismissedSidebarMenus) { requestAnimationFrame(() => { - this.restoreFocusTo(this.visibleNavDrawerToggle()); + this.restoreFocusTo(visibleNavDrawerToggle(host)); }); } }); @@ -393,15 +383,34 @@ export class ShellChromeOwner { } }; - dismissSidebarTransientMenus(): boolean { - return ( - this.host.querySelector("openclaw-app-sidebar")?.dismissTransientMenus() ?? - false - ); - } + dismissSidebarTransientMenus = (): boolean => dismissNavigationTransientSurfaces(this.host); + + private readonly handleDocumentKeydownBubble = (event: KeyboardEvent): void => { + const host = this.host; + if (event.defaultPrevented || !matchesShortcutCombo(KEYBOARD_SHORTCUT_COMBOS.escape, event)) { + return; + } + if (host.navDrawerOpen && isMobileNavLayout() && !document.openClawModalLayers?.size) { + event.preventDefault(); + host.closeNavDrawer({ restoreFocus: true }); + } else if ( + isSettingsTakeover(host.routeState.routeId) && + !this.shouldIgnoreSettingsEscape(event) + ) { + event.preventDefault(); + host.exitSettings(); + } + }; readonly handleDocumentKeydown = (event: KeyboardEvent): void => { const host = this.host; + if (document.openClawModalLayers?.size) { + return; + } + if (host.navDrawerOpen && isMobileNavLayout()) { + handleNavDrawerKeydown(host, event); + return; + } if (!host.commandPalette && isCommandPaletteShortcut(event)) { event.preventDefault(); this.togglePalette(); @@ -436,13 +445,16 @@ export class ShellChromeOwner { window.dispatchEvent(new CustomEvent(DEBUG_OVERLAY_REQUEST_EVENT)); return; } - if (matchesShortcutCombo(KEYBOARD_SHORTCUT_COMBOS.escape, event) && this.isSettingsTakeover()) { + if ( + matchesShortcutCombo(KEYBOARD_SHORTCUT_COMBOS.escape, event) && + isSettingsTakeover(host.routeState.routeId) + ) { if (host.navDrawerOpen) { event.preventDefault(); host.closeNavDrawer({ restoreFocus: true }); return; } - if (this.shouldIgnoreSettingsEscape(event)) { + if (event.eventPhase === Event.CAPTURING_PHASE || this.shouldIgnoreSettingsEscape(event)) { return; } event.preventDefault(); @@ -462,6 +474,9 @@ export class ShellChromeOwner { private readonly handleDebugOverlayRequest = (event: Event): void => { const host = this.host; + if (host.navDrawerOpen && isMobileNavLayout()) { + host.closeNavDrawer({ restoreFocus: false }); + } const descriptor = lazyShellEvent(DEBUG_OVERLAY_REQUEST_EVENT, event); if (isOptionalElementDefined(DEBUG_OVERLAY_ELEMENT)) { host.querySelector(DEBUG_OVERLAY_ELEMENT.tagName)?.toggle(); @@ -518,17 +533,12 @@ export class ShellChromeOwner { this.requestLazyElement(host.commandPaletteElement, descriptor, replay); }; - readonly openPalette = (): void => { + readonly openPalette = (): void => this.handleCommandPaletteOpen(new CustomEvent(COMMAND_PALETTE_OPEN_EVENT), this.openPalette); - }; - - readonly refreshControlUi = (): void => { - globalThis.location.reload(); - }; + readonly refreshControlUi = (): void => globalThis.location.reload(); readonly handleShellNavDrawerToggle = (event: Event): void => { - const trigger = (event as CustomEvent).detail?.trigger; - this.toggleNavigationSurface(trigger instanceof HTMLElement ? trigger : undefined); + this.toggleNavigationSurface(shellNavDrawerTriggerFromEvent(event)); }; readonly togglePalette = (): void => { @@ -540,9 +550,8 @@ export class ShellChromeOwner { } }; - readonly openApprovals = (): void => { - window.dispatchEvent(new CustomEvent(SHELL_APPROVALS_OPEN_EVENT)); - }; + readonly openApprovals = (): void => + void window.dispatchEvent(new CustomEvent(SHELL_APPROVALS_OPEN_EVENT)); private readonly handleApprovalsOpen = (event: Event): void => { const host = this.host; @@ -730,36 +739,19 @@ export class ShellChromeOwner { host.navigate("chat", { ...navigation, search: `?${search.toString()}` }); }; - readonly handleCommandPaletteTarget = (event: Event): void => { - const host = this.host; - const detail = (event as CustomEvent).detail; - if (!detail || !(detail.owner instanceof Element)) { - return; - } - if (detail.onSlashCommand) { - host.commandPaletteTarget = detail; - } else if (host.commandPaletteTarget?.owner === detail.owner) { - host.commandPaletteTarget = undefined; - } - host.requestUpdate(); - }; + readonly handleCommandPaletteTarget = (event: Event): void => + applyCommandPaletteTargetEvent(this.host, event); - /** Native titlebar chrome treats drawer, takeover, and onboarding layouts as collapsed. */ nativeNavCollapsed(): boolean { const host = this.host; const mobileNavLayout = isMobileNavLayout(); return ( host.onboardingMode || mobileNavLayout || - (this.isSettingsTakeover() && !mobileNavLayout) || + (isSettingsTakeover(host.routeState.routeId) && !mobileNavLayout) || (!host.navDrawerOpen && !host.desktopNavigationExpanded && (host.context?.navigation.snapshot.navCollapsed ?? false)) ); } - - private isSettingsTakeover(): boolean { - const routeId = this.host.routeState.routeId; - return routeId !== undefined && isSettingsNavigationRoute(routeId); - } } diff --git a/ui/src/app/app-shell-view.ts b/ui/src/app/app-shell-view.ts index 7537a8aaca8b..f75c2ce1df67 100644 --- a/ui/src/app/app-shell-view.ts +++ b/ui/src/app/app-shell-view.ts @@ -26,7 +26,6 @@ import { isTerminalAvailable } from "../lib/terminal-availability.ts"; import type { NewSessionTarget } from "../pages/new-session/location.ts"; import { pluginTabKey, pluginTabRefFromSearch } from "../pages/plugin/route.ts"; import type { ShellRouteState } from "./app-host-route-state.ts"; -import { isBrowserPanelAvailable, isDesktopPanelAvailable } from "./app-shell-chrome.ts"; import type { OutboxStoreRuntime, StoredOutboxScopeHost } from "./app-shell-gateway.ts"; import type { ApplicationRuntime } from "./bootstrap.ts"; import type { ApplicationContext, ApplicationNavigationOptions } from "./context.ts"; @@ -47,6 +46,7 @@ import { renderFloatingUpdateCard, } from "./navigation-surface.ts"; import { readGatewayOperatorAccess } from "./operator-access.ts"; +import { isBrowserPanelAvailable, isDesktopPanelAvailable } from "./panel-availability.ts"; import { NAV_WIDTH_MAX, NAV_WIDTH_MIN, @@ -468,10 +468,13 @@ export function renderApplicationShell(host: ShellViewHost) { style=${`--shell-nav-expanded-width: ${navigationSnapshot.navWidth}px`} @theme-change=${(event: CustomEvent) => host.handleThemeChange(event)} > - ${t("common.skipToMainContent")} + + ${t("common.skipToMainContent")} + ${nativeWebChrome && !onboarding ? html` ` : nothing} -
- ${mobileNavLayout - ? html` host.closeNavDrawer({ restoreFocus: true })} - > -
- ${navigationContent} -
-
` - : navigationContent} + +
+ ${navigationContent}
${!navCollapsed && !onboarding && !settingsTakeover ? html` @@ -591,6 +600,7 @@ export function renderApplicationShell(host: ShellViewHost) { ? "content--custodian" : ""} ${activeRoute === "workboard" ? "content--workboard" : ""}" .tabIndex=${-1} + ?inert=${pageActionsBlocked || (mobileNavLayout && navDrawerOpen)} > ${renderFloatingUpdateCard({ navigationSurfaceHidden, @@ -629,6 +639,7 @@ export function renderApplicationShell(host: ShellViewHost) { > `} ; + readonly navDrawerOpen: boolean; +}; + +export class NavDrawerSwipeLoader { + private owner?: NavDrawerSwipeOwner; + private pending = false; + + constructor( + private readonly host: NavDrawerSwipeHost, + private readonly requestOpen: () => void, + ) {} + + load(): void { + if (this.owner || this.pending) { + return; + } + this.pending = true; + void import("./nav-drawer-swipe.runtime.ts").then( + ({ NavDrawerSwipeOwner }) => { + this.owner = new NavDrawerSwipeOwner(this.host, this.requestOpen); + this.pending = false; + if (this.host.isConnected) { + this.owner.connect(); + } + }, + () => (this.pending = false), + ); + } + + connect(): void { + this.owner?.connect(); + } + + disconnect(): void { + this.owner?.disconnect(); + } + + opened(): boolean { + this.owner?.opened(); + return Boolean(this.owner); + } + + closed(): void { + this.owner?.closed(); + } +} diff --git a/ui/src/app/nav-drawer-swipe.runtime.ts b/ui/src/app/nav-drawer-swipe.runtime.ts new file mode 100644 index 000000000000..fd889f4e19e3 --- /dev/null +++ b/ui/src/app/nav-drawer-swipe.runtime.ts @@ -0,0 +1,211 @@ +import { isMobileNavLayout } from "./mobile-nav-layout.ts"; + +const MIN_OPEN_DISTANCE_PX = 44; +const OPEN_RATIO = 0.15; +const LOCK_DISTANCE_PX = 7; +const DIRECTION_RATIO = 1.25; +const FOCUSABLE_SELECTOR = + "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])"; + +type Swipe = { + identifier: number; + startX: number; + startY: number; + lockedHorizontal: boolean; + drawerWidth: number; + drawer: HTMLElement | null; + backdrop: HTMLElement | null; +}; + +type NavDrawerHost = HTMLElement & { + readonly onboardingMode: boolean; + readonly updateComplete: Promise; + readonly navDrawerOpen: boolean; +}; + +export class NavDrawerSwipeOwner { + private swipe: Swipe | null = null; + + constructor( + private readonly host: NavDrawerHost, + private readonly requestOpen: () => void, + ) {} + + private canOpen(): boolean { + return isMobileNavLayout() && !this.host.navDrawerOpen && !this.host.onboardingMode; + } + + connect(): void { + this.host.addEventListener("touchstart", this.handleStart, { passive: true }); + this.host.addEventListener("touchmove", this.handleMove, { passive: false }); + this.host.addEventListener("touchend", this.handleEnd, { passive: true }); + this.host.addEventListener("touchcancel", this.handleCancel, { passive: true }); + if (this.host.navDrawerOpen) { + this.opened(); + } + } + + disconnect(): void { + this.host.removeEventListener("touchstart", this.handleStart); + this.host.removeEventListener("touchmove", this.handleMove); + this.host.removeEventListener("touchend", this.handleEnd); + this.host.removeEventListener("touchcancel", this.handleCancel); + this.closed(); + } + + reset(): void { + this.swipe = null; + const drawer = this.host.querySelector(".shell-nav"); + const backdrop = this.host.querySelector(".shell-nav-backdrop"); + drawer?.removeAttribute("data-nav-drawer-dragging"); + drawer?.style.removeProperty("transform"); + drawer?.style.removeProperty("opacity"); + backdrop?.removeAttribute("data-nav-drawer-dragging"); + backdrop?.style.removeProperty("visibility"); + backdrop?.style.removeProperty("opacity"); + } + + opened(): void { + void this.host.updateComplete.then(() => { + if (!this.host.isConnected || !this.host.navDrawerOpen) { + return; + } + this.reset(); + const drawer = this.host.querySelector(".shell-nav"); + (this.focusable()[0] ?? drawer)?.focus({ preventScroll: true }); + }); + } + + closed(): void { + this.reset(); + } + + private focusable(): HTMLElement[] { + const drawer = this.host.querySelector(".shell-nav"); + return drawer + ? [...drawer.querySelectorAll(FOCUSABLE_SELECTOR)].filter((candidate) => + candidate.checkVisibility(), + ) + : []; + } + + private paint(swipe: Swipe, deltaX: number): void { + const drawer = swipe.drawer ?? this.host.querySelector(".shell-nav"); + const backdrop = swipe.backdrop ?? this.host.querySelector(".shell-nav-backdrop"); + if (!drawer || !backdrop) { + return; + } + swipe.drawer = drawer; + swipe.backdrop = backdrop; + if (swipe.drawerWidth === 0) { + swipe.drawerWidth = drawer.getBoundingClientRect().width; + drawer.setAttribute("data-nav-drawer-dragging", ""); + backdrop.setAttribute("data-nav-drawer-dragging", ""); + } + const reveal = Math.min(swipe.drawerWidth, Math.max(0, deltaX)); + drawer.style.transform = `translateX(${reveal - swipe.drawerWidth}px)`; + drawer.style.opacity = "1"; + backdrop.style.visibility = "visible"; + backdrop.style.opacity = String(swipe.drawerWidth > 0 ? reveal / swipe.drawerWidth : 0); + } + + private cancel(): void { + this.swipe = null; + requestAnimationFrame(() => this.reset()); + } + + private readonly handleStart = (event: TouchEvent): void => { + this.reset(); + if (!this.canOpen() || event.touches.length !== 1) { + return; + } + const path = event.composedPath(); + const content = path.find( + (target): target is HTMLElement => + target instanceof HTMLElement && target.classList.contains("content"), + ); + if (!content) { + return; + } + const blocked = path.slice(0, path.indexOf(content)).some((target) => { + if (!(target instanceof Element)) { + return false; + } + if ( + target.matches( + "a, button, input, textarea, select, pre, [role='slider'], [contenteditable]:not([contenteditable='false'])", + ) + ) { + return true; + } + return target instanceof HTMLElement && target.scrollWidth > target.clientWidth + 1; + }); + const touch = event.touches[0]; + if (blocked || !touch) { + return; + } + this.swipe = { + identifier: touch.identifier, + startX: touch.clientX, + startY: touch.clientY, + lockedHorizontal: false, + drawerWidth: 0, + drawer: null, + backdrop: null, + }; + }; + + private readonly handleMove = (event: TouchEvent): void => { + const swipe = this.swipe; + const touch = swipe + ? Array.from(event.touches).find((candidate) => candidate.identifier === swipe.identifier) + : undefined; + if (!swipe || event.touches.length !== 1 || !touch) { + this.cancel(); + return; + } + const deltaX = touch.clientX - swipe.startX; + const deltaY = touch.clientY - swipe.startY; + if (swipe.lockedHorizontal) { + event.preventDefault(); + this.paint(swipe, deltaX); + return; + } + const distanceX = Math.abs(deltaX); + const distanceY = Math.abs(deltaY); + if (Math.max(distanceX, distanceY) < LOCK_DISTANCE_PX) { + return; + } + if (deltaX > 0 && distanceX >= distanceY * DIRECTION_RATIO) { + swipe.lockedHorizontal = true; + event.preventDefault(); + this.paint(swipe, deltaX); + } else if (deltaX <= -LOCK_DISTANCE_PX || distanceY >= distanceX * DIRECTION_RATIO) { + this.reset(); + } + }; + + private readonly handleEnd = (event: TouchEvent): void => { + const swipe = this.swipe; + const touch = swipe + ? Array.from(event.changedTouches).find( + (candidate) => candidate.identifier === swipe.identifier, + ) + : undefined; + if (!swipe || !touch || !swipe.lockedHorizontal || !this.canOpen()) { + this.reset(); + return; + } + const deltaX = touch.clientX - swipe.startX; + this.paint(swipe, deltaX); + const shouldOpen = deltaX >= Math.max(MIN_OPEN_DISTANCE_PX, swipe.drawerWidth * OPEN_RATIO); + this.swipe = null; + if (shouldOpen) { + this.requestOpen(); + } else { + requestAnimationFrame(() => this.reset()); + } + }; + + private readonly handleCancel = (): void => this.cancel(); +} diff --git a/ui/src/app/navigation-surface.ts b/ui/src/app/navigation-surface.ts index e01d05fd92c1..01d7b9f6c150 100644 --- a/ui/src/app/navigation-surface.ts +++ b/ui/src/app/navigation-surface.ts @@ -1,8 +1,111 @@ import { html, nothing } from "lit"; import type { NavigationRouteId } from "../app-navigation.ts"; +import { isCommandPaletteShortcut } from "../components/command-palette-contract.ts"; +import { isTerminalPanelShortcut } from "../components/panel-toggle-contract.ts"; +import { + KEYBOARD_SHORTCUT_COMBOS, + matchesShortcutCombo, +} from "../lib/keyboard-shortcut-contract.ts"; import type { ApplicationContext } from "./context.ts"; import type { UpdateProgress } from "./update-confirmation.ts"; +const NAV_DRAWER_FOCUSABLE_SELECTOR = + "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])"; + +type AppSidebarElement = HTMLElement & { dismissTransientMenus(): boolean }; +type SidebarAttentionElement = HTMLElement & { dismissPanel(): boolean }; + +export function dismissNavigationTransientSurfaces(host: HTMLElement): boolean { + const dismissedPanel = [ + ...host.querySelectorAll("openclaw-sidebar-attention"), + ] + .map((attention) => attention.dismissPanel()) + .some((dismissed) => dismissed); + const dismissedMenu = host + .querySelector("openclaw-app-sidebar") + ?.dismissTransientMenus(); + return dismissedMenu === true || dismissedPanel; +} + +function trapNavDrawerFocus(host: HTMLElement, event: KeyboardEvent): void { + const drawer = host.querySelector(".shell-nav"); + if (!drawer) { + return; + } + if ( + event + .composedPath() + .some( + (target) => + target instanceof Element && + target !== drawer && + target.matches("dialog, [role='dialog']"), + ) + ) { + return; + } + const focusable = [...drawer.querySelectorAll(NAV_DRAWER_FOCUSABLE_SELECTOR)].filter( + (candidate) => candidate.checkVisibility(), + ); + const target = event.shiftKey ? focusable.at(-1) : focusable[0]; + const boundary = event.shiftKey ? focusable[0] : focusable.at(-1); + if ( + !drawer.contains(document.activeElement) || + document.activeElement === boundary || + (event.shiftKey && document.activeElement === drawer) + ) { + event.preventDefault(); + (target ?? drawer).focus({ preventScroll: true }); + } +} + +export function handleNavDrawerKeydown( + host: HTMLElement & { closeNavDrawer(options?: { restoreFocus?: boolean }): void }, + event: KeyboardEvent, +): void { + if (event.defaultPrevented) { + return; + } + if (matchesShortcutCombo(KEYBOARD_SHORTCUT_COMBOS.escape, event)) { + return; + } + if (matchesShortcutCombo(KEYBOARD_SHORTCUT_COMBOS.toggleSidebar, event)) { + event.preventDefault(); + host.closeNavDrawer({ restoreFocus: true }); + } else if (event.key === "Tab") { + trapNavDrawerFocus(host, event); + } else if ( + isCommandPaletteShortcut(event) || + isTerminalPanelShortcut(event) || + matchesShortcutCombo(KEYBOARD_SHORTCUT_COMBOS.workspaceFiles, event) + ) { + event.preventDefault(); + event.stopImmediatePropagation(); + } +} + +export function moveToastToNavDrawer(host: HTMLElement): void { + const drawer = host.querySelector(".shell-nav"); + const toastHost = host.querySelector("openclaw-toast-host"); + if (drawer && toastHost && toastHost.parentElement !== drawer) { + drawer.moveBefore(toastHost, null); + } +} + +export function restoreToastFromNavDrawer(host: HTMLElement): void { + const shell = host.querySelector(".shell"); + const toastHost = host.querySelector("openclaw-toast-host"); + if (shell && toastHost?.parentElement?.classList.contains("shell-nav")) { + shell.moveBefore(toastHost, null); + } +} + +export function visibleNavDrawerToggle(host: HTMLElement): HTMLElement | undefined { + return [...host.querySelectorAll(".topbar-nav-toggle, .chat-pane__nav-toggle")].find( + (candidate) => candidate.checkVisibility(), + ); +} + export function navigationSurfaceIsHidden(params: { onboarding: boolean; navCollapsed: boolean; diff --git a/ui/src/app/panel-availability.ts b/ui/src/app/panel-availability.ts new file mode 100644 index 000000000000..3ffda6ae06aa --- /dev/null +++ b/ui/src/app/panel-availability.ts @@ -0,0 +1,21 @@ +import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts"; +import type { ApplicationContext } from "./context.ts"; +import { hasOperatorAdminAccess } from "./operator-access.ts"; + +type GatewaySnapshot = ApplicationContext["gateway"]["snapshot"]; + +function isPanelAvailable(snapshot: GatewaySnapshot, method: string): boolean { + return ( + snapshot.phase === "connected" && + hasOperatorAdminAccess(snapshot.hello?.auth ?? null) && + isGatewayMethodAdvertised(snapshot, method) === true + ); +} + +export function isBrowserPanelAvailable(snapshot: GatewaySnapshot): boolean { + return isPanelAvailable(snapshot, "browser.request"); +} + +export function isDesktopPanelAvailable(snapshot: GatewaySnapshot): boolean { + return isPanelAvailable(snapshot, "desktop.observe"); +} diff --git a/ui/src/components/command-palette-contract.ts b/ui/src/components/command-palette-contract.ts index 4e38b40c296f..123b20ecced4 100644 --- a/ui/src/components/command-palette-contract.ts +++ b/ui/src/components/command-palette-contract.ts @@ -11,6 +11,13 @@ export type ShellNavDrawerToggleDetail = { trigger: HTMLElement; }; +export function shellNavDrawerTriggerFromEvent(event: Event): HTMLElement | undefined { + const detail: unknown = event instanceof CustomEvent ? event.detail : undefined; + const trigger = + detail && typeof detail === "object" && "trigger" in detail ? detail.trigger : null; + return trigger instanceof HTMLElement ? trigger : undefined; +} + export function isCommandPaletteShortcut(event: KeyboardEvent): boolean { return matchesShortcutCombo(KEYBOARD_SHORTCUT_COMBOS.commandPalette, event); } @@ -20,6 +27,42 @@ export type CommandPaletteTargetDetail = { onSlashCommand: ((command: string) => void) | null; }; +function isCommandPaletteTargetDetail(value: unknown): value is CommandPaletteTargetDetail { + return ( + value !== null && + typeof value === "object" && + "owner" in value && + value.owner instanceof Element && + "onSlashCommand" in value && + (value.onSlashCommand === null || typeof value.onSlashCommand === "function") + ); +} + +function commandPaletteTargetFromEvent( + current: CommandPaletteTargetDetail | undefined, + event: Event, +): CommandPaletteTargetDetail | null | undefined { + const detail: unknown = event instanceof CustomEvent ? event.detail : undefined; + if (!isCommandPaletteTargetDetail(detail)) { + return null; + } + return detail.onSlashCommand ? detail : current?.owner === detail.owner ? undefined : current; +} + +export function applyCommandPaletteTargetEvent( + host: HTMLElement & { + commandPaletteTarget: CommandPaletteTargetDetail | undefined; + requestUpdate(): void; + }, + event: Event, +): void { + const target = commandPaletteTargetFromEvent(host.commandPaletteTarget, event); + if (target !== null) { + host.commandPaletteTarget = target; + host.requestUpdate(); + } +} + export type CommandPaletteElement = HTMLElement & { custodianAvailable: boolean; desktopAvailable: boolean; diff --git a/ui/src/components/file-preview-modal.browser.test.ts b/ui/src/components/file-preview-modal.browser.test.ts index 2feabec7a0d1..686b9a180e14 100644 --- a/ui/src/components/file-preview-modal.browser.test.ts +++ b/ui/src/components/file-preview-modal.browser.test.ts @@ -67,7 +67,7 @@ async function mountModal( width: number, options: { fullscreen?: boolean; - kind?: "drawer" | "nav-drawer"; + kind?: "drawer"; modalWidth: string; }, ) { @@ -80,7 +80,6 @@ async function mountModal( modal.style.setProperty("--openclaw-modal-width", options.modalWidth); modal.classList.toggle("fullscreen", options.fullscreen === true); modal.classList.toggle("drawer", options.kind !== undefined); - modal.classList.toggle("nav-drawer", options.kind === "nav-drawer"); const content = document.createElement("div"); content.style.cssText = "width: 100%; height: 80px;"; modal.append(content); @@ -227,13 +226,4 @@ describe.runIf(browserMode)("file preview modal responsive layout", () => { expect(dialog.getBoundingClientRect().width).toBeCloseTo(expectedWidth, 0); }); - - it("preserves the navigation drawer's narrower owned width", async () => { - const dialog = await mountModal(390, { - kind: "nav-drawer", - modalWidth: "min(460px, 100vw)", - }); - - expect(dialog.getBoundingClientRect().width).toBeCloseTo(320, 0); - }); }); diff --git a/ui/src/components/modal-dialog.test.ts b/ui/src/components/modal-dialog.test.ts index a826739eb463..a8b7c95d4996 100644 --- a/ui/src/components/modal-dialog.test.ts +++ b/ui/src/components/modal-dialog.test.ts @@ -8,7 +8,7 @@ import { installDialogPolyfill, nextFrame, } from "../test-helpers/modal-dialog.ts"; -import { OpenClawModalDialog } from "./modal-dialog.ts"; +import "./modal-dialog.ts"; let container: HTMLDivElement; let restoreDialogPolyfill: () => void; @@ -142,17 +142,6 @@ describe("openclaw-modal-dialog", () => { } }); - it("keeps the navigation drawer sidebar in a full-height, shrinkable flex column", () => { - const styles = OpenClawModalDialog.styles.cssText; - - expect(styles).toMatch( - /:host\(\.nav-drawer\)\s+wa-dialog::part\(body\)\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*min-height:\s*0;/u, - ); - expect(styles).toMatch( - /::slotted\(\.shell-nav-modal__content\)\s*\{[^}]*display:\s*flex;[^}]*flex:\s*1\s+1\s+auto;[^}]*flex-direction:\s*column;[^}]*height:\s*100%;[^}]*min-height:\s*0;/u, - ); - }); - it("emits modal-cancel on Escape", async () => { const { modal, dialog } = await renderModal(); const onCancel = vi.fn(); diff --git a/ui/src/components/modal-dialog.ts b/ui/src/components/modal-dialog.ts index 0bd79c989102..775b6aecdd36 100644 --- a/ui/src/components/modal-dialog.ts +++ b/ui/src/components/modal-dialog.ts @@ -96,30 +96,6 @@ export class OpenClawModalDialog extends OpenClawLitElement { border-radius: 0; } - :host(.nav-drawer) wa-dialog { - --width: min(86vw, 320px); - } - - :host(.nav-drawer) wa-dialog::part(dialog) { - max-width: min(86vw, 320px); - margin: 0 auto 0 0; - } - - :host(.nav-drawer) wa-dialog::part(body) { - display: flex; - flex-direction: column; - min-height: 0; - } - - ::slotted(.shell-nav-modal__content) { - display: flex; - flex: 1 1 auto; - flex-direction: column; - height: 100%; - min-height: 0; - min-width: 0; - } - @media (max-width: 640px) { wa-dialog { --width: min(var(--openclaw-modal-width, 540px), calc(100vw - 24px)); diff --git a/ui/src/components/sidebar-attention-panel.runtime.ts b/ui/src/components/sidebar-attention-panel.runtime.ts index 305f68ab371c..0cd3b9c582db 100644 --- a/ui/src/components/sidebar-attention-panel.runtime.ts +++ b/ui/src/components/sidebar-attention-panel.runtime.ts @@ -3,6 +3,7 @@ import type { NavigationRouteId } from "../app-navigation.ts"; import type { ApplicationContext } from "../app/context.ts"; import { ScopeUpgradeController } from "../app/device-scope-upgrade-controller.runtime.ts"; import type { ExecApprovalDecision } from "../app/exec-approval.ts"; +import { isMobileNavLayout } from "../app/mobile-nav-layout.ts"; import type { UpdateProgress } from "../app/update-confirmation.ts"; import { t } from "../i18n/index.ts"; import "../styles/sidebar-issues.css"; @@ -121,6 +122,7 @@ export function renderSidebarAttentionPanel(params: SidebarAttentionPanelParams) id="sidebar-issues-panel" class="sidebar-issues-panel" role="dialog" + aria-modal=${isMobileNavLayout() ? "true" : nothing} aria-labelledby="sidebar-issues-panel-heading" style=${panelStyle} @keydown=${params.onKeydown} diff --git a/ui/src/components/sidebar-attention.ts b/ui/src/components/sidebar-attention.ts index 69f89e7080bc..653667f02de5 100644 --- a/ui/src/components/sidebar-attention.ts +++ b/ui/src/components/sidebar-attention.ts @@ -502,6 +502,12 @@ class SidebarAttention extends OpenClawLightDomElement { } } + dismissPanel(): boolean { + const wasOpen = this.panelOpen; + this.closePanel(false); + return wasOpen; + } + private readonly syncOverflowCue = () => { const list = this.querySelector(".sidebar-issues-panel__list"); const above = Boolean(list && list.scrollTop > 2); diff --git a/ui/src/e2e/model-setup.e2e.test.ts b/ui/src/e2e/model-setup.e2e.test.ts index d36ede8edbf7..f14a0b54bec5 100644 --- a/ui/src/e2e/model-setup.e2e.test.ts +++ b/ui/src/e2e/model-setup.e2e.test.ts @@ -794,12 +794,9 @@ suite.define(() => { await page.setViewportSize({ height: 844, width: 390 }); await expect .poll(() => - page.locator("openclaw-modal-dialog.nav-drawer").evaluate((element) => { - const dialog = element.shadowRoot - ?.querySelector("wa-dialog") - ?.shadowRoot?.querySelector("dialog"); - return dialog?.open ?? false; - }), + page + .locator(".shell-nav.nav-drawer") + .evaluate((element) => element.getAttribute("aria-hidden") !== "true"), ) .toBe(false); await page.screenshot({ @@ -899,12 +896,9 @@ suite.define(() => { await page.setViewportSize({ height: 844, width: 390 }); await expect .poll(() => - page.locator("openclaw-modal-dialog.nav-drawer").evaluate((element) => { - const dialog = element.shadowRoot - ?.querySelector("wa-dialog") - ?.shadowRoot?.querySelector("dialog"); - return dialog?.open ?? false; - }), + page + .locator(".shell-nav.nav-drawer") + .evaluate((element) => element.getAttribute("aria-hidden") !== "true"), ) .toBe(false); await page.screenshot({ diff --git a/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts b/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts index 9f3a1c7284c9..8683b599f5b6 100644 --- a/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts +++ b/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts @@ -165,6 +165,13 @@ suite.define(() => { it("keeps the web expand/collapse controls in plain browsers", async () => { const page = await openPage({ nativeNav: false }); + expect( + await page.evaluate(() => + performance + .getEntriesByType("resource") + .some((entry) => entry.name.includes("nav-drawer-swipe")), + ), + ).toBe(false); const toggle = page.locator(".shell-chrome-controls__nav-toggle"); await expect.poll(() => toggle.isVisible()).toBe(true); await expect.poll(() => toggle.getAttribute("aria-label")).toBe("Collapse sidebar"); @@ -172,6 +179,12 @@ suite.define(() => { await expect.poll(() => toggle.getAttribute("aria-label")).toBe("Expand sidebar"); await toggle.click(); await expect.poll(() => toggle.getAttribute("aria-label")).toBe("Collapse sidebar"); + + await page.locator(".sidebar-issues-button").click(); + const desktopInbox = page.locator("#sidebar-issues-panel"); + await desktopInbox.waitFor(); + await expect.poll(() => desktopInbox.getAttribute("aria-modal")).toBeNull(); + await page.keyboard.press("Escape"); }); it("keeps pointer-triggered sidebar focus from opening its tooltip", async () => { @@ -512,6 +525,54 @@ suite.define(() => { await page.locator(".cmd-palette__input").waitFor({ state: "visible" }); }); + it("opens the mobile drawer by swipe across the full mobile-layout range", async () => { + const page = await openPage({ hasTouch: true, height: 393, nativeNav: false, width: 852 }); + const shell = page.locator(".shell"); + await expect.poll(() => shell.getAttribute("class")).toContain("shell--mobile-nav"); + + await page.locator(".content").evaluate((content) => { + const touch = (clientX: number, clientY: number) => + new Touch({ + identifier: 1, + target: content, + clientX, + clientY, + pageX: clientX, + pageY: clientY, + screenX: clientX, + screenY: clientY, + }); + content.dispatchEvent( + new TouchEvent("touchstart", { + bubbles: true, + composed: true, + touches: [touch(24, 180)], + changedTouches: [touch(24, 180)], + }), + ); + content.dispatchEvent( + new TouchEvent("touchmove", { + bubbles: true, + cancelable: true, + composed: true, + touches: [touch(210, 184)], + changedTouches: [touch(210, 184)], + }), + ); + content.dispatchEvent( + new TouchEvent("touchend", { + bubbles: true, + composed: true, + touches: [], + changedTouches: [touch(210, 184)], + }), + ); + }); + + await expect.poll(() => shell.getAttribute("class")).toContain("shell--nav-drawer-open"); + await expect.poll(() => page.locator(".shell-nav.nav-drawer").isVisible()).toBe(true); + }); + it("keeps the mobile drawer modal, keyboard-contained, and focus-restoring", async () => { const page = await openPage({ nativeNav: false, @@ -521,7 +582,6 @@ suite.define(() => { width: 900, }); const navigation = page.locator(".shell-nav"); - const drawer = navigation.locator("openclaw-modal-dialog.nav-drawer"); const dialog = page.getByRole("dialog", { name: "Navigation" }); const trigger = page.locator(".chat-pane__nav-toggle").first(); const readFocusLocation = () => @@ -535,7 +595,7 @@ suite.define(() => { }); await expect.poll(() => navigation.getAttribute("inert")).toBe(""); - await expect.poll(() => page.locator(".shell-nav-backdrop").count()).toBe(0); + await expect.poll(() => page.locator(".shell-nav-backdrop").count()).toBe(1); await expect.poll(() => dialog.isVisible()).toBe(false); await page.locator(".shell-skip-link").focus(); await page.keyboard.press("Tab"); @@ -546,15 +606,7 @@ suite.define(() => { await expect.poll(() => trigger.getAttribute("aria-expanded")).toBe("false"); await expect.poll(() => trigger.getAttribute("aria-label")).toBe("Expand sidebar"); await trigger.focus(); - const afterShowMarker = "data-e2e-after-show"; - await drawer.evaluate((element, marker) => { - element.removeAttribute(marker); - element.addEventListener("wa-after-show", () => element.setAttribute(marker, ""), { - once: true, - }); - }, afterShowMarker); await page.keyboard.press("Enter"); - await expect.poll(() => drawer.getAttribute(afterShowMarker)).toBe(""); await expect.poll(readFocusLocation).toBe("navigation"); await expect @@ -562,6 +614,9 @@ suite.define(() => { .toContain("shell--nav-drawer-open"); await expect.poll(() => navigation.getAttribute("inert")).toBeNull(); await expect.poll(() => dialog.isVisible()).toBe(true); + await expect + .poll(() => page.locator(".shell-nav-backdrop").getAttribute("aria-hidden")) + .toBe("true"); await expect.poll(() => trigger.getAttribute("aria-expanded")).toBe("true"); await expect.poll(() => trigger.getAttribute("aria-label")).toBe("Collapse sidebar"); @@ -586,6 +641,16 @@ suite.define(() => { await expect.poll(() => sessionMenu.count()).toBe(0); await expect.poll(() => dialog.isVisible()).toBe(true); + const pageDetails = page.locator(".chat-controls__model-picker").first(); + await pageDetails.evaluate((element) => ((element as HTMLDetailsElement).open = true)); + await expect.poll(() => pageDetails.getAttribute("open")).toBe(""); + await page.keyboard.press("Escape"); + await expect.poll(() => pageDetails.getAttribute("open")).toBe(""); + await expect.poll(() => dialog.isVisible()).toBe(false); + + await trigger.click(); + await expect.poll(() => dialog.isVisible()).toBe(true); + await page.keyboard.press("Escape"); await expect .poll(() => page.locator(".shell").getAttribute("class")) @@ -597,16 +662,47 @@ suite.define(() => { .toBe(true); await trigger.click(); + const inbox = navigation.locator(".sidebar-issues-button"); + await inbox.click(); + const attentionDialog = page.getByRole("dialog", { name: "Inbox" }); + await attentionDialog.waitFor(); + await expect.poll(() => attentionDialog.getAttribute("aria-modal")).toBe("true"); + const attentionControls = attentionDialog.locator("button, a[href], summary"); + const lastAttentionControl = attentionControls.last(); + await lastAttentionControl.focus(); + await page.keyboard.press("Tab"); + await expect + .poll(() => + page.evaluate(() => document.activeElement?.closest("#sidebar-issues-panel") !== null), + ) + .toBe(true); + await page.keyboard.press("Escape"); + await expect.poll(() => attentionDialog.count()).toBe(0); await expect.poll(() => dialog.isVisible()).toBe(true); + + await page.evaluate(() => { + window.dispatchEvent(new CustomEvent("openclaw:debug-overlay-request")); + }); + const debugOverlay = page.locator(".debug-overlay"); + await debugOverlay.waitFor(); + await expect.poll(() => dialog.isVisible()).toBe(false); + await page.keyboard.press("Escape"); + await expect.poll(() => debugOverlay.count()).toBe(0); + + await trigger.click(); await page.mouse.click(899, 450); await expect.poll(() => dialog.isVisible()).toBe(false); await expect .poll(() => trigger.evaluate((element) => element === document.activeElement)) .toBe(true); + await trigger.click(); + await navigation.locator(".sidebar-issues-button").click(); + await attentionDialog.waitFor(); await page.setViewportSize({ width: 1280, height: 900 }); + await expect.poll(() => attentionDialog.count()).toBe(0); await expect.poll(() => navigation.getAttribute("inert")).toBeNull(); - await expect.poll(() => drawer.count()).toBe(0); + await expect.poll(() => navigation.getAttribute("class")).not.toContain("nav-drawer"); }); it.each(["dark", "light"] as const)( @@ -619,7 +715,7 @@ suite.define(() => { scenario: TOAST_SCENARIO, width: 390, }); - const drawer = page.locator("openclaw-modal-dialog.nav-drawer"); + const drawer = page.locator(".shell-nav.nav-drawer"); const dialog = page.getByRole("dialog", { name: "Navigation" }); await page.locator(".chat-pane__nav-toggle").first().click(); await expect.poll(() => dialog.isVisible()).toBe(true); @@ -649,10 +745,10 @@ suite.define(() => { await page.setViewportSize({ width: 1280, height: 900 }); await expect.poll(() => drawer.count()).toBe(0); } - const handedOffToast = page.locator(".shell > openclaw-toast-host .app-toast"); - await expect.poll(() => handedOffToast.textContent()).toContain("Codex hidden"); + const retainedToast = page.locator(".shell > openclaw-toast-host .app-toast"); + await expect.poll(() => retainedToast.textContent()).toContain("Codex hidden"); const [toastBounds, composerBounds] = await Promise.all([ - handedOffToast.boundingBox(), + retainedToast.boundingBox(), page.locator(".agent-chat__composer-shell").boundingBox(), ]); if (!toastBounds || !composerBounds) { @@ -660,8 +756,8 @@ suite.define(() => { } expect(Math.round(toastBounds.y)).toBe(20); expect(toastBounds.y + toastBounds.height).toBeLessThan(composerBounds.y); - await handedOffToast.getByRole("button", { name: "Dismiss" }).click(); - await expect.poll(() => handedOffToast.isVisible()).toBe(false); + await retainedToast.getByRole("button", { name: "Dismiss" }).click(); + await expect.poll(() => retainedToast.isVisible()).toBe(false); }, ); diff --git a/ui/src/lib/toast.ts b/ui/src/lib/toast.ts index 5a0474b4623a..3399c67c8859 100644 --- a/ui/src/lib/toast.ts +++ b/ui/src/lib/toast.ts @@ -29,6 +29,12 @@ function activeModalToastLayer() { return [...(document.openClawModalLayers ?? [])].findLast((candidate) => candidate.isConnected); } +function restingToastLayer() { + return ( + document.querySelector(".shell-nav[aria-modal='true']") ?? document.querySelector(".shell") + ); +} + // Outcomes reported during startup (a restored post-update result, for example) // race the shell that owns the host element. Hold the latest one instead of // dropping it, so no caller's message disappears because it arrived too early. @@ -51,7 +57,7 @@ class OpenClawToastHost extends OpenClawLightDomContentsElement { } override disconnectedCallback() { - const target = activeModalToastLayer() ?? document.querySelector(".shell"); + const target = activeModalToastLayer() ?? restingToastLayer(); if (!this.isConnected && this.parentElement?.localName === "openclaw-modal-dialog" && target) { target.append(this); } else { @@ -208,7 +214,7 @@ export function showToast(options: ToastOptions): boolean { } modal.removeEventListener("wa-after-hide", handoff); queueMicrotask(() => - (activeModalToastLayer() ?? document.querySelector(".shell"))?.moveBefore(host, null), + (activeModalToastLayer() ?? restingToastLayer())?.moveBefore(host, null), ); }; modal.addEventListener("wa-after-hide", handoff); diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index 495926ef66f9..954908cb2433 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -2,10 +2,10 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { html, nothing } from "lit"; import { buildControlUiResourcePath } from "../../../../src/gateway/control-ui-resource-routes.js"; import type { GatewaySessionRow } from "../../api/types.ts"; -import { isDesktopPanelAvailable } from "../../app/app-shell-chrome.ts"; import { resolveControlUiAuthCandidates } from "../../app/control-ui-auth.ts"; import { isNativeLocalGateway } from "../../app/native-editor-locality.runtime.ts"; import { hasOperatorAdminAccess } from "../../app/operator-access.ts"; +import { isDesktopPanelAvailable } from "../../app/panel-availability.ts"; import { COMMAND_PALETTE_OPEN_EVENT } from "../../components/command-palette-contract.ts"; import { icons } from "../../components/icons.ts"; import { diff --git a/ui/src/pages/chat/chat-pane-layout-render.ts b/ui/src/pages/chat/chat-pane-layout-render.ts index 2528ff31595d..396d1381b9d6 100644 --- a/ui/src/pages/chat/chat-pane-layout-render.ts +++ b/ui/src/pages/chat/chat-pane-layout-render.ts @@ -1,7 +1,7 @@ import { html, nothing } from "lit"; import type { SessionObserverDigest } from "../../../../packages/gateway-protocol/src/index.js"; import type { GatewaySessionRow } from "../../api/types.ts"; -import { isDesktopPanelAvailable } from "../../app/app-shell-chrome.ts"; +import { isDesktopPanelAvailable } from "../../app/panel-availability.ts"; import { ChatPaneBrowserAnnotationRender } from "./chat-pane-browser-annotation-render.ts"; import { availableSidebarSlots, diff --git a/ui/src/pages/chat/chat-pane-lifecycle.ts b/ui/src/pages/chat/chat-pane-lifecycle.ts index fb9d16d8e0a9..948e5d993933 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.ts @@ -305,6 +305,9 @@ export abstract class ChatPaneLifecycle extends ChatPaneSessionCreation { } protected readonly handleDocumentKeydown = (event: KeyboardEvent) => { + if (document.querySelector(".shell-nav[aria-modal='true']")) { + return; + } const togglePanelSlot = (slot: SidebarSlotId) => { const state = this.state; if (!state) { diff --git a/ui/src/pages/chat/chat-pane-rails.ts b/ui/src/pages/chat/chat-pane-rails.ts index 454ba9e35fd7..ad2f6b74754c 100644 --- a/ui/src/pages/chat/chat-pane-rails.ts +++ b/ui/src/pages/chat/chat-pane-rails.ts @@ -1,4 +1,4 @@ -import { isDesktopPanelAvailable } from "../../app/app-shell-chrome.ts"; +import { isDesktopPanelAvailable } from "../../app/panel-availability.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; import { createBackgroundTasksProps } from "./components/chat-background-tasks.ts"; import { openTaskDetailId } from "./components/chat-detail-slot.ts"; diff --git a/ui/src/pages/chat/components/chat-picker-overlay.ts b/ui/src/pages/chat/components/chat-picker-overlay.ts index fbd245b0399e..9f3ed0c3c65e 100644 --- a/ui/src/pages/chat/components/chat-picker-overlay.ts +++ b/ui/src/pages/chat/components/chat-picker-overlay.ts @@ -63,7 +63,7 @@ function dismissChatComposerPickersOutside(event: PointerEvent): void { } function dismissChatComposerPickersOnEscape(event: KeyboardEvent): void { - if (event.key !== "Escape") { + if (event.key !== "Escape" || document.querySelector(".shell-nav[aria-modal='true']")) { return; } const pickers = openChatComposerPickers(); diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index 1192217ac508..935400666b49 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -12,13 +12,13 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { ModelCatalogEntry } from "../../api/types.ts"; import { titleForRoute } from "../../app-navigation.ts"; import { pathForRoute, type RouteId } from "../../app-route-paths.ts"; -import { isBrowserPanelAvailable } from "../../app/app-shell-chrome.ts"; import { applicationContext, type ApplicationContext, type ApplicationGatewaySnapshot, } from "../../app/context.ts"; import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts"; +import { isBrowserPanelAvailable } from "../../app/panel-availability.ts"; import { resetServerUiPref, resolveServerUiPrefState, diff --git a/ui/src/pages/new-session/new-session-runtime.ts b/ui/src/pages/new-session/new-session-runtime.ts index 2c841b60248d..a9f9989e8d92 100644 --- a/ui/src/pages/new-session/new-session-runtime.ts +++ b/ui/src/pages/new-session/new-session-runtime.ts @@ -57,6 +57,9 @@ export function closeSessionMenus(root: ParentNode) { } export function handleSessionPickerEvent(root: ParentNode, event: Event) { + if (document.querySelector(".shell-nav[aria-modal='true']")) { + return; + } const pickers = root.querySelectorAll(".chat-controls__inline-select[open]"); if (pickers.length === 0) { return; diff --git a/ui/src/pages/plugins/plugins-page.ts b/ui/src/pages/plugins/plugins-page.ts index 9ab9ceb420ed..a07987c4ada9 100644 --- a/ui/src/pages/plugins/plugins-page.ts +++ b/ui/src/pages/plugins/plugins-page.ts @@ -196,6 +196,9 @@ class PluginsPage extends OpenClawLightDomElement { } private readonly handleDocumentKeydown = (event: KeyboardEvent) => { + if (document.querySelector(".shell-nav[aria-modal='true']")) { + return; + } if (event.key !== "Escape") { return; } diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 1c72777fc43e..1c0a2a7774a5 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -935,6 +935,10 @@ openclaw-settings-save-indicator:empty { transition: width var(--shell-focus-duration) var(--shell-focus-ease); } +.shell-nav-backdrop { + display: none; +} + /* The shell owns the resting boundary. The wider resize target takes over that same edge during interaction, avoiding a doubled divider. */ .sidebar-resizer { diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index c7cb379aa158..8df501899dac 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -76,20 +76,24 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native- .shell--mobile-nav .shell-nav, .shell--mobile-nav.shell--nav-collapsed .shell-nav { position: fixed; - top: 0; - bottom: 0; + top: max(8px, var(--safe-area-top, 0px)); + bottom: max(8px, var(--safe-area-bottom, 0px)); left: 0; z-index: 70; width: min(86vw, 320px); min-width: 0; + min-height: 0; border-inline-end: none; - box-shadow: 0 30px 80px color-mix(in srgb, black 40%, transparent); + overflow: hidden; + overscroll-behavior: contain; + border-radius: 0 22px 22px 0; + box-shadow: 0 16px 48px color-mix(in srgb, black 30%, transparent); transform: translateX(-100%); opacity: 0; pointer-events: none; transition: - transform var(--shell-focus-duration) var(--shell-focus-ease), - opacity var(--shell-focus-duration) var(--shell-focus-ease); + transform 160ms cubic-bezier(0.32, 0.72, 0, 1), + opacity 100ms ease-out; } /* A session dashboard keeps the board primary on narrow shells. Every desktop @@ -123,11 +127,66 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native- display: none; } +.shell--mobile-nav .shell-nav-backdrop { + position: fixed; + z-index: 65; + inset: 0; + display: block; + width: auto; + padding: 0; + pointer-events: none; + visibility: hidden; + border: 0; + border-radius: 0; + background: rgb(0 0 0 / 44%); + opacity: 0; + transition: + opacity 160ms cubic-bezier(0.32, 0.72, 0, 1), + visibility 0s linear 160ms; + touch-action: none; +} + +.shell--mobile-nav .shell-nav:is([data-nav-drawer-dragging], [data-nav-drawer-settling]) { + will-change: transform; +} + +.shell--mobile-nav .shell-nav-backdrop:is([data-nav-drawer-dragging], [data-nav-drawer-settling]) { + will-change: opacity; +} + +.shell--mobile-nav .shell-nav[data-nav-drawer-dragging], +.shell--mobile-nav .shell-nav-backdrop[data-nav-drawer-dragging] { + transition: none; +} + +.shell--mobile-nav.shell--nav-drawer-open .shell-nav-backdrop { + pointer-events: auto; + visibility: visible; + opacity: 1; + transition: + opacity 200ms cubic-bezier(0.32, 0.72, 0, 1), + visibility 0s; +} + .shell--mobile-nav.shell--nav-drawer-open .shell-nav, .shell--mobile-nav.shell--nav-collapsed.shell--nav-drawer-open .shell-nav { transform: translateX(0); opacity: 1; pointer-events: auto; + transition-duration: 200ms, 140ms; +} + +@media (prefers-reduced-motion: reduce) { + .shell--mobile-nav .shell-nav, + .shell--mobile-nav.shell--nav-collapsed .shell-nav, + .shell--mobile-nav.shell--nav-drawer-open .shell-nav { + transition-duration: 0.01ms, 100ms; + } + + .shell--mobile-nav .shell-nav-backdrop, + .shell--mobile-nav.shell--nav-drawer-open .shell-nav-backdrop { + transition-duration: 100ms, 0s; + } } /* Inside the drawer there is no Escape key to hint at. */ @@ -285,12 +344,13 @@ html.openclaw-native-macos body .shell--mobile-nav .topnav-shell__actions { .shell--mobile-nav .shell-nav, .shell--mobile-nav.shell--nav-collapsed .shell-nav { - width: min(92vw, 320px); + width: min(86vw, 320px); + box-shadow: 0 16px 48px color-mix(in srgb, black 30%, transparent); } .shell--mobile-nav .sidebar-shell { --sidebar-pad-x: 14px; - padding: 16px var(--sidebar-pad-x) 12px; + padding: 8px var(--sidebar-pad-x) 10px; } .shell--mobile-nav .nav-item { @@ -532,6 +592,15 @@ html.openclaw-native-macos body .shell--mobile-nav .topnav-shell__actions { /* ≤768px: hide Tokens (col 6); tighten padding; stack pagination */ @media (max-width: 768px) { + wa-dropdown.session-menu { + --menu-item-height: 40px; + --menu-padding: 3px; + } + + .session-menu .session-menu__separator { + margin-block: 2px; + } + .data-table.sessions-table { min-width: 500px; }