import { memo, useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore, type Dispatch, type RefObject, type SetStateAction, } from "react"; import { View, Pressable, Text, Platform, useWindowDimensions } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { useAnimatedStyle, interpolate, Extrapolation, runOnJS, useSharedValue, } from "react-native-reanimated"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { MessagesSquare, Plus, Settings } from "lucide-react-native"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { router, usePathname } from "expo-router"; import { usePanelStore, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH } from "@/stores/panel-store"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model"; import { useSidebarWorkspacesList, type SidebarProjectEntry, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window"; import { Combobox } from "@/components/ui/combobox"; import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; import { formatConnectionStatus } from "@/utils/daemons"; import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout"; import { buildHostSessionsRoute, buildHostSettingsRoute, mapPathnameToServer, parseServerIdFromPathname, } from "@/utils/host-routes"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; const MIN_CHAT_WIDTH = 400; type SidebarShortcutModel = ReturnType; type SidebarTheme = ReturnType["theme"]; interface LeftSidebarProps { selectedAgentId?: string; } interface HostOption { id: string; label: string; description: string; } interface SidebarSharedProps { theme: SidebarTheme; activeServerId: string | null; activeHostLabel: string; activeHostStatusColor: string; hostOptions: HostOption[]; hostTriggerRef: RefObject; isHostPickerOpen: boolean; setIsHostPickerOpen: Dispatch>; projects: SidebarProjectEntry[]; isInitialLoad: boolean; isRevalidating: boolean; isManualRefresh: boolean; collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"]; shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"]; toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"]; handleRefresh: () => void; handleHostSelect: (nextServerId: string) => void; handleOpenProject: () => void; handleSettings: () => void; } interface MobileSidebarProps extends SidebarSharedProps { insetsTop: number; insetsBottom: number; isOpen: boolean; closeToAgent: () => void; handleViewMoreNavigate: () => void; } interface DesktopSidebarProps extends SidebarSharedProps { isOpen: boolean; handleViewMore: () => void; } export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selectedAgentId, }: LeftSidebarProps) { void _selectedAgentId; const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const mobileView = usePanelStore((state) => state.mobileView); const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const closeToAgent = usePanelStore((state) => state.closeToAgent); const pathname = usePathname(); const daemons = useHosts(); const runtime = getHostRuntimeStore(); const runtimeConnectionStatusSignature = useSyncExternalStore( (onStoreChange) => runtime.subscribeAll(onStoreChange), () => daemons .map( (daemon) => `${daemon.serverId}:${ runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting" }`, ) .join("|"), () => daemons .map( (daemon) => `${daemon.serverId}:${ runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting" }`, ) .join("|"), ); const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]); const activeDaemon = useMemo(() => { if (daemons.length === 0) { return null; } if (activeServerIdFromPath) { const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath); if (routeMatch) { return routeMatch; } } return daemons[0] ?? null; }, [activeServerIdFromPath, daemons]); const activeServerId = activeDaemon?.serverId ?? null; const activeHostLabel = useMemo(() => { if (!activeDaemon) return "No host"; const trimmed = activeDaemon.label?.trim(); return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId; }, [activeDaemon]); const activeHostStatus = activeServerId ? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting") : "idle"; const activeHostStatusColor = activeHostStatus === "online" ? theme.colors.palette.green[400] : activeHostStatus === "connecting" ? theme.colors.palette.amber[500] : theme.colors.palette.red[500]; const hostOptions = useMemo( () => daemons.map((daemon) => ({ id: daemon.serverId, label: daemon.label?.trim() || daemon.serverId, description: formatConnectionStatus( runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting", ), })), [daemons, runtime, runtimeConnectionStatusSignature], ); const hostTriggerRef = useRef(null); const [isHostPickerOpen, setIsHostPickerOpen] = useState(false); const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({ serverId: activeServerId, enabled: isOpen, }); const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, } = useSidebarShortcutModel(projects); const [isManualRefresh, setIsManualRefresh] = useState(false); const handleRefresh = useCallback(() => { setIsManualRefresh(true); refreshAll(); }, [refreshAll]); useEffect(() => { if (!isRevalidating && isManualRefresh) { setIsManualRefresh(false); } }, [isRevalidating, isManualRefresh]); const openProjectPicker = useOpenProjectPicker(activeServerId); const handleOpenProjectMobile = useCallback(() => { closeToAgent(); void openProjectPicker(); }, [closeToAgent, openProjectPicker]); const handleOpenProjectDesktop = useCallback(() => { void openProjectPicker(); }, [openProjectPicker]); const handleSettingsMobile = useCallback(() => { if (!activeServerId) { return; } closeToAgent(); router.push(buildHostSettingsRoute(activeServerId) as any); }, [activeServerId, closeToAgent]); const handleSettingsDesktop = useCallback(() => { if (!activeServerId) { return; } router.push(buildHostSettingsRoute(activeServerId) as any); }, [activeServerId]); const handleViewMoreNavigate = useCallback(() => { if (!activeServerId) { return; } router.push(buildHostSessionsRoute(activeServerId) as any); }, [activeServerId]); const handleHostSelect = useCallback( (nextServerId: string) => { if (!nextServerId) { return; } const nextPath = mapPathnameToServer(pathname, nextServerId); setIsHostPickerOpen(false); router.push(nextPath as any); }, [pathname], ); const sharedProps = { theme, activeServerId, activeHostLabel, activeHostStatusColor, hostOptions, hostTriggerRef, isHostPickerOpen, setIsHostPickerOpen, projects, isInitialLoad, isRevalidating, isManualRefresh, collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, handleRefresh, handleHostSelect, }; if (isMobile) { return ( ); } return ( ); }); function SessionsButton({ onPress }: { onPress: () => void }) { const { theme } = useUnistyles(); const pathname = usePathname(); const isActive = pathname.includes("/sessions"); return ( [ styles.newAgentButton, hovered && styles.newAgentButtonHovered, isActive && styles.newAgentButtonActive, ]} testID="sidebar-sessions" accessible accessibilityRole="button" accessibilityLabel="Sessions" onPress={onPress} > {({ hovered }) => ( <> Sessions )} ); } function MobileSidebar({ theme, activeServerId, activeHostLabel, activeHostStatusColor, hostOptions, hostTriggerRef, isHostPickerOpen, setIsHostPickerOpen, projects, isInitialLoad, isRevalidating, isManualRefresh, collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, handleRefresh, handleHostSelect, handleOpenProject, handleSettings, insetsTop, insetsBottom, isOpen, closeToAgent, handleViewMoreNavigate, }: MobileSidebarProps) { const newAgentKeys = useShortcutKeys("new-agent"); const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing, closeGestureRef, } = useSidebarAnimation(); const closeTouchStartX = useSharedValue(0); const closeTouchStartY = useSharedValue(0); const handleClose = useCallback(() => { closeToAgent(); }, [closeToAgent]); const handleViewMore = useCallback(() => { if (!activeServerId) { return; } translateX.value = -windowWidth; backdropOpacity.value = 0; closeToAgent(); handleViewMoreNavigate(); }, [ activeServerId, backdropOpacity, closeToAgent, handleViewMoreNavigate, translateX, windowWidth, ]); const closeGesture = useMemo( () => Gesture.Pan() .withRef(closeGestureRef) .enabled(isOpen) .manualActivation(true) .onTouchesDown((event) => { const touch = event.changedTouches[0]; if (!touch) { return; } closeTouchStartX.value = touch.absoluteX; closeTouchStartY.value = touch.absoluteY; }) .onTouchesMove((event, stateManager) => { const touch = event.changedTouches[0]; if (!touch || event.numberOfTouches !== 1) { stateManager.fail(); return; } const deltaX = touch.absoluteX - closeTouchStartX.value; const deltaY = touch.absoluteY - closeTouchStartY.value; const absDeltaX = Math.abs(deltaX); const absDeltaY = Math.abs(deltaY); if (deltaX >= 10) { stateManager.fail(); return; } if (absDeltaY > 10 && absDeltaY > absDeltaX) { stateManager.fail(); return; } if (deltaX <= -15 && absDeltaX > absDeltaY) { stateManager.activate(); } }) .onStart(() => { isGesturing.value = true; }) .onUpdate((event) => { 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; const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500; if (shouldClose) { animateToClose(); runOnJS(handleClose)(); } else { animateToOpen(); } }) .onFinalize(() => { isGesturing.value = false; }), [ isOpen, closeGestureRef, closeTouchStartX, closeTouchStartY, isGesturing, windowWidth, translateX, backdropOpacity, animateToClose, animateToOpen, handleClose, ], ); const mobileSidebarInsetStyle = useMemo( () => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }), [windowWidth, insetsTop, insetsBottom], ); const hostStatusDotStyle = useMemo( () => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }], [activeHostStatusColor], ); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }], })); const backdropAnimatedStyle = useAnimatedStyle(() => ({ opacity: backdropOpacity.value, pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", })); const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none"; return ( {isInitialLoad ? ( ) : ( )} [ styles.hostTrigger, hovered && styles.hostTriggerHovered, ]} onPress={() => setIsHostPickerOpen(true)} disabled={hostOptions.length === 0} > {activeHostLabel} {({ hovered }) => ( )} Add project {newAgentKeys && } {({ hovered }) => ( )} ); } function DesktopSidebar({ theme, activeServerId, activeHostLabel, activeHostStatusColor, hostOptions, hostTriggerRef, isHostPickerOpen, setIsHostPickerOpen, projects, isInitialLoad, isRevalidating, isManualRefresh, collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, handleRefresh, handleHostSelect, handleOpenProject, handleSettings, isOpen, handleViewMore, }: DesktopSidebarProps) { const newAgentKeys = useShortcutKeys("new-agent"); const dragHandlers = useDesktopDragHandlers(); const trafficLightPadding = useTrafficLightPadding(); const sidebarWidth = usePanelStore((state) => state.sidebarWidth); const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); const { width: viewportWidth } = useWindowDimensions(); const hostStatusDotStyle = useMemo( () => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }], [activeHostStatusColor], ); const startWidthRef = useRef(sidebarWidth); const resizeWidth = useSharedValue(sidebarWidth); useEffect(() => { resizeWidth.value = sidebarWidth; }, [sidebarWidth, resizeWidth]); const resizeGesture = useMemo( () => Gesture.Pan() .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) .onStart(() => { startWidthRef.current = sidebarWidth; resizeWidth.value = sidebarWidth; }) .onUpdate((event) => { // Dragging right (positive translationX) increases width const newWidth = startWidthRef.current + event.translationX; const maxWidth = Math.max( MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), ); const clampedWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth)); resizeWidth.value = clampedWidth; }) .onEnd(() => { runOnJS(setSidebarWidth)(resizeWidth.value); }), [sidebarWidth, resizeWidth, setSidebarWidth, viewportWidth], ); const resizeAnimatedStyle = useAnimatedStyle(() => ({ width: resizeWidth.value, })); if (!isOpen) { return null; } return ( {trafficLightPadding.top > 0 ? ( ) : null} {isInitialLoad ? ( ) : ( )} [ styles.hostTrigger, hovered && styles.hostTriggerHovered, ]} onPress={() => setIsHostPickerOpen(true)} disabled={hostOptions.length === 0} > {activeHostLabel} {({ hovered }) => ( )} Add project {newAgentKeys ? : null} {({ hovered }) => ( )} {/* Resize handle - absolutely positioned over right border */} ); } 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.surfaceSidebar, overflow: "hidden", }, sidebarContent: { flex: 1, minHeight: 0, overflow: "hidden", }, desktopSidebar: { position: "relative", borderRightWidth: 1, borderRightColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, }, resizeHandle: { position: "absolute", right: -5, top: 0, bottom: 0, width: 10, zIndex: 10, }, sidebarHeader: { height: { xs: HEADER_INNER_HEIGHT_MOBILE, md: HEADER_INNER_HEIGHT, }, paddingHorizontal: theme.spacing[2], justifyContent: "center", borderBottomWidth: 1, borderBottomColor: theme.colors.border, userSelect: "none", }, sidebarHeaderRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], }, newAgentButton: { flex: 1, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.lg, }, newAgentButtonText: { fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, newAgentButtonTextHovered: { color: theme.colors.foreground, }, newAgentButtonHovered: { backgroundColor: theme.colors.surface1, }, newAgentButtonActive: { backgroundColor: theme.colors.surface1, }, hostTrigger: { flexDirection: "row", alignItems: "center", justifyContent: "flex-start", gap: theme.spacing[2], minWidth: 0, paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, }, hostTriggerHovered: { backgroundColor: theme.colors.surface1, }, hostStatusDot: { width: 8, height: 8, borderRadius: theme.borderRadius.full, }, hostTriggerText: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, flexShrink: 1, minWidth: 0, }, sidebarFooter: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], borderTopWidth: 1, borderTopColor: theme.colors.border, }, footerHostSlot: { flexGrow: 0, flexShrink: 1, minWidth: 0, marginRight: theme.spacing[2], }, footerIconRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], flexShrink: 0, }, footerIconButton: { width: 28, height: 28, alignItems: "center", justifyContent: "center", paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[1], }, hostPickerList: { gap: theme.spacing[2], }, hostPickerOption: { paddingVertical: theme.spacing[3], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.lg, backgroundColor: theme.colors.surface2, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, }, hostPickerOptionText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, hostPickerCancel: { paddingVertical: theme.spacing[3], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.lg, backgroundColor: theme.colors.surface0, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, alignItems: "center", }, hostPickerCancelText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, tooltipRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, tooltipText: { fontSize: theme.fontSize.sm, color: theme.colors.popoverForeground, }, }));