import { router, usePathname } from "expo-router"; import { CalendarClock, FolderPlus, History, Home, Plus, Search, Server, Settings, X, } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { Pressable, StyleSheet as RNStyleSheet, Text, useWindowDimensions, View, type PressableStateCallbackType, } from "react-native"; import { Gesture } from "react-native-gesture-handler"; import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout"; import { HostPicker } from "@/components/hosts/host-picker"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu"; import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu"; import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { canCreateWorktreeForProjectKind } from "@/projects/host-projects"; import { useHostFeature } from "@/runtime/host-features"; import { type SidebarProjectEntry, type SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarModel } from "@/components/sidebar/sidebar-model"; import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; import { RetainedPanelActivity } from "@/components/retained-panel"; import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useHosts } from "@/runtime/host-runtime"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useWorkspace } from "@/stores/session-store-hooks"; import { usePanelStore } from "@/stores/panel-store"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { useIsMobilePanelPresented } from "@/mobile-panels/provider"; import { buildOpenProjectRoute, buildNewWorkspaceRoute, buildSchedulesRoute, buildSessionsRoute, buildSettingsAddHostRoute, buildSettingsHostSectionRoute, buildSettingsRoute, } from "@/utils/host-routes"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; type SidebarTheme = ReturnType["theme"]; interface SidebarSharedProps { theme: SidebarTheme; statusGroups: StatusGroup[]; pinnedGroups: PinnedSidebarGroups; projects: SidebarProjectEntry[]; workspaceEntriesByKey: ReadonlyMap; isInitialLoad: boolean; isRevalidating: boolean; isManualRefresh: boolean; groupMode: SidebarGroupMode; collapsedProjectKeys: ReadonlySet; shortcutIndexByWorkspaceKey: Map; toggleProjectCollapsed: (projectViewKey: string) => void; handleRefresh: () => void; handleOpenProject: () => void; handleHome: () => void; handleSettings: () => void; labels: SidebarLabels; newWorkspaceKeys: ShortcutKey[][] | null; handleAddHost: () => void; handleOpenHostSettings: (serverId: string) => void; } interface SidebarLabels { addProject: string; newWorkspace: string; hosts: string; home: string; settings: string; searchHosts: string; sessions: string; schedules: string; closeSidebar: string; } interface MobileSidebarProps extends SidebarSharedProps { insetsTop: number; insetsBottom: number; closeSidebar: () => void; handleViewMoreNavigate: () => void; handleViewSchedulesNavigate: () => void; } interface DesktopSidebarProps extends SidebarSharedProps { insetsTop: number; active: boolean; handleViewMore: () => void; handleViewSchedules: () => void; } export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boolean }) { const { theme } = useUnistyles(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); const isCompactLayout = useIsCompactFormFactor(); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const { projects, workspaceEntriesByKey, isInitialLoad, isRevalidating, refreshAll, statusGroups, pinnedGroups, collapsedProjectKeys, toggleProjectCollapsed, groupMode, shortcutModel, } = useSidebarModel(); const { shortcutIndexByWorkspaceKey } = shortcutModel; const [isManualRefresh, setIsManualRefresh] = useState(false); const handleRefresh = useCallback(() => { setIsManualRefresh(true); refreshAll(); }, [refreshAll]); useEffect(() => { if (!isRevalidating && isManualRefresh) { setIsManualRefresh(false); } }, [isRevalidating, isManualRefresh]); const openProjectPicker = useOpenAddProject(); const handleOpenProjectMobile = useCallback(() => { showMobileAgent(); void openProjectPicker(); }, [showMobileAgent, openProjectPicker]); const handleOpenProjectDesktop = useCallback(() => { void openProjectPicker(); }, [openProjectPicker]); const handleSettingsMobile = useCallback(() => { showMobileAgent(); router.push(buildSettingsRoute()); }, [showMobileAgent]); const handleSettingsDesktop = useCallback(() => { router.push(buildSettingsRoute()); }, []); const handleAddHostMobile = useCallback(() => { showMobileAgent(); router.push(buildSettingsAddHostRoute(Date.now())); }, [showMobileAgent]); const handleAddHostDesktop = useCallback(() => { router.push(buildSettingsAddHostRoute(Date.now())); }, []); const handleOpenHostSettingsMobile = useCallback( (serverId: string) => { showMobileAgent(); router.push(buildSettingsHostSectionRoute(serverId, "connections")); }, [showMobileAgent], ); const handleOpenHostSettingsDesktop = useCallback((serverId: string) => { router.push(buildSettingsHostSectionRoute(serverId, "connections")); }, []); const handleHomeMobile = useCallback(() => { showMobileAgent(); router.push(buildOpenProjectRoute()); }, [showMobileAgent]); const handleHomeDesktop = useCallback(() => { router.push(buildOpenProjectRoute()); }, []); const handleViewMoreNavigate = useCallback(() => { router.push(buildSessionsRoute()); }, []); const handleViewSchedulesNavigate = useCallback(() => { router.push(buildSchedulesRoute()); }, []); const newWorkspaceKeys = useShortcutKeys("new-workspace"); const labels = useMemo( (): SidebarLabels => ({ addProject: t("sidebar.actions.addProject"), newWorkspace: t("sidebar.actions.newWorkspace"), hosts: t("sidebar.actions.hosts"), home: t("sidebar.actions.home"), settings: t("sidebar.actions.settings"), searchHosts: t("sidebar.host.searchPlaceholder"), sessions: t("sidebar.sections.sessions"), schedules: t("sidebar.sections.schedules"), closeSidebar: t("sidebar.actions.closeSidebar"), }), [t], ); const sharedProps = { theme, statusGroups, pinnedGroups, projects, workspaceEntriesByKey, isInitialLoad, isRevalidating, isManualRefresh, groupMode, collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, handleRefresh, labels, newWorkspaceKeys, }; if (isCompactLayout) { return ( ); } return ( ); }); function sidebarHostOptionTestID(serverId: string): string { return `sidebar-host-row-${serverId}`; } function FooterIconButton({ buttonRef, onPress, testID, label, icon: Icon, iconSize, shortcutKeys, theme, }: { onPress: () => void; testID: string; label: string; icon: typeof FolderPlus; iconSize?: number; shortcutKeys?: ReturnType; theme: SidebarTheme; buttonRef?: RefObject; }) { return ( {({ hovered }) => ( )} ); } function footerAddProjectButtonStyle({ hovered, }: PressableStateCallbackType & { hovered?: boolean }) { return [styles.footerAddProjectButton, Boolean(hovered) && styles.footerAddProjectButtonHovered]; } function FooterAddProjectButton({ onPress, label, shortcutKeys, theme, }: { onPress: () => void; label: string; shortcutKeys: ReturnType; theme: SidebarTheme; }) { return ( {({ hovered }) => { const isHovered = Boolean(hovered); return ( <> {label} ); }} ); } function SidebarHostPicker({ theme, label, onAddHost, onOpenHostSettings, }: { theme: SidebarTheme; label: string; onAddHost: () => void; onOpenHostSettings: (serverId: string) => void; }) { const hosts = useHosts(); const triggerRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const handleSelect = useCallback( (id: string) => { onOpenHostSettings(id); }, [onOpenHostSettings], ); const handleOpen = useCallback(() => setIsOpen(true), []); return ( ); } function IconTooltipContent({ label, shortcutKeys, }: { label: string; shortcutKeys?: ReturnType; }) { return ( {label} {shortcutKeys ? : null} ); } const SidebarNewWorkspaceHeaderRow = memo(function SidebarNewWorkspaceHeaderRow({ label, testID, variant, shortcutKeys, onBeforeNavigate, }: { label: string; testID: string; variant: "header" | "compact"; shortcutKeys: ShortcutKey[][] | null; onBeforeNavigate?: () => void; }) { const activeWorkspaceSelection = useActiveWorkspaceSelection(); const activeWorkspaceServerId = activeWorkspaceSelection?.serverId ?? null; const activeWorkspaceId = activeWorkspaceSelection?.workspaceId ?? null; const activeWorkspace = useWorkspace(activeWorkspaceServerId, activeWorkspaceId); const supportsWorkspaceMultiplicity = useHostFeature( activeWorkspaceServerId, "workspaceMultiplicity", ); const canUseActiveWorkspaceContext = Boolean( activeWorkspace && (supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)), ); const handlePress = useCallback(() => { onBeforeNavigate?.(); router.push( activeWorkspaceServerId ? buildNewWorkspaceRoute( activeWorkspace && canUseActiveWorkspaceContext ? { serverId: activeWorkspaceServerId, sourceDirectory: activeWorkspace.projectRootPath, projectId: activeWorkspace.projectId, } : { serverId: activeWorkspaceServerId }, ) : buildNewWorkspaceRoute(), ); }, [activeWorkspace, activeWorkspaceServerId, canUseActiveWorkspaceContext, onBeforeNavigate]); return ( ); }); function SidebarFooter({ theme, handleOpenProject, handleHome, handleSettings, labels, handleAddHost, handleOpenHostSettings, }: { theme: SidebarTheme; handleOpenProject: () => void; handleHome: () => void; handleSettings: () => void; labels: { addProject: string; hosts: string; home: string; settings: string; searchHosts: string; }; handleAddHost: () => void; handleOpenHostSettings: (serverId: string) => void; }) { const newAgentKeys = useShortcutKeys("new-agent"); const settingsKeys = useShortcutKeys("toggle-settings"); return ( ); } function MobileSidebar({ theme, statusGroups, pinnedGroups, projects, workspaceEntriesByKey, isInitialLoad, isRevalidating, isManualRefresh, groupMode, collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, handleRefresh, newWorkspaceKeys, handleOpenProject, handleHome, handleSettings, labels, handleAddHost, handleOpenHostSettings, insetsTop, insetsBottom, closeSidebar, handleViewMoreNavigate, handleViewSchedulesNavigate, }: MobileSidebarProps) { const pathname = usePathname(); const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const isSessionsActive = pathname.includes("/sessions"); const isSchedulesActive = pathname.includes("/schedules"); const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture(); const dragGestureHostPresented = useIsMobilePanelPresented("agent-list"); const handleViewMore = useCallback(() => { closeSidebar(); handleViewMoreNavigate(); }, [closeSidebar, handleViewMoreNavigate]); const handleViewSchedules = useCallback(() => { closeSidebar(); handleViewSchedulesNavigate(); }, [closeSidebar, handleViewSchedulesNavigate]); const handleWorkspacePress = useCallback(() => { closeSidebar(); }, [closeSidebar]); const mobileSidebarInsetStyle = useMemo( () => ({ paddingTop: insetsTop, paddingBottom: insetsBottom, backgroundColor: theme.colors.surfaceSidebar, }), [insetsTop, insetsBottom, theme.colors.surfaceSidebar], ); return ( {({ hovered, pressed }) => ( )} {isInitialLoad && !hasActiveHostFilter ? ( ) : ( )} ); } function DesktopSidebar({ theme, statusGroups, pinnedGroups, projects, workspaceEntriesByKey, isInitialLoad, isRevalidating, isManualRefresh, groupMode, collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, handleRefresh, newWorkspaceKeys, handleOpenProject, handleHome, handleSettings, labels, handleAddHost, handleOpenHostSettings, insetsTop, active, handleViewMore, handleViewSchedules, }: DesktopSidebarProps) { const ownsTopLeft = useOwnsWindowChromeCorner("top-left"); const pathname = usePathname(); const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const isSessionsActive = pathname.includes("/sessions"); const isSchedulesActive = pathname.includes("/schedules"); const sidebarWidth = usePanelStore((state) => state.sidebarWidth); const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); const { width: viewportWidth } = useWindowDimensions(); const visibleSidebarWidth = resolveDesktopSidebarWidth({ requestedWidth: sidebarWidth, viewportWidth, }); const startWidthRef = useRef(visibleSidebarWidth); const resizeWidth = useSharedValue(visibleSidebarWidth); useEffect(() => { resizeWidth.value = visibleSidebarWidth; }, [resizeWidth, visibleSidebarWidth]); const resizeGesture = useMemo( () => Gesture.Pan() .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) .onStart(() => { startWidthRef.current = visibleSidebarWidth; resizeWidth.value = visibleSidebarWidth; }) .onUpdate((event) => { // Dragging right (positive translationX) increases width const newWidth = startWidthRef.current + event.translationX; resizeWidth.value = resolveDesktopSidebarWidth({ requestedWidth: newWidth, viewportWidth, }); }) .onEnd(() => { runOnJS(setSidebarWidth)(resizeWidth.value); }), [resizeWidth, setSidebarWidth, viewportWidth, visibleSidebarWidth], ); const resizeAnimatedStyle = useAnimatedStyle(() => ({ width: resizeWidth.value, })); const desktopSidebarStyle = useMemo( () => [ staticStyles.desktopSidebar, !active && staticStyles.desktopSidebarHidden, resizeAnimatedStyle, ], [active, resizeAnimatedStyle], ); const desktopSidebarBorderStyle = useMemo( () => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }], [insetsTop], ); const sidebarHeaderGroupStyle = useMemo( () => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome], [ownsTopLeft], ); return ( {ownsTopLeft ? ( ) : ( )} {isInitialLoad && !hasActiveHostFilter ? ( ) : ( )} ); } function WorkspacesSectionHeader() { const { theme } = useUnistyles(); const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen); const commandCenterKeys = useShortcutKeys("toggle-command-center"); const handleSearchPress = useCallback(() => setCommandCenterOpen(true), [setCommandCenterOpen]); const searchButtonStyle = useCallback( ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.workspacesHeaderIconButton, (hovered || pressed) && styles.workspacesHeaderIconButtonHovered, ], [], ); return ( Workspaces {({ hovered, pressed }) => ( )} ); } // Stable element so the sidebar list's listHeaderComponent prop keeps identity across // renders (WorkspacesSectionHeader takes no props). const workspacesSectionHeaderElement = ; // Static styles for Animated.Views — must NOT use Unistyles dynamic theme to // avoid the "Unable to find node on an unmounted component" crash when Unistyles // tries to patch the native node that Reanimated also manages. const staticStyles = RNStyleSheet.create({ desktopSidebar: { position: "relative" as const, }, desktopSidebarHidden: { display: "none", }, }); const styles = StyleSheet.create((theme) => ({ sidebarHeaderGroup: { paddingTop: theme.spacing[2], gap: 2, paddingBottom: theme.spacing[1.5], borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, sidebarHeaderGroupBelowChrome: { paddingTop: 0, }, workspacesSectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], // Rendered inside the scroll's listContent (paddingHorizontal spacing[2]). The title // lands at spacing[2] left to align with project icons. Settings2's painted path stops // inside its 14px SVG, so 4px aligns the ink rather than the SVG box to the row rail. paddingLeft: theme.spacing[2], paddingRight: 4, paddingTop: theme.spacing[1], paddingBottom: theme.spacing[1], }, workspacesSectionTitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, }, workspacesSectionActions: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], }, workspacesHeaderIconButton: { width: 28, height: 28, alignItems: "center", justifyContent: "center", borderRadius: theme.borderRadius.md, }, workspacesHeaderIconButtonHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, sidebarContent: { flex: 1, minHeight: 0, }, mobileCloseButtonRow: { position: "absolute", top: theme.spacing[3], left: 0, right: 0, zIndex: 2, alignItems: "flex-end", pointerEvents: "box-none", }, mobileCloseButton: { // The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph. // This optical inset puts their painted right edges on the same sidebar rail. marginRight: theme.spacing[2] + 1.5, width: 32, height: 32, alignItems: "center", justifyContent: "center", borderRadius: theme.borderRadius.lg, backgroundColor: theme.colors.surfaceSidebar, }, desktopSidebarBorder: { borderRightWidth: 1, borderRightColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, }, sidebarDragArea: { position: "relative", }, desktopChromeRow: { position: "relative", height: HEADER_INNER_HEIGHT, flexDirection: "row", alignItems: "center", borderBottomWidth: theme.borderWidth[1], borderBottomColor: "transparent", }, sidebarFooter: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[3], borderTopWidth: 1, borderTopColor: theme.colors.border, }, footerIconRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], flexShrink: 0, }, footerAddProjectButton: { minWidth: 0, minHeight: 32, flex: 1, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingVertical: theme.spacing[1.5], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, }, footerAddProjectButtonHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, footerAddProjectLabel: { minWidth: 0, flexShrink: 1, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, footerAddProjectLabelHovered: { color: theme.colors.foreground, }, footerIconButton: { width: 28, height: 28, alignItems: "center", justifyContent: "center", paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[1], }, tooltipRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, tooltipText: { fontSize: theme.fontSize.sm, color: theme.colors.popoverForeground, }, }));