/** * SessionDrawer — Gmail-style left drawer for session navigation. * * Slides in from the left with a dark overlay. Shows sessions with * unread badges. Swipe left to remove, long press to rename. */ import React, { useCallback, useEffect, useRef, useState } from "react"; import { Animated, Dimensions, Keyboard, KeyboardAvoidingView, LayoutAnimation, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native"; import { GestureHandlerRootView, Swipeable, } from "react-native-gesture-handler"; import DraggableFlatList, { RenderItemParams, ScaleDecorator, } from "react-native-draggable-flatlist"; import * as Haptics from "expo-haptics"; import { WsSession, PaiProject } from "../types"; import { useChat } from "../contexts/ChatContext"; import { useTheme, type ThemeColors } from "../contexts/ThemeContext"; const SCREEN_WIDTH = Dimensions.get("window").width; const DRAWER_WIDTH = SCREEN_WIDTH * 0.82; const ROW_WIDTH = DRAWER_WIDTH; interface SessionDrawerProps { visible: boolean; onClose: () => void; } /* ── Swipeable row ── */ function SessionRow({ session, unreadCount, onSwitch, onLongPress, onDelete, onDrag, isDragging, colors, }: { session: WsSession; unreadCount: number; onSwitch: () => void; onLongPress: () => void; onDelete: () => void; onDrag: () => void; isDragging: boolean; colors: ThemeColors; }) { const swipeRef = useRef(null); const renderRightActions = ( _progress: Animated.AnimatedInterpolation, dragX: Animated.AnimatedInterpolation, ) => { const scale = dragX.interpolate({ inputRange: [-80, -40, 0], outputRange: [1, 0.8, 0], extrapolate: "clamp", }); return ( { swipeRef.current?.close(); onDelete(); }} style={{ backgroundColor: colors.danger, justifyContent: "center", alignItems: "center", width: 72, borderRadius: 12, marginLeft: 8, }} > Remove ); }; return ( {/* Icon — left */} {session.isActive ? ( ) : ( {session.kind === "api" ? "A" : "T"} )} {/* Name + subtitle — middle */} {session.name} {session.kind === "api" ? "Headless" : "Terminal"} {session.isActive ? " — active" : ""} {/* Unread badge */} {unreadCount > 0 && ( {unreadCount > 99 ? "99+" : unreadCount} )} {/* Drag handle */} ⋮⋮ ); } /* ── Inline rename ── */ function RenameEditor({ name, onConfirm, onCancel, colors, }: { name: string; onConfirm: (newName: string) => void; onCancel: () => void; colors: ThemeColors; }) { const [editName, setEditName] = useState(name); return ( onConfirm(editName.trim())} onBlur={onCancel} returnKeyType="done" style={{ color: colors.text, fontSize: 17, fontWeight: "600", borderBottomWidth: 2, borderBottomColor: colors.accent, paddingVertical: 10, paddingHorizontal: 4, }} placeholderTextColor={colors.textMuted} placeholder="Session name..." /> ); } /* ── Main Drawer ── */ export function SessionDrawer({ visible, onClose }: SessionDrawerProps) { const { sessions, activeSessionId, requestSessions, switchSession, renameSession, removeSession, createSession, fetchProjects, projects, unreadCounts, } = useChat(); const { colors } = useTheme(); const [editingId, setEditingId] = useState(null); const [showProjectPicker, setShowProjectPicker] = useState(false); const [customPath, setCustomPath] = useState(""); const slideAnim = useRef(new Animated.Value(-DRAWER_WIDTH)).current; const fadeAnim = useRef(new Animated.Value(0)).current; const [rendered, setRendered] = useState(false); // Local ordering: merge server sessions while preserving user's drag order const [orderedSessions, setOrderedSessions] = useState([]); useEffect(() => { setOrderedSessions((prev) => { if (prev.length === 0) return sessions; const serverIds = new Set(sessions.map((s) => s.id)); // Keep existing order, update session data, remove deleted const kept = prev .filter((p) => serverIds.has(p.id)) .map((p) => sessions.find((s) => s.id === p.id)!); // Append any new sessions at the end const keptIds = new Set(kept.map((s) => s.id)); const added = sessions.filter((s) => !keptIds.has(s.id)); return [...kept, ...added]; }); }, [sessions]); useEffect(() => { if (visible) { Keyboard.dismiss(); setRendered(true); requestSessions(); Animated.parallel([ Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, damping: 22, stiffness: 200, }), Animated.timing(fadeAnim, { toValue: 1, duration: 200, useNativeDriver: true, }), ]).start(); } else { Animated.parallel([ Animated.timing(slideAnim, { toValue: -DRAWER_WIDTH, duration: 200, useNativeDriver: true, }), Animated.timing(fadeAnim, { toValue: 0, duration: 200, useNativeDriver: true, }), ]).start(() => { setRendered(false); setEditingId(null); }); } }, [visible]); const handleClose = useCallback(() => { setEditingId(null); setShowProjectPicker(false); setCustomPath(""); Keyboard.dismiss(); onClose(); }, [onClose]); const handleSwitch = useCallback( (session: WsSession) => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); switchSession(session.id); handleClose(); }, [switchSession, handleClose], ); const handleStartRename = useCallback((session: WsSession) => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setEditingId(session.id); }, []); const handleConfirmRename = useCallback( (sessionId: string, newName: string) => { if (newName) renameSession(sessionId, newName); setEditingId(null); }, [renameSession], ); const handleRemove = useCallback( (session: WsSession) => { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); removeSession(session.id); }, [removeSession], ); const handleNewSession = useCallback(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setShowProjectPicker((prev) => { if (!prev) fetchProjects(); return !prev; }); }, [fetchProjects]); const launchSession = useCallback((opts?: { project?: string; path?: string }) => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); createSession(opts); setShowProjectPicker(false); setCustomPath(""); handleClose(); setTimeout(() => requestSessions(), 2500); }, [createSession, requestSessions, handleClose]); const renderItem = useCallback( ({ item, drag, isActive }: RenderItemParams) => { if (editingId === item.id) { return ( handleConfirmRename(item.id, name)} onCancel={() => setEditingId(null)} colors={colors} /> ); } return ( handleSwitch(item)} onLongPress={() => handleStartRename(item)} onDelete={() => handleRemove(item)} onDrag={drag} isDragging={isActive} colors={colors} /> ); }, [editingId, unreadCounts, colors, handleSwitch, handleStartRename, handleRemove, handleConfirmRename], ); const keyExtractor = useCallback((item: WsSession) => item.id, []); const handleDragEnd = useCallback( ({ data }: { data: WsSession[] }) => { setOrderedSessions(data); }, [], ); const renderSeparator = useCallback( () => ( ), [colors.border], ); if (!rendered) return null; return ( {/* Backdrop */} {/* Drawer panel */} {/* Header */} Sessions requestSessions()} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} style={({ pressed }) => ({ paddingHorizontal: 10, paddingVertical: 5, borderRadius: 10, backgroundColor: pressed ? colors.bgTertiary : colors.bgTertiary + "80", })} > Refresh {/* Session list */} {orderedSessions.length === 0 ? ( No sessions found ) : ( )} {/* New session button + project picker */} + New Session {showProjectPicker && ( {/* Home directory — always first */} launchSession({ path: "~" })} style={{ flexDirection: "row", alignItems: "center", paddingHorizontal: 16, paddingVertical: 14, }} > 🏠 Home ~/ {/* PAI projects */} {projects.map((p) => ( launchSession({ project: p.name })} style={{ flexDirection: "row", alignItems: "center", paddingHorizontal: 16, paddingVertical: 14, borderTopWidth: 1, borderTopColor: colors.border, }} > 📂 {p.name} {p.path.replace(/^\/Users\/[^/]+/, "~")} ))} {/* Custom path input */} { if (customPath.trim()) launchSession({ path: customPath.trim() }); }} style={{ flex: 1, color: colors.text, fontSize: 15, paddingVertical: 6, }} /> {customPath.trim().length > 0 && ( launchSession({ path: customPath.trim() })} style={{ paddingHorizontal: 14, paddingVertical: 8, borderRadius: 8, backgroundColor: colors.accent, marginLeft: 8, }} > Go )} )} {/* Footer */} Tap to switch — Long press to rename — Swipe to remove ); }