mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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
This commit is contained in:
@@ -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 `<Modal>` 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
|
||||
|
||||
@@ -100,11 +100,7 @@ async function selectMode(page: Page, label: string): Promise<void> {
|
||||
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 });
|
||||
|
||||
@@ -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<boolean> {
|
||||
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,
|
||||
}) => {
|
||||
|
||||
@@ -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<FlatList<CommandCenterListRow>>(null);
|
||||
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
|
||||
const bottomSheetInputRef = useRef<React.ElementRef<typeof BottomSheetTextInput>>(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<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
@@ -663,27 +673,29 @@ export function CommandCenter() {
|
||||
}
|
||||
if (!state.open) return null;
|
||||
return (
|
||||
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={state.close} />
|
||||
<View testID="command-center-panel" style={styles.panel}>
|
||||
<View style={styles.header}>
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
value={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={t("shell.commandCenter.placeholder")}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
|
||||
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={state.close} />
|
||||
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
|
||||
<View style={styles.header}>
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
value={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={t("shell.commandCenter.placeholder")}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
<FlatList ref={listRef} style={styles.results} {...commonListProps} />
|
||||
</View>
|
||||
<FlatList ref={listRef} style={styles.results} {...commonListProps} />
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</Modal>
|
||||
</OverlayLayerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
<>
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<SheetHeaderView header={header} onClose={onClose} />
|
||||
{scrollable ? (
|
||||
<View style={styles.desktopScrollContainer}>
|
||||
@@ -717,7 +706,7 @@ export function AdaptiveModalSheet({
|
||||
<View style={[styles.desktopStaticContent, contentContainerStyle]}>{children}</View>
|
||||
)}
|
||||
{footer ? <View style={footerStyle}>{footer}</View> : null}
|
||||
</>
|
||||
</OverlayLayerProvider>
|
||||
);
|
||||
|
||||
const desktopContent = (
|
||||
@@ -727,7 +716,15 @@ export function AdaptiveModalSheet({
|
||||
style={ABSOLUTE_FILL_STYLE}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<View style={desktopCardStyle}>{cardInner}</View>
|
||||
<View
|
||||
ref={setWebOverlayScope}
|
||||
style={desktopCardStyle}
|
||||
role="dialog"
|
||||
aria-modal
|
||||
tabIndex={-1}
|
||||
>
|
||||
{cardInner}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
<Modal visible transparent animationType="fade" onRequestClose={isWeb ? undefined : handleBack}>
|
||||
<View style={styles.overlay} testID="add-project-flow">
|
||||
<Pressable style={styles.backdrop} onPress={onClose} testID="add-project-flow-backdrop" />
|
||||
<View
|
||||
ref={setWebOverlayScope}
|
||||
style={styles.panel}
|
||||
testID={`add-project-flow-page-${page.kind}`}
|
||||
accessibilityLabel={`Add project: ${page.kind}`}
|
||||
@@ -913,6 +933,8 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
disabled={!serverId}
|
||||
hitSlop={8}
|
||||
style={iconButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={`selector-header-settings-${provider}`}
|
||||
>
|
||||
<HeaderSettingsIcon disabled={!serverId} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const IndependentScrollGestureContext = createContext<ReturnType<typeof Gesture.Native> | 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<SheetHeader>(() => {
|
||||
if (view.kind === "all") {
|
||||
@@ -254,19 +280,13 @@ export function useModelBrowser({
|
||||
),
|
||||
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
||||
actions: (
|
||||
<Pressable
|
||||
onPress={openProviderSettings}
|
||||
disabled={!serverId}
|
||||
hitSlop={8}
|
||||
style={iconButtonStyle}
|
||||
accessibilityRole="button"
|
||||
<ProviderSettingsAction
|
||||
serverId={serverId}
|
||||
provider={view.providerId}
|
||||
accessibilityLabel={t("modelSelector.openProviderSettings", {
|
||||
provider: view.providerLabel,
|
||||
})}
|
||||
testID={`selector-header-settings-${view.providerId}`}
|
||||
>
|
||||
<HeaderSettingsIcon disabled={!serverId} />
|
||||
</Pressable>
|
||||
/>
|
||||
),
|
||||
search: {
|
||||
onChange: handleSearchQueryChange,
|
||||
@@ -279,7 +299,6 @@ export function useModelBrowser({
|
||||
}, [
|
||||
handleBackToAll,
|
||||
handleSearchQueryChange,
|
||||
openProviderSettings,
|
||||
searchResetKey,
|
||||
serverId,
|
||||
singleProviderView,
|
||||
|
||||
@@ -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 (
|
||||
<ProviderDiagnosticSheet
|
||||
key={`${serverId}:${provider}`}
|
||||
provider={provider}
|
||||
serverId={serverId}
|
||||
visible={visible}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
<OverlayLayerProvider layer={overlayParentLayer}>
|
||||
<ProviderDiagnosticSheet
|
||||
key={`${serverId}:${provider}`}
|
||||
provider={provider}
|
||||
serverId={serverId}
|
||||
visible={visible}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</OverlayLayerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof useFloating>["refs"];
|
||||
shouldUseDesktopFade: boolean;
|
||||
desktopFrameStyle: StyleProp<ViewStyle>;
|
||||
@@ -1166,14 +1161,38 @@ function DesktopComboboxOptionsBody(props: {
|
||||
}
|
||||
|
||||
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="none"
|
||||
visible={props.isOpen}
|
||||
onRequestClose={props.handleClose}
|
||||
>
|
||||
<View ref={props.refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
|
||||
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 = (
|
||||
<OverlayLayerProvider layer={props.overlayLayer}>
|
||||
<View
|
||||
ref={props.refs.setOffsetParent}
|
||||
collapsable={false}
|
||||
style={[
|
||||
styles.desktopOverlay,
|
||||
isWeb ? styles.desktopOverlayWeb : null,
|
||||
isWeb ? { zIndex: props.overlayLayer } : null,
|
||||
]}
|
||||
>
|
||||
<Pressable style={styles.desktopBackdrop} onPress={props.handleClose} />
|
||||
<FloatingSurface
|
||||
testID="combobox-desktop-container"
|
||||
@@ -1181,8 +1200,11 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
||||
exiting={props.shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
|
||||
style={styles.desktopContainer}
|
||||
frameStyle={props.desktopFrameStyle}
|
||||
ref={props.refs.setFloating}
|
||||
ref={setFloatingRef}
|
||||
collapsable={false}
|
||||
role="dialog"
|
||||
aria-modal
|
||||
tabIndex={-1}
|
||||
onLayout={props.handleDesktopContentLayout}
|
||||
>
|
||||
{props.hasChildren ? (
|
||||
@@ -1215,6 +1237,21 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
||||
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
</OverlayLayerProvider>
|
||||
);
|
||||
|
||||
if (isWeb && typeof document !== "undefined") {
|
||||
return createPortal(overlay, getOverlayRoot());
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="none"
|
||||
visible={props.isOpen}
|
||||
onRequestClose={props.handleClose}
|
||||
>
|
||||
{overlay}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<DesktopComboboxBody
|
||||
overlayLayer={floatingLayer}
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
handleDesktopKey={handleDesktopKey}
|
||||
refs={refs}
|
||||
shouldUseDesktopFade={shouldUseDesktopFade}
|
||||
desktopFrameStyle={desktopFrameStyle}
|
||||
@@ -1707,6 +1746,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
desktopOverlay: {
|
||||
flex: 1,
|
||||
},
|
||||
desktopOverlayWeb: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
pointerEvents: "auto" as const,
|
||||
},
|
||||
desktopBackdrop: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
|
||||
@@ -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 (
|
||||
<FloatingSurface
|
||||
ref={scopeRef}
|
||||
collapsable={false}
|
||||
tabIndex={-1}
|
||||
nativeID={surfaceNativeID}
|
||||
testID={testID}
|
||||
style={surfaceStyle}
|
||||
@@ -449,6 +458,7 @@ export function DropdownMenuContent({
|
||||
testID?: string;
|
||||
}>): 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 = (
|
||||
<View style={[styles.overlay, isWeb ? styles.overlayWeb : null]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("menu.backdrop")}
|
||||
style={styles.backdrop}
|
||||
onPress={handleClose}
|
||||
testID={testID ? `${testID}-backdrop` : undefined}
|
||||
/>
|
||||
{!closing
|
||||
? renderDropdownSurface({
|
||||
frameStyle,
|
||||
testID,
|
||||
surfaceStyle,
|
||||
scrollable,
|
||||
scrollViewportStyle,
|
||||
content,
|
||||
surfaceNativeID,
|
||||
onExited: () => setModalVisible(false),
|
||||
})
|
||||
: null}
|
||||
</View>
|
||||
<OverlayLayerProvider layer={floatingLayer}>
|
||||
<View
|
||||
style={[
|
||||
styles.overlay,
|
||||
isWeb ? styles.overlayWeb : null,
|
||||
isWeb ? { zIndex: floatingLayer } : null,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("menu.backdrop")}
|
||||
style={styles.backdrop}
|
||||
onPress={handleClose}
|
||||
testID={testID ? `${testID}-backdrop` : undefined}
|
||||
/>
|
||||
{!closing
|
||||
? renderDropdownSurface({
|
||||
frameStyle,
|
||||
testID,
|
||||
surfaceStyle,
|
||||
scrollable,
|
||||
scrollViewportStyle,
|
||||
content,
|
||||
surfaceNativeID,
|
||||
scopeRef: setWebOverlayScope,
|
||||
onExited: () => setModalVisible(false),
|
||||
})
|
||||
: null}
|
||||
</View>
|
||||
</OverlayLayerProvider>
|
||||
);
|
||||
|
||||
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: {
|
||||
|
||||
@@ -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<TextInput>(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 = (
|
||||
<Modal visible transparent animationType="fade" onRequestClose={close} testID="host-chooser">
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={close} />
|
||||
<View style={styles.panel}>
|
||||
<View ref={setWebOverlayScope} style={styles.panel}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{request.title}</Text>
|
||||
<TextInput
|
||||
@@ -268,6 +276,8 @@ export function HostChooserModal() {
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
@@ -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<WebOverlayEntry | undefined>((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<HTMLElement>(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<HTMLElement | null>(null);
|
||||
const layerRef = useRef(layer);
|
||||
const keyHandlerRef = useRef(onKeyDown);
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(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;
|
||||
}
|
||||
|
||||
28
packages/app/src/stores/provider-settings-store.test.ts
Normal file
28
packages/app/src/stores/provider-settings-store.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<ProviderSettingsStoreState>()((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 });
|
||||
|
||||
Reference in New Issue
Block a user