Files
paseo/packages/app/src/components/sliding-sidebar.tsx
T
Mohamed Boudra 70e2d66dd5 feat: persist draft messages and UI refinements
- Add dedicated draft-store with AsyncStorage persistence for drafts
  that survive app refresh/crash
- Reduce markdown bold weight from bold to medium (500)
- Various theme and component style updates
- Server bootstrap and config improvements

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-14 10:07:54 +07:00

367 lines
11 KiB
TypeScript

import { useCallback, useMemo, useState, useEffect } from "react";
import { View, Pressable, Text, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useAnimatedStyle,
interpolate,
Extrapolation,
runOnJS,
} 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";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
const DESKTOP_SIDEBAR_WIDTH = 320;
const SIDEBAR_AGENT_LIMIT = 15;
interface SlidingSidebarProps {
selectedAgentId?: string;
}
export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const { isOpen, close } = useSidebarStore();
const { agents, isRevalidating, refreshAll } = useAggregatedAgents();
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
} = useSidebarAnimation();
// Track user-initiated refresh to avoid showing spinner on background revalidation
const [isManualRefresh, setIsManualRefresh] = useState(false);
const handleRefresh = useCallback(() => {
setIsManualRefresh(true);
refreshAll();
}, [refreshAll]);
// Reset manual refresh flag when revalidation completes
useEffect(() => {
if (!isRevalidating && isManualRefresh) {
setIsManualRefresh(false);
}
}, [isRevalidating, isManualRefresh]);
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const sortedAgents = useMemo(() => {
return [...agents].sort((a, b) => {
if (a.requiresAttention && !b.requiresAttention) return -1;
if (!a.requiresAttention && b.requiresAttention) return 1;
return 0;
});
}, [agents]);
const limitedAgents = useMemo(
() => sortedAgents.slice(0, SIDEBAR_AGENT_LIMIT),
[sortedAgents]
);
const hasMore = agents.length > SIDEBAR_AGENT_LIMIT;
const handleClose = useCallback(() => {
close();
}, [close]);
// 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
// Snap immediately since navigation interrupts animations
const handleAgentSelectMobile = useCallback(() => {
translateX.value = -windowWidth;
backdropOpacity.value = 0;
close();
}, [close, translateX, backdropOpacity, windowWidth]);
const handleViewMore = useCallback(() => {
if (isMobile) {
translateX.value = -windowWidth;
backdropOpacity.value = 0;
}
close();
router.push("/agents");
}, [backdropOpacity, close, isMobile, translateX, 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)
.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) {
animateToClose();
runOnJS(handleClose)();
} else {
animateToOpen();
}
})
.onFinalize(() => {
isGesturing.value = false;
});
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
const backdropAnimatedStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value,
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
}));
const viewMoreButton = hasMore ? (
<View style={styles.viewMoreContainer}>
<Pressable
style={({ hovered }) => [
styles.newAgentButton,
styles.viewMoreButton,
hovered && styles.newAgentButtonHovered,
]}
onPress={handleViewMore}
>
<Text style={styles.viewMoreButtonText}>View More</Text>
</Pressable>
</View>
) : null;
// 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 (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
{/* Backdrop */}
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable style={styles.backdropPressable} onPress={handleClose} />
</Animated.View>
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View
style={[
styles.mobileSidebar,
{ width: windowWidth, paddingTop: insets.top, paddingBottom: insets.bottom },
sidebarAnimatedStyle,
]}
pointerEvents="auto"
>
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header */}
<View style={styles.sidebarHeader}>
<Pressable
style={({ hovered }) => [
styles.newAgentButton,
hovered && styles.newAgentButtonHovered,
]}
onPress={handleCreateAgentMobile}
>
<Plus size={18} color={theme.colors.foreground} />
<Text style={styles.newAgentButtonText}>New Agent</Text>
</Pressable>
</View>
{/* Middle: scrollable agent list */}
<AgentList
agents={limitedAgents}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
selectedAgentId={selectedAgentId}
onAgentSelect={handleAgentSelectMobile}
listFooterComponent={viewMoreButton}
/>
{/* Footer */}
<View style={styles.sidebarFooter}>
<Pressable
style={styles.settingsButton}
onPress={handleSettingsMobile}
>
<Settings size={20} color={theme.colors.foregroundMuted} />
<Text style={styles.settingsButtonText}>Settings</Text>
</Pressable>
</View>
</View>
</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 }]}>
{/* Header: New Agent button */}
<View style={styles.sidebarHeader}>
<Pressable
style={({ hovered }) => [
styles.newAgentButton,
hovered && styles.newAgentButtonHovered,
]}
onPress={handleCreateAgentDesktop}
>
<Plus size={18} color={theme.colors.foreground} />
<Text style={styles.newAgentButtonText}>New Agent</Text>
</Pressable>
</View>
{/* Middle: scrollable agent list */}
<AgentList
agents={limitedAgents}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
selectedAgentId={selectedAgentId}
listFooterComponent={viewMoreButton}
/>
{/* Footer: Settings button */}
<View style={styles.sidebarFooter}>
<Pressable
style={styles.settingsButton}
onPress={handleSettingsDesktop}
>
<Settings size={20} color={theme.colors.foregroundMuted} />
<Text style={styles.settingsButtonText}>Settings</Text>
</Pressable>
</View>
</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.surface0,
overflow: "hidden",
},
sidebarContent: {
flex: 1,
minHeight: 0,
overflow: "hidden",
},
desktopSidebar: {
borderRightWidth: 1,
borderRightColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
},
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,
borderWidth: 1,
borderColor: theme.colors.border,
transitionProperty: "border-color, background-color",
transitionDuration: "150ms",
},
newAgentButtonHovered: {
borderColor: theme.colors.palette.zinc[500],
backgroundColor: theme.colors.surface2,
},
newAgentButtonText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
},
viewMoreContainer: {
paddingTop: theme.spacing[2],
},
viewMoreButton: {
paddingVertical: theme.spacing[2],
},
viewMoreButtonText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
},
sidebarFooter: {
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
settingsButton: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
},
settingsButtonText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
}));