import { memo, useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore, type Dispatch, type RefObject, type SetStateAction, } from 'react' import { View, Pressable, Text, Platform } 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 { router, usePathname } from 'expo-router' import { usePanelStore } 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 { buildHostAgentsRoute, buildHostSettingsRoute, mapPathnameToServer, parseServerIdFromPathname, } from '@/utils/host-routes' import { useOpenProjectPicker } from '@/hooks/use-open-project-picker' const DESKTOP_SIDEBAR_WIDTH = 320 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'] setProjectCollapsed: SidebarShortcutModel['setProjectCollapsed'] 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, setProjectCollapsed } = 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(buildHostAgentsRoute(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, setProjectCollapsed, handleRefresh, handleHostSelect, } if (isMobile) { return ( ) } return ( ) }) function MobileSidebar({ theme, activeServerId, activeHostLabel, activeHostStatusColor, hostOptions, hostTriggerRef, isHostPickerOpen, setIsHostPickerOpen, projects, isInitialLoad, isRevalidating, isManualRefresh, collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed, handleRefresh, handleHostSelect, handleOpenProject, handleSettings, insetsTop, insetsBottom, isOpen, closeToAgent, handleViewMoreNavigate, }: MobileSidebarProps) { 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 ( {({ hovered }) => ( <> Sessions )} {isInitialLoad ? ( ) : ( )} [ styles.hostTrigger, hovered && styles.hostTriggerHovered, ]} onPress={() => setIsHostPickerOpen(true)} disabled={hostOptions.length === 0} > {activeHostLabel} {({ hovered }) => ( )} Add project {({ hovered }) => ( )} ) } function DesktopSidebar({ theme, activeServerId, activeHostLabel, activeHostStatusColor, hostOptions, hostTriggerRef, isHostPickerOpen, setIsHostPickerOpen, projects, isInitialLoad, isRevalidating, isManualRefresh, collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed, handleRefresh, handleHostSelect, handleOpenProject, handleSettings, isOpen, handleViewMore, }: DesktopSidebarProps) { const dragHandlers = useDesktopDragHandlers() const trafficLightPadding = useTrafficLightPadding() const hostStatusDotStyle = useMemo( () => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }], [activeHostStatusColor] ) if (!isOpen) { return null } return ( {trafficLightPadding.top > 0 ? ( ) : null} {({ hovered }) => ( <> Sessions )} {isInitialLoad ? ( ) : ( )} [ styles.hostTrigger, hovered && styles.hostTriggerHovered, ]} onPress={() => setIsHostPickerOpen(true)} disabled={hostOptions.length === 0} > {activeHostLabel} {({ hovered }) => ( )} Add project {({ hovered }) => ( )} ) } 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: { borderRightWidth: 1, borderRightColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, }, 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: { flexDirection: 'row', alignItems: 'center', gap: theme.spacing[2], paddingVertical: theme.spacing[1], paddingRight: theme.spacing[1], paddingLeft: theme.spacing[3], flexShrink: 0, }, newAgentButtonHovered: {}, newAgentButtonText: { fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, newAgentButtonTextHovered: { color: theme.colors.foreground, }, 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, }, }))