Update files

This commit is contained in:
Mohamed Boudra
2026-01-24 14:08:01 +07:00
parent b919a3cdd9
commit 657644b5e7
9 changed files with 1250 additions and 710 deletions

58
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<KeyboardProvider>
<BottomSheetModalProvider>
<QueryProvider>
<DaemonRegistryProvider>
<DaemonConnectionsProvider>
<MultiDaemonSessionHost />
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<AppWithSidebar>
<Stack
screenOptions={{
headerShown: false,
animation: "none",
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="agents" />
<Stack.Screen name="orchestrator" />
<Stack.Screen name="agent/[id]" options={{ gestureEnabled: false }} />
<Stack.Screen name="agent/[serverId]/[agentId]" options={{ gestureEnabled: false }} />
<Stack.Screen name="settings" />
<Stack.Screen name="audio-test" />
<Stack.Screen name="pair-scan" />
</Stack>
</AppWithSidebar>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</DaemonConnectionsProvider>
</DaemonRegistryProvider>
</QueryProvider>
</BottomSheetModalProvider>
</KeyboardProvider>
</SafeAreaProvider>
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<BottomSheetModalProvider>
<QueryProvider>
<DaemonRegistryProvider>
<DaemonConnectionsProvider>
<MultiDaemonSessionHost />
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<AppWithSidebar>
<Stack
screenOptions={{
headerShown: false,
animation: "none",
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="agents" />
<Stack.Screen name="orchestrator" />
<Stack.Screen name="agent/[id]" options={{ gestureEnabled: false }} />
<Stack.Screen name="agent/[serverId]/[agentId]" options={{ gestureEnabled: false }} />
<Stack.Screen name="settings" />
<Stack.Screen name="audio-test" />
<Stack.Screen name="pair-scan" />
</Stack>
</AppWithSidebar>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</DaemonConnectionsProvider>
</DaemonRegistryProvider>
</QueryProvider>
</BottomSheetModalProvider>
</KeyboardProvider>
</SafeAreaProvider>
</PortalProvider>
</GestureHandlerRootView>
);
}

View File

@@ -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<View>(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({
/>
)}
</Pressable>
<View ref={menuButtonRef} collapsable={false}>
<Pressable testID="agent-overflow-menu" onPress={handleOpenMenu} style={styles.menuButton}>
<DropdownMenu
onOpenChange={(open) => {
if (open && agent?.cwd) {
refetchRepoInfo().catch(() => {});
}
}}
>
<DropdownMenuTrigger testID="agent-overflow-menu" style={styles.menuButton}>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content">
<View style={styles.menuMetaContainer}>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Directory</Text>
<Text
style={styles.menuMetaValue}
numberOfLines={2}
ellipsizeMode="middle"
>
{shortenPath(agent.cwd)}
</Text>
</View>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Model</Text>
<Text
style={styles.menuMetaValue}
numberOfLines={1}
ellipsizeMode="middle"
>
{modelDisplayValue}
</Text>
</View>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Branch</Text>
<View style={styles.menuMetaValueRow}>
{branchStatus === "loading" ? (
<>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
/>
<Text style={styles.menuMetaPendingText}>Fetching</Text>
</>
) : (
<Text
style={[
styles.menuMetaValue,
branchStatus === "error" ? styles.menuMetaValueError : null,
]}
numberOfLines={1}
ellipsizeMode="middle"
>
{branchDisplayValue}
</Text>
)}
</View>
</View>
</View>
<DropdownMenuSeparator />
<DropdownMenuItem
leading={<GitBranch size={16} color={theme.colors.foreground} />}
onSelect={handleViewChanges}
>
View Changes
</DropdownMenuItem>
<DropdownMenuItem
leading={<Folder size={16} color={theme.colors.foreground} />}
onSelect={handleBrowseFiles}
>
Browse Files
</DropdownMenuItem>
<DropdownMenuItem
leading={<RotateCcw size={16} color={theme.colors.foreground} />}
disabled={isInitializing}
trailing={
isInitializing ? (
<ActivityIndicator
size="small"
color={theme.colors.primary}
style={styles.menuItemSpinner}
/>
) : null
}
onSelect={handleRefreshAgent}
>
{isInitializing ? "Refreshing..." : "Refresh"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
}
/>
@@ -640,114 +648,6 @@ function AgentScreenContent({
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus onAddImages={handleAddImagesCallback} />
)}
{/* Dropdown Menu */}
<Modal
visible={menuVisible}
animationType="fade"
transparent={true}
onRequestClose={handleCloseMenu}
>
<View style={styles.menuOverlay}>
<Pressable style={styles.menuBackdrop} onPress={handleCloseMenu} />
<View
style={[
styles.dropdownMenu,
{
position: "absolute",
top: menuPosition.top,
left: menuPosition.left,
width: DROPDOWN_WIDTH,
},
]}
onLayout={handleMenuLayout}
>
<View style={styles.menuMetaContainer}>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Directory</Text>
<Text
style={styles.menuMetaValue}
numberOfLines={2}
ellipsizeMode="middle"
>
{shortenPath(agent.cwd)}
</Text>
</View>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Model</Text>
<Text
style={styles.menuMetaValue}
numberOfLines={1}
ellipsizeMode="middle"
>
{modelDisplayValue}
</Text>
</View>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Branch</Text>
<View style={styles.menuMetaValueRow}>
{branchStatus === "loading" ? (
<>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
/>
<Text style={styles.menuMetaPendingText}>
Fetching
</Text>
</>
) : (
<Text
style={[
styles.menuMetaValue,
branchStatus === "error"
? styles.menuMetaValueError
: null,
]}
numberOfLines={1}
ellipsizeMode="middle"
>
{branchDisplayValue}
</Text>
)}
</View>
</View>
</View>
<View style={styles.menuDivider} />
<Pressable onPress={handleViewChanges} style={styles.menuItem}>
<GitBranch size={16} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>View Changes</Text>
</Pressable>
<Pressable onPress={handleBrowseFiles} style={styles.menuItem}>
<Folder size={16} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>Browse Files</Text>
</Pressable>
<Pressable
onPress={handleRefreshAgent}
style={[
styles.menuItem,
isInitializing ? styles.menuItemDisabled : null,
]}
disabled={isInitializing}
>
<RotateCcw size={16} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>
{isInitializing ? "Refreshing..." : "Refresh"}
</Text>
{isInitializing && (
<ActivityIndicator
size="small"
color={theme.colors.primary}
style={styles.menuItemSpinner}
/>
)}
</Pressable>
</View>
</View>
</Modal>
</View>
</FileDropZone>
@@ -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,
},
}));

View File

@@ -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<BottomSheetModal>(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<number | null>(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}
>
<View style={styles.desktopDropdownOverlay}>
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopDropdownOverlay}>
<Pressable style={styles.desktopDropdownBackdrop} onPress={onClose} />
<View
style={[
styles.desktopDropdownContainer,
{
position: "absolute",
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
minWidth: 200,
width: referenceWidth ?? undefined,
},
floatingStyles,
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
typeof availableSize?.width === "number" ? { maxWidth: availableSize.width } : null,
]}
ref={refs.setFloating}
collapsable={false}
onLayout={() => update()}
>
<ScrollView
contentContainerStyle={styles.desktopDropdownScrollContent}

View File

@@ -1,9 +1,14 @@
import { View, Text, Pressable } from "react-native";
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native";
import { useState } from "react";
import { ModeSelectorModal } from "./mode-selector-modal";
import { useSessionStore } from "@/stores/session-store";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface AgentStatusBarProps {
agentId: string;
@@ -23,8 +28,6 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
(state) => state.sessions[serverId]?.methods?.setAgentMode
);
const [showModeSelector, setShowModeSelector] = useState(false);
if (!agent) {
return null;
}
@@ -39,30 +42,48 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
<View style={styles.container}>
{/* Agent Mode Badge */}
{agent.availableModes && agent.availableModes.length > 0 && (
<Pressable
onPress={() => setShowModeSelector(true)}
style={({ pressed }) => [
styles.modeBadge,
pressed && styles.modeBadgePressed,
]}
>
<Text style={styles.modeBadgeText}>
{agent.availableModes?.find((m) => m.id === agent.currentModeId)
?.label ||
agent.currentModeId ||
"default"}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.modeBadge,
pressed && styles.modeBadgePressed,
]}
accessibilityRole="button"
accessibilityLabel="Select agent mode"
testID="agent-mode-selector"
>
<Text style={styles.modeBadgeText}>
{agent.availableModes?.find((m) => m.id === agent.currentModeId)
?.label ||
agent.currentModeId ||
"default"}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="center"
fullWidth
testID="agent-mode-menu"
>
<DropdownMenuLabel>Mode</DropdownMenuLabel>
{agent.availableModes.map((mode) => {
const isActive = mode.id === agent.currentModeId;
return (
<DropdownMenuItem
key={mode.id}
selected={isActive}
selectedVariant="accent"
description={mode.description}
onSelect={() => handleModeChange(mode.id)}
>
{mode.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Mode selector modal */}
<ModeSelectorModal
visible={showModeSelector}
agent={agent}
onModeChange={handleModeChange}
onClose={() => setShowModeSelector(false)}
/>
</View>
);
}

View File

@@ -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<string | null>(null);
const [actionStatus, setActionStatus] = useState<string | null>(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 (
<View style={styles.container}>
<View style={styles.header} testID="changes-header">
@@ -912,18 +775,35 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
)}
</View>
{isGit ? (
<Pressable
testID="changes-view-selector"
onPress={handleOpenViewMenu}
style={styles.viewSelector}
accessibilityRole="button"
accessibilityLabel="Change diff view"
>
<Text style={styles.viewSelectorText}>
{diffMode === "uncommitted" ? "Working" : "Base"}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<DropdownMenu>
<DropdownMenuTrigger
testID="changes-view-selector"
style={styles.viewSelector}
accessibilityRole="button"
accessibilityLabel="Change diff view"
>
<Text style={styles.viewSelectorText}>
{diffMode === "uncommitted" ? "Working" : "Base"}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={180} testID="changes-view-menu">
<DropdownMenuItem
testID="changes-mode-uncommitted"
selected={diffMode === "uncommitted"}
onSelect={() => setDiffMode("uncommitted")}
>
Working
</DropdownMenuItem>
<DropdownMenuItem
testID="changes-mode-base"
selected={diffMode === "base"}
onSelect={() => setDiffMode("base")}
>
Base
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</View>
@@ -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) {
</>
)}
</Pressable>
<Pressable
testID="changes-ship-caret"
onPress={handleOpenShipMenu}
style={styles.shipCaretButton}
accessibilityRole="button"
accessibilityLabel="More ship options"
>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</Pressable>
<DropdownMenu>
<DropdownMenuTrigger
testID="changes-ship-caret"
style={styles.shipCaretButton}
accessibilityRole="button"
accessibilityLabel="More ship options"
>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260} testID="changes-ship-menu">
<DropdownMenuItem
testID="changes-ship-merge"
disabled={mergeDisabled}
onSelect={() => {
void persistShipDefault("merge");
mergeMutation.mutate();
}}
>
Merge into {baseRefLabel}
</DropdownMenuItem>
{mergeDisabled && hasUncommittedChanges ? (
<DropdownMenuHint>Requires a clean working tree.</DropdownMenuHint>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
testID={hasPullRequest ? "changes-ship-open-pr" : "changes-ship-create-pr"}
disabled={prDisabled}
onSelect={() => {
void persistShipDefault("pr");
if (hasPullRequest && prStatus?.url) {
void Linking.openURL(prStatus.url);
return;
}
prMutation.mutate();
}}
>
{hasPullRequest ? "Open PR" : `Create PR into ${baseRefLabel}`}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
<Pressable
testID="changes-overflow-menu"
onPress={handleOpenOverflowMenu}
hitSlop={8}
style={styles.iconButton}
accessibilityRole="button"
accessibilityLabel="More actions"
>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</Pressable>
<DropdownMenu>
<DropdownMenuTrigger
testID="changes-overflow-menu"
hitSlop={8}
style={styles.iconButton}
accessibilityRole="button"
accessibilityLabel="More actions"
>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} testID="changes-overflow-content">
<DropdownMenuItem
testID="changes-menu-refresh"
leading={<RotateCcw size={16} color={theme.colors.foreground} />}
onSelect={handleRefresh}
>
Refresh
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
testID="changes-menu-archive"
destructive
disabled={archiveDisabled}
onSelect={() => archiveMutation.mutate()}
>
Archive worktree
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
</View>
) : null}
@@ -1040,195 +974,6 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
) : null}
<View style={styles.diffContainer}>{bodyContent}</View>
<Modal
visible={overflowVisible}
animationType="fade"
transparent={true}
onRequestClose={handleCloseOverflowMenu}
>
<View style={styles.menuOverlay}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Overflow menu backdrop"
style={styles.menuBackdrop}
onPress={handleCloseOverflowMenu}
/>
{overflowPosition ? (
<View
style={[
styles.dropdownMenu,
{
position: "absolute",
top: overflowPosition.top,
left: overflowPosition.left,
width: overflowPosition.width,
},
]}
onLayout={handleOverflowLayout}
>
<Pressable
testID="changes-menu-refresh"
style={styles.menuItem}
onPress={() => {
handleCloseOverflowMenu();
handleRefresh();
}}
accessibilityRole="button"
>
<RotateCcw size={16} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>Refresh</Text>
</Pressable>
<View style={styles.menuDivider} />
<Pressable
testID="changes-menu-archive"
style={[
styles.menuItem,
styles.menuItemDestructive,
archiveDisabled && styles.menuItemDisabled,
]}
onPress={() => {
if (archiveDisabled) return;
handleCloseOverflowMenu();
archiveMutation.mutate();
}}
disabled={archiveDisabled}
accessibilityRole="button"
>
<Text style={[styles.menuItemText, styles.menuItemTextDestructive]}>
Archive worktree
</Text>
</Pressable>
</View>
) : null}
</View>
</Modal>
<Modal
visible={viewMenuVisible}
animationType="fade"
transparent={true}
onRequestClose={handleCloseViewMenu}
>
<View style={styles.menuOverlay}>
<Pressable
accessibilityRole="button"
accessibilityLabel="View menu backdrop"
style={styles.menuBackdrop}
onPress={handleCloseViewMenu}
/>
{viewMenuPosition ? (
<View
style={[
styles.dropdownMenu,
{
position: "absolute",
top: viewMenuPosition.top,
left: viewMenuPosition.left,
width: viewMenuPosition.width,
},
]}
onLayout={handleViewMenuLayout}
>
<Pressable
testID="changes-mode-uncommitted"
style={[styles.menuItem, diffMode === "uncommitted" && styles.menuItemSelected]}
onPress={() => {
handleCloseViewMenu();
setDiffMode("uncommitted");
}}
accessibilityRole="button"
>
<Text style={styles.menuItemText}>Working</Text>
</Pressable>
<Pressable
testID="changes-mode-base"
style={[styles.menuItem, diffMode === "base" && styles.menuItemSelected]}
onPress={() => {
handleCloseViewMenu();
setDiffMode("base");
}}
accessibilityRole="button"
>
<Text style={styles.menuItemText}>Base</Text>
</Pressable>
</View>
) : null}
</View>
</Modal>
<Modal
visible={shipMenuVisible}
animationType="fade"
transparent={true}
onRequestClose={handleCloseShipMenu}
>
<View style={styles.menuOverlay}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Ship menu backdrop"
style={styles.menuBackdrop}
onPress={handleCloseShipMenu}
/>
{shipMenuPosition ? (
<View
style={[
styles.dropdownMenu,
{
position: "absolute",
top: shipMenuPosition.top,
left: shipMenuPosition.left,
width: shipMenuPosition.width,
},
]}
onLayout={handleShipMenuLayout}
>
<Pressable
testID="changes-ship-merge"
style={[styles.menuItem, mergeDisabled && styles.menuItemDisabled]}
onPress={() => {
if (mergeDisabled) return;
handleCloseShipMenu();
void persistShipDefault("merge");
mergeMutation.mutate();
}}
disabled={mergeDisabled}
accessibilityRole="button"
>
<Text style={styles.menuItemText}>Merge into {baseRefLabel}</Text>
</Pressable>
{mergeDisabled && hasUncommittedChanges ? (
<Text style={styles.menuHintText}>Requires a clean working tree.</Text>
) : null}
<View style={styles.menuDivider} />
<Pressable
testID={hasPullRequest ? "changes-ship-open-pr" : "changes-ship-create-pr"}
style={[styles.menuItem, prDisabled && styles.menuItemDisabled]}
onPress={() => {
if (prDisabled) return;
handleCloseShipMenu();
void persistShipDefault("pr");
if (hasPullRequest && prStatus?.url) {
void Linking.openURL(prStatus.url);
return;
}
prMutation.mutate();
}}
disabled={prDisabled}
accessibilityRole="button"
>
<Text style={styles.menuItemText}>
{hasPullRequest ? "Open PR" : `Create PR into ${baseRefLabel}`}
</Text>
</Pressable>
</View>
) : null}
</View>
</Modal>
</View>
);
}

View File

@@ -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<Rect> {
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<View | null>(null);
const [geometry, setGeometry] = useState<GeometryResult | null>(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,
};
}

View File

@@ -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<View | null>;
};
const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);
function useDropdownMenuContext(componentName: string): DropdownMenuContextValue {
const ctx = useContext(DropdownMenuContext);
if (!ctx) {
throw new Error(`${componentName} must be used within <DropdownMenu />`);
}
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<Rect> {
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<View>(null);
const [isOpen, setIsOpen] = useControllableOpenState({
open,
defaultOpen,
onOpenChange,
});
const value = useMemo<DropdownMenuContextValue>(
() => ({
open: isOpen,
setOpen: setIsOpen,
triggerRef,
}),
[isOpen, setIsOpen]
);
return <DropdownMenuContext.Provider value={value}>{children}</DropdownMenuContext.Provider>;
}
export function DropdownMenuTrigger({
children,
disabled,
...props
}: PropsWithChildren<PressableProps>): ReactElement {
const { setOpen, open, triggerRef } = useDropdownMenuContext("DropdownMenuTrigger");
const handlePress = useCallback(() => {
if (disabled) return;
setOpen(!open);
}, [disabled, open, setOpen]);
return (
<Pressable
{...props}
ref={triggerRef}
collapsable={false}
disabled={disabled}
onPress={handlePress}
>
{children}
</Pressable>
);
}
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<Rect | null>(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 (
<Modal
visible={open}
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
onRequestClose={handleClose}
>
<View style={styles.overlay}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Menu backdrop"
style={styles.backdrop}
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
<Animated.View
entering={FadeIn.duration(100)}
exiting={FadeOut.duration(100)}
collapsable={false}
testID={testID}
onLayout={handleContentLayout}
style={[
styles.content,
resolvedWidthStyle,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
>
<ScrollView bounces={false} showsVerticalScrollIndicator contentContainerStyle={{ flexGrow: 1 }}>
{children}
</ScrollView>
</Animated.View>
</View>
</Modal>
);
}
export function DropdownMenuLabel({
children,
style,
testID,
}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement {
return (
<View style={[styles.labelContainer, style]} testID={testID}>
<Text style={styles.labelText}>{children}</Text>
</View>
);
}
export function DropdownMenuSeparator({ style, testID }: { style?: ViewStyle; testID?: string }): ReactElement {
return <View style={[styles.separator, style]} testID={testID} />;
}
export function DropdownMenuHint({
children,
testID,
}: PropsWithChildren<{ testID?: string }>): ReactElement {
return (
<View style={styles.hintContainer} testID={testID}>
<Text style={styles.hintText}>{children}</Text>
</View>
);
}
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 (
<Pressable
testID={testID}
accessibilityRole="button"
disabled={disabled}
onPress={() => {
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 ? (
<View style={styles.checkSlot}>
{selected ? <Check size={16} color={theme.colors.foreground} /> : null}
</View>
) : null}
{leading ? <View style={styles.leadingSlot}>{leading}</View> : null}
<View style={styles.itemContent}>
<Text
numberOfLines={1}
style={[
styles.itemText,
destructive ? styles.itemTextDestructive : null,
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
]}
>
{children}
</Text>
{description ? (
<Text
numberOfLines={2}
style={[
styles.itemDescription,
selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null,
]}
>
{description}
</Text>
) : null}
</View>
{trailing ? <View style={styles.trailingSlot}>{trailing}</View> : null}
</Pressable>
);
}
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,
},
}));