diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index ab898f32e..141572169 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -10,7 +10,6 @@ import { import { SafeAreaProvider } from "react-native-safe-area-context"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler"; -import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { PortalProvider } from "@gorhom/portal"; import { VoiceProvider } from "@/contexts/voice-context"; import { useAppSettings } from "@/hooks/use-settings"; @@ -898,23 +897,21 @@ export default function RootLayout() { - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index d5e015d71..fd6e131bc 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; +import { forwardRef, useCallback, useEffect, useMemo } from "react"; import type { ReactNode } from "react"; import { createPortal } from "react-dom"; import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native"; @@ -7,7 +7,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root"; import { - BottomSheetModal, BottomSheetBackdrop, BottomSheetScrollView, BottomSheetTextInput, @@ -16,6 +15,10 @@ import { import { X } from "lucide-react-native"; import { FileDropZone } from "@/components/file-drop-zone"; import type { ImageAttachment } from "@/components/message-input"; +import { + IsolatedBottomSheetModal, + useIsolatedBottomSheetVisibility, +} from "@/components/ui/isolated-bottom-sheet-modal"; import { isWeb } from "@/constants/platform"; type EscHandler = () => void; @@ -163,7 +166,6 @@ export interface AdaptiveModalSheetProps { children: ReactNode; headerActions?: ReactNode; snapPoints?: string[]; - stackBehavior?: "push" | "switch" | "replace"; testID?: string; /** Override the max width of the desktop card. */ desktopMaxWidth?: number; @@ -180,7 +182,6 @@ export function AdaptiveModalSheet({ children, headerActions, snapPoints, - stackBehavior, testID, desktopMaxWidth, onFilesDropped, @@ -188,33 +189,13 @@ export function AdaptiveModalSheet({ }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); const isMobile = useIsCompactFormFactor(); - const sheetRef = useRef(null); - const dismissingForVisibilityRef = useRef(false); + const titleColor = theme.colors.foreground; const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); - - useEffect(() => { - if (!isMobile) return; - if (visible) { - dismissingForVisibilityRef.current = false; - sheetRef.current?.present(); - } else { - dismissingForVisibilityRef.current = true; - sheetRef.current?.dismiss(); - } - }, [visible, isMobile]); - - const handleSheetChange = useCallback( - (index: number) => { - if (index === -1) { - if (dismissingForVisibilityRef.current) { - dismissingForVisibilityRef.current = false; - return; - } - onClose(); - } - }, - [onClose], - ); + const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({ + visible, + isEnabled: isMobile, + onClose, + }); const renderBackdrop = useCallback( (props: React.ComponentProps) => ( @@ -230,7 +211,7 @@ export function AdaptiveModalSheet({ if (isMobile) { return ( - - + {title} {subtitle} @@ -268,7 +248,7 @@ export function AdaptiveModalSheet({ ) : ( {children} )} - + ); } @@ -276,7 +256,7 @@ export function AdaptiveModalSheet({ <> - + {title} {subtitle} diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index 231348280..049fbc01e 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -4,7 +4,6 @@ import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-nativ import type { StyleProp, ViewStyle, TextProps } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { - BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop, BottomSheetBackgroundProps, @@ -30,6 +29,10 @@ import type { import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest"; import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox"; +import { + IsolatedBottomSheetModal, + useIsolatedBottomSheetVisibility, +} from "@/components/ui/isolated-bottom-sheet-modal"; import { baseColors } from "@/styles/theme"; import { isNative } from "@/constants/platform"; @@ -256,31 +259,17 @@ export function DropdownSheet({ children, }: DropdownSheetProps): ReactElement { const { theme } = useUnistyles(); - const bottomSheetRef = useRef(null); + const titleColor = theme.colors.foreground; const snapPoints = useMemo(() => ["60%", "90%"], []); + const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({ + visible, + onClose, + }); const handleClose = useCallback(() => { - bottomSheetRef.current?.dismiss(); onClose(); }, [onClose]); - useEffect(() => { - if (visible) { - bottomSheetRef.current?.present(); - } else { - bottomSheetRef.current?.dismiss(); - } - }, [visible]); - - const handleSheetChange = useCallback( - (index: number) => { - if (index === -1) { - onClose(); - } - }, - [onClose], - ); - const renderBackdrop = useCallback( (props: React.ComponentProps) => ( @@ -289,8 +278,8 @@ export function DropdownSheet({ ); return ( - - {title} + + {title} + {children} - + ); } @@ -1229,7 +1220,6 @@ const styles = StyleSheet.create((theme) => ({ dropdownSheetTitle: { fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, textAlign: "center", }, dropdownSheetScrollContent: { diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index d4540dc0e..03ed8a694 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -630,7 +630,6 @@ function ControlledStatusBar({ title="Preferences" visible={prefsOpen} onClose={() => setPrefsOpen(false)} - stackBehavior="replace" testID="agent-preferences-sheet" > {canSelectModel ? ( diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 65e208dca..a8cca514a 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -657,7 +657,6 @@ export function CombinedModelSelector({ onSelect={() => {}} open={isOpen} onOpenChange={handleOpenChange} - stackBehavior="push" anchorRef={anchorRef} desktopPlacement="top-start" desktopMinWidth={360} diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index 617b82a64..372774358 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -3,13 +3,16 @@ import { View, Text, Pressable } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import Animated from "react-native-reanimated"; import { - BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop, BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; import { X } from "lucide-react-native"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; +import { + IsolatedBottomSheetModal, + type IsolatedBottomSheetModalRef, +} from "@/components/ui/isolated-bottom-sheet-modal"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { ToolCallDetailsContent } from "./tool-call-details"; @@ -60,18 +63,25 @@ interface ToolCallSheetProviderProps { export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) { const { theme } = useUnistyles(); - const bottomSheetRef = useRef(null); + const bottomSheetRef = useRef(null); + const hasPresentedRef = useRef(false); const [sheetData, setSheetData] = React.useState(null); const snapPoints = useMemo(() => ["60%", "95%"], []); const openToolCall = useCallback((data: ToolCallSheetData) => { setSheetData(data); + if (hasPresentedRef.current) { + requestAnimationFrame(() => bottomSheetRef.current?.snapToIndex(0)); + return; + } + + hasPresentedRef.current = true; bottomSheetRef.current?.present(); }, []); const closeToolCall = useCallback(() => { - bottomSheetRef.current?.dismiss(); + bottomSheetRef.current?.close(); }, []); const handleSheetChange = useCallback((index: number) => { @@ -95,11 +105,10 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) return ( {children} - {sheetData && } - + ); } diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 3bf33e278..907ae876e 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -14,7 +14,6 @@ import { import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { - BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop, BottomSheetTextInput, @@ -38,6 +37,10 @@ import { } from "./combobox-options"; import type { ComboboxOptionModel } from "./combobox-options"; import { isWeb } from "@/constants/platform"; +import { + IsolatedBottomSheetModal, + useIsolatedBottomSheetVisibility, +} from "./isolated-bottom-sheet-modal"; const IS_WEB = isWeb; @@ -66,8 +69,6 @@ export interface ComboboxProps { title?: string; open?: boolean; onOpenChange?: (open: boolean) => void; - enableDismissOnClose?: boolean; - stackBehavior?: "push" | "switch" | "replace"; desktopPlacement?: "top-start" | "bottom-start"; /** * Prevents an initial frame at 0,0 by hiding desktop content until floating @@ -271,8 +272,6 @@ export function Combobox({ title = "Select", open, onOpenChange, - enableDismissOnClose, - stackBehavior, desktopPlacement = "top-start", desktopPreventInitialFlash = true, desktopMinWidth, @@ -284,11 +283,10 @@ export function Combobox({ }: ComboboxProps): ReactElement { const { theme } = useUnistyles(); const isMobile = useIsCompactFormFactor(); + const titleColor = theme.colors.foreground; const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search"; const { height: windowHeight, width: windowWidth } = useWindowDimensions(); - const bottomSheetRef = useRef(null); - const hasPresentedBottomSheetRef = useRef(false); const snapPoints = useMemo(() => ["60%", "90%"], []); const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>( null, @@ -474,32 +472,11 @@ export function Combobox({ } : floatingStyles; - useEffect(() => { - if (!isMobile) return; - if (isOpen) { - if (enableDismissOnClose === false && hasPresentedBottomSheetRef.current) { - bottomSheetRef.current?.snapToIndex(0); - } else { - hasPresentedBottomSheetRef.current = true; - bottomSheetRef.current?.present(); - } - } else { - if (enableDismissOnClose === false) { - bottomSheetRef.current?.close(); - } else { - bottomSheetRef.current?.dismiss(); - } - } - }, [enableDismissOnClose, isOpen, isMobile]); - - const handleSheetChange = useCallback( - (index: number) => { - if (index === -1) { - handleClose(); - } - }, - [handleClose], - ); + const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({ + visible: isOpen, + isEnabled: isMobile, + onClose: handleClose, + }); const renderBackdrop = useCallback( (props: React.ComponentProps) => ( @@ -718,7 +695,7 @@ export function Combobox({ if (isMobile) { return ( - - {title} + + {title} + {stickyHeader} {!children && searchable ? searchInput : null} @@ -745,7 +722,7 @@ export function Combobox({ > {content} - + ); } @@ -931,7 +908,6 @@ const styles = StyleSheet.create((theme) => ({ comboboxTitle: { fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.medium, - color: theme.colors.foreground, textAlign: "left", }, comboboxScrollContent: { diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index 854813214..8698a0bfe 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -30,8 +30,12 @@ import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, CheckCircle } from "lucide-react-native"; -import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; +import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + IsolatedBottomSheetModal, + useIsolatedBottomSheetVisibility, +} from "@/components/ui/isolated-bottom-sheet-modal"; import { isWeb, isNative } from "@/constants/platform"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; @@ -354,7 +358,6 @@ export function ContextMenuContent({ const isMobile = useIsCompactFormFactor(); const useMobileSheet = isMobile && mobileMode === "sheet"; const { open, setOpen, triggerRef, anchorRect } = context; - const bottomSheetRef = useRef(null); const sheetSnapPoints = useMemo(() => ["30%", "55%"], []); const [triggerRect, setTriggerRect] = useState(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); @@ -364,23 +367,11 @@ export function ContextMenuContent({ setOpen(false); }, [setOpen]); - useEffect(() => { - if (!useMobileSheet) return; - if (open) { - bottomSheetRef.current?.present(); - return; - } - bottomSheetRef.current?.dismiss(); - }, [open, useMobileSheet]); - - const handleSheetChange = useCallback( - (index: number) => { - if (index === -1) { - handleClose(); - } - }, - [handleClose], - ); + const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({ + visible: open, + isEnabled: useMobileSheet, + onClose: handleClose, + }); const renderSheetBackdrop = useCallback( (props: ComponentProps) => ( @@ -468,7 +459,7 @@ export function ContextMenuContent({ if (useMobileSheet) { return ( - {children} - + ); } diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx new file mode 100644 index 000000000..bcd43f279 --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment jsdom +import { cleanup, render } from "@testing-library/react"; +import React from "react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + IsolatedBottomSheetModal, + useIsolatedBottomSheetVisibility, +} from "./isolated-bottom-sheet-modal"; + +const { modalMethods, modalProps } = vi.hoisted(() => ({ + modalMethods: { + present: vi.fn(), + close: vi.fn(), + snapToIndex: vi.fn(), + dismiss: vi.fn(), + }, + modalProps: vi.fn(), +})); + +vi.mock("@gorhom/bottom-sheet", async () => { + const React = await import("react"); + const MockBottomSheetModal = React.forwardRef( + (props: Record, ref: React.ForwardedRef) => { + modalProps(props); + React.useImperativeHandle(ref, () => modalMethods); + return React.createElement( + "div", + { "data-testid": "bottom-sheet" }, + props.children as ReactNode, + ); + }, + ); + + return { + BottomSheetModal: MockBottomSheetModal, + BottomSheetModalProvider: ({ children }: { children: ReactNode }) => + React.createElement("div", { "data-testid": "bottom-sheet-provider" }, children), + }; +}); + +vi.mock("@gorhom/portal", async () => { + const React = await import("react"); + return { + Portal: ({ children, hostName }: { children: ReactNode; hostName?: string }) => + React.createElement("div", { "data-host": hostName, "data-testid": "app-portal" }, children), + }; +}); + +function Harness({ visible, onClose }: { visible: boolean; onClose: () => void }) { + const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({ + visible, + onClose, + }); + + return ( + +
Sheet content
+
+ ); +} + +describe("IsolatedBottomSheetModal", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("forces sheet isolation and keeps modal content mounted while hidden", () => { + const onClose = vi.fn(); + const { getByTestId, rerender } = render(); + + expect(getByTestId("app-portal").getAttribute("data-host")).toBe("root"); + expect(modalProps).toHaveBeenLastCalledWith( + expect.objectContaining({ + enableDismissOnClose: false, + stackBehavior: "replace", + }), + ); + expect(modalMethods.present).not.toHaveBeenCalled(); + + rerender(); + expect(modalMethods.present).toHaveBeenCalledTimes(1); + + rerender(); + expect(modalMethods.close).toHaveBeenCalledTimes(1); + expect(modalMethods.dismiss).not.toHaveBeenCalled(); + + rerender(); + expect(modalMethods.present).toHaveBeenCalledTimes(1); + expect(modalMethods.snapToIndex).toHaveBeenCalledWith(0); + }); + + it("only reports a user close when the sheet was visible", () => { + const onClose = vi.fn(); + const { rerender } = render(); + + const latestProps = modalProps.mock.lastCall?.[0] as { onChange: (index: number) => void }; + latestProps.onChange(-1); + expect(onClose).toHaveBeenCalledTimes(1); + + rerender(); + const closedProps = modalProps.mock.lastCall?.[0] as { onChange: (index: number) => void }; + closedProps.onChange(-1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("allows nested sheets inside a parent sheet without creating a sibling provider", () => { + const { getAllByTestId } = render( + + {}}> +
Nested model picker
+
+
, + ); + + expect(getAllByTestId("bottom-sheet-provider")).toHaveLength(1); + expect(modalProps).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + stackBehavior: "replace", + }), + ); + expect(modalProps).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + stackBehavior: "push", + }), + ); + }); +}); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal.tsx new file mode 100644 index 000000000..5ff1757a5 --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal.tsx @@ -0,0 +1,106 @@ +import { + BottomSheetModal as GorhomBottomSheetModal, + BottomSheetModalProvider, + type BottomSheetModalProps, +} from "@gorhom/bottom-sheet"; +import { Portal } from "@gorhom/portal"; +import React, { createContext, useContext } from "react"; +import { forwardRef, useCallback, useEffect, useRef } from "react"; +import type { ElementRef } from "react"; + +type GorhomBottomSheetModalMethods = ElementRef; + +type IsolatedBottomSheetModalProps = Omit< + BottomSheetModalProps, + "enableDismissOnClose" | "stackBehavior" +>; + +export type IsolatedBottomSheetModalRef = GorhomBottomSheetModalMethods; + +const IsolatedBottomSheetScopeContext = createContext(false); + +export const IsolatedBottomSheetModal = forwardRef< + IsolatedBottomSheetModalRef, + IsolatedBottomSheetModalProps +>(function IsolatedBottomSheetModal(props, ref) { + const isNestedSheet = useContext(IsolatedBottomSheetScopeContext); + const { children, ...bottomSheetProps } = props; + const scopedChildren = + typeof children === "function" ? ( + (input: { data?: unknown }) => ( + + {children(input) as React.ReactNode} + + ) + ) : ( + + {children} + + ); + const modal = ( + + {scopedChildren} + + ); + + if (isNestedSheet) { + return modal; + } + + return ( + + {modal} + + ); +}); + +export function useIsolatedBottomSheetVisibility({ + visible, + isEnabled, + onClose, +}: { + visible: boolean; + isEnabled?: boolean; + onClose: () => void; +}) { + const sheetRef = useRef(null); + const hasPresentedRef = useRef(false); + + useEffect(() => { + if (isEnabled === false) return; + + if (visible) { + if (hasPresentedRef.current) { + sheetRef.current?.snapToIndex(0); + return; + } + + hasPresentedRef.current = true; + sheetRef.current?.present(); + return; + } + + if (hasPresentedRef.current) { + sheetRef.current?.close(); + } + }, [isEnabled, visible]); + + const handleSheetChange = useCallback( + (index: number) => { + if (index === -1 && visible) { + onClose(); + } + }, + [onClose, visible], + ); + + return { + sheetRef, + handleSheetChange, + }; +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index a5d62daa5..3a92ed8d5 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -471,7 +471,6 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ searchPlaceholder="Search tabs" open={isOpen} onOpenChange={setIsOpen} - enableDismissOnClose={false} anchorRef={anchorRef} renderOption={({ option, selected, active, onPress }) => { const tab = tabByKey.get(option.id);