From 2acb10fce96ff65eae0cad9894ff7338139d417b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 27 Jul 2026 14:04:29 +0200 Subject: [PATCH] Keep provider settings above the model selector (#2476) * fix(app): keep provider settings above model selector Desktop web comboboxes used the browser top layer, so ordinary modal portals could not cover them. Keep web overlays in one relative layer stack while preserving native bottom-sheet stacking. * fix(app): route overlay keyboard ownership * fix(app): preserve nested overlay ownership * fix(app): route command center overlay input * fix(app): preserve global overlay ancestry * fix(app): register global dialog hosts --- docs/floating-panels.md | 23 ++ ...w-workspace-codex-mode-preferences.spec.ts | 6 +- .../app/e2e/provider-settings-refresh.spec.ts | 92 +++++- .../app/src/command-center/command-center.tsx | 68 +++-- .../src/components/adaptive-modal-sheet.tsx | 73 +++-- .../app/src/components/add-project-flow.tsx | 42 ++- packages/app/src/components/model-browser.tsx | 51 ++-- .../src/components/provider-settings-host.tsx | 18 +- packages/app/src/components/ui/combobox.tsx | 117 +++++--- .../app/src/components/ui/dropdown-menu.tsx | 85 +++--- packages/app/src/hosts/host-chooser.tsx | 50 ++-- packages/app/src/lib/overlay-root.ts | 261 +++++++++++++++++- .../stores/provider-settings-store.test.ts | 28 ++ .../app/src/stores/provider-settings-store.ts | 7 +- 14 files changed, 720 insertions(+), 201 deletions(-) create mode 100644 packages/app/src/stores/provider-settings-store.test.ts 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 });