From edb3258ae95355301fd1bae9061e5480318cf85f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 4 Jan 2026 12:46:53 +0700 Subject: [PATCH] feat(sidebar): add interactive drag-to-open gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar now follows the finger during swipe-to-open gesture from anywhere on the screen. Uses shared animation context between AppContainer and SlidingSidebar for synchronized translateX values. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/app/src/app/_layout.tsx | 111 +++++++-- packages/app/src/components/agent-list.tsx | 1 + .../app/src/components/sliding-sidebar.tsx | 216 +++++------------- .../contexts/sidebar-animation-context.tsx | 117 ++++++++++ 4 files changed, 269 insertions(+), 176 deletions(-) create mode 100644 packages/app/src/contexts/sidebar-animation-context.tsx diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index a166c880a..506cd5b4a 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -1,7 +1,7 @@ import { Stack, usePathname } from "expo-router"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { KeyboardProvider } from "react-native-keyboard-controller"; -import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { RealtimeProvider } from "@/contexts/realtime-context"; import { useAppSettings } from "@/hooks/use-settings"; @@ -13,6 +13,12 @@ import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useState, type ReactNode, useMemo } from "react"; import { SlidingSidebar } from "@/components/sliding-sidebar"; +import { useSidebarStore } from "@/stores/sidebar-store"; +import { runOnJS, interpolate, Extrapolation } from "react-native-reanimated"; +import { + SidebarAnimationProvider, + useSidebarAnimation, +} from "@/contexts/sidebar-animation-context"; function QueryProvider({ children }: { children: ReactNode }) { const [queryClient] = useState( @@ -40,10 +46,59 @@ interface AppContainerProps { function AppContainer({ children, selectedAgentId }: AppContainerProps) { const { theme } = useUnistyles(); + const { isOpen, open } = useSidebarStore(); + const { + translateX, + backdropOpacity, + windowWidth, + animateToOpen, + animateToClose, + isGesturing, + } = useSidebarAnimation(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - return ( + // Open gesture: swipe right from anywhere to open sidebar (interactive drag) + const openGesture = useMemo( + () => + Gesture.Pan() + .enabled(isMobile && !isOpen) + // Only activate after 15px horizontal movement to the right + .activeOffsetX(15) + // Fail if 10px vertical movement happens first (allow vertical scroll) + .failOffsetY([-10, 10]) + .onStart(() => { + isGesturing.value = true; + }) + .onUpdate((event) => { + // Start from closed position (-windowWidth) and move towards 0 + const newTranslateX = Math.min(0, -windowWidth + event.translationX); + translateX.value = newTranslateX; + backdropOpacity.value = interpolate( + newTranslateX, + [-windowWidth, 0], + [0, 1], + Extrapolation.CLAMP + ); + }) + .onEnd((event) => { + isGesturing.value = false; + // Open if dragged more than 1/3 of sidebar or fast swipe + const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500; + if (shouldOpen) { + animateToOpen(); + runOnJS(open)(); + } else { + animateToClose(); + } + }) + .onFinalize(() => { + isGesturing.value = false; + }), + [isMobile, isOpen, windowWidth, translateX, backdropOpacity, animateToOpen, animateToClose, open, isGesturing] + ); + + const content = ( {!isMobile && } @@ -52,6 +107,16 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { {isMobile && } ); + + if (!isMobile) { + return content; + } + + return ( + + {content} + + ); } function ProvidersWrapper({ children }: { children: ReactNode }) { @@ -141,26 +206,28 @@ export default function RootLayout() { - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 67733c4bd..2ad5085e2 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -221,6 +221,7 @@ export function AgentList({ agents, isRefreshing = false, onRefresh, selectedAge const styles = StyleSheet.create((theme) => ({ list: { flex: 1, + minHeight: 0, }, listContent: { paddingHorizontal: theme.spacing[4], diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx index 78dd8d938..ab85ce86c 100644 --- a/packages/app/src/components/sliding-sidebar.tsx +++ b/packages/app/src/components/sliding-sidebar.tsx @@ -1,14 +1,11 @@ -import { useCallback, useEffect } from "react"; -import { View, Pressable, useWindowDimensions, Text } from "react-native"; +import { useCallback } from "react"; +import { View, Pressable, Text, Platform } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { useAnimatedStyle, - useSharedValue, - withTiming, interpolate, Extrapolation, runOnJS, - Easing, } from "react-native-reanimated"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; @@ -17,10 +14,9 @@ import { router } from "expo-router"; import { useSidebarStore } from "@/stores/sidebar-store"; import { AgentList } from "./agent-list"; import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; +import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; const DESKTOP_SIDEBAR_WIDTH = 320; -const ANIMATION_DURATION = 220; -const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); interface SlidingSidebarProps { selectedAgentId?: string; @@ -29,46 +25,24 @@ interface SlidingSidebarProps { export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); - const { width: windowWidth } = useWindowDimensions(); - const { isOpen, open, close } = useSidebarStore(); + const { isOpen, close } = useSidebarStore(); const { agents, isRevalidating, refreshAll } = useAggregatedAgents(); + const { + translateX, + backdropOpacity, + windowWidth, + animateToOpen, + animateToClose, + isGesturing, + } = useSidebarAnimation(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - // Mobile sidebar is full width - const sidebarWidth = isMobile ? windowWidth : DESKTOP_SIDEBAR_WIDTH; - - const translateX = useSharedValue(isOpen ? 0 : -sidebarWidth); - const backdropOpacity = useSharedValue(isOpen ? 1 : 0); - - // Track if we're currently in a gesture (to prevent useEffect from interfering) - const isGesturing = useSharedValue(false); - - useEffect(() => { - // Don't animate if we're in the middle of a gesture - if (isGesturing.value) { - return; - } - - const width = isMobile ? windowWidth : DESKTOP_SIDEBAR_WIDTH; - translateX.value = withTiming(isOpen ? 0 : -width, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); - backdropOpacity.value = withTiming(isOpen ? 1 : 0, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); - }, [isOpen, translateX, backdropOpacity, isMobile, windowWidth, isGesturing]); - const handleClose = useCallback(() => { close(); }, [close]); - const handleOpen = useCallback(() => { - open(); - }, [open]); // Mobile: close sidebar and navigate const handleCreateAgentMobile = useCallback(() => { @@ -93,19 +67,16 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { }, []); // Mobile: close sidebar when agent is selected - // Use a quick fade instead of slide since navigation interrupts the animation + // Snap immediately since navigation interrupts animations const handleAgentSelectMobile = useCallback(() => { - // Fast fade out - slide animations freeze during navigation - backdropOpacity.value = withTiming(0, { - duration: 100, - easing: ANIMATION_EASING, - }); - translateX.value = -windowWidth; // Snap immediately + translateX.value = -windowWidth; + backdropOpacity.value = 0; close(); }, [close, translateX, backdropOpacity, windowWidth]); // Close gesture (swipe left to close when sidebar is open) const closeGesture = Gesture.Pan() + .enabled(isOpen) // Only activate after 15px horizontal movement .activeOffsetX([-15, 15]) // Fail if 10px vertical movement happens first (allow vertical scroll) @@ -130,89 +101,16 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { if (!isMobile) return; const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500; if (shouldClose) { - translateX.value = withTiming(-windowWidth, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); - backdropOpacity.value = withTiming(0, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); + animateToClose(); runOnJS(handleClose)(); } else { - translateX.value = withTiming(0, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); - backdropOpacity.value = withTiming(1, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); + animateToOpen(); } }) .onFinalize(() => { isGesturing.value = false; }); - // Open gesture (swipe right from left edge to open when sidebar is closed) - const openGesture = Gesture.Pan() - .hitSlop({ right: windowWidth * 0.5 }) - // Only activate after 15px horizontal movement to the right - .activeOffsetX(15) - // Fail if 10px vertical movement happens first (allow vertical scroll) - .failOffsetY([-10, 10]) - .onStart(() => { - isGesturing.value = true; - }) - .onUpdate((event) => { - if (!isMobile) return; - // Start from closed position (-windowWidth) and move towards 0 - const newTranslateX = Math.min(0, -windowWidth + event.translationX); - translateX.value = newTranslateX; - backdropOpacity.value = interpolate( - newTranslateX, - [-windowWidth, 0], - [0, 1], - Extrapolation.CLAMP - ); - }) - .onEnd((event) => { - isGesturing.value = false; - if (!isMobile) return; - // Open if dragged more than 1/3 of sidebar or fast swipe - const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500; - if (shouldOpen) { - translateX.value = withTiming(0, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); - backdropOpacity.value = withTiming(1, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); - runOnJS(handleOpen)(); - } else { - translateX.value = withTiming(-windowWidth, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); - backdropOpacity.value = withTiming(0, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); - } - }) - .onFinalize(() => { - isGesturing.value = false; - }); - - // Use Race so that whichever gesture activates first wins - if vertical scroll - // happens first (via Native), the Pan gesture fails; if horizontal swipe - // happens first, Pan wins and closes the sidebar - const swipeGesture = Gesture.Race( - isOpen ? closeGesture : openGesture, - Gesture.Native() - ); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }], @@ -223,56 +121,60 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", })); - // Render mobile sidebar with edge swipe + // Render mobile sidebar + // On web, use "auto" instead of "box-none" because web's pointer-events: none blocks scroll + const overlayPointerEvents = Platform.OS === "web" ? "auto" : "box-none"; if (isMobile) { return ( - + {/* Backdrop */} - {/* Sidebar */} - + - {/* Header: New Agent button */} - - [ - styles.newAgentButton, - hovered && styles.newAgentButtonHovered, - ]} - onPress={handleCreateAgentMobile} - > - - New Agent - - + + {/* Header */} + + [ + styles.newAgentButton, + hovered && styles.newAgentButtonHovered, + ]} + onPress={handleCreateAgentMobile} + > + + New Agent + + - {/* Middle: scrollable agent list */} - + {/* Middle: scrollable agent list */} + - {/* Footer: Settings button */} - - - - Settings - + {/* Footer */} + + + + Settings + + @@ -337,6 +239,12 @@ const styles = StyleSheet.create((theme) => ({ left: 0, bottom: 0, backgroundColor: theme.colors.background, + overflow: "hidden", + }, + sidebarContent: { + flex: 1, + minHeight: 0, + overflow: "hidden", }, desktopSidebar: { borderRightWidth: 1, diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx new file mode 100644 index 000000000..f257c3e14 --- /dev/null +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -0,0 +1,117 @@ +import { createContext, useContext, useEffect, useRef, type ReactNode } from "react"; +import { useWindowDimensions } from "react-native"; +import { + useSharedValue, + withTiming, + Easing, + type SharedValue, +} from "react-native-reanimated"; +import { useSidebarStore } from "@/stores/sidebar-store"; + +const ANIMATION_DURATION = 220; +const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); + +interface SidebarAnimationContextValue { + translateX: SharedValue; + backdropOpacity: SharedValue; + windowWidth: number; + animateToOpen: () => void; + animateToClose: () => void; + isGesturing: SharedValue; +} + +const SidebarAnimationContext = createContext(null); + +export function SidebarAnimationProvider({ children }: { children: ReactNode }) { + const { width: windowWidth } = useWindowDimensions(); + const { isOpen } = useSidebarStore(); + + // Initialize based on current state + const translateX = useSharedValue(isOpen ? 0 : -windowWidth); + const backdropOpacity = useSharedValue(isOpen ? 1 : 0); + const isGesturing = useSharedValue(false); + + // Track previous isOpen to detect changes + const prevIsOpen = useRef(isOpen); + + // Sync animation with store state changes (e.g., backdrop tap, programmatic open/close) + useEffect(() => { + // Skip if this is initial render or if we're mid-gesture + if (prevIsOpen.current === isOpen) { + return; + } + prevIsOpen.current = isOpen; + + // Don't animate if we're in the middle of a gesture - the gesture handler will handle it + if (isGesturing.value) { + return; + } + + if (isOpen) { + translateX.value = withTiming(0, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + backdropOpacity.value = withTiming(1, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + } else { + translateX.value = withTiming(-windowWidth, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + backdropOpacity.value = withTiming(0, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + } + }, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]); + + const animateToOpen = () => { + "worklet"; + translateX.value = withTiming(0, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + backdropOpacity.value = withTiming(1, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + }; + + const animateToClose = () => { + "worklet"; + translateX.value = withTiming(-windowWidth, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + backdropOpacity.value = withTiming(0, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + }; + + return ( + + {children} + + ); +} + +export function useSidebarAnimation() { + const context = useContext(SidebarAnimationContext); + if (!context) { + throw new Error("useSidebarAnimation must be used within SidebarAnimationProvider"); + } + return context; +}