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} ) : (