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,
+ },
+}));