mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat(app): add sliding sidebar navigation for mobile
- Homepage now shows "New Agent" form, agent list moves to sidebar - Hamburger menu replaces back arrow on all screens - Mobile: full-screen sidebar overlay with swipe-to-close gesture - Desktop: fixed 320px sidebar, toggle via hamburger - Smooth bezier-eased animations (220ms) - Sidebar closes on agent select (mobile only) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { Stack, useLocalSearchParams, 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";
|
||||
@@ -6,12 +6,13 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { RealtimeProvider } from "@/contexts/realtime-context";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { View, ActivityIndicator, Text } from "react-native";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context";
|
||||
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useState, type ReactNode, useMemo } from "react";
|
||||
import { SlidingSidebar } from "@/components/sliding-sidebar";
|
||||
|
||||
function QueryProvider({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
@@ -32,12 +33,23 @@ function QueryProvider({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
function AppContainer({ children }: { children: ReactNode }) {
|
||||
interface AppContainerProps {
|
||||
children: ReactNode;
|
||||
selectedAgentId?: string;
|
||||
}
|
||||
|
||||
function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
||||
{children}
|
||||
<View style={{ flex: 1, flexDirection: "row" }}>
|
||||
{!isMobile && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
||||
<View style={{ flex: 1 }}>{children}</View>
|
||||
</View>
|
||||
{isMobile && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +70,22 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
return <RealtimeProvider>{children}</RealtimeProvider>;
|
||||
}
|
||||
|
||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const params = useLocalSearchParams<{ agentId?: string }>();
|
||||
|
||||
const selectedAgentId = useMemo(() => {
|
||||
if (pathname.startsWith("/agent/") && params.agentId) {
|
||||
return params.agentId;
|
||||
}
|
||||
return undefined;
|
||||
}, [pathname, params.agentId]);
|
||||
|
||||
return (
|
||||
<AppContainer selectedAgentId={selectedAgentId}>{children}</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingView() {
|
||||
return (
|
||||
<View
|
||||
@@ -109,7 +137,7 @@ export default function RootLayout() {
|
||||
<DaemonConnectionsProvider>
|
||||
<MultiDaemonSessionHost />
|
||||
<ProvidersWrapper>
|
||||
<AppContainer>
|
||||
<AppWithSidebar>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
@@ -128,7 +156,7 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="git-diff" />
|
||||
<Stack.Screen name="file-explorer" />
|
||||
</Stack>
|
||||
</AppContainer>
|
||||
</AppWithSidebar>
|
||||
</ProvidersWrapper>
|
||||
</DaemonConnectionsProvider>
|
||||
</DaemonRegistryProvider>
|
||||
|
||||
@@ -24,17 +24,15 @@ import {
|
||||
GitBranch,
|
||||
Folder,
|
||||
RotateCcw,
|
||||
Plus,
|
||||
Download,
|
||||
Users,
|
||||
ChevronRight,
|
||||
PlusIcon,
|
||||
} from "lucide-react-native";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { AgentList } from "@/components/agent-list";
|
||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||
import { ImportAgentModal } from "@/components/create-agent-modal";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
@@ -148,7 +146,6 @@ export default function AgentScreen() {
|
||||
<AgentScreenContent
|
||||
serverId={resolvedServerId}
|
||||
agentId={resolvedAgentId}
|
||||
onBack={handleBackToHome}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -156,28 +153,17 @@ export default function AgentScreen() {
|
||||
type AgentScreenContentProps = {
|
||||
serverId: string;
|
||||
agentId?: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
const SIDEBAR_WIDTH = 280;
|
||||
const LARGE_SCREEN_BREAKPOINT = 768;
|
||||
|
||||
function AgentScreenContent({
|
||||
serverId,
|
||||
agentId,
|
||||
onBack,
|
||||
}: AgentScreenContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const isLargeScreen = windowWidth >= LARGE_SCREEN_BREAKPOINT;
|
||||
|
||||
const {
|
||||
agents: aggregatedAgents,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
} = useAggregatedAgents();
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
|
||||
const [menuContentHeight, setMenuContentHeight] = useState(0);
|
||||
@@ -193,27 +179,6 @@ function AgentScreenContent({
|
||||
: undefined
|
||||
);
|
||||
|
||||
|
||||
// Get parent agent ID for back navigation
|
||||
const parentAgentId = agent?.parentAgentId;
|
||||
|
||||
// Navigate to parent agent if this is a child, otherwise go to homepage
|
||||
const handleBack = useCallback(() => {
|
||||
if (parentAgentId) {
|
||||
// Child agent: navigate back to parent
|
||||
router.push({
|
||||
pathname: "/agent/[serverId]/[agentId]",
|
||||
params: {
|
||||
serverId: serverId,
|
||||
agentId: parentAgentId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Root agent: navigate to homepage
|
||||
onBack();
|
||||
}
|
||||
}, [parentAgentId, router, serverId, onBack]);
|
||||
|
||||
// Select the agents Map directly - this is a stable reference that only changes when agents are added/removed
|
||||
const allAgents = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.agents
|
||||
@@ -560,7 +525,7 @@ function AgentScreenContent({
|
||||
if (agentModel) {
|
||||
params.model = agentModel;
|
||||
}
|
||||
router.push({ pathname: "/agent/new", params });
|
||||
router.push({ pathname: "/", params });
|
||||
}, [agent, agentModel, handleCloseMenu, router, serverId]);
|
||||
|
||||
const handleImportAgent = useCallback(() => {
|
||||
@@ -598,7 +563,7 @@ function AgentScreenContent({
|
||||
return (
|
||||
<>
|
||||
<View style={styles.container}>
|
||||
<BackHeader onBack={onBack} />
|
||||
<MenuHeader title="Agent" />
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Agent not found</Text>
|
||||
</View>
|
||||
@@ -611,87 +576,48 @@ function AgentScreenContent({
|
||||
return (
|
||||
<>
|
||||
<View style={styles.container}>
|
||||
<View
|
||||
style={[styles.mainLayout, isLargeScreen && styles.mainLayoutRow]}
|
||||
>
|
||||
{/* Sidebar - only on large screens */}
|
||||
{isLargeScreen && (
|
||||
<View style={[styles.sidebar, { width: SIDEBAR_WIDTH }]}>
|
||||
<View style={styles.sidebarHeader}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.newAgentButton,
|
||||
{ backgroundColor: theme.colors.primary },
|
||||
]}
|
||||
onPress={handleCreateNewAgent}
|
||||
>
|
||||
<Plus size={18} color={theme.colors.primaryForeground} />
|
||||
<Text
|
||||
style={[
|
||||
styles.newAgentButtonText,
|
||||
{ color: theme.colors.primaryForeground },
|
||||
]}
|
||||
>
|
||||
New Agent
|
||||
</Text>
|
||||
</Pressable>
|
||||
{/* Header */}
|
||||
<MenuHeader
|
||||
title={agent.title || "Agent"}
|
||||
rightContent={
|
||||
<View ref={menuButtonRef} collapsable={false}>
|
||||
<Pressable onPress={handleOpenMenu} style={styles.menuButton}>
|
||||
<MoreVertical size={20} color={theme.colors.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Content Area with Keyboard Animation */}
|
||||
<View style={styles.contentContainer}>
|
||||
<ReanimatedAnimated.View
|
||||
style={[styles.content, animatedKeyboardStyle]}
|
||||
>
|
||||
{isInitializing ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator
|
||||
size="large"
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
<Text style={styles.loadingText}>Loading agent...</Text>
|
||||
</View>
|
||||
<AgentList
|
||||
agents={aggregatedAgents}
|
||||
isRefreshing={isRevalidating}
|
||||
onRefresh={refreshAll}
|
||||
selectedAgentId={resolvedAgentId}
|
||||
) : (
|
||||
<AgentStreamView
|
||||
agentId={agent.id}
|
||||
serverId={serverId}
|
||||
agent={agent}
|
||||
streamItems={streamItems}
|
||||
pendingPermissions={pendingPermissions}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Main agent panel */}
|
||||
<View style={styles.agentPanel}>
|
||||
{/* Header */}
|
||||
<BackHeader
|
||||
title={agent.title || "Agent"}
|
||||
onBack={handleBack}
|
||||
rightContent={
|
||||
<View ref={menuButtonRef} collapsable={false}>
|
||||
<Pressable onPress={handleOpenMenu} style={styles.menuButton}>
|
||||
<MoreVertical size={20} color={theme.colors.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Content Area with Keyboard Animation */}
|
||||
<View style={styles.contentContainer}>
|
||||
<ReanimatedAnimated.View
|
||||
style={[styles.content, animatedKeyboardStyle]}
|
||||
>
|
||||
{isInitializing ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator
|
||||
size="large"
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
<Text style={styles.loadingText}>Loading agent...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<AgentStreamView
|
||||
agentId={agent.id}
|
||||
serverId={serverId}
|
||||
agent={agent}
|
||||
streamItems={streamItems}
|
||||
pendingPermissions={pendingPermissions}
|
||||
/>
|
||||
)}
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
|
||||
{/* Agent Input Area */}
|
||||
{!isInitializing && agent && resolvedAgentId && (
|
||||
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus />
|
||||
)}
|
||||
</View>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
|
||||
{/* Agent Input Area */}
|
||||
{!isInitializing && agent && resolvedAgentId && (
|
||||
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus />
|
||||
)}
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
<Modal
|
||||
visible={menuVisible}
|
||||
@@ -931,36 +857,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
mainLayout: {
|
||||
flex: 1,
|
||||
},
|
||||
mainLayoutRow: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
sidebar: {
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
},
|
||||
sidebarHeader: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
newAgentButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
newAgentButtonText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
agentPanel: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,10 @@ interface AgentListProps {
|
||||
isRefreshing?: boolean;
|
||||
onRefresh?: () => void;
|
||||
selectedAgentId?: string;
|
||||
onAgentSelect?: () => void;
|
||||
}
|
||||
|
||||
export function AgentList({ agents, isRefreshing = false, onRefresh, selectedAgentId }: AgentListProps) {
|
||||
export function AgentList({ agents, isRefreshing = false, onRefresh, selectedAgentId, onAgentSelect }: AgentListProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const pathname = usePathname();
|
||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||
@@ -65,8 +66,9 @@ export function AgentList({ agents, isRefreshing = false, onRefresh, selectedAge
|
||||
agentId,
|
||||
},
|
||||
});
|
||||
onAgentSelect?.();
|
||||
},
|
||||
[isActionSheetVisible, pathname]
|
||||
[isActionSheetVisible, pathname, onAgentSelect]
|
||||
);
|
||||
|
||||
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
|
||||
|
||||
57
packages/app/src/components/headers/menu-header.tsx
Normal file
57
packages/app/src/components/headers/menu-header.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Menu } from "lucide-react-native";
|
||||
import { ScreenHeader } from "./screen-header";
|
||||
import { useSidebarStore } from "@/stores/sidebar-store";
|
||||
|
||||
interface MenuHeaderProps {
|
||||
title?: string;
|
||||
rightContent?: ReactNode;
|
||||
}
|
||||
|
||||
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { toggle } = useSidebarStore();
|
||||
|
||||
return (
|
||||
<ScreenHeader
|
||||
left={
|
||||
<>
|
||||
<Pressable onPress={toggle} style={styles.menuButton}>
|
||||
<Menu size={20} color={theme.colors.mutedForeground} />
|
||||
</Pressable>
|
||||
{title && (
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
right={rightContent}
|
||||
leftStyle={styles.left}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
left: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
menuButton: {
|
||||
padding: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[2],
|
||||
},
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: {
|
||||
xs: theme.fontWeight.semibold,
|
||||
md: "400",
|
||||
},
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -163,7 +163,7 @@ export function HomeFooter() {
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
console.log("[HomeFooter] New Agent button pressed");
|
||||
router.push("/agent/new");
|
||||
router.push("/");
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.footerButton,
|
||||
|
||||
354
packages/app/src/components/sliding-sidebar.tsx
Normal file
354
packages/app/src/components/sliding-sidebar.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { View, Pressable, useWindowDimensions, Text } 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";
|
||||
import { Plus, Settings } from "lucide-react-native";
|
||||
import { router } from "expo-router";
|
||||
import { useSidebarStore } from "@/stores/sidebar-store";
|
||||
import { AgentList } from "./agent-list";
|
||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const { isOpen, open, close } = useSidebarStore();
|
||||
const { agents, isRevalidating, refreshAll } = useAggregatedAgents();
|
||||
|
||||
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(() => {
|
||||
close();
|
||||
router.push("/");
|
||||
}, [close]);
|
||||
|
||||
// Desktop: just navigate, don't close
|
||||
const handleCreateAgentDesktop = useCallback(() => {
|
||||
router.push("/");
|
||||
}, []);
|
||||
|
||||
// Mobile: close sidebar and navigate
|
||||
const handleSettingsMobile = useCallback(() => {
|
||||
close();
|
||||
router.push("/settings");
|
||||
}, [close]);
|
||||
|
||||
// Desktop: just navigate, don't close
|
||||
const handleSettingsDesktop = useCallback(() => {
|
||||
router.push("/settings");
|
||||
}, []);
|
||||
|
||||
// Mobile: close sidebar when agent is selected
|
||||
const handleAgentSelectMobile = useCallback(() => {
|
||||
close();
|
||||
}, [close]);
|
||||
|
||||
// Close gesture (swipe left to close when sidebar is open)
|
||||
const closeGesture = Gesture.Pan()
|
||||
// Only activate after 15px horizontal movement
|
||||
.activeOffsetX([-15, 15])
|
||||
// Fail if 10px vertical movement happens first (allow vertical scroll)
|
||||
.failOffsetY([-10, 10])
|
||||
.onStart(() => {
|
||||
isGesturing.value = true;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
if (!isMobile) return;
|
||||
// Only allow swiping left (closing)
|
||||
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX));
|
||||
translateX.value = newTranslateX;
|
||||
backdropOpacity.value = interpolate(
|
||||
newTranslateX,
|
||||
[-windowWidth, 0],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
})
|
||||
.onEnd((event) => {
|
||||
isGesturing.value = false;
|
||||
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,
|
||||
});
|
||||
runOnJS(handleClose)();
|
||||
} else {
|
||||
translateX.value = withTiming(0, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
});
|
||||
backdropOpacity.value = withTiming(1, {
|
||||
duration: ANIMATION_DURATION,
|
||||
easing: ANIMATION_EASING,
|
||||
});
|
||||
}
|
||||
})
|
||||
.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;
|
||||
});
|
||||
|
||||
const swipeGesture = Gesture.Simultaneous(
|
||||
isOpen ? closeGesture : openGesture,
|
||||
Gesture.Native()
|
||||
);
|
||||
|
||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}));
|
||||
|
||||
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: backdropOpacity.value,
|
||||
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
|
||||
}));
|
||||
|
||||
// Render mobile sidebar with edge swipe
|
||||
if (isMobile) {
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents="box-none">
|
||||
{/* Backdrop */}
|
||||
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
|
||||
<Pressable style={styles.backdropPressable} onPress={handleClose} />
|
||||
</Animated.View>
|
||||
|
||||
{/* Sidebar */}
|
||||
<GestureDetector gesture={swipeGesture}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.mobileSidebar,
|
||||
{ width: windowWidth, paddingTop: insets.top },
|
||||
sidebarAnimatedStyle,
|
||||
]}
|
||||
>
|
||||
<View style={styles.sidebarHeader}>
|
||||
<Pressable
|
||||
style={styles.headerIconButton}
|
||||
onPress={handleSettingsMobile}
|
||||
>
|
||||
<Settings size={20} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.newAgentButton,
|
||||
{ backgroundColor: theme.colors.primary },
|
||||
]}
|
||||
onPress={handleCreateAgentMobile}
|
||||
>
|
||||
<Plus size={18} color={theme.colors.primaryForeground} />
|
||||
<Text
|
||||
style={[
|
||||
styles.newAgentButtonText,
|
||||
{ color: theme.colors.primaryForeground },
|
||||
]}
|
||||
>
|
||||
New Agent
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<AgentList
|
||||
agents={agents}
|
||||
isRefreshing={isRevalidating}
|
||||
onRefresh={refreshAll}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={handleAgentSelectMobile}
|
||||
/>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: no edge swipe, just show/hide based on isOpen
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
||||
<View style={styles.sidebarHeader}>
|
||||
<Pressable style={styles.headerIconButton} onPress={handleSettingsDesktop}>
|
||||
<Settings size={20} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.newAgentButton,
|
||||
{ backgroundColor: theme.colors.primary },
|
||||
]}
|
||||
onPress={handleCreateAgentDesktop}
|
||||
>
|
||||
<Plus size={18} color={theme.colors.primaryForeground} />
|
||||
<Text
|
||||
style={[
|
||||
styles.newAgentButtonText,
|
||||
{ color: theme.colors.primaryForeground },
|
||||
]}
|
||||
>
|
||||
New Agent
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<AgentList
|
||||
agents={agents}
|
||||
isRefreshing={isRevalidating}
|
||||
onRefresh={refreshAll}
|
||||
selectedAgentId={selectedAgentId}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
backdropPressable: {
|
||||
flex: 1,
|
||||
},
|
||||
mobileSidebar: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
desktopSidebar: {
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
sidebarHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
headerIconButton: {
|
||||
padding: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
newAgentButton: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
newAgentButtonText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
30
packages/app/src/stores/sidebar-store.ts
Normal file
30
packages/app/src/stores/sidebar-store.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
interface SidebarState {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const useSidebarStore = create<SidebarState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
isOpen: false,
|
||||
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
|
||||
open: () => set({ isOpen: true }),
|
||||
close: () => set({ isOpen: false }),
|
||||
}),
|
||||
{
|
||||
name: "sidebar-state",
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({ isOpen: state.isOpen }),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export function useSidebar() {
|
||||
return useSidebarStore();
|
||||
}
|
||||
Reference in New Issue
Block a user