diff --git a/docs/MOBILE_TESTING.md b/docs/MOBILE_TESTING.md new file mode 100644 index 000000000..12f543ad7 --- /dev/null +++ b/docs/MOBILE_TESTING.md @@ -0,0 +1,206 @@ +# Mobile Testing + +## Maestro + +Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`. + +Run a flow: + +```bash +maestro test packages/app/maestro/my-flow.yaml +``` + +### Screenshots + +`takeScreenshot` writes to the **current working directory** — there's no way to configure the output path in the YAML. To keep screenshots out of the checkout, `cd` into a temp directory and use an absolute path for the flow: + +```bash +FLOW="$(pwd)/packages/app/maestro/my-flow.yaml" +mkdir -p /tmp/maestro-out +cd /tmp/maestro-out && maestro test "$FLOW" +``` + +`packages/app/maestro/.gitignore` excludes `*.png` as a safety net. + +### Element targeting + +Use `testID` or `nativeID` on components, then target with `id:` in flows. Prefer this over text matching — text breaks on copy changes. + +```tsx +// Component + +``` + +```yaml +# Flow +- tapOn: + id: "sidebar-sessions" +- assertVisible: + id: "sidebar-sessions" +``` + +### Conditional steps + +Use `runFlow:when:visible` for steps that should only execute when a specific element is on screen: + +```yaml +- runFlow: + when: + visible: + id: "sidebar-sessions" + commands: + - swipe: + direction: LEFT + duration: 300 +``` + +This is how `flows/dev-client.yaml` handles Expo dev client screens that only appear in dev builds. + +### Don't use launchApp against a running dev app + +`launchApp` kills and restarts the app, disrupting Expo dev client state and host connections. For flows that test against an already-running dev app, **omit launchApp entirely** — just interact with whatever is on screen. + +Use `launchApp` only in flows that need a clean start (e.g., onboarding tests). + +### Swipe gestures + +Use `start`/`end` with percentage coordinates for precise control: + +```yaml +# Edge swipe from left to open sidebar +- swipe: + start: "5%,50%" + end: "80%,50%" + duration: 300 +``` + +`direction: RIGHT` is simpler but less precise — use it for generic swipes, use coordinates when the start position matters (edge gestures, avoiding specific UI regions). + +### Assertions + +`assertVisible` checks **actual screen visibility**, not just view tree presence. An element that exists in the tree but is off-screen (e.g., `translateX: -400`) will correctly fail `assertVisible`. This makes it reliable for catching animation bugs where state says "open" but the view is visually hidden. + +For async elements, use `extendedWaitUntil`: + +```yaml +- extendedWaitUntil: + visible: ".*Online.*" + timeout: 90000 +``` + +### Dev client handling + +Two reusable flows handle Expo dev client screens after launch: + +- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo" +- `flows/dev-client.yaml` — same but without asserting a particular app route + +## Self-verification loops + +Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs. + +This pattern also lets agents self-verify fixes without manual user testing. + +### Pattern + +1. Run baseline Maestro flow (confirm feature works) +2. Make system-level change via `xcrun simctl` (toggle appearance, etc.) +3. Re-run Maestro flow (confirm feature still works) +4. Repeat N iterations to catch intermittent failures + +Scripts run `maestro test` from inside a temp directory so screenshots don't dirty the checkout. + +See `packages/app/maestro/test-sidebar-theme.sh` for the canonical example: + +```bash +bash packages/app/maestro/test-sidebar-theme.sh 6 1 +# Args: iterations=6, wait_seconds=1 between toggle and test +``` + +Key elements of the script pattern: + +```bash +set -euo pipefail +ITERATIONS="${1:-3}" + +for i in $(seq 1 "$ITERATIONS"); do + # Toggle system state + xcrun simctl ui booted appearance light + + # Wait for change to propagate + sleep 1 + + # Run Maestro flow and capture result + if maestro test "$FLOW" 2>&1 | tee "$ITER_DIR/test.log"; then + echo "PASS" + else + echo "FAIL" + xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png" + fi +done +``` + +## Unistyles + Reanimated + +### The crash + +Applying Unistyles theme-reactive styles (`StyleSheet.create((theme) => ...)`) directly to `Animated.View` causes **"Unable to find node on an unmounted component"** on theme change. + +Unistyles wraps styled components in `` and patches native view properties via C++. Reanimated also manages the same native node for animated transforms. When the theme changes, both systems try to update the node simultaneously and the view crashes. + +### The fix + +Use plain React Native `StyleSheet.create` for static positioning on `Animated.View`. Pass theme-dependent values as inline styles from `useUnistyles()`: + +```tsx +// BAD: Unistyles dynamic style on Animated.View +const styles = StyleSheet.create((theme) => ({ + sidebar: { + position: "absolute", + top: 0, + left: 0, + bottom: 0, + backgroundColor: theme.colors.surfaceSidebar, // theme-reactive + overflow: "hidden", + }, +})); + + +``` + +```tsx +// GOOD: static stylesheet + inline theme values +import { StyleSheet as RNStyleSheet } from "react-native"; + +const staticStyles = RNStyleSheet.create({ + sidebar: { + position: "absolute", + top: 0, + left: 0, + bottom: 0, + overflow: "hidden", + }, +}); + +const { theme } = useUnistyles(); + + +``` + +Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`. + +## iOS Simulator + +```bash +# Screenshot +xcrun simctl io booted screenshot /tmp/screenshot.png + +# Dark/light mode +xcrun simctl ui booted appearance # check current +xcrun simctl ui booted appearance dark # set dark +xcrun simctl ui booted appearance light # set light +``` + +Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)). diff --git a/packages/app/maestro/.gitignore b/packages/app/maestro/.gitignore new file mode 100644 index 000000000..c7a365a58 --- /dev/null +++ b/packages/app/maestro/.gitignore @@ -0,0 +1,2 @@ +# Maestro takeScreenshot artifacts +*.png diff --git a/packages/app/maestro/sidebar-theme-repro.yaml b/packages/app/maestro/sidebar-theme-repro.yaml new file mode 100644 index 000000000..3c9dff9e9 --- /dev/null +++ b/packages/app/maestro/sidebar-theme-repro.yaml @@ -0,0 +1,31 @@ +appId: sh.paseo +--- +# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left +- runFlow: + when: + visible: + id: "sidebar-sessions" + commands: + - swipe: + direction: LEFT + duration: 300 + +# Small pause for close animation +- takeScreenshot: 00-sidebar-closed + +# Open sidebar via swipe right gesture (the actual path that triggers the bug) +- swipe: + start: "5%,50%" + end: "80%,50%" + duration: 300 + +# Verify sidebar opened +- assertVisible: + id: "sidebar-sessions" +- takeScreenshot: 01-sidebar-opened + +# Close sidebar via swipe left +- swipe: + direction: LEFT + duration: 300 +- takeScreenshot: 02-sidebar-closed-again diff --git a/packages/app/maestro/test-sidebar-theme.sh b/packages/app/maestro/test-sidebar-theme.sh new file mode 100755 index 000000000..6a89f133b --- /dev/null +++ b/packages/app/maestro/test-sidebar-theme.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Verification loop for sidebar theme bug. +# +# Maestro can't toggle iOS appearance, so this script bridges the gap: +# toggle appearance via xcrun simctl, then run Maestro to verify the sidebar +# still works. Runs N iterations to catch intermittent failures. +# +# Usage: +# bash packages/app/maestro/test-sidebar-theme.sh [iterations] [wait_seconds] +# bash packages/app/maestro/test-sidebar-theme.sh 6 1 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +FLOW="$REPO_ROOT/packages/app/maestro/sidebar-theme-repro.yaml" +OUT_DIR="/tmp/sidebar-theme-test-$(date +%s)" +ITERATIONS="${1:-3}" +WAIT_SECS="${2:-1}" +mkdir -p "$OUT_DIR" + +echo "=== Sidebar Theme Bug Verification ===" +echo "Output dir: $OUT_DIR" +echo "Iterations: $ITERATIONS, wait after toggle: ${WAIT_SECS}s" + +FAILURES=0 + +for i in $(seq 1 "$ITERATIONS"); do + echo "" + echo "========== Iteration $i / $ITERATIONS ==========" + + CURRENT=$(xcrun simctl ui booted appearance 2>&1 | tr -d '[:space:]') + echo "Current appearance: $CURRENT" + + if [ "$CURRENT" = "dark" ]; then + xcrun simctl ui booted appearance light + echo "Switched to light mode" + else + xcrun simctl ui booted appearance dark + echo "Switched to dark mode" + fi + + echo "Waiting ${WAIT_SECS}s..." + sleep "$WAIT_SECS" + + ITER_DIR="$OUT_DIR/iter-$i" + mkdir -p "$ITER_DIR" + + # Run maestro from the output dir so takeScreenshot artifacts land there + if (cd "$ITER_DIR" && maestro test "$FLOW") 2>&1 | tee "$ITER_DIR/test.log"; then + echo " -> PASS (iteration $i)" + else + echo " -> FAIL (iteration $i) — bug reproduced!" + FAILURES=$((FAILURES + 1)) + xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png" 2>/dev/null || true + fi +done + +# Restore to dark mode +xcrun simctl ui booted appearance dark + +echo "" +echo "=== Summary: $FAILURES failures out of $ITERATIONS iterations ===" +echo "Output: $OUT_DIR" diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 47b989309..b517c1072 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -420,14 +420,28 @@ function MobileGestureWrapper({ const mobileView = usePanelStore((state) => state.mobileView); const openAgentList = usePanelStore((state) => state.openAgentList); const horizontalScroll = useHorizontalScrollOptional(); - const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } = - useSidebarAnimation(); + const { + translateX, + backdropOpacity, + windowWidth, + animateToOpen, + animateToClose, + isGesturing, + gestureAnimatingRef, + openGestureRef, + } = useSidebarAnimation(); const touchStartX = useSharedValue(0); const openGestureEnabled = chromeEnabled && mobileView === "agent"; + const handleGestureOpen = useCallback(() => { + gestureAnimatingRef.current = true; + openAgentList(); + }, [openAgentList, gestureAnimatingRef]); + const openGesture = useMemo( () => Gesture.Pan() + .withRef(openGestureRef) .enabled(openGestureEnabled) .manualActivation(true) .failOffsetY([-10, 10]) @@ -470,7 +484,7 @@ function MobileGestureWrapper({ const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500; if (shouldOpen) { animateToOpen(); - runOnJS(openAgentList)(); + runOnJS(handleGestureOpen)(); } else { animateToClose(); } @@ -485,8 +499,9 @@ function MobileGestureWrapper({ backdropOpacity, animateToOpen, animateToClose, - openAgentList, + handleGestureOpen, isGesturing, + openGestureRef, horizontalScroll?.isAnyScrolledRight, touchStartX, ], diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 5d22549c5..1ab3eb94c 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -1,5 +1,12 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; -import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native"; +import { + View, + Text, + Pressable, + Platform, + useWindowDimensions, + StyleSheet as RNStyleSheet, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useIsFocused } from "@react-navigation/native"; import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated"; @@ -81,6 +88,7 @@ export function ExplorerSidebar({ animateToOpen, animateToClose, isGesturing, + gestureAnimatingRef, closeGestureRef, } = useExplorerSidebarAnimation(); @@ -101,6 +109,11 @@ export function ExplorerSidebar({ [closeToAgent, desktopFileExplorerOpen, isOpen, mobileView], ); + const handleCloseFromGesture = useCallback(() => { + gestureAnimatingRef.current = true; + closeToAgent(); + }, [closeToAgent, gestureAnimatingRef]); + const enableSidebarCloseGesture = isMobile && isOpen; const handleTabPress = useCallback( @@ -175,7 +188,7 @@ export function ExplorerSidebar({ }); if (shouldClose) { animateToClose(); - runOnJS(handleClose)("swipe-close-gesture"); + runOnJS(handleCloseFromGesture)(); } else { animateToOpen(); } @@ -190,7 +203,7 @@ export function ExplorerSidebar({ backdropOpacity, animateToOpen, animateToClose, - handleClose, + handleCloseFromGesture, isGesturing, closeGestureRef, closeTouchStartX, @@ -251,18 +264,13 @@ export function ExplorerSidebar({ return ( {/* Backdrop */} - - handleClose("backdrop-press")} - /> - + - {/* Resize handle - absolutely positioned over left border */} - - - + + + {/* Resize handle - absolutely positioned over left border */} + + + - handleClose("desktop-close-button")} - serverId={serverId} - workspaceId={workspaceId} - workspaceRoot={workspaceRoot} - isGit={isGit} - isMobile={false} - onOpenFile={onOpenFile} - /> + handleClose("desktop-close-button")} + serverId={serverId} + workspaceId={workspaceId} + workspaceRoot={workspaceRoot} + isGit={isGit} + isMobile={false} + onOpenFile={onOpenFile} + /> + ); } @@ -400,24 +410,28 @@ function SidebarContent({ ); } -const styles = StyleSheet.create((theme) => ({ +// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to +// avoid the "Unable to find node on an unmounted component" crash when Unistyles +// tries to patch the native node that Reanimated also manages. +const explorerStaticStyles = RNStyleSheet.create({ backdrop: { - ...StyleSheet.absoluteFillObject, + ...RNStyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)", }, - backdropPressable: { - flex: 1, - }, mobileSidebar: { - position: "absolute", + position: "absolute" as const, top: 0, right: 0, bottom: 0, - backgroundColor: theme.colors.surfaceSidebar, - overflow: "hidden", + overflow: "hidden" as const, }, desktopSidebar: { - position: "relative", + position: "relative" as const, + }, +}); + +const styles = StyleSheet.create((theme) => ({ + desktopSidebarBorder: { borderLeftWidth: 1, borderLeftColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index ca7377e44..ab5b40371 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -10,7 +10,14 @@ import { type RefObject, type SetStateAction, } from "react"; -import { View, Pressable, Text, Platform, useWindowDimensions } from "react-native"; +import { + View, + Pressable, + Text, + Platform, + useWindowDimensions, + StyleSheet as RNStyleSheet, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { useAnimatedStyle, @@ -370,14 +377,16 @@ function MobileSidebar({ animateToOpen, animateToClose, isGesturing, + gestureAnimatingRef, closeGestureRef, } = useSidebarAnimation(); const closeTouchStartX = useSharedValue(0); const closeTouchStartY = useSharedValue(0); - const handleClose = useCallback(() => { + const handleCloseFromGesture = useCallback(() => { + gestureAnimatingRef.current = true; closeToAgent(); - }, [closeToAgent]); + }, [closeToAgent, gestureAnimatingRef]); const handleViewMore = useCallback(() => { if (!activeServerId) { @@ -452,7 +461,7 @@ function MobileSidebar({ const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500; if (shouldClose) { animateToClose(); - runOnJS(handleClose)(); + runOnJS(handleCloseFromGesture)(); } else { animateToOpen(); } @@ -471,7 +480,7 @@ function MobileSidebar({ backdropOpacity, animateToClose, animateToOpen, - handleClose, + handleCloseFromGesture, ], ); @@ -498,13 +507,11 @@ function MobileSidebar({ return ( - - - + @@ -526,7 +533,7 @@ function MobileSidebar({ projects={projects} isRefreshing={isManualRefresh && isRevalidating} onRefresh={handleRefresh} - onWorkspacePress={closeToAgent} + onWorkspacePress={() => closeToAgent()} onAddProject={handleOpenProject} parentGestureRef={closeGestureRef} /> @@ -688,7 +695,8 @@ function DesktopSidebar({ } return ( - + + {padding.top > 0 ? : null} @@ -796,32 +804,37 @@ function DesktopSidebar({ style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]} /> + ); } -const styles = StyleSheet.create((theme) => ({ +// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to +// avoid the "Unable to find node on an unmounted component" crash when Unistyles +// tries to patch the native node that Reanimated also manages. +const staticStyles = RNStyleSheet.create({ backdrop: { - ...StyleSheet.absoluteFillObject, + ...RNStyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)", }, - backdropPressable: { - flex: 1, - }, mobileSidebar: { - position: "absolute", + position: "absolute" as const, top: 0, left: 0, bottom: 0, - backgroundColor: theme.colors.surfaceSidebar, - overflow: "hidden", + overflow: "hidden" as const, }, + desktopSidebar: { + position: "relative" as const, + }, +}); + +const styles = StyleSheet.create((theme) => ({ sidebarContent: { flex: 1, minHeight: 0, }, - desktopSidebar: { - position: "relative", + desktopSidebarBorder: { borderRightWidth: 1, borderRightColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, diff --git a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx index c6e1c3715..80d5c191d 100644 --- a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx +++ b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx @@ -18,6 +18,8 @@ interface ExplorerSidebarAnimationContextValue { animateToOpen: () => void; animateToClose: () => void; isGesturing: SharedValue; + gestureAnimatingRef: React.MutableRefObject; + openGestureRef: React.MutableRefObject; closeGestureRef: React.MutableRefObject; } @@ -39,6 +41,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React const translateX = useSharedValue(initialTargets.translateX); const backdropOpacity = useSharedValue(initialTargets.backdropOpacity); const isGesturing = useSharedValue(false); + const gestureAnimatingRef = useRef(false); + const openGestureRef = useRef(undefined); const closeGestureRef = useRef(undefined); // Track previous isOpen to detect changes @@ -61,6 +65,11 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React return; } + if (gestureAnimatingRef.current) { + gestureAnimatingRef.current = false; + return; + } + // Don't animate if we're in the middle of a gesture - the gesture handler will handle it if (isGesturing.value) { return; @@ -123,6 +132,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React animateToOpen, animateToClose, isGesturing, + gestureAnimatingRef, + openGestureRef, closeGestureRef, }} > diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx index 4e10c0361..f315eee6c 100644 --- a/packages/app/src/contexts/sidebar-animation-context.tsx +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -27,6 +27,8 @@ interface SidebarAnimationContextValue { animateToOpen: () => void; animateToClose: () => void; isGesturing: SharedValue; + gestureAnimatingRef: React.MutableRefObject; + openGestureRef: React.MutableRefObject; closeGestureRef: React.MutableRefObject; } @@ -46,6 +48,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) const translateX = useSharedValue(initialTargets.translateX); const backdropOpacity = useSharedValue(initialTargets.backdropOpacity); const isGesturing = useSharedValue(false); + const gestureAnimatingRef = useRef(false); + const openGestureRef = useRef(undefined); const closeGestureRef = useRef(undefined); // Track previous isOpen to detect changes @@ -68,6 +72,14 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) return; } + // Gesture onEnd already started the animation on the UI thread — skip to avoid + // a second competing withTiming that can desync translateX and backdropOpacity + // after a provider remount (e.g. theme change). + if (gestureAnimatingRef.current) { + gestureAnimatingRef.current = false; + return; + } + // Don't animate if we're in the middle of a gesture - the gesture handler will handle it if (isGesturing.value) { return; @@ -123,6 +135,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) animateToOpen, animateToClose, isGesturing, + gestureAnimatingRef, + openGestureRef, closeGestureRef, }), [ @@ -132,6 +146,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) animateToOpen, animateToClose, isGesturing, + gestureAnimatingRef, + openGestureRef, closeGestureRef, ], ); diff --git a/packages/app/src/hooks/use-explorer-open-gesture.ts b/packages/app/src/hooks/use-explorer-open-gesture.ts index ad0a031cf..052a3d06b 100644 --- a/packages/app/src/hooks/use-explorer-open-gesture.ts +++ b/packages/app/src/hooks/use-explorer-open-gesture.ts @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { Gesture } from "react-native-gesture-handler"; import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; @@ -9,14 +9,28 @@ interface UseExplorerOpenGestureParams { } export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) { - const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } = - useExplorerSidebarAnimation(); + const { + translateX, + backdropOpacity, + windowWidth, + animateToOpen, + animateToClose, + isGesturing, + gestureAnimatingRef, + openGestureRef, + } = useExplorerSidebarAnimation(); const touchStartX = useSharedValue(0); const touchStartY = useSharedValue(0); + const handleGestureOpen = useCallback(() => { + gestureAnimatingRef.current = true; + onOpen(); + }, [onOpen, gestureAnimatingRef]); + return useMemo( () => Gesture.Pan() + .withRef(openGestureRef) .enabled(enabled) .manualActivation(true) .onTouchesDown((event) => { @@ -78,7 +92,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity; if (shouldOpen) { animateToOpen(); - runOnJS(onOpen)(); + runOnJS(handleGestureOpen)(); } else { animateToClose(); } @@ -94,7 +108,8 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu animateToOpen, animateToClose, isGesturing, - onOpen, + openGestureRef, + handleGestureOpen, touchStartX, touchStartY, ],