diff --git a/package-lock.json b/package-lock.json index 88f9cc7b5..d8bd966db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4643,6 +4643,34 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-native": { + "version": "0.10.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-native/-/react-native-0.10.7.tgz", + "integrity": "sha512-deSecLPrdfl8RL1yyNJlbgqDDZFPuhBtJhY2aTnOZOoJWaal2vVOad9EBVIa0QV/YordgTyFPgDI8oLfyLZuZA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-native": ">=0.64.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@gorhom/bottom-sheet": { "version": "5.2.6", "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.6.tgz", @@ -7176,6 +7204,12 @@ "node": ">= 20.19.4" } }, + "node_modules/@react-native/normalize-color": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-native/normalize-color/-/normalize-color-2.1.0.tgz", + "integrity": "sha512-Z1jQI2NpdFJCVgpY+8Dq/Bt3d+YUi1928Q+/CZm/oh66fzM0RUl54vvuXlPJKybH4pdCZey1eDTPaLHkMPNgWA==", + "license": "MIT" + }, "node_modules/@react-native/normalize-colors": { "version": "0.82.1", "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.82.1.tgz", @@ -11937,6 +11971,17 @@ "node": ">= 0.8" } }, + "node_modules/deprecated-react-native-prop-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/deprecated-react-native-prop-types/-/deprecated-react-native-prop-types-2.3.0.tgz", + "integrity": "sha512-pWD0voFtNYxrVqvBMYf5gq3NA2GCpfodS1yNynTPc93AYA/KEMGeWDqqeUB6R2Z9ZofVhks2aeJXiuQqKNpesA==", + "license": "MIT", + "dependencies": { + "@react-native/normalize-color": "*", + "invariant": "*", + "prop-types": "*" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -20197,6 +20242,16 @@ } } }, + "node_modules/react-native-popover-view": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/react-native-popover-view/-/react-native-popover-view-6.1.0.tgz", + "integrity": "sha512-j1CB+yPwTKlBvIJBNb1AwiHyF/r+W5+AJIbHk79GRa+0z6PVtW4C7NWJWPqUVkCMOcJtewl6Pr6f2dc/87cVyQ==", + "license": "MIT", + "dependencies": { + "deprecated-react-native-prop-types": "^2.3.0", + "prop-types": "^15.8.1" + } + }, "node_modules/react-native-reanimated": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.3.tgz", @@ -25052,7 +25107,9 @@ "dependencies": { "@boudra/expo-two-way-audio": "^0.1.3", "@expo/vector-icons": "^15.0.2", + "@floating-ui/react-native": "^0.10.7", "@gorhom/bottom-sheet": "^5.2.6", + "@gorhom/portal": "^1.0.14", "@lezer/common": "^1.5.0", "@lezer/css": "^1.3.0", "@lezer/highlight": "^1.2.3", @@ -25105,6 +25162,7 @@ "react-native-markdown-display": "^7.0.2", "react-native-nitro-modules": "^0.30.0", "react-native-permissions": "^5.4.2", + "react-native-popover-view": "^6.1.0", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", diff --git a/packages/app/package.json b/packages/app/package.json index 944a25cf6..45fb746e2 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -21,7 +21,9 @@ "dependencies": { "@boudra/expo-two-way-audio": "^0.1.3", "@expo/vector-icons": "^15.0.2", + "@floating-ui/react-native": "^0.10.7", "@gorhom/bottom-sheet": "^5.2.6", + "@gorhom/portal": "^1.0.14", "@lezer/common": "^1.5.0", "@lezer/css": "^1.3.0", "@lezer/highlight": "^1.2.3", @@ -74,6 +76,7 @@ "react-native-markdown-display": "^7.0.2", "react-native-nitro-modules": "^0.30.0", "react-native-permissions": "^5.4.2", + "react-native-popover-view": "^6.1.0", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 477716531..e2127035c 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -4,6 +4,7 @@ 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 { RealtimeProvider } from "@/contexts/realtime-context"; import { useAppSettings } from "@/hooks/use-settings"; import { useFaviconStatus } from "@/hooks/use-favicon-status"; @@ -306,42 +307,44 @@ function MissingDaemonView() { export default function RootLayout() { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index 4b5a5769b..b1ad5f533 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -4,9 +4,6 @@ import { Text, ActivityIndicator, Pressable, - Modal, - useWindowDimensions, - LayoutChangeEvent, ScrollView, Platform, BackHandler, @@ -58,6 +55,13 @@ import { import { extractAgentModel } from "@/utils/extract-agent-model"; import { startPerfMonitor } from "@/utils/perf-monitor"; import { shortenPath } from "@/utils/shorten-path"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; const DROPDOWN_WIDTH = 220; const EMPTY_STREAM_ITEMS: StreamItem[] = []; @@ -169,12 +173,7 @@ function AgentScreenContent({ const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const router = useRouter(); - const { width: windowWidth, height: windowHeight } = useWindowDimensions(); - const [menuVisible, setMenuVisible] = useState(false); - const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); - const [menuContentHeight, setMenuContentHeight] = useState(0); - const menuButtonRef = useRef(null); const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); const handleFilesDropped = useCallback((files: ImageAttachment[]) => { @@ -464,101 +463,22 @@ function AgentScreenContent({ } }, [resolvedAgentId, agent?.status, client]); - const recalculateMenuPosition = useCallback( - (onMeasured?: () => void) => { - requestAnimationFrame(() => { - const anchor = menuButtonRef.current; - - if (!anchor) { - if (onMeasured) { - onMeasured(); - } - return; - } - - anchor.measureInWindow((x, y, width, height) => { - const verticalOffset = 8; - const horizontalMargin = 16; - const desiredLeft = x + width - DROPDOWN_WIDTH; - const maxLeft = windowWidth - DROPDOWN_WIDTH - horizontalMargin; - const clampedLeft = Math.min( - Math.max(desiredLeft, horizontalMargin), - maxLeft - ); - - // Position menu below button using the raw coordinates from measureInWindow - const buttonBottom = y + height; - const top = buttonBottom + verticalOffset; - - // If menu would go off screen, clamp to visible area - const bottomEdge = top + menuContentHeight; - const maxBottom = windowHeight - horizontalMargin; - const clampedTop = - bottomEdge > maxBottom - ? Math.max(verticalOffset, maxBottom - menuContentHeight) - : top; - - setMenuPosition({ - top: clampedTop, - left: clampedLeft, - }); - - if (onMeasured) { - onMeasured(); - } - }); - }); - }, - [menuContentHeight, windowHeight, windowWidth] - ); - - const handleOpenMenu = useCallback(() => { - if (agent?.cwd) { - refetchRepoInfo().catch(() => {}); - } - - recalculateMenuPosition(() => { - setMenuVisible(true); - }); - }, [agent?.cwd, recalculateMenuPosition, refetchRepoInfo]); - - const handleCloseMenu = useCallback(() => { - setMenuVisible(false); - setMenuContentHeight(0); - }, []); - - useEffect(() => { - if (!menuVisible) { - return; - } - - recalculateMenuPosition(); - }, [menuVisible, recalculateMenuPosition]); - - const handleMenuLayout = useCallback((event: LayoutChangeEvent) => { - const { height } = event.nativeEvent.layout; - setMenuContentHeight((current) => (current === height ? current : height)); - }, []); - const handleViewChanges = useCallback(() => { - handleCloseMenu(); setExplorerTab("changes"); openFileExplorer(); - }, [handleCloseMenu, setExplorerTab, openFileExplorer]); + }, [setExplorerTab, openFileExplorer]); const handleBrowseFiles = useCallback(() => { - handleCloseMenu(); setExplorerTab("files"); openFileExplorer(); - }, [handleCloseMenu, setExplorerTab, openFileExplorer]); + }, [setExplorerTab, openFileExplorer]); const handleRefreshAgent = useCallback(() => { if (!resolvedAgentId || !refreshAgent) { return; } - handleCloseMenu(); refreshAgent({ agentId: resolvedAgentId }); - }, [handleCloseMenu, resolvedAgentId, refreshAgent]); + }, [resolvedAgentId, refreshAgent]); if (!agent) { return ( @@ -601,11 +521,99 @@ function AgentScreenContent({ /> )} - - + { + if (open && agent?.cwd) { + refetchRepoInfo().catch(() => {}); + } + }} + > + - - + + + + + Directory + + {shortenPath(agent.cwd)} + + + + + Model + + {modelDisplayValue} + + + + + Branch + + {branchStatus === "loading" ? ( + <> + + Fetching… + + ) : ( + + {branchDisplayValue} + + )} + + + + + + + } + onSelect={handleViewChanges} + > + View Changes + + } + onSelect={handleBrowseFiles} + > + Browse Files + + } + disabled={isInitializing} + trailing={ + isInitializing ? ( + + ) : null + } + onSelect={handleRefreshAgent} + > + {isInitializing ? "Refreshing..." : "Refresh"} + + + } /> @@ -640,114 +648,6 @@ function AgentScreenContent({ )} - {/* Dropdown Menu */} - - - - - - - Directory - - {shortenPath(agent.cwd)} - - - - - Model - - {modelDisplayValue} - - - - - Branch - - {branchStatus === "loading" ? ( - <> - - - Fetching… - - - ) : ( - - {branchDisplayValue} - - )} - - - - - - - - - View Changes - - - - Browse Files - - - - - {isInitializing ? "Refreshing..." : "Refresh"} - - {isInitializing && ( - - )} - - - - @@ -924,25 +824,10 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[3], borderRadius: theme.borderRadius.lg, }, - menuOverlay: { - flex: 1, - }, - menuBackdrop: { - ...StyleSheet.absoluteFillObject, - }, - dropdownMenu: { - backgroundColor: theme.colors.surface2, - borderRadius: theme.borderRadius.lg, - padding: theme.spacing[2], - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.25, - shadowRadius: 8, - elevation: 5, - }, menuMetaContainer: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], gap: theme.spacing[2], - marginBottom: theme.spacing[2], }, menuMetaRow: { gap: theme.spacing[1], @@ -969,28 +854,7 @@ const styles = StyleSheet.create((theme) => ({ menuMetaValueError: { color: theme.colors.destructive, }, - menuDivider: { - height: StyleSheet.hairlineWidth, - backgroundColor: theme.colors.border, - marginVertical: theme.spacing[2], - }, - menuItem: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, - }, - menuItemDisabled: { - opacity: 0.6, - }, menuItemSpinner: { marginLeft: "auto", }, - menuItemText: { - fontSize: theme.fontSize.base, - color: theme.colors.foreground, - fontWeight: theme.fontWeight.normal, - }, })); 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 72db4af2e..2ab40a508 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -7,9 +7,9 @@ import { Modal, TextInput, ActivityIndicator, - useWindowDimensions, ScrollView, Platform, + StatusBar, } from "react-native"; import { StyleSheet, UnistylesRuntime } from "react-native-unistyles"; import { @@ -21,6 +21,13 @@ import { } from "@gorhom/bottom-sheet"; import Animated from "react-native-reanimated"; import { ChevronDown, ChevronRight, Pencil, Check, X } from "lucide-react-native"; +import { + flip, + offset as floatingOffset, + shift, + size as floatingSize, + useFloating, +} from "@floating-ui/react-native"; import { theme as defaultTheme } from "@/styles/theme"; import type { AgentMode, @@ -263,43 +270,62 @@ export function AdaptiveSelect({ }: AdaptiveSelectProps): ReactElement { const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const bottomSheetRef = useRef(null); const snapPoints = useMemo(() => ["60%", "90%"], []); - const [dropdownPosition, setDropdownPosition] = useState({ - top: 0, - left: 0, - width: 0, + const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null); + const [referenceWidth, setReferenceWidth] = useState(null); + + const collisionPadding = useMemo(() => { + const basePadding = 16; + if (Platform.OS !== "android") return basePadding; + const statusBarHeight = StatusBar.currentHeight ?? 0; + return Math.max(basePadding, statusBarHeight + basePadding); + }, []); + + const middleware = useMemo( + () => [ + floatingOffset({ mainAxis: 4 }), + flip({ padding: collisionPadding }), + // Avoid `crossAxis: true` here: per Floating UI docs it can cause overlap with the reference. + shift({ padding: collisionPadding }), + floatingSize({ + padding: collisionPadding, + apply({ availableWidth, availableHeight, rects }) { + setAvailableSize((prev) => { + const next = { width: availableWidth, height: availableHeight }; + if (!prev) return next; + if (prev.width === next.width && prev.height === next.height) return prev; + return next; + }); + setReferenceWidth((prev) => { + const next = rects.reference.width; + if (prev === next) return prev; + return next; + }); + }, + }), + ], + [collisionPadding] + ); + + const { refs, floatingStyles, update } = useFloating({ + placement: "bottom-start", + middleware, + sameScrollView: false, + elements: { + reference: anchorRef.current ?? undefined, + }, }); useEffect(() => { if (!visible || isMobile) { + setAvailableSize(null); + setReferenceWidth(null); return; } - - const anchor = anchorRef.current; - if (!anchor) { - return; - } - - anchor.measureInWindow((x, y, width, height) => { - const verticalOffset = 4; - const horizontalMargin = 16; - const maxDropdownHeight = 400; - - let top = y + height + verticalOffset; - const left = Math.max(horizontalMargin, Math.min(x, windowWidth - width - horizontalMargin)); - - if (top + maxDropdownHeight > windowHeight - horizontalMargin) { - top = y - maxDropdownHeight - verticalOffset; - if (top < horizontalMargin) { - top = y + height + verticalOffset; - } - } - - setDropdownPosition({ top, left, width }); - }); - }, [visible, isMobile, anchorRef, windowWidth, windowHeight]); + const raf = requestAnimationFrame(() => update()); + return () => cancelAnimationFrame(raf); + }, [isMobile, update, visible]); useEffect(() => { if (!isMobile) return; @@ -367,19 +393,23 @@ export function AdaptiveSelect({ visible={visible} onRequestClose={onClose} > - + update()} > state.sessions[serverId]?.methods?.setAgentMode ); - const [showModeSelector, setShowModeSelector] = useState(false); - if (!agent) { return null; } @@ -39,30 +42,48 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { {/* Agent Mode Badge */} {agent.availableModes && agent.availableModes.length > 0 && ( - setShowModeSelector(true)} - style={({ pressed }) => [ - styles.modeBadge, - pressed && styles.modeBadgePressed, - ]} - > - - {agent.availableModes?.find((m) => m.id === agent.currentModeId) - ?.label || - agent.currentModeId || - "default"} - - - + + [ + styles.modeBadge, + pressed && styles.modeBadgePressed, + ]} + accessibilityRole="button" + accessibilityLabel="Select agent mode" + testID="agent-mode-selector" + > + + {agent.availableModes?.find((m) => m.id === agent.currentModeId) + ?.label || + agent.currentModeId || + "default"} + + + + + Mode + {agent.availableModes.map((mode) => { + const isActive = mode.id === agent.currentModeId; + return ( + handleModeChange(mode.id)} + > + {mode.label} + + ); + })} + + )} - - {/* Mode selector modal */} - setShowModeSelector(false)} - /> ); } diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index cfd02036b..d62f42bb4 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -6,9 +6,6 @@ import { ActivityIndicator, Pressable, FlatList, - Modal, - useWindowDimensions, - type LayoutChangeEvent, type NativeSyntheticEvent, type NativeScrollEvent, type ListRenderItem, @@ -32,6 +29,14 @@ import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-contex import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; import { Fonts } from "@/constants/theme"; import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuHint, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; const DIFF_PANE_LOG_TAG = "[GitDiffPane]"; const DIFF_FILE_LOG_TAG = "[DiffFileSection]"; @@ -353,22 +358,12 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { const { theme } = useUnistyles(); const router = useRouter(); const queryClient = useQueryClient(); - const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const client = useSessionStore( (state) => state.sessions[serverId]?.client ?? null ); const [diffMode, setDiffMode] = useState<"uncommitted" | "base">("uncommitted"); const [actionError, setActionError] = useState(null); const [actionStatus, setActionStatus] = useState(null); - const [overflowVisible, setOverflowVisible] = useState(false); - const [overflowAnchor, setOverflowAnchor] = useState({ left: 0, top: 0 }); - const [overflowHeight, setOverflowHeight] = useState(0); - const [viewMenuVisible, setViewMenuVisible] = useState(false); - const [viewMenuAnchor, setViewMenuAnchor] = useState({ left: 0, top: 0 }); - const [viewMenuHeight, setViewMenuHeight] = useState(0); - const [shipMenuVisible, setShipMenuVisible] = useState(false); - const [shipMenuAnchor, setShipMenuAnchor] = useState({ left: 0, top: 0 }); - const [shipMenuHeight, setShipMenuHeight] = useState(0); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } = useCheckoutStatusQuery({ serverId, agentId }); @@ -475,63 +470,6 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { [shipDefaultStorageKey] ); - const handleOpenOverflowMenu = useCallback((event: any) => { - const { pageX, pageY } = event?.nativeEvent ?? {}; - setOverflowAnchor({ - left: typeof pageX === "number" ? pageX : 0, - top: typeof pageY === "number" ? pageY : 0, - }); - setOverflowVisible(true); - }, []); - - const handleCloseOverflowMenu = useCallback(() => { - setOverflowVisible(false); - setOverflowHeight(0); - }, []); - - const handleOverflowLayout = useCallback((event: LayoutChangeEvent) => { - const { height } = event.nativeEvent.layout; - setOverflowHeight((current) => (current === height ? current : height)); - }, []); - - const handleOpenViewMenu = useCallback((event: any) => { - const { pageX, pageY } = event?.nativeEvent ?? {}; - setViewMenuAnchor({ - left: typeof pageX === "number" ? pageX : 0, - top: typeof pageY === "number" ? pageY : 0, - }); - setViewMenuVisible(true); - }, []); - - const handleCloseViewMenu = useCallback(() => { - setViewMenuVisible(false); - setViewMenuHeight(0); - }, []); - - const handleViewMenuLayout = useCallback((event: LayoutChangeEvent) => { - const { height } = event.nativeEvent.layout; - setViewMenuHeight((current) => (current === height ? current : height)); - }, []); - - const handleOpenShipMenu = useCallback((event: any) => { - const { pageX, pageY } = event?.nativeEvent ?? {}; - setShipMenuAnchor({ - left: typeof pageX === "number" ? pageX : 0, - top: typeof pageY === "number" ? pageY : 0, - }); - setShipMenuVisible(true); - }, []); - - const handleCloseShipMenu = useCallback(() => { - setShipMenuVisible(false); - setShipMenuHeight(0); - }, []); - - const handleShipMenuLayout = useCallback((event: LayoutChangeEvent) => { - const { height } = event.nativeEvent.layout; - setShipMenuHeight((current) => (current === height ? current : height)); - }, []); - const handleToggleExpanded = useCallback((path: string) => { setExpandedByPath((prev) => ({ ...prev, @@ -824,81 +762,6 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { return hasPullRequest ? false : prMutation.isPending; }, [hasPullRequest, mergeMutation.isPending, prMutation.isPending, resolvedShipPrimary]); - const overflowPosition = useMemo(() => { - if (!overflowVisible) { - return null; - } - const menuWidth = 220; - const horizontalPadding = theme.spacing[2]; - const verticalPadding = theme.spacing[2]; - const maxLeft = Math.max(horizontalPadding, windowWidth - menuWidth - horizontalPadding); - const maxTop = Math.max(verticalPadding, windowHeight - overflowHeight - verticalPadding); - const left = Math.min( - Math.max(overflowAnchor.left - menuWidth + horizontalPadding, horizontalPadding), - maxLeft - ); - const top = Math.min(Math.max(overflowAnchor.top + verticalPadding, verticalPadding), maxTop); - return { top, left, width: menuWidth }; - }, [ - overflowAnchor.left, - overflowAnchor.top, - overflowHeight, - overflowVisible, - theme.spacing, - windowHeight, - windowWidth, - ]); - - const viewMenuPosition = useMemo(() => { - if (!viewMenuVisible) { - return null; - } - const menuWidth = 180; - const horizontalPadding = theme.spacing[2]; - const verticalPadding = theme.spacing[2]; - const maxLeft = Math.max(horizontalPadding, windowWidth - menuWidth - horizontalPadding); - const maxTop = Math.max(verticalPadding, windowHeight - viewMenuHeight - verticalPadding); - const left = Math.min( - Math.max(viewMenuAnchor.left - menuWidth + horizontalPadding, horizontalPadding), - maxLeft - ); - const top = Math.min(Math.max(viewMenuAnchor.top + verticalPadding, verticalPadding), maxTop); - return { top, left, width: menuWidth }; - }, [ - theme.spacing, - viewMenuAnchor.left, - viewMenuAnchor.top, - viewMenuHeight, - viewMenuVisible, - windowHeight, - windowWidth, - ]); - - const shipMenuPosition = useMemo(() => { - if (!shipMenuVisible) { - return null; - } - const menuWidth = 220; - const horizontalPadding = theme.spacing[2]; - const verticalPadding = theme.spacing[2]; - const maxLeft = Math.max(horizontalPadding, windowWidth - menuWidth - horizontalPadding); - const maxTop = Math.max(verticalPadding, windowHeight - shipMenuHeight - verticalPadding); - const left = Math.min( - Math.max(shipMenuAnchor.left - menuWidth + horizontalPadding, horizontalPadding), - maxLeft - ); - const top = Math.min(Math.max(shipMenuAnchor.top + verticalPadding, verticalPadding), maxTop); - return { top, left, width: menuWidth }; - }, [ - shipMenuAnchor.left, - shipMenuAnchor.top, - shipMenuHeight, - shipMenuVisible, - theme.spacing, - windowHeight, - windowWidth, - ]); - return ( @@ -912,18 +775,35 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { )} {isGit ? ( - - - {diffMode === "uncommitted" ? "Working" : "Base"} - - - + + + + {diffMode === "uncommitted" ? "Working" : "Base"} + + + + + setDiffMode("uncommitted")} + > + Working + + setDiffMode("base")} + > + Base + + + ) : null} @@ -965,15 +845,17 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { if (resolvedShipPrimary === "merge") { if (mergeDisabled) return; mergeMutation.mutate(); - return; - } - if (hasPullRequest && prStatus?.url) { + return; + } + + if (hasPullRequest && prStatus?.url) { void Linking.openURL(prStatus.url); return; - } - if (prDisabled) return; - prMutation.mutate(); - }} + } + + if (prDisabled) return; + prMutation.mutate(); + }} disabled={shipPrimaryDisabled} accessibilityRole="button" accessibilityLabel="Ship changes" @@ -991,27 +873,79 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { )} - - - + + + + + + { + void persistShipDefault("merge"); + mergeMutation.mutate(); + }} + > + Merge into {baseRefLabel} + + {mergeDisabled && hasUncommittedChanges ? ( + Requires a clean working tree. + ) : null} + + + + { + void persistShipDefault("pr"); + if (hasPullRequest && prStatus?.url) { + void Linking.openURL(prStatus.url); + return; + } + prMutation.mutate(); + }} + > + {hasPullRequest ? "Open PR" : `Create PR into ${baseRefLabel}`} + + + ) : null} - - - + + + + + + } + onSelect={handleRefresh} + > + Refresh + + + archiveMutation.mutate()} + > + Archive worktree + + + ) : null} @@ -1040,195 +974,6 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { ) : null} {bodyContent} - - - - - {overflowPosition ? ( - - { - handleCloseOverflowMenu(); - handleRefresh(); - }} - accessibilityRole="button" - > - - Refresh - - - - - { - if (archiveDisabled) return; - handleCloseOverflowMenu(); - archiveMutation.mutate(); - }} - disabled={archiveDisabled} - accessibilityRole="button" - > - - Archive worktree - - - - ) : null} - - - - - - - {viewMenuPosition ? ( - - { - handleCloseViewMenu(); - setDiffMode("uncommitted"); - }} - accessibilityRole="button" - > - Working - - { - handleCloseViewMenu(); - setDiffMode("base"); - }} - accessibilityRole="button" - > - Base - - - ) : null} - - - - - - - {shipMenuPosition ? ( - - { - if (mergeDisabled) return; - handleCloseShipMenu(); - void persistShipDefault("merge"); - mergeMutation.mutate(); - }} - disabled={mergeDisabled} - accessibilityRole="button" - > - Merge into {baseRefLabel} - - {mergeDisabled && hasUncommittedChanges ? ( - Requires a clean working tree. - ) : null} - - - - { - if (prDisabled) return; - handleCloseShipMenu(); - void persistShipDefault("pr"); - if (hasPullRequest && prStatus?.url) { - void Linking.openURL(prStatus.url); - return; - } - prMutation.mutate(); - }} - disabled={prDisabled} - accessibilityRole="button" - > - - {hasPullRequest ? "Open PR" : `Create PR into ${baseRefLabel}`} - - - - ) : null} - - ); } diff --git a/packages/app/src/components/ui/dropdown-menu-floating.ts b/packages/app/src/components/ui/dropdown-menu-floating.ts new file mode 100644 index 000000000..0e3676804 --- /dev/null +++ b/packages/app/src/components/ui/dropdown-menu-floating.ts @@ -0,0 +1,259 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Dimensions, Platform, StatusBar, type View } from "react-native"; + +export type Placement = "top" | "bottom" | "left" | "right"; +export type Alignment = "start" | "center" | "end"; + +interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +interface FloatingStyles { + position: "absolute"; + top: number; + left: number; +} + +interface GeometryResult { + popoverOrigin: { x: number; y: number }; + placement: Placement; + availableSize: { width: number; height: number }; +} + +function measureElement(element: View): Promise { + return new Promise((resolve) => { + element.measureInWindow((x, y, width, height) => { + resolve({ x, y, width, height }); + }); + }); +} + +function computeGeometry({ + fromRect, + contentSize, + displayArea, + placement, + alignment, + offset, + padding, +}: { + fromRect: Rect; + contentSize: { width: number; height: number }; + displayArea: Rect; + placement: Placement; + alignment: Alignment; + offset: number; + padding: number; +}): GeometryResult { + const { width: contentWidth, height: contentHeight } = contentSize; + + // Calculate available space in each direction + const spaceTop = fromRect.y - displayArea.y - padding; + const spaceBottom = displayArea.y + displayArea.height - (fromRect.y + fromRect.height) - padding; + const spaceLeft = fromRect.x - displayArea.x - padding; + const spaceRight = displayArea.x + displayArea.width - (fromRect.x + fromRect.width) - padding; + + // Determine actual placement (may flip if not enough space) + let actualPlacement = placement; + if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) { + actualPlacement = "top"; + } else if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) { + actualPlacement = "bottom"; + } else if (placement === "left" && spaceLeft < contentWidth && spaceRight > spaceLeft) { + actualPlacement = "right"; + } else if (placement === "right" && spaceRight < contentWidth && spaceLeft > spaceRight) { + actualPlacement = "left"; + } + + let x: number; + let y: number; + + // Position based on placement + if (actualPlacement === "bottom") { + y = fromRect.y + fromRect.height + offset; + } else if (actualPlacement === "top") { + y = fromRect.y - contentHeight - offset; + } else if (actualPlacement === "left") { + x = fromRect.x - contentWidth - offset; + } else { + x = fromRect.x + fromRect.width + offset; + } + + // Alignment on cross axis + if (actualPlacement === "top" || actualPlacement === "bottom") { + if (alignment === "start") { + x = fromRect.x; + } else if (alignment === "end") { + x = fromRect.x + fromRect.width - contentWidth; + } else { + x = fromRect.x + (fromRect.width - contentWidth) / 2; + } + } else { + if (alignment === "start") { + y = fromRect.y; + } else if (alignment === "end") { + y = fromRect.y + fromRect.height - contentHeight; + } else { + y = fromRect.y + (fromRect.height - contentHeight) / 2; + } + } + + // Constrain to display area (shift) + const minX = displayArea.x + padding; + const maxX = displayArea.x + displayArea.width - contentWidth - padding; + const minY = displayArea.y + padding; + const maxY = displayArea.y + displayArea.height - contentHeight - padding; + + x = Math.max(minX, Math.min(maxX, x!)); + y = Math.max(minY, Math.min(maxY, y!)); + + // Calculate available size + const availableWidth = displayArea.width - padding * 2; + const availableHeight = + actualPlacement === "bottom" + ? displayArea.y + displayArea.height - (fromRect.y + fromRect.height) - offset - padding + : actualPlacement === "top" + ? fromRect.y - displayArea.y - offset - padding + : displayArea.height - padding * 2; + + return { + popoverOrigin: { x, y }, + placement: actualPlacement, + availableSize: { + width: Math.max(0, availableWidth), + height: Math.max(0, availableHeight), + }, + }; +} + +export interface UseDropdownFloatingOptions { + open: boolean; + placement?: Placement; + alignment?: Alignment; + offset?: number; + padding?: number; + referenceEl: View | null; +} + +export interface UseDropdownFloatingReturn { + floatingRef: (el: View | null) => void; + floatingStyles: FloatingStyles; + update: () => void; + onLayout: () => void; + availableSize: { width: number; height: number } | null; + actualPlacement: Placement; +} + +export function useDropdownFloating({ + open, + placement = "bottom", + alignment = "start", + offset = 8, + padding = 8, + referenceEl, +}: UseDropdownFloatingOptions): UseDropdownFloatingReturn { + const floatingElRef = useRef(null); + const [geometry, setGeometry] = useState(null); + + const displayArea = useMemo(() => { + const { width, height } = Dimensions.get("window"); + const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; + return { + x: 0, + y: statusBarHeight, + width, + height: height - statusBarHeight, + }; + }, []); + + const update = useCallback(async () => { + console.log("[useDropdownFloating] update called", { + hasReferenceEl: !!referenceEl, + hasFloatingEl: !!floatingElRef.current, + }); + + if (!referenceEl || !floatingElRef.current) { + return; + } + + try { + const [fromRect, contentRect] = await Promise.all([ + measureElement(referenceEl), + measureElement(floatingElRef.current), + ]); + + console.log("[useDropdownFloating] measured", { + fromRect, + contentRect, + displayArea, + }); + + const result = computeGeometry({ + fromRect, + contentSize: { width: contentRect.width, height: contentRect.height }, + displayArea, + placement, + alignment, + offset, + padding, + }); + + console.log("[useDropdownFloating] geometry result", result); + + setGeometry(result); + } catch (e) { + console.warn("[useDropdownFloating] measure failed:", e); + } + }, [referenceEl, displayArea, placement, alignment, offset, padding]); + + const floatingRef = useCallback((el: View | null) => { + console.log("[useDropdownFloating] floatingRef called", { hasEl: !!el }); + floatingElRef.current = el; + }, []); + + // Track when floating element is ready + const [floatingReady, setFloatingReady] = useState(false); + + const handleFloatingLayout = useCallback(() => { + console.log("[useDropdownFloating] handleFloatingLayout called"); + setFloatingReady(true); + }, []); + + useEffect(() => { + console.log("[useDropdownFloating] useEffect", { + open, + floatingReady, + hasReferenceEl: !!referenceEl, + hasFloatingEl: !!floatingElRef.current, + }); + + if (!open) { + setGeometry(null); + setFloatingReady(false); + return; + } + + if (floatingReady && referenceEl && floatingElRef.current) { + console.log("[useDropdownFloating] calling update from useEffect"); + update(); + } + }, [open, floatingReady, referenceEl, update]); + + const floatingStyles: FloatingStyles = { + position: "absolute", + top: geometry?.popoverOrigin.y ?? 0, + left: geometry?.popoverOrigin.x ?? 0, + }; + + return { + floatingRef, + floatingStyles, + update, + onLayout: handleFloatingLayout, + availableSize: geometry?.availableSize ?? null, + actualPlacement: geometry?.placement ?? placement, + }; +} diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..34941aa41 --- /dev/null +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,557 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type PropsWithChildren, + type ReactElement, +} from "react"; +import { + Modal, + Pressable, + ScrollView, + Text, + View, + Dimensions, + Platform, + StatusBar, + type PressableProps, + type ViewStyle, +} from "react-native"; +import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Check } from "lucide-react-native"; + +type Placement = "top" | "bottom" | "left" | "right"; +type Alignment = "start" | "center" | "end"; + +interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +type DropdownMenuContextValue = { + open: boolean; + setOpen: (open: boolean) => void; + triggerRef: React.RefObject; +}; + +const DropdownMenuContext = createContext(null); + +function useDropdownMenuContext(componentName: string): DropdownMenuContextValue { + const ctx = useContext(DropdownMenuContext); + if (!ctx) { + throw new Error(`${componentName} must be used within `); + } + return ctx; +} + +function useControllableOpenState({ + open, + defaultOpen, + onOpenChange, +}: { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}): [boolean, (next: boolean) => void] { + const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen)); + const isControlled = typeof open === "boolean"; + const value = isControlled ? Boolean(open) : internalOpen; + const setValue = useCallback( + (next: boolean) => { + if (!isControlled) setInternalOpen(next); + onOpenChange?.(next); + }, + [isControlled, onOpenChange] + ); + return [value, setValue]; +} + +function measureElement(element: View): Promise { + return new Promise((resolve) => { + element.measureInWindow((x, y, width, height) => { + resolve({ x, y, width, height }); + }); + }); +} + +function computePosition({ + triggerRect, + contentSize, + displayArea, + placement, + alignment, + offset, +}: { + triggerRect: Rect; + contentSize: { width: number; height: number }; + displayArea: Rect; + placement: Placement; + alignment: Alignment; + offset: number; +}): { x: number; y: number; actualPlacement: Placement } { + const { width: contentWidth, height: contentHeight } = contentSize; + + // Calculate available space + const spaceTop = triggerRect.y - displayArea.y; + const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height); + + // Flip if needed + let actualPlacement = placement; + if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) { + actualPlacement = "top"; + } else if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) { + actualPlacement = "bottom"; + } + + let x: number; + let y: number; + + // Position based on placement + if (actualPlacement === "bottom") { + y = triggerRect.y + triggerRect.height + offset; + } else if (actualPlacement === "top") { + y = triggerRect.y - contentHeight - offset; + } else if (actualPlacement === "left") { + x = triggerRect.x - contentWidth - offset; + y = triggerRect.y; + } else { + x = triggerRect.x + triggerRect.width + offset; + y = triggerRect.y; + } + + // Alignment + if (actualPlacement === "top" || actualPlacement === "bottom") { + if (alignment === "start") { + x = triggerRect.x; + } else if (alignment === "end") { + x = triggerRect.x + triggerRect.width - contentWidth; + } else { + x = triggerRect.x + (triggerRect.width - contentWidth) / 2; + } + } + + // Constrain to screen + const padding = 8; + x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x!)); + y = Math.max(displayArea.y + padding, Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!)); + + return { x, y, actualPlacement }; +} + +export function DropdownMenu({ + open, + defaultOpen, + onOpenChange, + children, +}: PropsWithChildren<{ + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}>): ReactElement { + const triggerRef = useRef(null); + const [isOpen, setIsOpen] = useControllableOpenState({ + open, + defaultOpen, + onOpenChange, + }); + + const value = useMemo( + () => ({ + open: isOpen, + setOpen: setIsOpen, + triggerRef, + }), + [isOpen, setIsOpen] + ); + + return {children}; +} + +export function DropdownMenuTrigger({ + children, + disabled, + ...props +}: PropsWithChildren): ReactElement { + const { setOpen, open, triggerRef } = useDropdownMenuContext("DropdownMenuTrigger"); + + const handlePress = useCallback(() => { + if (disabled) return; + setOpen(!open); + }, [disabled, open, setOpen]); + + return ( + + {children} + + ); +} + +export function DropdownMenuContent({ + children, + side = "bottom", + align = "start", + offset = 4, + width, + minWidth = 180, + maxWidth, + fullWidth = false, + horizontalPadding = 16, + testID, +}: PropsWithChildren<{ + side?: Placement; + align?: Alignment; + offset?: number; + width?: number; + minWidth?: number; + maxWidth?: number; + fullWidth?: boolean; + horizontalPadding?: number; + testID?: string; +}>): ReactElement | null { + const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent"); + const [triggerRect, setTriggerRect] = useState(null); + const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + + const handleClose = useCallback(() => { + setOpen(false); + }, [setOpen]); + + // Measure trigger when opening + useEffect(() => { + if (open && triggerRef.current) { + measureElement(triggerRef.current).then((rect) => { + // On Android with statusBarTranslucent, measureInWindow returns coordinates + // from screen top, but Modal content area also starts from screen top. + // However, the measured y doesn't account for status bar offset correctly, + // so we need to add it (same as react-native-popover-view does) + const statusBarAdjustment = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; + const adjustedRect = { + ...rect, + y: rect.y + statusBarAdjustment, + }; + setTriggerRect(adjustedRect); + }); + } else { + setTriggerRect(null); + setContentSize(null); + setPosition(null); + } + }, [open, triggerRef]); + + // Calculate position when we have both measurements + useEffect(() => { + if (!triggerRect || !contentSize) return; + + const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); + // measureInWindow returns screen coordinates including status bar + // Modal also uses full screen coordinates, so displayArea should start at 0 + const displayArea = { + x: 0, + y: 0, + width: screenWidth, + height: screenHeight, + }; + + const result = computePosition({ + triggerRect, + contentSize, + displayArea, + placement: side, + alignment: align, + offset, + }); + + // For fullWidth, x is simply the horizontal padding to center on screen + const x = fullWidth ? horizontalPadding : result.x; + setPosition({ x, y: result.y }); + }, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding]); + + const handleContentLayout = useCallback((event: { nativeEvent: { layout: { width: number; height: number } } }) => { + const { width: w, height: h } = event.nativeEvent.layout; + setContentSize({ width: w, height: h }); + }, []); + + if (!open) return null; + + const { width: screenWidth } = Dimensions.get("window"); + const resolvedWidthStyle: ViewStyle = fullWidth + ? { width: screenWidth - horizontalPadding * 2 } + : { + ...(typeof width === "number" ? { width } : null), + ...(typeof minWidth === "number" ? { minWidth } : null), + ...(typeof maxWidth === "number" ? { maxWidth } : null), + }; + + return ( + + + + + + {children} + + + + + ); +} + +export function DropdownMenuLabel({ + children, + style, + testID, +}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement { + return ( + + {children} + + ); +} + +export function DropdownMenuSeparator({ style, testID }: { style?: ViewStyle; testID?: string }): ReactElement { + return ; +} + +export function DropdownMenuHint({ + children, + testID, +}: PropsWithChildren<{ testID?: string }>): ReactElement { + return ( + + {children} + + ); +} + +export function DropdownMenuItem({ + children, + description, + onSelect, + disabled, + destructive, + selected, + showSelectedCheck = false, + selectedVariant = "default", + leading, + trailing, + closeOnSelect = true, + testID, +}: PropsWithChildren<{ + description?: string; + onSelect?: () => void; + disabled?: boolean; + destructive?: boolean; + selected?: boolean; + showSelectedCheck?: boolean; + selectedVariant?: "default" | "accent"; + leading?: ReactElement | null; + trailing?: ReactElement | null; + closeOnSelect?: boolean; + testID?: string; +}>): ReactElement { + const { theme } = useUnistyles(); + const { setOpen } = useDropdownMenuContext("DropdownMenuItem"); + + return ( + { + if (disabled) return; + if (closeOnSelect) { + setOpen(false); + } + onSelect?.(); + }} + style={({ pressed }) => [ + styles.item, + selected ? (selectedVariant === "accent" ? styles.itemSelectedAccent : styles.itemSelected) : null, + destructive ? styles.itemDestructive : null, + disabled ? styles.itemDisabled : null, + pressed && !disabled ? styles.itemPressed : null, + ]} + > + {showSelectedCheck ? ( + + {selected ? : null} + + ) : null} + {leading ? {leading} : null} + + + {children} + + {description ? ( + + {description} + + ) : null} + + {trailing ? {trailing} : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + overlay: { + flex: 1, + }, + backdrop: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + content: { + backgroundColor: theme.colors.surface0, + borderWidth: 1, + borderColor: theme.colors.borderAccent, + borderRadius: theme.borderRadius.lg, + overflow: "hidden", + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 8, + }, + labelContainer: { + paddingHorizontal: theme.spacing[3], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[1], + }, + labelText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + textTransform: "uppercase", + letterSpacing: 0.6, + }, + separator: { + height: 1, + backgroundColor: theme.colors.border, + }, + hintContainer: { + paddingHorizontal: theme.spacing[3], + paddingBottom: theme.spacing[2], + }, + hintText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + item: { + flexDirection: "row", + alignItems: "center", + minHeight: 36, + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + itemPressed: { + backgroundColor: theme.colors.surface2, + }, + itemSelected: { + backgroundColor: theme.colors.surface2, + }, + itemSelectedAccent: { + backgroundColor: theme.colors.accent, + }, + itemDisabled: { + opacity: 0.5, + }, + itemDestructive: { + backgroundColor: "rgba(248, 81, 73, 0.08)", + }, + itemText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + fontWeight: theme.fontWeight.medium, + }, + itemTextDestructive: { + color: theme.colors.destructive, + }, + itemTextSelectedAccent: { + color: theme.colors.accentForeground, + }, + itemDescription: { + marginTop: 2, + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + itemDescriptionSelectedAccent: { + color: theme.colors.accentForeground, + opacity: 0.85, + }, + checkSlot: { + width: 16, + alignItems: "center", + justifyContent: "center", + }, + leadingSlot: { + width: 16, + alignItems: "center", + justifyContent: "center", + }, + trailingSlot: { + marginLeft: "auto", + alignItems: "center", + justifyContent: "center", + }, + itemContent: { + flexShrink: 1, + }, +}));