Extract mobile sidebar and gesture wrappers
This commit is contained in:
@@ -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 (
|
||||
<GestureDetector gesture={openGesture} touchAction="pan-y">
|
||||
<MobileGestureWrapper chromeEnabled={chromeEnabled}>
|
||||
{content}
|
||||
</MobileGestureWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<GestureDetector gesture={openGesture} touchAction="pan-y">
|
||||
{children}
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof useSidebarShortcutModel>
|
||||
type SidebarTheme = ReturnType<typeof useUnistyles>['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<View | null>
|
||||
isHostPickerOpen: boolean
|
||||
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>
|
||||
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<View>(null)
|
||||
const hostTriggerRef = useRef<View | null>(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 (
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
isOpen={isOpen}
|
||||
closeToAgent={closeToAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
isOpen={isOpen}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
handleViewMore={handleViewMoreNavigate}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
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 (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
{/* Backdrop */}
|
||||
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
|
||||
<Pressable style={styles.backdropPressable} onPress={handleClose} />
|
||||
</Animated.View>
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.mobileSidebar,
|
||||
mobileSidebarInsetStyle,
|
||||
sidebarAnimatedStyle,
|
||||
]}
|
||||
pointerEvents="auto"
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
{/* Header */}
|
||||
<View style={styles.sidebarHeader}>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-sessions"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<MessagesSquare
|
||||
size={theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.newAgentButtonText,
|
||||
hovered && styles.newAgentButtonTextHovered,
|
||||
]}
|
||||
>
|
||||
Sessions
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
|
||||
<Pressable style={styles.backdropPressable} onPress={handleClose} />
|
||||
</Animated.View>
|
||||
|
||||
{/* Middle: scrollable project/workspace tree */}
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
onSetProjectCollapsed={setProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={closeToAgent}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.sidebarFooter}>
|
||||
<View style={styles.footerHostSlot}>
|
||||
<Pressable
|
||||
ref={hostTriggerRef}
|
||||
style={({ hovered = false }) => [
|
||||
styles.hostTrigger,
|
||||
hovered && styles.hostTriggerHovered,
|
||||
]}
|
||||
onPress={() => setIsHostPickerOpen(true)}
|
||||
disabled={hostOptions.length === 0}
|
||||
>
|
||||
<View
|
||||
style={[styles.hostStatusDot, { backgroundColor: activeHostStatusColor }]}
|
||||
/>
|
||||
<Text style={styles.hostTriggerText} numberOfLines={1}>
|
||||
{activeHostLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-add-project"
|
||||
nativeID="sidebar-add-project"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Add project"
|
||||
accessibilityRole="button"
|
||||
onPress={handleOpenProjectMobile}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Plus
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Add project</Text>
|
||||
<Shortcut keys={['⌘', '⇧', 'O']} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
nativeID="sidebar-settings"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
onPress={handleSettingsMobile}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Settings
|
||||
size={theme.iconSize.lg}
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View
|
||||
style={[styles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle]}
|
||||
pointerEvents="auto"
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<View style={styles.sidebarHeader}>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-sessions"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<MessagesSquare
|
||||
size={theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
<Combobox
|
||||
options={hostOptions}
|
||||
value={activeServerId ?? ''}
|
||||
onSelect={handleHostSelect}
|
||||
searchable={false}
|
||||
title="Switch host"
|
||||
searchPlaceholder="Search hosts..."
|
||||
open={isHostPickerOpen}
|
||||
onOpenChange={setIsHostPickerOpen}
|
||||
anchorRef={hostTriggerRef}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
|
||||
>
|
||||
Sessions
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop: no edge swipe, just show/hide based on isOpen
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
onSetProjectCollapsed={setProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={closeToAgent}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={styles.sidebarFooter}>
|
||||
<View style={styles.footerHostSlot}>
|
||||
<Pressable
|
||||
ref={hostTriggerRef}
|
||||
style={({ hovered = false }) => [
|
||||
styles.hostTrigger,
|
||||
hovered && styles.hostTriggerHovered,
|
||||
]}
|
||||
onPress={() => setIsHostPickerOpen(true)}
|
||||
disabled={hostOptions.length === 0}
|
||||
>
|
||||
<View style={hostStatusDotStyle} />
|
||||
<Text style={styles.hostTriggerText} numberOfLines={1}>
|
||||
{activeHostLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-add-project"
|
||||
nativeID="sidebar-add-project"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Add project"
|
||||
accessibilityRole="button"
|
||||
onPress={handleOpenProject}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Plus
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Add project</Text>
|
||||
<Shortcut keys={['⌘', '⇧', 'O']} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
nativeID="sidebar-settings"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
onPress={handleSettings}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Settings
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
<Combobox
|
||||
options={hostOptions}
|
||||
value={activeServerId ?? ''}
|
||||
onSelect={handleHostSelect}
|
||||
searchable={false}
|
||||
title="Switch host"
|
||||
searchPlaceholder="Search hosts..."
|
||||
open={isHostPickerOpen}
|
||||
onOpenChange={setIsHostPickerOpen}
|
||||
anchorRef={hostTriggerRef}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Middle: scrollable project/workspace tree */}
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
@@ -517,7 +657,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.sidebarFooter}>
|
||||
<View style={styles.footerHostSlot}>
|
||||
<Pressable
|
||||
@@ -546,7 +685,7 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
|
||||
accessible
|
||||
accessibilityLabel="Add project"
|
||||
accessibilityRole="button"
|
||||
onPress={handleOpenProjectDesktop}
|
||||
onPress={handleOpenProject}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Plus
|
||||
@@ -571,7 +710,7 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
|
||||
accessible
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
onPress={handleSettingsDesktop}
|
||||
onPress={handleSettings}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Settings
|
||||
@@ -595,7 +734,7 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
backdrop: {
|
||||
|
||||
Reference in New Issue
Block a user