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: {