diff --git a/docs/floating-panels.md b/docs/floating-panels.md
index d646dc609..12c91a046 100644
--- a/docs/floating-panels.md
+++ b/docs/floating-panels.md
@@ -78,6 +78,29 @@ ordinary portals regardless of `z-index`, which would hide app toasts and
tooltips behind the menu. The shared overlay scale keeps menus below toasts and
lets tooltip portals paint above both.
+The shared overlay scale is relative for interactive surfaces: a base floating
+panel is below a base modal, while a floating panel rendered from inside a modal
+inherits that modal's layer and paints above it. Wrap portal content in
+`OverlayLayerProvider`; do not assign one global menu z-index. Desktop web
+comboboxes must use `overlay-root` too. Rendering them through React Native
+Web's `` puts them in the browser top layer, where no ordinary modal
+portal can cover them.
+
+Painting and keyboard ownership use the same relative layer model. Register
+desktop modal, combobox, and dropdown focus scopes with `useWebOverlayRegistration`; the
+highest painted scope alone receives overlay keys, traps focus, and restores
+focus when it closes. Do not add component-local global Escape listeners: two
+stacked overlays would both close on one keypress.
+
+If an overlay is rendered by a global host rather than beneath its opener in
+the React tree, carry the opener's current layer through the host store and
+restore it with `OverlayLayerProvider`. Otherwise painting and keyboard
+ownership silently reset at the app root. When the opener is a global keyboard
+action and has no component context to carry, resolve the host layer with
+`useGlobalWebOverlayLayer` on its closed-to-open transition. It captures the
+current top registered layer before the new host joins the stack; do not give a
+global dialog a fixed root-derived modal layer.
+
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
A Portal escapes Android's hit-test, but it also escapes two things you were
diff --git a/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts b/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts
index fa184ae0a..b106810b8 100644
--- a/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts
+++ b/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts
@@ -100,11 +100,7 @@ async function selectMode(page: Page, label: string): Promise {
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await searchInput.fill(label);
- const option = page
- .getByRole("dialog")
- .last()
- .getByText(new RegExp(`^${escapeRegex(label)}$`, "i"))
- .first();
+ const option = popup.getByText(new RegExp(`^${escapeRegex(label)}$`, "i")).first();
await expect(option).toBeVisible({ timeout: 10_000 });
await option.click({ force: true });
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
diff --git a/packages/app/e2e/provider-settings-refresh.spec.ts b/packages/app/e2e/provider-settings-refresh.spec.ts
index 14234cf80..3ade95634 100644
--- a/packages/app/e2e/provider-settings-refresh.spec.ts
+++ b/packages/app/e2e/provider-settings-refresh.spec.ts
@@ -1,3 +1,4 @@
+import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { expectComposerVisible } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
@@ -63,6 +64,33 @@ async function closeSheetByHeaderButton(page: Page, testId: string) {
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
}
+async function expectOverlayAbove(page: Page, frontTestId: string, backTestId: string) {
+ const frontCoversBack = await page.evaluate(
+ ({ frontTestId: frontId, backTestId: backId }) => {
+ const front = document.querySelector(`[data-testid="${frontId}"]`);
+ const back = document.querySelector(`[data-testid="${backId}"]`);
+ if (!(front instanceof HTMLElement) || !(back instanceof HTMLElement)) return false;
+
+ const frontRect = front.getBoundingClientRect();
+ const backRect = back.getBoundingClientRect();
+ const left = Math.max(frontRect.left, backRect.left);
+ const right = Math.min(frontRect.right, backRect.right);
+ const top = Math.max(frontRect.top, backRect.top);
+ const bottom = Math.min(frontRect.bottom, backRect.bottom);
+ if (left >= right || top >= bottom) return false;
+
+ const topElement = document.elementFromPoint((left + right) / 2, (top + bottom) / 2);
+ return topElement != null && front.contains(topElement);
+ },
+ { frontTestId, backTestId },
+ );
+ expect(frontCoversBack).toBe(true);
+}
+
+async function hasFocusWithin(locator: Locator): Promise {
+ return locator.evaluate((element) => element.contains(document.activeElement));
+}
+
async function expectProviderSettingsVisible(page: Page) {
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
@@ -89,7 +117,69 @@ async function exerciseProviderSettingsStack(page: Page) {
await expectProviderSettingsVisible(page);
}
-test.describe("provider settings bottom-sheet stack", () => {
+test.describe("provider settings overlay stack", () => {
+ test("provider settings covers the desktop model selector without closing it", async ({
+ page,
+ }) => {
+ const session = await seedMockAgentWorkspace({
+ repoPrefix: "provider-modal-layer-",
+ title: "Provider modal layer e2e",
+ });
+
+ try {
+ await openAgentRoute(page, session);
+ await expectComposerVisible(page);
+
+ await page.getByRole("button", { name: /Select model/ }).click();
+ const selector = page.getByTestId("combobox-desktop-container");
+ await expect(selector).toBeVisible({ timeout: 10_000 });
+ const searchInput = page.getByRole("textbox", { name: /search models/i });
+ await expect(searchInput).toBeFocused();
+ await page.keyboard.press("Shift+Tab");
+ await expect.poll(() => hasFocusWithin(selector)).toBe(true);
+
+ await page.keyboard.press("Shift+?");
+ const shortcuts = page.getByTestId("keyboard-shortcuts-dialog");
+ await expect(shortcuts).toBeVisible({ timeout: 10_000 });
+ await expect(page.getByPlaceholder("Search shortcuts")).toBeFocused();
+ await page.keyboard.press("Escape");
+ await expect(shortcuts).not.toBeVisible({ timeout: 10_000 });
+ await expect(selector).toBeVisible();
+
+ await page.keyboard.press("ControlOrMeta+K");
+ const commandCenter = page.getByTestId("command-center-panel");
+ await expect(commandCenter).toBeVisible({ timeout: 10_000 });
+ await expect(commandCenter.getByTestId("command-center-input")).toBeFocused();
+ await page.keyboard.press("Escape");
+ await expect(commandCenter).not.toBeVisible({ timeout: 10_000 });
+ await expect(selector).toBeVisible();
+
+ await page.keyboard.press("ControlOrMeta+K");
+ await expect(commandCenter).toBeVisible({ timeout: 10_000 });
+ await commandCenter.getByText("Add project", { exact: true }).click();
+ const addProject = page.getByTestId("add-project-flow");
+ await expect(addProject).toBeVisible({ timeout: 10_000 });
+ await expect(addProject.getByTestId("add-project-flow-input")).toBeFocused();
+ await page.keyboard.press("Escape");
+ await expect(addProject).not.toBeVisible({ timeout: 10_000 });
+ await expect(selector).toBeVisible();
+
+ const settingsButton = page.getByTestId("selector-header-settings-mock");
+ await settingsButton.click();
+
+ const settings = page.getByTestId("provider-settings-sheet");
+ await expect(settings).toBeVisible({ timeout: 10_000 });
+ await expectOverlayAbove(page, "provider-settings-sheet", "combobox-desktop-container");
+
+ await page.keyboard.press("Escape");
+ await expect(settings).not.toBeVisible({ timeout: 10_000 });
+ await expect(selector).toBeVisible();
+ await expect(settingsButton).toBeFocused();
+ } finally {
+ await session.cleanup();
+ }
+ });
+
test("provider settings and children close back through the model selector stack", async ({
page,
}) => {
diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx
index 11d59048c..f66cbf254 100644
--- a/packages/app/src/command-center/command-center.tsx
+++ b/packages/app/src/command-center/command-center.tsx
@@ -31,6 +31,11 @@ import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregate
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useProjects } from "@/hooks/use-projects";
+import {
+ OverlayLayerProvider,
+ useGlobalWebOverlayLayer,
+ useWebOverlayRegistration,
+} from "@/lib/overlay-root";
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
@@ -372,15 +377,6 @@ function useCommandCenterState(): CommandCenterState {
return cancel;
}, [open]);
- useEffect(() => {
- if (!open || !isWeb) return;
- const listener = (event: KeyboardEvent) => {
- if (key(event.key)) event.preventDefault();
- };
- window.addEventListener("keydown", listener, true);
- return () => window.removeEventListener("keydown", listener, true);
- }, [key, open]);
-
return {
open,
query,
@@ -553,6 +549,7 @@ export function CommandCenter() {
const state = useCommandCenterState();
const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative;
+ const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
const listRef = useRef>(null);
const bottomSheetListRef = useRef(null);
const bottomSheetInputRef = useRef>(null);
@@ -618,6 +615,19 @@ export function CommandCenter() {
[state],
);
const submit = useCallback(() => state.key("Enter"), [state]);
+ const handleWebOverlayKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (!state.key(event.key)) return false;
+ event.preventDefault();
+ return true;
+ },
+ [state],
+ );
+ const setWebOverlayScope = useWebOverlayRegistration({
+ active: isWeb && state.open && !showBottomSheet,
+ layer: modalLayer,
+ onKeyDown: handleWebOverlayKeyDown,
+ });
const backdrop = useCallback(
(props: React.ComponentProps) => (
@@ -663,27 +673,29 @@ export function CommandCenter() {
}
if (!state.open) return null;
return (
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
+
+
);
}
diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx
index 07c157fa8..8d06a2181 100644
--- a/packages/app/src/components/adaptive-modal-sheet.tsx
+++ b/packages/app/src/components/adaptive-modal-sheet.tsx
@@ -6,7 +6,12 @@ import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "r
import type { StyleProp, TextInputProps, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
-import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
+import {
+ getOverlayRoot,
+ OverlayLayerProvider,
+ useGlobalWebOverlayLayer,
+ useWebOverlayRegistration,
+} from "../lib/overlay-root";
import {
BottomSheetBackdrop,
BottomSheetScrollView,
@@ -54,36 +59,8 @@ export interface SheetHeader {
search?: SheetHeaderSearch;
}
-type EscHandler = () => void;
-const escStack: EscHandler[] = [];
-let escListenerAttached = false;
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
-function handleEscKeyDown(event: KeyboardEvent) {
- if (event.key !== "Escape") return;
- const top = escStack[escStack.length - 1];
- if (!top) return;
- event.stopPropagation();
- event.preventDefault();
- top();
-}
-
-function pushEscHandler(handler: EscHandler): () => void {
- escStack.push(handler);
- if (!escListenerAttached && typeof window !== "undefined") {
- window.addEventListener("keydown", handleEscKeyDown, true);
- escListenerAttached = true;
- }
- return () => {
- const index = escStack.lastIndexOf(handler);
- if (index !== -1) escStack.splice(index, 1);
- if (escStack.length === 0 && escListenerAttached && typeof window !== "undefined") {
- window.removeEventListener("keydown", handleEscKeyDown, true);
- escListenerAttached = false;
- }
- };
-}
-
const styles = StyleSheet.create((theme) => ({
desktopOverlay: {
...StyleSheet.absoluteFillObject,
@@ -91,7 +68,6 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
alignItems: "center",
padding: theme.spacing[6],
- zIndex: OVERLAY_Z.modal,
pointerEvents: "auto" as const,
},
desktopCard: {
@@ -582,6 +558,7 @@ export function AdaptiveModalSheet({
});
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
const [isWebClosing, setIsWebClosing] = useState(false);
+ const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb);
const nativeModalDismissNotifiedRef = useRef(!visible);
const handleDismiss = useCallback(() => {
handleSheetDismiss();
@@ -610,19 +587,31 @@ export function AdaptiveModalSheet({
() => [
styles.desktopOverlay,
isWeb && {
+ zIndex: modalLayer,
opacity: isWebClosing ? 0 : 1,
transitionDuration: `${WEB_EXIT_DURATION_MS}ms`,
transitionProperty: "opacity",
transitionTimingFunction: "ease",
},
],
- [isWebClosing],
+ [isWebClosing, modalLayer],
);
- useEffect(() => {
- if (!isWeb || isMobile || !visible) return;
- return pushEscHandler(onClose);
- }, [visible, isMobile, onClose]);
+ const handleWebOverlayKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (event.key !== "Escape") return false;
+ event.preventDefault();
+ event.stopPropagation();
+ onClose();
+ return true;
+ },
+ [onClose],
+ );
+ const setWebOverlayScope = useWebOverlayRegistration({
+ active: isWeb && !isMobile && visible,
+ layer: modalLayer,
+ onKeyDown: handleWebOverlayKeyDown,
+ });
useEffect(() => {
if (visible) {
@@ -700,7 +689,7 @@ export function AdaptiveModalSheet({
}
const cardInner = (
- <>
+
{scrollable ? (
@@ -717,7 +706,7 @@ export function AdaptiveModalSheet({
{children}
)}
{footer ? {footer} : null}
- >
+
);
const desktopContent = (
@@ -727,7 +716,15 @@ export function AdaptiveModalSheet({
style={ABSOLUTE_FILL_STYLE}
onPress={onClose}
/>
- {cardInner}
+
+ {cardInner}
+
);
diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx
index cfdda3d86..3078062a7 100644
--- a/packages/app/src/components/add-project-flow.tsx
+++ b/packages/app/src/components/add-project-flow.tsx
@@ -11,7 +11,15 @@ import {
Search,
Server,
} from "lucide-react-native";
-import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
+import {
+ createElement,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ComponentType,
+} from "react";
import {
Modal,
Pressable,
@@ -66,6 +74,11 @@ import { useFetchQuery } from "@/data/query";
import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project";
import { useIsLocalDaemon, useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useCloneGithubProject, useOpenProject } from "@/hooks/use-open-project";
+import {
+ OverlayLayerProvider,
+ useGlobalWebOverlayLayer,
+ useWebOverlayRegistration,
+} from "@/lib/overlay-root";
import {
useHosts,
useHostRuntimeClient,
@@ -769,14 +782,20 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
[activeIndex, handleBack, rows, submitActive],
);
- useEffect(() => {
- if (!isWeb || typeof window === "undefined") return;
- const listener = (event: KeyboardEvent) => {
- if (handleKey(event.key)) event.preventDefault();
- };
- window.addEventListener("keydown", listener, true);
- return () => window.removeEventListener("keydown", listener, true);
- }, [handleKey]);
+ const modalLayer = useGlobalWebOverlayLayer("modal", isWeb);
+ const handleWebOverlayKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (!handleKey(event.key)) return false;
+ event.preventDefault();
+ return true;
+ },
+ [handleKey],
+ );
+ const setWebOverlayScope = useWebOverlayRegistration({
+ active: isWeb,
+ layer: modalLayer,
+ onKeyDown: handleWebOverlayKeyDown,
+ });
const handleNativeKeyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => {
@@ -816,11 +835,12 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
? joinDirectoryPath(page.parentPath, page.name.trim())
: null;
- return (
+ const modal = (
);
+
+ return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal);
}
const styles = StyleSheet.create((theme) => ({
diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx
index 7f656c8c4..e9c34b0ec 100644
--- a/packages/app/src/components/model-browser.tsx
+++ b/packages/app/src/components/model-browser.tsx
@@ -34,6 +34,7 @@ import {
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
+import { useCurrentOverlayLayer } from "@/lib/overlay-root";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
resolveInitialModelBrowserView,
@@ -53,6 +54,36 @@ const ThemedSearch = withUnistyles(Search);
const ThemedSettings = withUnistyles(Settings);
const ThemedStar = withUnistyles(Star);
+function ProviderSettingsAction({
+ accessibilityLabel,
+ provider,
+ serverId,
+}: {
+ accessibilityLabel: string;
+ provider: string;
+ serverId: string | null;
+}) {
+ const overlayParentLayer = useCurrentOverlayLayer();
+ const handlePress = useCallback(() => {
+ if (!serverId) return;
+ useProviderSettingsStore.getState().open({ serverId, provider, overlayParentLayer });
+ }, [overlayParentLayer, provider, serverId]);
+
+ return (
+
+
+
+ );
+}
+
const IndependentScrollGestureContext = createContext | null>(
null,
);
@@ -237,11 +268,6 @@ export function useModelBrowser({
setSearchQuery(value);
}, []);
- const openProviderSettings = useCallback(() => {
- if (!serverId || view.kind !== "provider") return;
- useProviderSettingsStore.getState().open({ serverId, provider: view.providerId });
- }, [serverId, view]);
-
const singleProviderView = providers.length === 1;
const header = useMemo(() => {
if (view.kind === "all") {
@@ -254,19 +280,13 @@ export function useModelBrowser({
),
back: singleProviderView ? undefined : { onPress: handleBackToAll },
actions: (
-
-
-
+ />
),
search: {
onChange: handleSearchQueryChange,
@@ -279,7 +299,6 @@ export function useModelBrowser({
}, [
handleBackToAll,
handleSearchQueryChange,
- openProviderSettings,
searchResetKey,
serverId,
singleProviderView,
diff --git a/packages/app/src/components/provider-settings-host.tsx b/packages/app/src/components/provider-settings-host.tsx
index 7518fc28f..f6ff75c74 100644
--- a/packages/app/src/components/provider-settings-host.tsx
+++ b/packages/app/src/components/provider-settings-host.tsx
@@ -1,11 +1,13 @@
import { useCallback } from "react";
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
+import { OverlayLayerProvider } from "@/lib/overlay-root";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
export function ProviderSettingsHost() {
const serverId = useProviderSettingsStore((state) => state.serverId);
const provider = useProviderSettingsStore((state) => state.provider);
const visible = useProviderSettingsStore((state) => state.visible);
+ const overlayParentLayer = useProviderSettingsStore((state) => state.overlayParentLayer);
const close = useProviderSettingsStore((state) => state.close);
const handleClose = useCallback(() => {
@@ -17,12 +19,14 @@ export function ProviderSettingsHost() {
}
return (
-
+
+
+
);
}
diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx
index 2d862a133..50f328787 100644
--- a/packages/app/src/components/ui/combobox.tsx
+++ b/packages/app/src/components/ui/combobox.tsx
@@ -23,6 +23,7 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
+import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -62,6 +63,12 @@ import {
} from "@/components/adaptive-modal-sheet";
import { FloatingSurface } from "@/components/ui/floating";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
+import {
+ getOverlayRoot,
+ OverlayLayerProvider,
+ useOverlayLayer,
+ useWebOverlayRegistration,
+} from "@/lib/overlay-root";
import { buildDesktopFrameStyle } from "./combobox-frame-style";
export { buildDesktopFrameStyle } from "./combobox-frame-style";
@@ -863,45 +870,31 @@ function isDesktopKey(key: string): key is DesktopKey {
return key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape";
}
-function useWebKeyboardListener(
- isOpen: boolean,
- handleDesktopKey: (key: DesktopKey, event?: KeyboardEvent) => void,
-) {
- useEffect(() => {
- if (!IS_WEB || !isOpen) return;
-
- const handler = (event: KeyboardEvent) => {
- if (!isDesktopKey(event.key)) return;
- handleDesktopKey(event.key, event);
- };
-
- // react-native-web's TextInput can stop propagation on key events, so listen in capture phase.
- window.addEventListener("keydown", handler, true);
- return () => {
- window.removeEventListener("keydown", handler, true);
- };
- }, [handleDesktopKey, isOpen]);
-}
-
-function dispatchDesktopKey(input: DesktopKeyHandlerInput, key: DesktopKey, event?: KeyboardEvent) {
- if (!input.isOpen) return;
- if (!IS_WEB && input.isMobile) return;
+function dispatchDesktopKey(
+ input: DesktopKeyHandlerInput,
+ key: DesktopKey,
+ event?: KeyboardEvent,
+): boolean {
+ if (!input.isOpen) return false;
+ if (!IS_WEB && input.isMobile) return false;
if (key === "ArrowDown" || key === "ArrowUp") {
event?.preventDefault();
handleDesktopArrowKey(input, key);
- return;
+ return true;
}
if (key === "Enter") {
- if (input.orderedVisibleOptions.length === 0) return;
+ if (input.orderedVisibleOptions.length === 0) return false;
event?.preventDefault();
handleDesktopEnterKey(input);
- return;
+ return true;
}
if (key === "Escape") {
event?.preventDefault();
input.handleClose();
+ return true;
}
+ return false;
}
function resolveInitialActiveIndex(
@@ -1050,8 +1043,10 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
}
interface DesktopBodyProps {
+ overlayLayer: number;
isOpen: boolean;
handleClose: () => void;
+ handleDesktopKey: (key: DesktopKey, event?: KeyboardEvent) => boolean;
refs: ReturnType["refs"];
shouldUseDesktopFade: boolean;
desktopFrameStyle: StyleProp;
@@ -1166,14 +1161,38 @@ function DesktopComboboxOptionsBody(props: {
}
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
- return (
-
-
+ const handleDesktopKey = props.handleDesktopKey;
+ const handleWebOverlayKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (!isDesktopKey(event.key)) return false;
+ return handleDesktopKey(event.key, event);
+ },
+ [handleDesktopKey],
+ );
+ const setWebOverlayScope = useWebOverlayRegistration({
+ active: isWeb && props.isOpen,
+ layer: props.overlayLayer,
+ onKeyDown: handleWebOverlayKeyDown,
+ });
+ const setFloatingRef = useCallback(
+ (node: View | null) => {
+ props.refs.setFloating(node);
+ setWebOverlayScope(node);
+ },
+ [props.refs, setWebOverlayScope],
+ );
+
+ const overlay = (
+
+
{props.hasChildren ? (
@@ -1215,6 +1237,21 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
{props.footer ? {props.footer} : null}
+
+ );
+
+ if (isWeb && typeof document !== "undefined") {
+ return createPortal(overlay, getOverlayRoot());
+ }
+
+ return (
+
+ {overlay}
);
}
@@ -1257,6 +1294,7 @@ export function Combobox({
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
const resolvedTitle = title ?? t("common.actions.select");
const isMobile = useIsCompactFormFactor();
+ const floatingLayer = useOverlayLayer("floating");
const safeAreaInsets = useSafeAreaInsets();
const titleColor = theme.colors.foreground;
const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition);
@@ -1462,7 +1500,7 @@ export function Combobox({
const handleDesktopKey = useCallback(
(key: DesktopKey, event?: KeyboardEvent) => {
- dispatchDesktopKey(
+ return dispatchDesktopKey(
{
isOpen,
isMobile,
@@ -1479,7 +1517,6 @@ export function Combobox({
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions],
);
- useWebKeyboardListener(isOpen, handleDesktopKey);
useDismissKeyboardOnOpen(isOpen, isMobile);
const handleIndicatorStyle = useMemo(
@@ -1556,8 +1593,10 @@ export function Combobox({
return (
({
desktopOverlay: {
flex: 1,
},
+ desktopOverlayWeb: {
+ ...StyleSheet.absoluteFillObject,
+ pointerEvents: "auto" as const,
+ },
desktopBackdrop: {
position: "absolute",
top: 0,
diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx
index 8c8badfa0..0f21bdb23 100644
--- a/packages/app/src/components/ui/dropdown-menu.tsx
+++ b/packages/app/src/components/ui/dropdown-menu.tsx
@@ -34,7 +34,12 @@ import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { isWeb } from "@/constants/platform";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
-import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
+import {
+ getOverlayRoot,
+ OverlayLayerProvider,
+ useOverlayLayer,
+ useWebOverlayRegistration,
+} from "@/lib/overlay-root";
// Action status for menu items with loading/success feedback
export type ActionStatus = "idle" | "pending" | "success";
@@ -185,6 +190,7 @@ function renderDropdownSurface(input: {
content: ReactElement;
surfaceNativeID: string;
onExited: () => void;
+ scopeRef: (node: View | null) => void;
}): ReactElement {
const {
frameStyle,
@@ -195,6 +201,7 @@ function renderDropdownSurface(input: {
content,
surfaceNativeID,
onExited,
+ scopeRef,
} = input;
const body = scrollable ? (
@@ -212,7 +219,9 @@ function renderDropdownSurface(input: {
return (
): ReactElement | null {
const { t } = useTranslation();
+ const floatingLayer = useOverlayLayer("floating");
const { open, setOpen, triggerRef, flushPendingSelect } =
useDropdownMenuContext("DropdownMenuContent");
const [modalVisible, setModalVisible] = useState(false);
@@ -502,16 +512,21 @@ export function DropdownMenuContent({
setOpen(false);
}, [setOpen]);
- useEffect(() => {
- if (!isWeb || !modalVisible || typeof window === "undefined") return undefined;
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key !== "Escape") return;
+ const handleWebOverlayKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (event.key !== "Escape") return false;
+ event.preventDefault();
event.stopPropagation();
handleClose();
- };
- window.addEventListener("keydown", handleKeyDown, true);
- return () => window.removeEventListener("keydown", handleKeyDown, true);
- }, [handleClose, modalVisible]);
+ return true;
+ },
+ [handleClose],
+ );
+ const setWebOverlayScope = useWebOverlayRegistration({
+ active: isWeb && modalVisible,
+ layer: floatingLayer,
+ onKeyDown: handleWebOverlayKeyDown,
+ });
// Measure trigger when opening
useEffect(() => {
@@ -631,27 +646,36 @@ export function DropdownMenuContent({
);
const overlay = (
-
-
- {!closing
- ? renderDropdownSurface({
- frameStyle,
- testID,
- surfaceStyle,
- scrollable,
- scrollViewportStyle,
- content,
- surfaceNativeID,
- onExited: () => setModalVisible(false),
- })
- : null}
-
+
+
+
+ {!closing
+ ? renderDropdownSurface({
+ frameStyle,
+ testID,
+ surfaceStyle,
+ scrollable,
+ scrollViewportStyle,
+ content,
+ surfaceNativeID,
+ scopeRef: setWebOverlayScope,
+ onExited: () => setModalVisible(false),
+ })
+ : null}
+
+
);
if (isWeb && typeof document !== "undefined") {
@@ -905,7 +929,6 @@ const styles = StyleSheet.create((theme) => ({
},
overlayWeb: {
...StyleSheet.absoluteFillObject,
- zIndex: OVERLAY_Z.modal,
pointerEvents: "auto" as const,
},
backdrop: {
diff --git a/packages/app/src/hosts/host-chooser.tsx b/packages/app/src/hosts/host-chooser.tsx
index 4f8debd14..d0724e9cf 100644
--- a/packages/app/src/hosts/host-chooser.tsx
+++ b/packages/app/src/hosts/host-chooser.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
@@ -13,8 +13,13 @@ import { Server } from "lucide-react-native";
import { create } from "zustand";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
-import { isNative } from "@/constants/platform";
+import { isWeb } from "@/constants/platform";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
+import {
+ OverlayLayerProvider,
+ useGlobalWebOverlayLayer,
+ useWebOverlayRegistration,
+} from "@/lib/overlay-root";
import { useHosts } from "@/runtime/host-runtime";
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
@@ -143,6 +148,7 @@ export function HostChooserModal() {
const inputRef = useRef(null);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
+ const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && request != null);
const requestHosts = useMemo(() => {
if (!request) return [];
@@ -182,58 +188,60 @@ export function HostChooserModal() {
[close, request],
);
- useEffect(() => {
- if (!request || isNative || typeof window === "undefined") return;
-
- const handleKeyDown = (event: KeyboardEvent) => {
+ const handleWebOverlayKeyDown = useCallback(
+ (event: KeyboardEvent) => {
if (
event.key !== "ArrowDown" &&
event.key !== "ArrowUp" &&
event.key !== "Enter" &&
event.key !== "Escape"
) {
- return;
+ return false;
}
if (event.key === "Escape") {
event.preventDefault();
close();
- return;
+ return true;
}
if (event.key === "Enter") {
const host = options[activeOptionIndex];
- if (!host) return;
+ if (!host) return false;
event.preventDefault();
chooseHost(host.serverId);
- return;
+ return true;
}
- if (options.length === 0) return;
+ if (options.length === 0) return false;
event.preventDefault();
const next = event.key === "ArrowDown" ? activeOptionIndex + 1 : activeOptionIndex - 1;
if (next < 0) {
setActiveIndex(options.length - 1);
- return;
+ return true;
}
if (next >= options.length) {
setActiveIndex(0);
- return;
+ return true;
}
setActiveIndex(next);
- };
-
- window.addEventListener("keydown", handleKeyDown, true);
- return () => window.removeEventListener("keydown", handleKeyDown, true);
- }, [activeOptionIndex, chooseHost, close, options, request]);
+ return true;
+ },
+ [activeOptionIndex, chooseHost, close, options],
+ );
+ const setWebOverlayScope = useWebOverlayRegistration({
+ active: isWeb && request != null,
+ layer: modalLayer,
+ onKeyDown: handleWebOverlayKeyDown,
+ });
if (!request) return null;
- return (
+ const modal = (
-
+
{request.title}
);
+
+ return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal);
}
const styles = StyleSheet.create((theme) => ({
diff --git a/packages/app/src/lib/overlay-root.ts b/packages/app/src/lib/overlay-root.ts
index 95b4e4d2c..9b19b8825 100644
--- a/packages/app/src/lib/overlay-root.ts
+++ b/packages/app/src/lib/overlay-root.ts
@@ -1,11 +1,27 @@
+import {
+ createContext,
+ createElement,
+ useCallback,
+ useContext,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ type ReactNode,
+} from "react";
+
/**
* Shared overlay root for web portals (modals, toasts, etc.)
* This ensures consistent stacking order by controlling a single overlay container.
*
* Z-index scale within overlay root:
- * - Modal backdrop/content: 10
- * - Toast: 20
- * - Tooltip: 30
+ * - Floating panel: parent layer + 10
+ * - Modal: parent layer + 20
+ * - Toast: 10,000
+ * - Tooltip: 20,000
+ *
+ * Floating panels and modals provide their resolved layer to descendants. A
+ * dropdown opened inside a modal therefore paints above that modal, while a
+ * base dropdown remains below a modal opened over it.
*/
export function getOverlayRoot(): HTMLElement {
let el = document.getElementById("overlay-root");
@@ -21,7 +37,240 @@ export function getOverlayRoot(): HTMLElement {
}
export const OVERLAY_Z = {
- modal: 10,
- toast: 20,
- tooltip: 30,
+ floating: 10,
+ modal: 20,
+ toast: 10_000,
+ tooltip: 20_000,
} as const;
+
+type OverlayKind = "floating" | "modal";
+
+const OverlayLayerContext = createContext(0);
+
+export function useOverlayLayer(kind: OverlayKind): number {
+ return useContext(OverlayLayerContext) + OVERLAY_Z[kind];
+}
+
+/**
+ * Resolves a globally hosted web overlay above whichever registered overlay
+ * was topmost when it opened. Global hosts live outside their opener's React
+ * tree, so context alone cannot preserve that relative ownership.
+ */
+export function useGlobalWebOverlayLayer(kind: OverlayKind, active: boolean): number {
+ const contextualLayer = useOverlayLayer(kind);
+ return useMemo(() => {
+ if (!active) return contextualLayer;
+ const topLayer = getTopWebOverlay()?.getLayer() ?? 0;
+ return Math.max(contextualLayer, topLayer + OVERLAY_Z[kind]);
+ }, [active, contextualLayer, kind]);
+}
+
+export function useCurrentOverlayLayer(): number {
+ return useContext(OverlayLayerContext);
+}
+
+export function OverlayLayerProvider({ layer, children }: { layer: number; children?: ReactNode }) {
+ return createElement(OverlayLayerContext.Provider, { value: layer }, children);
+}
+
+type WebOverlayKeyHandler = (event: KeyboardEvent) => boolean;
+
+interface WebOverlayEntry {
+ id: symbol;
+ order: number;
+ getLayer: () => number;
+ getScope: () => HTMLElement | null;
+ getKeyHandler: () => WebOverlayKeyHandler;
+ restoreFocus: HTMLElement | null;
+}
+
+const webOverlayEntries: WebOverlayEntry[] = [];
+let webOverlayOrder = 0;
+let webOverlayListenersAttached = false;
+let webOverlayFocusCheckQueued = false;
+
+function getTopWebOverlay(): WebOverlayEntry | undefined {
+ return webOverlayEntries.reduce((top, entry) => {
+ if (!top) return entry;
+ const layerDifference = entry.getLayer() - top.getLayer();
+ return layerDifference > 0 || (layerDifference === 0 && entry.order > top.order) ? entry : top;
+ }, undefined);
+}
+
+function getFocusableElements(scope: HTMLElement): HTMLElement[] {
+ const selector = [
+ "a[href]",
+ "button:not([disabled])",
+ "input:not([disabled])",
+ "select:not([disabled])",
+ "textarea:not([disabled])",
+ '[tabindex]:not([tabindex="-1"])',
+ ].join(",");
+
+ return Array.from(scope.querySelectorAll(selector)).filter(
+ (element) =>
+ element.getAttribute("aria-hidden") !== "true" &&
+ !element.hasAttribute("hidden") &&
+ element.getClientRects().length > 0,
+ );
+}
+
+function focusFirstElement(scope: HTMLElement): void {
+ const first = getFocusableElements(scope)[0];
+ (first ?? scope).focus();
+}
+
+function handleWebOverlayFocus(event: FocusEvent): void {
+ const top = getTopWebOverlay();
+ const scope = top?.getScope();
+ if (!scope || scope.contains(event.target as Node)) return;
+ if (webOverlayFocusCheckQueued) return;
+
+ // React can autofocus a child before its parent scope ref attaches. Defer
+ // enforcement until the commit finishes so a newly mounted higher overlay
+ // can register without the previous scope stealing the requested focus.
+ webOverlayFocusCheckQueued = true;
+ queueMicrotask(() => {
+ webOverlayFocusCheckQueued = false;
+ const currentScope = getTopWebOverlay()?.getScope();
+ if (!currentScope || currentScope.contains(document.activeElement)) return;
+ focusFirstElement(currentScope);
+ });
+}
+
+function handleWebOverlayKeyDown(event: KeyboardEvent): void {
+ const top = getTopWebOverlay();
+ const scope = top?.getScope();
+ if (!top || !scope) return;
+
+ if (event.key === "Tab") {
+ const focusable = getFocusableElements(scope);
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ const active = document.activeElement;
+ const shouldWrapBackward = event.shiftKey && (!scope.contains(active) || active === first);
+ const shouldWrapForward = !event.shiftKey && (!scope.contains(active) || active === last);
+ if (shouldWrapBackward || shouldWrapForward) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ (shouldWrapBackward ? last : first)?.focus();
+ if (!first) scope.focus();
+ return;
+ }
+ }
+
+ if (!top.getKeyHandler()(event)) return;
+ event.stopImmediatePropagation();
+}
+
+function attachWebOverlayListeners(): void {
+ if (webOverlayListenersAttached) return;
+ window.addEventListener("keydown", handleWebOverlayKeyDown, true);
+ document.addEventListener("focusin", handleWebOverlayFocus, true);
+ webOverlayListenersAttached = true;
+}
+
+function detachWebOverlayListeners(): void {
+ if (!webOverlayListenersAttached || webOverlayEntries.length > 0) return;
+ window.removeEventListener("keydown", handleWebOverlayKeyDown, true);
+ document.removeEventListener("focusin", handleWebOverlayFocus, true);
+ webOverlayListenersAttached = false;
+}
+
+function addWebOverlay(entry: WebOverlayEntry): () => void {
+ webOverlayEntries.push(entry);
+ attachWebOverlayListeners();
+
+ const focusFrame = window.requestAnimationFrame(() => {
+ const scope = entry.getScope();
+ if (getTopWebOverlay() === entry && scope && !scope.contains(document.activeElement)) {
+ focusFirstElement(scope);
+ }
+ });
+
+ return () => {
+ window.cancelAnimationFrame(focusFrame);
+ const index = webOverlayEntries.findIndex((candidate) => candidate.id === entry.id);
+ if (index !== -1) webOverlayEntries.splice(index, 1);
+ detachWebOverlayListeners();
+ if (entry.restoreFocus && document.contains(entry.restoreFocus)) {
+ entry.restoreFocus.focus();
+ }
+ };
+}
+
+interface WebOverlayRegistration {
+ active: boolean;
+ layer: number;
+ onKeyDown: WebOverlayKeyHandler;
+}
+
+/**
+ * Registers a focus scope in the same relative layer model used for painting.
+ * Only the highest painted overlay receives keyboard input; focus is trapped
+ * there and restored to the opener when that overlay closes.
+ */
+export function useWebOverlayRegistration({ active, layer, onKeyDown }: WebOverlayRegistration) {
+ const idRef = useRef(Symbol("web-overlay"));
+ const scopeRef = useRef(null);
+ const layerRef = useRef(layer);
+ const keyHandlerRef = useRef(onKeyDown);
+ const restoreFocusRef = useRef(null);
+ const removeEntryRef = useRef<(() => void) | null>(null);
+ const activeRef = useRef(active);
+ const wasActiveRef = useRef(false);
+
+ activeRef.current = active;
+ layerRef.current = layer;
+ keyHandlerRef.current = onKeyDown;
+ if (active && !wasActiveRef.current && typeof document !== "undefined") {
+ restoreFocusRef.current =
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ }
+ wasActiveRef.current = active;
+
+ const syncRegistration = useCallback(() => {
+ const shouldRegister =
+ activeRef.current &&
+ scopeRef.current != null &&
+ typeof window !== "undefined" &&
+ typeof document !== "undefined";
+ if (!shouldRegister) {
+ removeEntryRef.current?.();
+ removeEntryRef.current = null;
+ return;
+ }
+ if (removeEntryRef.current) return;
+
+ const entry: WebOverlayEntry = {
+ id: idRef.current,
+ order: ++webOverlayOrder,
+ getLayer: () => layerRef.current,
+ getScope: () => scopeRef.current,
+ getKeyHandler: () => keyHandlerRef.current,
+ restoreFocus: restoreFocusRef.current,
+ };
+ removeEntryRef.current = addWebOverlay(entry);
+ }, []);
+
+ const setScope = useCallback(
+ (node: unknown) => {
+ scopeRef.current =
+ typeof HTMLElement !== "undefined" && node instanceof HTMLElement ? node : null;
+ // Host refs attach before descendant layout effects and autofocus. Register
+ // here so the previous overlay cannot redirect that pending focus.
+ syncRegistration();
+ },
+ [syncRegistration],
+ );
+
+ useLayoutEffect(() => {
+ syncRegistration();
+ return () => {
+ removeEntryRef.current?.();
+ removeEntryRef.current = null;
+ };
+ }, [active, syncRegistration]);
+
+ return setScope;
+}
diff --git a/packages/app/src/stores/provider-settings-store.test.ts b/packages/app/src/stores/provider-settings-store.test.ts
new file mode 100644
index 000000000..ca3dc41e7
--- /dev/null
+++ b/packages/app/src/stores/provider-settings-store.test.ts
@@ -0,0 +1,28 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { useProviderSettingsStore } from "./provider-settings-store";
+
+describe("provider settings store", () => {
+ afterEach(() => {
+ useProviderSettingsStore.setState({
+ serverId: null,
+ provider: null,
+ overlayParentLayer: 0,
+ visible: false,
+ });
+ });
+
+ it("carries the opener layer without leaking it into later base-level opens", () => {
+ useProviderSettingsStore.getState().open({
+ serverId: "server-1",
+ provider: "codex",
+ overlayParentLayer: 30,
+ });
+ expect(useProviderSettingsStore.getState().overlayParentLayer).toBe(30);
+
+ useProviderSettingsStore.getState().open({
+ serverId: "server-1",
+ provider: "claude",
+ });
+ expect(useProviderSettingsStore.getState().overlayParentLayer).toBe(0);
+ });
+});
diff --git a/packages/app/src/stores/provider-settings-store.ts b/packages/app/src/stores/provider-settings-store.ts
index abd214c5a..4529f7e3e 100644
--- a/packages/app/src/stores/provider-settings-store.ts
+++ b/packages/app/src/stores/provider-settings-store.ts
@@ -3,11 +3,13 @@ import { create } from "zustand";
interface ProviderSettingsTarget {
serverId: string;
provider: string;
+ overlayParentLayer?: number;
}
interface ProviderSettingsStoreState {
serverId: string | null;
provider: string | null;
+ overlayParentLayer: number;
visible: boolean;
open: (target: ProviderSettingsTarget) => void;
close: () => void;
@@ -16,9 +18,10 @@ interface ProviderSettingsStoreState {
export const useProviderSettingsStore = create()((set) => ({
serverId: null,
provider: null,
+ overlayParentLayer: 0,
visible: false,
- open: ({ serverId, provider }) => {
- set({ serverId, provider, visible: true });
+ open: ({ serverId, provider, overlayParentLayer = 0 }) => {
+ set({ serverId, provider, overlayParentLayer, visible: true });
},
close: () => {
set({ visible: false });