From f9791d7ab64a82dffba73d9aea15d4a62dd2eb6f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 4 Jun 2026 13:32:15 +0700 Subject: [PATCH] Fix mobile sidebar swipes under load Move mobile sidebar gesture gating to shared visual panel state so swipes can open and close sidebars before React state catches up. Keep the sidebar list scroll simultaneous with the close pan so vertical scrolling stays immediate. --- packages/app/src/app/_layout.tsx | 14 ++++++- .../app/src/components/explorer-sidebar.tsx | 20 +++++++++- packages/app/src/components/left-sidebar.tsx | 18 +++++++-- .../src/components/sidebar-workspace-list.tsx | 15 +++++++- .../explorer-sidebar-animation-context.tsx | 38 ++++++++++++++++++- .../contexts/sidebar-animation-context.tsx | 38 ++++++++++++++++++- .../src/hooks/use-explorer-open-gesture.ts | 23 +++++++++-- .../screens/workspace/workspace-screen.tsx | 13 ++++--- 8 files changed, 158 insertions(+), 21 deletions(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 76997e8dd..411ab7b28 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -40,6 +40,8 @@ import { } from "@/contexts/horizontal-scroll-context"; import { SessionProvider } from "@/contexts/session-context"; import { + MOBILE_VISUAL_PANEL_AGENT, + MOBILE_VISUAL_PANEL_AGENT_LIST, SidebarAnimationProvider, useSidebarAnimation, } from "@/contexts/sidebar-animation-context"; @@ -493,7 +495,6 @@ function MobileGestureWrapper({ children: ReactNode; chromeEnabled: boolean; }) { - const mobileView = usePanelStore((state) => state.mobileView); const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList); const horizontalScroll = useHorizontalScrollOptional(); const { @@ -503,12 +504,13 @@ function MobileGestureWrapper({ animateToOpen, animateToClose, isGesturing, + mobileVisualPanel, gestureAnimatingRef, openGestureRef, } = useSidebarAnimation(); const touchStartX = useSharedValue(0); const touchStartY = useSharedValue(0); - const openGestureEnabled = chromeEnabled && mobileView === "agent"; + const openGestureEnabled = chromeEnabled; const handleGestureOpen = useCallback(() => { gestureAnimatingRef.current = true; @@ -538,6 +540,11 @@ function MobileGestureWrapper({ const absDeltaX = Math.abs(deltaX); const absDeltaY = Math.abs(deltaY); + if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT) { + stateManager.fail(); + return; + } + if (horizontalScroll?.isAnyScrolledRight.value) { stateManager.fail(); return; @@ -579,9 +586,11 @@ function MobileGestureWrapper({ isGesturing.value = false; const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500; if (shouldOpen) { + mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST; animateToOpen(); runOnJS(handleGestureOpen)(); } else { + mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT; animateToClose(); } }) @@ -593,6 +602,7 @@ function MobileGestureWrapper({ windowWidth, translateX, backdropOpacity, + mobileVisualPanel, animateToOpen, animateToClose, handleGestureOpen, diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 086fd2cc9..fa84d9bff 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -23,6 +23,11 @@ import { type ExplorerTab, } from "@/stores/panel-store"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; +import { + MOBILE_VISUAL_PANEL_AGENT, + MOBILE_VISUAL_PANEL_FILE_EXPLORER, + useSidebarAnimation, +} from "@/contexts/sidebar-animation-context"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { GitDiffPane } from "@/git/diff-pane"; import { FileExplorerPane } from "./file-explorer-pane"; @@ -63,6 +68,8 @@ export function ExplorerSidebar({ const { width: viewportWidth } = useWindowDimensions(); const closeTouchStartX = useSharedValue(0); const closeTouchStartY = useSharedValue(0); + const { mobileVisualPanel, gestureAnimatingRef: mobilePanelGestureAnimatingRef } = + useSidebarAnimation(); const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({ mode: "padding", @@ -114,10 +121,11 @@ export function ExplorerSidebar({ const handleCloseFromGesture = useCallback(() => { gestureAnimatingRef.current = true; + mobilePanelGestureAnimatingRef.current = true; showMobileAgent(); - }, [gestureAnimatingRef, showMobileAgent]); + }, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]); - const enableSidebarCloseGesture = isMobile && isOpen; + const enableSidebarCloseGesture = isMobile; const handleTabPress = useCallback( (tab: ExplorerTab) => { @@ -158,6 +166,11 @@ export function ExplorerSidebar({ const absDeltaX = Math.abs(deltaX); const absDeltaY = Math.abs(deltaY); + if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_FILE_EXPLORER) { + stateManager.fail(); + return; + } + // Fail quickly on clear leftward or vertical intent so child views keep control. if (deltaX <= -10) { stateManager.fail(); @@ -193,9 +206,11 @@ export function ExplorerSidebar({ windowWidth, }); if (shouldClose) { + mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT; animateToClose(); runOnJS(handleCloseFromGesture)(); } else { + mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER; animateToOpen(); } }) @@ -207,6 +222,7 @@ export function ExplorerSidebar({ windowWidth, translateX, backdropOpacity, + mobileVisualPanel, animateToOpen, animateToClose, handleCloseFromGesture, diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 529583de8..86d0af89c 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -40,7 +40,11 @@ import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; -import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; +import { + MOBILE_VISUAL_PANEL_AGENT, + MOBILE_VISUAL_PANEL_AGENT_LIST, + useSidebarAnimation, +} from "@/contexts/sidebar-animation-context"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model"; @@ -567,6 +571,7 @@ function MobileSidebar({ animateToOpen, animateToClose, isGesturing, + mobileVisualPanel, gestureAnimatingRef, closeGestureRef, } = useSidebarAnimation(); @@ -603,7 +608,7 @@ function MobileSidebar({ () => Gesture.Pan() .withRef(closeGestureRef) - .enabled(isOpen) + .enabled(true) .manualActivation(true) .onTouchesDown((event) => { const touch = event.changedTouches[0]; @@ -625,6 +630,11 @@ function MobileSidebar({ const absDeltaX = Math.abs(deltaX); const absDeltaY = Math.abs(deltaY); + if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT_LIST) { + stateManager.fail(); + return; + } + if (deltaX >= 10) { stateManager.fail(); return; @@ -654,9 +664,11 @@ function MobileSidebar({ isGesturing.value = false; const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500; if (shouldClose) { + mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT; animateToClose(); runOnJS(handleCloseFromGesture)(); } else { + mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST; animateToOpen(); } }) @@ -664,11 +676,11 @@ function MobileSidebar({ isGesturing.value = false; }), [ - isOpen, closeGestureRef, closeTouchStartX, closeTouchStartY, isGesturing, + mobileVisualPanel, windowWidth, translateX, backdropOpacity, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index c83d3b705..0912cecc3 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -2450,7 +2450,19 @@ function ProjectModeList({ ); const selectionEnabled = isWorkspaceRoute; const activeWorkspaceSelection = useActiveWorkspaceSelection(); - + const nativeScrollGestureProps = useMemo( + () => + parentGestureRef + ? ({ + // NestableScrollContainer forwards props to RNGH ScrollView. Keep + // vertical scroll and sidebar close pan simultaneous: vertical + // intent scrolls immediately, clear horizontal intent can still + // activate close from inside the list. + simultaneousHandlers: parentGestureRef, + } as object) + : undefined, + [parentGestureRef], + ); const projectIconRequests = useMemo(() => { if (!serverId) { return []; @@ -2724,6 +2736,7 @@ function ProjectModeList({ {platformIsNative ? ( state.mobileView); const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isCompactLayout }), ); @@ -53,6 +71,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React // Track previous isOpen to detect changes const prevIsOpen = useRef(isOpen); + const prevMobileView = useRef(mobileView); const prevWindowWidth = useRef(windowWidth); // Sync animation with store state changes (e.g., backdrop tap, programmatic open/close) @@ -63,11 +82,13 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React previousWindowWidth: prevWindowWidth.current, nextWindowWidth: windowWidth, }); + const didMobileViewChange = prevMobileView.current !== mobileView; const previousIsOpen = prevIsOpen.current; prevIsOpen.current = isOpen; + prevMobileView.current = mobileView; prevWindowWidth.current = windowWidth; - if (!didStateChange) { + if (!didStateChange && !didMobileViewChange) { return; } @@ -81,6 +102,10 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React return; } + if (isCompactLayout) { + mobileVisualPanel.value = getMobileVisualPanel(mobileView); + } + const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth }); if (previousIsOpen !== isOpen) { @@ -97,7 +122,16 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React translateX.value = targets.translateX; backdropOpacity.value = targets.backdropOpacity; - }, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]); + }, [ + isOpen, + mobileView, + translateX, + backdropOpacity, + windowWidth, + isGesturing, + isCompactLayout, + mobileVisualPanel, + ]); const animateToOpen = useCallback(() => { "worklet"; diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx index 1c321e0af..4a931ee2a 100644 --- a/packages/app/src/contexts/sidebar-animation-context.tsx +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -20,6 +20,9 @@ import { const ANIMATION_DURATION = 220; const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); +export const MOBILE_VISUAL_PANEL_AGENT = 0; +export const MOBILE_VISUAL_PANEL_AGENT_LIST = 1; +export const MOBILE_VISUAL_PANEL_FILE_EXPLORER = 2; interface SidebarAnimationContextValue { translateX: SharedValue; @@ -28,6 +31,7 @@ interface SidebarAnimationContextValue { animateToOpen: () => void; animateToClose: () => void; isGesturing: SharedValue; + mobileVisualPanel: SharedValue; gestureAnimatingRef: React.MutableRefObject; openGestureRef: React.MutableRefObject; closeGestureRef: React.MutableRefObject; @@ -35,9 +39,20 @@ interface SidebarAnimationContextValue { const SidebarAnimationContext = createContext(null); +function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explorer"): number { + if (mobileView === "agent-list") { + return MOBILE_VISUAL_PANEL_AGENT_LIST; + } + if (mobileView === "file-explorer") { + return MOBILE_VISUAL_PANEL_FILE_EXPLORER; + } + return MOBILE_VISUAL_PANEL_AGENT; +} + export function SidebarAnimationProvider({ children }: { children: ReactNode }) { const { width: windowWidth } = useWindowDimensions(); const isCompactLayout = useIsCompactFormFactor(); + const mobileView = usePanelStore((state) => state.mobileView); const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isCompactLayout }), ); @@ -47,12 +62,14 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) const translateX = useSharedValue(initialTargets.translateX); const backdropOpacity = useSharedValue(initialTargets.backdropOpacity); const isGesturing = useSharedValue(false); + const mobileVisualPanel = useSharedValue(getMobileVisualPanel(mobileView)); const gestureAnimatingRef = useRef(false); const openGestureRef = useRef(undefined); const closeGestureRef = useRef(undefined); // Track previous isOpen to detect changes const prevIsOpen = useRef(isOpen); + const prevMobileView = useRef(mobileView); const prevWindowWidth = useRef(windowWidth); // Sync animation with store state changes (e.g., backdrop tap, programmatic open/close) @@ -63,12 +80,14 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) previousWindowWidth: prevWindowWidth.current, nextWindowWidth: windowWidth, }); + const didMobileViewChange = prevMobileView.current !== mobileView; const previousIsOpen = prevIsOpen.current; prevIsOpen.current = isOpen; + prevMobileView.current = mobileView; prevWindowWidth.current = windowWidth; const didOpen = !previousIsOpen && isOpen; - if (!didStateChange) { + if (!didStateChange && !didMobileViewChange) { return; } @@ -89,6 +108,10 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) return; } + if (isCompactLayout) { + mobileVisualPanel.value = getMobileVisualPanel(mobileView); + } + const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth }); if (previousIsOpen !== isOpen) { @@ -105,7 +128,16 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) translateX.value = targets.translateX; backdropOpacity.value = targets.backdropOpacity; - }, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing, isCompactLayout]); + }, [ + isOpen, + mobileView, + translateX, + backdropOpacity, + windowWidth, + isGesturing, + isCompactLayout, + mobileVisualPanel, + ]); const animateToOpen = useCallback(() => { "worklet"; @@ -139,6 +171,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) animateToOpen, animateToClose, isGesturing, + mobileVisualPanel, gestureAnimatingRef, openGestureRef, closeGestureRef, @@ -150,6 +183,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) animateToOpen, animateToClose, isGesturing, + mobileVisualPanel, 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 7a978cb1c..5faef5fd6 100644 --- a/packages/app/src/hooks/use-explorer-open-gesture.ts +++ b/packages/app/src/hooks/use-explorer-open-gesture.ts @@ -2,7 +2,11 @@ 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"; -import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; +import { + MOBILE_VISUAL_PANEL_AGENT, + MOBILE_VISUAL_PANEL_FILE_EXPLORER, + useSidebarAnimation, +} from "@/contexts/sidebar-animation-context"; import { isWeb } from "@/constants/platform"; interface UseExplorerOpenGestureParams { @@ -23,14 +27,19 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu gestureAnimatingRef, openGestureRef, } = useExplorerSidebarAnimation(); - const { openGestureRef: leftOpenGestureRef } = useSidebarAnimation(); + const { + mobileVisualPanel, + gestureAnimatingRef: mobilePanelGestureAnimatingRef, + openGestureRef: leftOpenGestureRef, + } = useSidebarAnimation(); const touchStartX = useSharedValue(0); const touchStartY = useSharedValue(0); const handleGestureOpen = useCallback(() => { gestureAnimatingRef.current = true; + mobilePanelGestureAnimatingRef.current = true; onOpen(); - }, [onOpen, gestureAnimatingRef]); + }, [onOpen, gestureAnimatingRef, mobilePanelGestureAnimatingRef]); return useMemo( () => @@ -59,6 +68,11 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu const absDeltaX = Math.abs(deltaX); const absDeltaY = Math.abs(deltaY); + if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT) { + stateManager.fail(); + return; + } + // Browser back-swipe owns most of the viewport; keep this gesture on the right edge. if (isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) { stateManager.fail(); @@ -103,9 +117,11 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu const shouldOpenByVelocity = event.velocityX < -500; const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity; if (shouldOpen) { + mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER; animateToOpen(); runOnJS(handleGestureOpen)(); } else { + mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT; animateToClose(); } }) @@ -117,6 +133,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu windowWidth, translateX, backdropOpacity, + mobileVisualPanel, animateToOpen, animateToClose, isGesturing, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 301866a3a..146514d1f 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -774,19 +774,17 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({ interface MobileExplorerOpenGestureSurfaceProps { children: ReactNode; + enabled: boolean; onOpenExplorer: () => void; } function MobileExplorerOpenGestureSurface({ children, + enabled, onOpenExplorer, }: MobileExplorerOpenGestureSurfaceProps) { - const canOpenExplorerFromAgentView = usePanelStore( - (state) => - state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }), - ); const explorerOpenGesture = useExplorerOpenGesture({ - enabled: canOpenExplorerFromAgentView, + enabled, onOpen: onOpenExplorer, }); @@ -3444,7 +3442,10 @@ function WorkspaceScreenContent({ {isMobile ? ( - + {content} ) : (