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
This commit is contained in:
Vyctor H. Brzezowski
2026-08-27 07:05:58 -03:00
committed by GitHub
parent 8d47620d50
commit f15b825f64
31 changed files with 800 additions and 235 deletions
+1 -1
View File
@@ -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
@@ -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"
}
+7 -14
View File
@@ -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();
+2 -6
View File
@@ -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,
+1 -1
View File
@@ -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" }
+97 -105
View File
@@ -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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".content");
resolved?.focus();
}
visibleNavDrawerToggle(): HTMLElement | undefined {
return [
...this.host.querySelectorAll<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".content")
: null;
host
.querySelector<OpenClawModalDialog>("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<AppSidebarElement>("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<DebugOverlayElement>(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<ShellNavDrawerToggleDetail>).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<CommandPaletteTargetDetail>).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);
}
}
+29 -15
View File
@@ -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<ThemeModeChangeDetail>) => host.handleThemeChange(event)}
>
<a class="shell-skip-link" href="#control-ui-main"> ${t("common.skipToMainContent")} </a>
<a class="shell-skip-link" href="#control-ui-main" ?inert=${navDrawerOpen}>
${t("common.skipToMainContent")}
</a>
${nativeWebChrome && !onboarding
? html`
<openclaw-macos-titlebar-controls
?inert=${navDrawerOpen}
.navCollapsed=${host.nativeNavCollapsed()}
.historyOnly=${settingsTakeover}
.canGoBack=${host.nativeHistoryState.canGoBack}
@@ -486,6 +489,7 @@ export function renderApplicationShell(host: ShellViewHost) {
`
: nothing}
<openclaw-app-topbar
?inert=${navDrawerOpen}
.resourceBasePath=${context.resourceBasePath}
.environment=${config.environment}
.navDrawerOpen=${navDrawerOpen}
@@ -556,19 +560,24 @@ export function renderApplicationShell(host: ShellViewHost) {
</div>
`
: nothing}
<div class="shell-nav" ?inert=${navigationSurfaceHidden}>
${mobileNavLayout
? html`<openclaw-modal-dialog
class="drawer nav-drawer"
.open=${navDrawerOpen}
.label=${t("palette.categories.navigation")}
@modal-cancel=${() => host.closeNavDrawer({ restoreFocus: true })}
>
<div class="shell-nav-modal__content" tabindex="-1" autofocus>
${navigationContent}
</div>
</openclaw-modal-dialog>`
: navigationContent}
<button
type="button"
class="shell-nav-backdrop"
tabindex="-1"
aria-hidden="true"
?inert=${!navDrawerOpen}
@click=${() => host.closeNavDrawer({ restoreFocus: true })}
></button>
<div
class="shell-nav ${mobileNavLayout ? "nav-drawer" : ""}"
role=${mobileNavLayout ? "dialog" : nothing}
aria-modal=${mobileNavLayout && navDrawerOpen ? "true" : nothing}
aria-label=${mobileNavLayout ? t("palette.categories.navigation") : nothing}
aria-hidden=${mobileNavLayout && navigationSurfaceHidden ? "true" : nothing}
tabindex=${mobileNavLayout ? -1 : nothing}
?inert=${navigationSurfaceHidden}
>
${navigationContent}
</div>
${!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) {
></openclaw-router-outlet>
</main>
<openclaw-terminal-panel
?inert=${navDrawerOpen}
.client=${gatewayConnected ? gatewaySnapshot.client : null}
.available=${terminalAvailable}
.agentId=${selectedAgentId}
@@ -641,6 +652,7 @@ export function renderApplicationShell(host: ShellViewHost) {
? nothing
: html`
<openclaw-browser-panel
?inert=${navDrawerOpen}
data-chat-autotype-exempt
.client=${gatewayConnected ? gatewaySnapshot.client : null}
.available=${browserPanelAvailable}
@@ -653,6 +665,7 @@ export function renderApplicationShell(host: ShellViewHost) {
})}
></openclaw-browser-panel>
<openclaw-desktop-panel
?inert=${navDrawerOpen}
data-chat-autotype-exempt
.client=${gatewayConnected ? gatewaySnapshot.client : null}
.available=${desktopPanelAvailable}
@@ -661,6 +674,7 @@ export function renderApplicationShell(host: ShellViewHost) {
></openclaw-desktop-panel>
`}
<openclaw-custodian-panel
?inert=${navDrawerOpen}
.available=${custodianPanelAvailable}
.suppressed=${activeRoute === "custodian"}
.minimizeRequestId=${host.custodianMinimizeRequestId}
+1 -1
View File
@@ -32,7 +32,6 @@ import {
startModelSetupFirstRunRedirectAfterLocation,
} from "../pages/model-setup/first-run.ts";
import { createAgentSelectionCapability } from "./agent-selection.ts";
import { isBrowserPanelAvailable } from "./app-shell-chrome.ts";
import { resolveControlUiDocumentMode, type ControlUiDocumentMode } from "./approval-deep-link.ts";
import { createBrowserHistory, resolveControlUiPaths } from "./browser.ts";
import { createChatAttachmentHandoff } from "./chat-attachment-handoff.ts";
@@ -54,6 +53,7 @@ import { createNativeChatDrafts } from "./native-bridge.ts";
import { startNativeLinkRouting } from "./native-link-routing.ts";
import { createNativeNotificationsCapability } from "./native-notifications.ts";
import { createApplicationOverlays } from "./overlays.ts";
import { isBrowserPanelAvailable } from "./panel-availability.ts";
import { createApplicationPlacementStartup } from "./session-placement-startup.ts";
import {
loadSettings,
+51
View File
@@ -0,0 +1,51 @@
import type { NavDrawerSwipeOwner } from "./nav-drawer-swipe.runtime.ts";
type NavDrawerSwipeHost = HTMLElement & {
readonly onboardingMode: boolean;
readonly updateComplete: Promise<boolean>;
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();
}
}
+211
View File
@@ -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<boolean>;
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<HTMLElement>(".shell-nav");
const backdrop = this.host.querySelector<HTMLElement>(".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<HTMLElement>(".shell-nav");
(this.focusable()[0] ?? drawer)?.focus({ preventScroll: true });
});
}
closed(): void {
this.reset();
}
private focusable(): HTMLElement[] {
const drawer = this.host.querySelector<HTMLElement>(".shell-nav");
return drawer
? [...drawer.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter((candidate) =>
candidate.checkVisibility(),
)
: [];
}
private paint(swipe: Swipe, deltaX: number): void {
const drawer = swipe.drawer ?? this.host.querySelector<HTMLElement>(".shell-nav");
const backdrop = swipe.backdrop ?? this.host.querySelector<HTMLElement>(".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();
}
+103
View File
@@ -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<SidebarAttentionElement>("openclaw-sidebar-attention"),
]
.map((attention) => attention.dismissPanel())
.some((dismissed) => dismissed);
const dismissedMenu = host
.querySelector<AppSidebarElement>("openclaw-app-sidebar")
?.dismissTransientMenus();
return dismissedMenu === true || dismissedPanel;
}
function trapNavDrawerFocus(host: HTMLElement, event: KeyboardEvent): void {
const drawer = host.querySelector<HTMLElement>(".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<HTMLElement>(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<HTMLElement>(".shell-nav");
const toastHost = host.querySelector<HTMLElement>("openclaw-toast-host");
if (drawer && toastHost && toastHost.parentElement !== drawer) {
drawer.moveBefore(toastHost, null);
}
}
export function restoreToastFromNavDrawer(host: HTMLElement): void {
const shell = host.querySelector<HTMLElement>(".shell");
const toastHost = host.querySelector<HTMLElement>("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<HTMLElement>(".topbar-nav-toggle, .chat-pane__nav-toggle")].find(
(candidate) => candidate.checkVisibility(),
);
}
export function navigationSurfaceIsHidden(params: {
onboarding: boolean;
navCollapsed: boolean;
+21
View File
@@ -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");
}
@@ -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;
@@ -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);
});
});
+1 -12
View File
@@ -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();
-24
View File
@@ -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));
@@ -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}
+6
View File
@@ -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<HTMLElement>(".sidebar-issues-panel__list");
const above = Boolean(list && list.scrollTop > 2);
+6 -12
View File
@@ -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({
+113 -17
View File
@@ -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);
},
);
+8 -2
View File
@@ -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);
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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,
+3
View File
@@ -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) {
+1 -1
View File
@@ -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";
@@ -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();
+1 -1
View File
@@ -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,
@@ -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<HTMLDetailsElement>(".chat-controls__inline-select[open]");
if (pickers.length === 0) {
return;
+3
View File
@@ -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;
}
+4
View File
@@ -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 {
+76 -7
View File
@@ -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;
}