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:
co-authored by
Claude Opus 4.5
parent
719ef75f3d
commit
edb3258ae9
@@ -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],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user