diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 4eae2efaa..1651d5fcf 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -10,7 +10,7 @@ import { type RefObject, type SetStateAction, } from "react"; -import { View, Pressable, Text, Platform } from "react-native"; +import { View, Pressable, Text, Platform, useWindowDimensions } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { useAnimatedStyle, @@ -26,7 +26,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { Shortcut } from "@/components/ui/shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { router, usePathname } from "expo-router"; -import { usePanelStore } from "@/stores/panel-store"; +import { usePanelStore, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH } 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"; @@ -48,7 +48,8 @@ import { } from "@/utils/host-routes"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; -const DESKTOP_SIDEBAR_WIDTH = 320; +const MIN_CHAT_WIDTH = 400; + type SidebarShortcutModel = ReturnType; type SidebarTheme = ReturnType["theme"]; @@ -631,17 +632,55 @@ function DesktopSidebar({ const newAgentKeys = useShortcutKeys("new-agent"); const dragHandlers = useDesktopDragHandlers(); const trafficLightPadding = useTrafficLightPadding(); + const sidebarWidth = usePanelStore((state) => state.sidebarWidth); + const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); + const { width: viewportWidth } = useWindowDimensions(); const hostStatusDotStyle = useMemo( () => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }], [activeHostStatusColor], ); + const startWidthRef = useRef(sidebarWidth); + const resizeWidth = useSharedValue(sidebarWidth); + + useEffect(() => { + resizeWidth.value = sidebarWidth; + }, [sidebarWidth, resizeWidth]); + + const resizeGesture = useMemo( + () => + Gesture.Pan() + .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) + .onStart(() => { + startWidthRef.current = sidebarWidth; + resizeWidth.value = sidebarWidth; + }) + .onUpdate((event) => { + // Dragging right (positive translationX) increases width + const newWidth = startWidthRef.current + event.translationX; + const maxWidth = Math.max( + MIN_SIDEBAR_WIDTH, + Math.min(MAX_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), + ); + const clampedWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth)); + resizeWidth.value = clampedWidth; + }) + .onEnd(() => { + runOnJS(setSidebarWidth)(resizeWidth.value); + }), + [sidebarWidth, resizeWidth, setSidebarWidth, viewportWidth], + ); + + const resizeAnimatedStyle = useAnimatedStyle(() => ({ + width: resizeWidth.value, + })); + if (!isOpen) { return null; } return ( - + {trafficLightPadding.top > 0 ? ( ) : null} @@ -740,7 +779,14 @@ function DesktopSidebar({ anchorRef={hostTriggerRef} /> - + + {/* Resize handle - absolutely positioned over right border */} + + + + ); } @@ -766,10 +812,19 @@ const styles = StyleSheet.create((theme) => ({ overflow: "hidden", }, desktopSidebar: { + position: "relative", borderRightWidth: 1, borderRightColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, }, + resizeHandle: { + position: "absolute", + right: -5, + top: 0, + bottom: 0, + width: 10, + zIndex: 10, + }, sidebarHeader: { height: { xs: HEADER_INNER_HEIGHT_MOBILE, diff --git a/packages/app/src/stores/panel-store.ts b/packages/app/src/stores/panel-store.ts index 4765877ec..71782becc 100644 --- a/packages/app/src/stores/panel-store.ts +++ b/packages/app/src/stores/panel-store.ts @@ -42,6 +42,10 @@ export interface ExplorerCheckoutContext { isGit: boolean; } +export const DEFAULT_SIDEBAR_WIDTH = 320; +export const MIN_SIDEBAR_WIDTH = 200; +export const MAX_SIDEBAR_WIDTH = 600; + export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400; export const MIN_EXPLORER_SIDEBAR_WIDTH = 280; // Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint. @@ -62,6 +66,7 @@ interface PanelState { explorerTab: ExplorerTab; explorerTabByCheckout: Record; activeExplorerCheckout: ExplorerCheckoutContext | null; + sidebarWidth: number; explorerWidth: number; explorerSortOption: SortOption; explorerFilesSplitRatio: number; @@ -79,6 +84,7 @@ interface PanelState { setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void; activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void; setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void; + setSidebarWidth: (width: number) => void; setExplorerWidth: (width: number) => void; setExplorerSortOption: (option: SortOption) => void; setExplorerFilesSplitRatio: (ratio: number) => void; @@ -91,6 +97,10 @@ function clampNumber(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } +function clampSidebarWidth(width: number): number { + return clampNumber(width, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); +} + function clampWidth(width: number): number { return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH); } @@ -129,6 +139,7 @@ export const usePanelStore = create()( explorerTab: "changes", explorerTabByCheckout: {}, activeExplorerCheckout: null, + sidebarWidth: DEFAULT_SIDEBAR_WIDTH, explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH, explorerSortOption: "name", explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO, @@ -244,6 +255,7 @@ export const usePanelStore = create()( } return { activeExplorerCheckout: checkout }; }), + setSidebarWidth: (width) => set({ sidebarWidth: clampSidebarWidth(width) }), setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }), setExplorerSortOption: (option) => set({ explorerSortOption: option }), setExplorerFilesSplitRatio: (ratio) => @@ -255,7 +267,7 @@ export const usePanelStore = create()( }), { name: "panel-state", - version: 5, + version: 6, storage: createJSONStorage(() => AsyncStorage), migrate: (persistedState, version) => { const state = persistedState as Partial & Record; @@ -310,6 +322,10 @@ export const usePanelStore = create()( state.explorerTabByCheckout = next; } + if (version < 6 || typeof state.sidebarWidth !== "number") { + state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH; + } + state.activeExplorerCheckout = null; return state as PanelState; @@ -319,6 +335,7 @@ export const usePanelStore = create()( desktop: state.desktop, explorerTab: state.explorerTab, explorerTabByCheckout: state.explorerTabByCheckout, + sidebarWidth: state.sidebarWidth, explorerWidth: state.explorerWidth, explorerSortOption: state.explorerSortOption, explorerFilesSplitRatio: state.explorerFilesSplitRatio,