refactor: extract explorer open gesture and standardize icon sizes

This commit is contained in:
Mohamed Boudra
2026-02-19 09:53:44 +07:00
parent 08d4cfac11
commit 24a8db5763
25 changed files with 659 additions and 237 deletions
@@ -17,11 +17,8 @@ import { useQueryClient } from "@tanstack/react-query";
import ReanimatedAnimated, {
useAnimatedStyle,
useSharedValue,
runOnJS,
interpolate,
Extrapolation,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
MoreVertical,
@@ -41,7 +38,6 @@ import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import {
ExplorerSidebarAnimationProvider,
useExplorerSidebarAnimation,
} from "@/contexts/explorer-sidebar-animation-context";
import { usePanelStore } from "@/stores/panel-store";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
@@ -76,6 +72,7 @@ import {
import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
import type { FetchAgentsEntry } from "@server/client/daemon-client";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import {
DropdownMenu,
DropdownMenuContent,
@@ -90,6 +87,14 @@ const DROPDOWN_WIDTH = 220;
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const RECONNECT_NOTICE_DELAY_MS = 10_000;
const CONNECTED_NOTICE_DURATION_MS = 2_500;
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logAgentExplorer(event: string, details: Record<string, unknown>): void {
if (!IS_DEV) {
return;
}
console.log(`[AgentExplorer] ${event}`, details);
}
export function AgentReadyScreen({
serverId,
@@ -324,31 +329,27 @@ function AgentScreenContent({
}, [resolveCachedCheckoutIsGit, resolvedAgentId, checkout?.isGit, serverId]);
const openExplorerForActiveCheckout = useCallback(() => {
const checkoutContext = resolveCurrentExplorerCheckout();
logAgentExplorer("openExplorerForActiveCheckout", {
hasCheckoutContext: Boolean(checkoutContext),
checkoutContext,
});
if (checkoutContext) {
activateExplorerTabForCheckout(checkoutContext);
}
openFileExplorer();
}, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]);
const handleToggleExplorer = useCallback(() => {
logAgentExplorer("handleToggleExplorer", {
isExplorerOpen,
mobileView,
isMobile,
});
if (isExplorerOpen) {
toggleFileExplorer();
return;
}
openExplorerForActiveCheckout();
}, [isExplorerOpen, openExplorerForActiveCheckout, toggleFileExplorer]);
const {
translateX: explorerTranslateX,
backdropOpacity: explorerBackdropOpacity,
windowWidth: explorerWindowWidth,
animateToOpen: animateExplorerToOpen,
animateToClose: animateExplorerToClose,
isGesturing: isExplorerGesturing,
} = useExplorerSidebarAnimation();
const handleOpenExplorerFromGesture = useCallback(() => {
openExplorerForActiveCheckout();
animateExplorerToOpen();
}, [animateExplorerToOpen, openExplorerForActiveCheckout]);
}, [isExplorerOpen, isMobile, mobileView, openExplorerForActiveCheckout, toggleFileExplorer]);
useEffect(() => {
if (Platform.OS !== "web") {
@@ -360,53 +361,10 @@ function AgentScreenContent({
}, [serverId, agentId]);
// Swipe-left gesture to open explorer sidebar on mobile
const explorerOpenGesture = useMemo(
() =>
Gesture.Pan()
.enabled(isMobile && !isExplorerOpen)
// Only activate after 15px horizontal movement to the left (negative)
.activeOffsetX(-15)
// Fail if 10px vertical movement happens first (allow vertical scroll)
.failOffsetY([-10, 10])
.onStart(() => {
isExplorerGesturing.value = true;
})
.onUpdate((event) => {
// Right sidebar: start from closed position (+windowWidth) and move towards 0
// Swiping left means negative translationX
const newTranslateX = Math.max(0, explorerWindowWidth + event.translationX);
explorerTranslateX.value = newTranslateX;
explorerBackdropOpacity.value = interpolate(
newTranslateX,
[explorerWindowWidth, 0],
[0, 1],
Extrapolation.CLAMP
);
})
.onEnd((event) => {
isExplorerGesturing.value = false;
// Open if dragged more than 1/3 of window or fast swipe left
const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500;
if (shouldOpen) {
runOnJS(handleOpenExplorerFromGesture)();
} else {
animateExplorerToClose();
}
})
.onFinalize(() => {
isExplorerGesturing.value = false;
}),
[
isMobile,
isExplorerOpen,
explorerWindowWidth,
explorerTranslateX,
explorerBackdropOpacity,
animateExplorerToClose,
handleOpenExplorerFromGesture,
isExplorerGesturing,
]
);
const explorerOpenGesture = useExplorerOpenGesture({
enabled: isMobile && mobileView === "agent",
onOpen: openExplorerForActiveCheckout,
});
// Handle hardware back button - close explorer sidebar first, then navigate back
useEffect(() => {
@@ -947,7 +905,7 @@ function AgentScreenContent({
await Clipboard.setStringAsync(value);
toast.show(`Copied ${label}`, {
variant: "success",
icon: <CheckCircle2 size={16} color={theme.colors.primary} />,
icon: <CheckCircle2 size={theme.iconSize.md} color={theme.colors.primary} />,
});
} catch {
toast.error("Copy failed");
@@ -1015,7 +973,7 @@ function AgentScreenContent({
{isMobile ? (
checkout?.isGit ? (
<GitBranch
size={20}
size={theme.iconSize.lg}
color={
isExplorerOpen
? theme.colors.foreground
@@ -1024,7 +982,7 @@ function AgentScreenContent({
/>
) : (
<Folder
size={20}
size={theme.iconSize.lg}
color={
isExplorerOpen
? theme.colors.foreground
@@ -1034,7 +992,7 @@ function AgentScreenContent({
)
) : (
<PanelRight
size={16}
size={theme.iconSize.md}
color={
isExplorerOpen
? theme.colors.foreground
@@ -1051,7 +1009,10 @@ function AgentScreenContent({
}}
>
<DropdownMenuTrigger testID="agent-overflow-menu" style={styles.menuButton}>
<MoreVertical size={isMobile ? 20 : 16} color={theme.colors.foregroundMuted} />
<MoreVertical
size={isMobile ? theme.iconSize.lg : theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content">
<View style={styles.menuMetaContainer}>
@@ -1170,7 +1131,7 @@ function AgentScreenContent({
<DropdownMenuSeparator />
<DropdownMenuItem
leading={<RotateCcw size={16} color={theme.colors.foreground} />}
leading={<RotateCcw size={theme.iconSize.md} color={theme.colors.foreground} />}
disabled={isInitializing || shouldBlockForHistorySync}
trailing={
isInitializing ? (
@@ -1202,7 +1163,7 @@ function AgentScreenContent({
>
{showConnectedNotice ? (
<CheckCircle2
size={14}
size={theme.iconSize.sm}
color={theme.colors.palette.green[600]}
/>
) : null}
@@ -3,11 +3,12 @@ import { createNameId } from 'mnemonic-id'
import type { ImageAttachment } from '@/components/message-input'
import { View, Text, Pressable, ScrollView, Keyboard, Platform } from 'react-native'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useIsFocused } from '@react-navigation/native'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
import { GestureDetector } from 'react-native-gesture-handler'
import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
import { Folder, GitBranch, PanelRight } from 'lucide-react-native'
import { SidebarMenuToggle } from '@/components/headers/menu-header'
import { HeaderToggleButton } from '@/components/headers/header-toggle-button'
@@ -31,6 +32,7 @@ import { buildBranchComboOptions, normalizeBranchOptionName } from '@/utils/bran
import { shortenPath } from '@/utils/shorten-path'
import { collectAgentWorkingDirectorySuggestions } from '@/utils/agent-working-directory-suggestions'
import { buildWorkingDirectorySuggestions } from '@/utils/working-directory-suggestions'
import { useExplorerOpenGesture } from '@/hooks/use-explorer-open-gesture'
import { useSessionStore } from '@/stores/session-store'
import { useCreateFlowStore } from '@/stores/create-flow-store'
import { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context'
@@ -131,6 +133,7 @@ function DraftAgentScreenContent({
onCreateFlowActiveChange,
forcedServerId,
}: DraftAgentScreenProps = {}) {
const isFocused = useIsFocused()
const { theme } = useUnistyles()
const router = useRouter()
const insets = useSafeAreaInsets()
@@ -713,26 +716,10 @@ function DraftAgentScreenContent({
}
openExplorerForDraftCheckout()
}, [canOpenExplorer, isExplorerOpen, openExplorerForDraftCheckout, toggleFileExplorer])
const handleOpenExplorerFromGesture = useCallback(() => {
if (!canOpenExplorer) {
return
}
openExplorerForDraftCheckout()
}, [canOpenExplorer, openExplorerForDraftCheckout])
const explorerOpenGesture = useMemo(
() =>
Gesture.Pan()
.enabled(isMobile && !isExplorerOpen && canOpenExplorer)
.activeOffsetX(-15)
.failOffsetY([-10, 10])
.onEnd((event) => {
const shouldOpen = event.translationX < -80 || event.velocityX < -500
if (shouldOpen) {
runOnJS(handleOpenExplorerFromGesture)()
}
}),
[canOpenExplorer, handleOpenExplorerFromGesture, isExplorerOpen, isMobile]
)
const explorerOpenGesture = useExplorerOpenGesture({
enabled: isMobile && mobileView === 'agent' && canOpenExplorer,
onOpen: openExplorerForDraftCheckout,
})
const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0
const workingDirSearchError =
directorySuggestionsQuery.error instanceof Error
@@ -926,20 +913,23 @@ function DraftAgentScreenContent({
workingDir,
])
useEffect(() => {
if (!isFocused) {
return
}
setActiveExplorerCheckout(draftExplorerCheckout)
}, [draftExplorerCheckout, setActiveExplorerCheckout])
}, [draftExplorerCheckout, isFocused, setActiveExplorerCheckout])
useEffect(() => {
if (!draftExplorerCheckout) {
if (!isFocused || !draftExplorerCheckout) {
return
}
activateExplorerTabForCheckout(draftExplorerCheckout)
}, [activateExplorerTabForCheckout, draftExplorerCheckout])
}, [activateExplorerTabForCheckout, draftExplorerCheckout, isFocused])
useEffect(() => {
if (canOpenExplorer || !isExplorerOpen) {
if (!isFocused || canOpenExplorer || !isExplorerOpen) {
return
}
closeFileExplorer()
}, [canOpenExplorer, closeFileExplorer, isExplorerOpen])
}, [canOpenExplorer, closeFileExplorer, isExplorerOpen, isFocused])
useEffect(() => {
return () => {
setActiveExplorerCheckout(null)
@@ -1146,7 +1136,7 @@ function DraftAgentScreenContent({
accessibilityState={{ expanded: isExplorerOpen }}
>
<PanelRight
size={16}
size={theme.iconSize.md}
color={isExplorerOpen ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
</HeaderToggleButton>
@@ -1179,7 +1169,7 @@ function DraftAgentScreenContent({
value={displayWorkingDir}
placeholder="/path/to/project"
onPress={() => setIsWorkingDirOpen(true)}
icon={<Folder size={16} color={theme.colors.foregroundMuted} />}
icon={<Folder size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
showLabel={false}
valueEllipsizeMode="middle"
testID="working-directory-select"
@@ -1226,7 +1216,7 @@ function DraftAgentScreenContent({
value={worktreeTriggerValue}
placeholder="Select worktree"
onPress={() => setIsWorktreePickerOpen(true)}
icon={<GitBranch size={16} color={theme.colors.foregroundMuted} />}
icon={<GitBranch size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
showLabel={false}
valueEllipsizeMode="middle"
/>
@@ -1239,7 +1229,7 @@ function DraftAgentScreenContent({
placeholder="From branch"
onPress={() => setIsBranchOpen(true)}
disabled={repoInfoStatus === 'loading'}
icon={<GitBranch size={16} color={theme.colors.foregroundMuted} />}
icon={<GitBranch size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
showLabel={false}
/>
) : null}