feat(sidebar): add interactive drag-to-open gesture

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 <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2026-01-04 12:46:53 +07:00
co-authored by Claude Opus 4.5
parent 719ef75f3d
commit edb3258ae9
4 changed files with 269 additions and 176 deletions
+89 -22
View File
@@ -1,7 +1,7 @@
import { Stack, usePathname } from "expo-router"; import { Stack, usePathname } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller"; 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 { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { RealtimeProvider } from "@/contexts/realtime-context"; import { RealtimeProvider } from "@/contexts/realtime-context";
import { useAppSettings } from "@/hooks/use-settings"; 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode, useMemo } from "react"; import { useState, type ReactNode, useMemo } from "react";
import { SlidingSidebar } from "@/components/sliding-sidebar"; 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 }) { function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState( const [queryClient] = useState(
@@ -40,10 +46,59 @@ interface AppContainerProps {
function AppContainer({ children, selectedAgentId }: AppContainerProps) { function AppContainer({ children, selectedAgentId }: AppContainerProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { isOpen, open } = useSidebarStore();
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
} = useSidebarAnimation();
const isMobile = const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; 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 = (
<View style={{ flex: 1, backgroundColor: theme.colors.background }}> <View style={{ flex: 1, backgroundColor: theme.colors.background }}>
<View style={{ flex: 1, flexDirection: "row" }}> <View style={{ flex: 1, flexDirection: "row" }}>
{!isMobile && <SlidingSidebar selectedAgentId={selectedAgentId} />} {!isMobile && <SlidingSidebar selectedAgentId={selectedAgentId} />}
@@ -52,6 +107,16 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
{isMobile && <SlidingSidebar selectedAgentId={selectedAgentId} />} {isMobile && <SlidingSidebar selectedAgentId={selectedAgentId} />}
</View> </View>
); );
if (!isMobile) {
return content;
}
return (
<GestureDetector gesture={openGesture} touchAction="pan-y">
{content}
</GestureDetector>
);
} }
function ProvidersWrapper({ children }: { children: ReactNode }) { function ProvidersWrapper({ children }: { children: ReactNode }) {
@@ -141,26 +206,28 @@ export default function RootLayout() {
<DaemonConnectionsProvider> <DaemonConnectionsProvider>
<MultiDaemonSessionHost /> <MultiDaemonSessionHost />
<ProvidersWrapper> <ProvidersWrapper>
<AppWithSidebar> <SidebarAnimationProvider>
<Stack <AppWithSidebar>
screenOptions={{ <Stack
headerShown: false, screenOptions={{
animation: "none", headerShown: false,
gestureEnabled: true, animation: "none",
gestureDirection: "horizontal", gestureEnabled: true,
fullScreenGestureEnabled: true, gestureDirection: "horizontal",
}} fullScreenGestureEnabled: true,
> }}
<Stack.Screen name="index" /> >
<Stack.Screen name="orchestrator" /> <Stack.Screen name="index" />
<Stack.Screen name="agent/[id]" /> <Stack.Screen name="orchestrator" />
<Stack.Screen name="agent/[serverId]/[agentId]" /> <Stack.Screen name="agent/[id]" />
<Stack.Screen name="settings" /> <Stack.Screen name="agent/[serverId]/[agentId]" />
<Stack.Screen name="audio-test" /> <Stack.Screen name="settings" />
<Stack.Screen name="git-diff" /> <Stack.Screen name="audio-test" />
<Stack.Screen name="file-explorer" /> <Stack.Screen name="git-diff" />
</Stack> <Stack.Screen name="file-explorer" />
</AppWithSidebar> </Stack>
</AppWithSidebar>
</SidebarAnimationProvider>
</ProvidersWrapper> </ProvidersWrapper>
</DaemonConnectionsProvider> </DaemonConnectionsProvider>
</DaemonRegistryProvider> </DaemonRegistryProvider>
@@ -221,6 +221,7 @@ export function AgentList({ agents, isRefreshing = false, onRefresh, selectedAge
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
list: { list: {
flex: 1, flex: 1,
minHeight: 0,
}, },
listContent: { listContent: {
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
+62 -154
View File
@@ -1,14 +1,11 @@
import { useCallback, useEffect } from "react"; import { useCallback } from "react";
import { View, Pressable, useWindowDimensions, Text } from "react-native"; import { View, Pressable, Text, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, { import Animated, {
useAnimatedStyle, useAnimatedStyle,
useSharedValue,
withTiming,
interpolate, interpolate,
Extrapolation, Extrapolation,
runOnJS, runOnJS,
Easing,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
@@ -17,10 +14,9 @@ import { router } from "expo-router";
import { useSidebarStore } from "@/stores/sidebar-store"; import { useSidebarStore } from "@/stores/sidebar-store";
import { AgentList } from "./agent-list"; import { AgentList } from "./agent-list";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
const DESKTOP_SIDEBAR_WIDTH = 320; const DESKTOP_SIDEBAR_WIDTH = 320;
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
interface SlidingSidebarProps { interface SlidingSidebarProps {
selectedAgentId?: string; selectedAgentId?: string;
@@ -29,46 +25,24 @@ interface SlidingSidebarProps {
export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { width: windowWidth } = useWindowDimensions(); const { isOpen, close } = useSidebarStore();
const { isOpen, open, close } = useSidebarStore();
const { agents, isRevalidating, refreshAll } = useAggregatedAgents(); const { agents, isRevalidating, refreshAll } = useAggregatedAgents();
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
} = useSidebarAnimation();
const isMobile = const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; 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(() => { const handleClose = useCallback(() => {
close(); close();
}, [close]); }, [close]);
const handleOpen = useCallback(() => {
open();
}, [open]);
// Mobile: close sidebar and navigate // Mobile: close sidebar and navigate
const handleCreateAgentMobile = useCallback(() => { const handleCreateAgentMobile = useCallback(() => {
@@ -93,19 +67,16 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
}, []); }, []);
// Mobile: close sidebar when agent is selected // 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(() => { const handleAgentSelectMobile = useCallback(() => {
// Fast fade out - slide animations freeze during navigation translateX.value = -windowWidth;
backdropOpacity.value = withTiming(0, { backdropOpacity.value = 0;
duration: 100,
easing: ANIMATION_EASING,
});
translateX.value = -windowWidth; // Snap immediately
close(); close();
}, [close, translateX, backdropOpacity, windowWidth]); }, [close, translateX, backdropOpacity, windowWidth]);
// Close gesture (swipe left to close when sidebar is open) // Close gesture (swipe left to close when sidebar is open)
const closeGesture = Gesture.Pan() const closeGesture = Gesture.Pan()
.enabled(isOpen)
// Only activate after 15px horizontal movement // Only activate after 15px horizontal movement
.activeOffsetX([-15, 15]) .activeOffsetX([-15, 15])
// Fail if 10px vertical movement happens first (allow vertical scroll) // Fail if 10px vertical movement happens first (allow vertical scroll)
@@ -130,89 +101,16 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
if (!isMobile) return; if (!isMobile) return;
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500; const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
if (shouldClose) { if (shouldClose) {
translateX.value = withTiming(-windowWidth, { animateToClose();
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
runOnJS(handleClose)(); runOnJS(handleClose)();
} else { } else {
translateX.value = withTiming(0, { animateToOpen();
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
} }
}) })
.onFinalize(() => { .onFinalize(() => {
isGesturing.value = false; 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(() => ({ const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }], transform: [{ translateX: translateX.value }],
@@ -223,56 +121,60 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", 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) { if (isMobile) {
return ( return (
<View style={StyleSheet.absoluteFillObject} pointerEvents="box-none"> <View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
{/* Backdrop */} {/* Backdrop */}
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}> <Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable style={styles.backdropPressable} onPress={handleClose} /> <Pressable style={styles.backdropPressable} onPress={handleClose} />
</Animated.View> </Animated.View>
{/* Sidebar */} <GestureDetector gesture={closeGesture} touchAction="pan-y">
<GestureDetector gesture={swipeGesture}>
<Animated.View <Animated.View
style={[ style={[
styles.mobileSidebar, styles.mobileSidebar,
{ width: windowWidth, paddingTop: insets.top, paddingBottom: insets.bottom }, { width: windowWidth, paddingTop: insets.top, paddingBottom: insets.bottom },
sidebarAnimatedStyle, sidebarAnimatedStyle,
]} ]}
pointerEvents="auto"
> >
{/* Header: New Agent button */} <View style={styles.sidebarContent} pointerEvents="auto">
<View style={styles.sidebarHeader}> {/* Header */}
<Pressable <View style={styles.sidebarHeader}>
style={({ hovered }) => [ <Pressable
styles.newAgentButton, style={({ hovered }) => [
hovered && styles.newAgentButtonHovered, styles.newAgentButton,
]} hovered && styles.newAgentButtonHovered,
onPress={handleCreateAgentMobile} ]}
> onPress={handleCreateAgentMobile}
<Plus size={18} color={theme.colors.foreground} /> >
<Text style={styles.newAgentButtonText}>New Agent</Text> <Plus size={18} color={theme.colors.foreground} />
</Pressable> <Text style={styles.newAgentButtonText}>New Agent</Text>
</View> </Pressable>
</View>
{/* Middle: scrollable agent list */} {/* Middle: scrollable agent list */}
<AgentList <AgentList
agents={agents} agents={agents}
isRefreshing={isRevalidating} isRefreshing={isRevalidating}
onRefresh={refreshAll} onRefresh={refreshAll}
selectedAgentId={selectedAgentId} selectedAgentId={selectedAgentId}
onAgentSelect={handleAgentSelectMobile} onAgentSelect={handleAgentSelectMobile}
/> />
{/* Footer: Settings button */} {/* Footer */}
<View style={styles.sidebarFooter}> <View style={styles.sidebarFooter}>
<Pressable <Pressable
style={styles.settingsButton} style={styles.settingsButton}
onPress={handleSettingsMobile} onPress={handleSettingsMobile}
> >
<Settings size={20} color={theme.colors.mutedForeground} /> <Settings size={20} color={theme.colors.mutedForeground} />
<Text style={styles.settingsButtonText}>Settings</Text> <Text style={styles.settingsButtonText}>Settings</Text>
</Pressable> </Pressable>
</View>
</View> </View>
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
@@ -337,6 +239,12 @@ const styles = StyleSheet.create((theme) => ({
left: 0, left: 0,
bottom: 0, bottom: 0,
backgroundColor: theme.colors.background, backgroundColor: theme.colors.background,
overflow: "hidden",
},
sidebarContent: {
flex: 1,
minHeight: 0,
overflow: "hidden",
}, },
desktopSidebar: { desktopSidebar: {
borderRightWidth: 1, borderRightWidth: 1,
@@ -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<number>;
backdropOpacity: SharedValue<number>;
windowWidth: number;
animateToOpen: () => void;
animateToClose: () => void;
isGesturing: SharedValue<boolean>;
}
const SidebarAnimationContext = createContext<SidebarAnimationContextValue | null>(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 (
<SidebarAnimationContext.Provider
value={{
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
}}
>
{children}
</SidebarAnimationContext.Provider>
);
}
export function useSidebarAnimation() {
const context = useContext(SidebarAnimationContext);
if (!context) {
throw new Error("useSidebarAnimation must be used within SidebarAnimationProvider");
}
return context;
}