From c7fe944e7331a33dcffa9d321b42be560058f5c7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 20 Mar 2026 18:44:57 +0700 Subject: [PATCH] Extract mobile sidebar and gesture wrappers --- packages/app/src/app/_layout.tsx | 194 ++++--- packages/app/src/components/left-sidebar.tsx | 529 ++++++++++++------- 2 files changed, 430 insertions(+), 293 deletions(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index c9a0a7010..ddd192c8d 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -268,23 +268,12 @@ function AppContainer({ }: AppContainerProps) { const { theme } = useUnistyles(); const daemons = useHosts(); - const mobileView = usePanelStore((state) => state.mobileView); - const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); - const openAgentList = usePanelStore((state) => state.openAgentList); const toggleAgentList = usePanelStore((state) => state.toggleAgentList); const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); - const horizontalScroll = useHorizontalScrollOptional(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; - const isOpen = chromeEnabled - ? isMobile - ? mobileView === "agent-list" - : desktopAgentListOpen - : false; - const openGestureEnabled = - chromeEnabled && isMobile && mobileView === "agent"; useKeyboardShortcuts({ enabled: chromeEnabled, @@ -293,92 +282,6 @@ function AppContainer({ selectedAgentId, toggleFileExplorer, }); - const { - translateX, - backdropOpacity, - windowWidth, - animateToOpen, - animateToClose, - isGesturing, - } = useSidebarAnimation(); - - // Track initial touch position for manual activation - const touchStartX = useSharedValue(0); - - // Open gesture: swipe right from anywhere to open sidebar (interactive drag) - // If any horizontal scroll is scrolled right, let the scroll view handle the gesture first - const openGesture = useMemo( - () => - Gesture.Pan() - .enabled(openGestureEnabled) - .manualActivation(true) - // Fail if 10px vertical movement happens first (allow vertical scroll) - .failOffsetY([-10, 10]) - .onTouchesDown((event) => { - const touch = event.changedTouches[0]; - if (touch) { - touchStartX.value = touch.absoluteX; - } - }) - .onTouchesMove((event, stateManager) => { - const touch = event.changedTouches[0]; - if (!touch || event.numberOfTouches !== 1) return; - - const deltaX = touch.absoluteX - touchStartX.value; - - // If horizontal scroll is scrolled right, fail so ScrollView handles it - if (horizontalScroll?.isAnyScrolledRight.value) { - stateManager.fail(); - return; - } - - // Activate after 15px rightward movement - if (deltaX > 15) { - stateManager.activate(); - } - }) - .onStart(() => { - isGesturing.value = true; - }) - .onUpdate((event) => { - // Start from closed position (-windowWidth) and move towards 0 - const newTranslateX = Math.min(0, -windowWidth + event.translationX); - translateX.value = newTranslateX; - backdropOpacity.value = interpolate( - newTranslateX, - [-windowWidth, 0], - [0, 1], - Extrapolation.CLAMP - ); - }) - .onEnd((event) => { - isGesturing.value = false; - // Open if dragged more than 1/3 of sidebar or fast swipe - const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500; - if (shouldOpen) { - animateToOpen(); - runOnJS(openAgentList)(); - } else { - animateToClose(); - } - }) - .onFinalize(() => { - isGesturing.value = false; - }), - [ - openGestureEnabled, - windowWidth, - translateX, - backdropOpacity, - animateToOpen, - animateToClose, - openAgentList, - mobileView, - isGesturing, - horizontalScroll?.isAnyScrolledRight, - touchStartX, - ] - ); const containerStyle = useMemo( () => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }), @@ -407,8 +310,103 @@ function AppContainer({ } return ( - + {content} + + ); +} + +function MobileGestureWrapper({ + children, + chromeEnabled, +}: { + children: ReactNode; + chromeEnabled: boolean; +}) { + const mobileView = usePanelStore((state) => state.mobileView); + const openAgentList = usePanelStore((state) => state.openAgentList); + const horizontalScroll = useHorizontalScrollOptional(); + const { + translateX, + backdropOpacity, + windowWidth, + animateToOpen, + animateToClose, + isGesturing, + } = useSidebarAnimation(); + const touchStartX = useSharedValue(0); + const openGestureEnabled = chromeEnabled && mobileView === "agent"; + + const openGesture = useMemo( + () => + Gesture.Pan() + .enabled(openGestureEnabled) + .manualActivation(true) + .failOffsetY([-10, 10]) + .onTouchesDown((event) => { + const touch = event.changedTouches[0]; + if (touch) { + touchStartX.value = touch.absoluteX; + } + }) + .onTouchesMove((event, stateManager) => { + const touch = event.changedTouches[0]; + if (!touch || event.numberOfTouches !== 1) return; + + const deltaX = touch.absoluteX - touchStartX.value; + + if (horizontalScroll?.isAnyScrolledRight.value) { + stateManager.fail(); + return; + } + + if (deltaX > 15) { + stateManager.activate(); + } + }) + .onStart(() => { + isGesturing.value = true; + }) + .onUpdate((event) => { + const newTranslateX = Math.min(0, -windowWidth + event.translationX); + translateX.value = newTranslateX; + backdropOpacity.value = interpolate( + newTranslateX, + [-windowWidth, 0], + [0, 1], + Extrapolation.CLAMP + ); + }) + .onEnd((event) => { + isGesturing.value = false; + const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500; + if (shouldOpen) { + animateToOpen(); + runOnJS(openAgentList)(); + } else { + animateToClose(); + } + }) + .onFinalize(() => { + isGesturing.value = false; + }), + [ + openGestureEnabled, + windowWidth, + translateX, + backdropOpacity, + animateToOpen, + animateToClose, + openAgentList, + isGesturing, + horizontalScroll?.isAnyScrolledRight, + touchStartX, + ] + ); + + return ( + + {children} ); } diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 8256b58c5..c89c37710 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,4 +1,15 @@ -import { memo, useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore } from 'react' +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, { @@ -18,7 +29,7 @@ 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 } from '@/hooks/use-sidebar-workspaces-list' +import { useSidebarWorkspacesList, type SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list' import { useSidebarAnimation } from '@/contexts/sidebar-animation-context' import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window' import { Combobox } from '@/components/ui/combobox' @@ -34,12 +45,58 @@ import { 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' @@ -109,10 +166,9 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte })), [daemons, runtime, runtimeConnectionStatusSignature] ) - const hostTriggerRef = useRef(null) + const hostTriggerRef = useRef(null) const [isHostPickerOpen, setIsHostPickerOpen] = useState(false) - // Derive isOpen from the unified panel state const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({ @@ -121,21 +177,7 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte }) const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } = useSidebarShortcutModel(projects) - const { - translateX, - backdropOpacity, - windowWidth, - animateToOpen, - animateToClose, - isGesturing, - closeGestureRef, - } = useSidebarAnimation() - const dragHandlers = useTauriDragHandlers() - const trafficLightPadding = useTrafficLightPadding() - const closeTouchStartX = useSharedValue(0) - const closeTouchStartY = useSharedValue(0) - // Track user-initiated refresh to avoid showing spinner on background revalidation const [isManualRefresh, setIsManualRefresh] = useState(false) const handleRefresh = useCallback(() => { @@ -143,17 +185,12 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte refreshAll() }, [refreshAll]) - // Reset manual refresh flag when revalidation completes useEffect(() => { if (!isRevalidating && isManualRefresh) { setIsManualRefresh(false) } }, [isRevalidating, isManualRefresh]) - const handleClose = useCallback(() => { - closeToAgent() - }, [closeToAgent]) - const openProjectPicker = useOpenProjectPicker(activeServerId) const handleOpenProjectMobile = useCallback(() => { @@ -165,7 +202,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte void openProjectPicker() }, [openProjectPicker]) - // Mobile: close sidebar and navigate const handleSettingsMobile = useCallback(() => { if (!activeServerId) { return @@ -174,7 +210,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte router.push(buildHostSettingsRoute(activeServerId) as any) }, [activeServerId, closeToAgent]) - // Desktop: just navigate, don't close const handleSettingsDesktop = useCallback(() => { if (!activeServerId) { return @@ -182,17 +217,12 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte router.push(buildHostSettingsRoute(activeServerId) as any) }, [activeServerId]) - const handleViewMore = useCallback(() => { + const handleViewMoreNavigate = useCallback(() => { if (!activeServerId) { return } - if (isMobile) { - translateX.value = -windowWidth - backdropOpacity.value = 0 - closeToAgent() - } router.push(buildHostAgentsRoute(activeServerId) as any) - }, [activeServerId, backdropOpacity, closeToAgent, isMobile, translateX, windowWidth]) + }, [activeServerId]) const handleHostSelect = useCallback( (nextServerId: string) => { @@ -206,14 +236,118 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte [pathname] ) - // Close gesture (swipe left to close when sidebar is open) + 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) - // Use manual activation so child views keep touch streams unless we detect - // an intentional left-swipe close (mirrors explorer-sidebar pattern). .manualActivation(true) .onTouchesDown((event) => { const touch = event.changedTouches[0] @@ -235,7 +369,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte const absDeltaX = Math.abs(deltaX) const absDeltaY = Math.abs(deltaY) - // Fail quickly on clear rightward or vertical intent so child views keep control. if (deltaX >= 10) { stateManager.fail() return @@ -244,8 +377,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte stateManager.fail() return } - - // Activate only on intentional leftward movement. if (deltaX <= -15 && absDeltaX > absDeltaY) { stateManager.activate() } @@ -254,8 +385,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte 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( @@ -267,7 +396,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte }) .onEnd((event) => { isGesturing.value = false - if (!isMobile) return const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500 if (shouldClose) { animateToClose() @@ -285,7 +413,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte closeTouchStartX, closeTouchStartY, isGesturing, - isMobile, windowWidth, translateX, backdropOpacity, @@ -296,8 +423,8 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte ) const mobileSidebarInsetStyle = useMemo( - () => ({ width: windowWidth, paddingTop: insets.top, paddingBottom: insets.bottom }), - [windowWidth, insets.top, insets.bottom] + () => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }), + [windowWidth, insetsTop, insetsBottom] ) const hostStatusDotStyle = useMemo( @@ -314,160 +441,174 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte pointerEvents: backdropOpacity.value > 0.01 ? 'auto' : 'none', })) - // Render mobile sidebar - // On web, keep the overlay interactive only while the sidebar is open. - // This preserves swipe/scroll behavior without blocking taps when closed. const overlayPointerEvents = Platform.OS === 'web' ? (isOpen ? 'auto' : 'none') : 'box-none' - if (isMobile) { - return ( - - {/* Backdrop */} - - - - - - - {/* Header */} - - - - {({ hovered }) => ( - <> - - - Sessions - - - )} - - - + return ( + + + + - {/* Middle: scrollable project/workspace tree */} - {isInitialLoad ? ( - - ) : ( - - )} - - {/* Footer */} - - - [ - styles.hostTrigger, - hovered && styles.hostTriggerHovered, - ]} - onPress={() => setIsHostPickerOpen(true)} - disabled={hostOptions.length === 0} - > - - - {activeHostLabel} - - - - - - - - {({ hovered }) => ( - - )} - - - - - Add project - - - - - - {({ hovered }) => ( - + + + + + + {({ hovered }) => ( + <> + - )} - - - + + Sessions + + + )} + - - - - ) - } - // Desktop: no edge swipe, just show/hide based on isOpen + {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 = useTauriDragHandlers() + const trafficLightPadding = useTrafficLightPadding() + const hostStatusDotStyle = useMemo( + () => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }], + [activeHostStatusColor] + ) + if (!isOpen) { return null } @@ -501,7 +642,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte - {/* Middle: scrollable project/workspace tree */} {isInitialLoad ? ( ) : ( @@ -517,7 +657,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte /> )} - {/* Footer */} {({ hovered }) => ( {({ hovered }) => ( ) -}) +} const styles = StyleSheet.create((theme) => ({ backdrop: {