mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
committed by
GitHub
parent
8d47620d50
commit
f15b825f64
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user