Add resizable left sidebar on desktop

Mirror the right sidebar's resize pattern: Gesture.Pan() drag handle
on the right edge, animated width, persisted to panel store.
This commit is contained in:
Mohamed Boudra
2026-03-21 23:44:40 +07:00
parent 59f3f4737a
commit fcbdb09796
2 changed files with 78 additions and 6 deletions
+60 -5
View File
@@ -10,7 +10,7 @@ import {
type RefObject, type RefObject,
type SetStateAction, type SetStateAction,
} from "react"; } 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 { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, { import Animated, {
useAnimatedStyle, useAnimatedStyle,
@@ -26,7 +26,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { router, usePathname } from "expo-router"; 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 { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model"; import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
@@ -48,7 +48,8 @@ import {
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
const DESKTOP_SIDEBAR_WIDTH = 320; const MIN_CHAT_WIDTH = 400;
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>; type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"]; type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
@@ -631,17 +632,55 @@ function DesktopSidebar({
const newAgentKeys = useShortcutKeys("new-agent"); const newAgentKeys = useShortcutKeys("new-agent");
const dragHandlers = useDesktopDragHandlers(); const dragHandlers = useDesktopDragHandlers();
const trafficLightPadding = useTrafficLightPadding(); const trafficLightPadding = useTrafficLightPadding();
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
const hostStatusDotStyle = useMemo( const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }], () => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[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) { if (!isOpen) {
return null; return null;
} }
return ( return (
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}> <Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
{trafficLightPadding.top > 0 ? ( {trafficLightPadding.top > 0 ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} /> <View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null} ) : null}
@@ -740,7 +779,14 @@ function DesktopSidebar({
anchorRef={hostTriggerRef} anchorRef={hostTriggerRef}
/> />
</View> </View>
</View>
{/* Resize handle - absolutely positioned over right border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
/>
</GestureDetector>
</Animated.View>
); );
} }
@@ -766,10 +812,19 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden", overflow: "hidden",
}, },
desktopSidebar: { desktopSidebar: {
position: "relative",
borderRightWidth: 1, borderRightWidth: 1,
borderRightColor: theme.colors.border, borderRightColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar, backgroundColor: theme.colors.surfaceSidebar,
}, },
resizeHandle: {
position: "absolute",
right: -5,
top: 0,
bottom: 0,
width: 10,
zIndex: 10,
},
sidebarHeader: { sidebarHeader: {
height: { height: {
xs: HEADER_INNER_HEIGHT_MOBILE, xs: HEADER_INNER_HEIGHT_MOBILE,
+18 -1
View File
@@ -42,6 +42,10 @@ export interface ExplorerCheckoutContext {
isGit: boolean; 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 DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400;
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280; export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint. // Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint.
@@ -62,6 +66,7 @@ interface PanelState {
explorerTab: ExplorerTab; explorerTab: ExplorerTab;
explorerTabByCheckout: Record<string, ExplorerTab>; explorerTabByCheckout: Record<string, ExplorerTab>;
activeExplorerCheckout: ExplorerCheckoutContext | null; activeExplorerCheckout: ExplorerCheckoutContext | null;
sidebarWidth: number;
explorerWidth: number; explorerWidth: number;
explorerSortOption: SortOption; explorerSortOption: SortOption;
explorerFilesSplitRatio: number; explorerFilesSplitRatio: number;
@@ -79,6 +84,7 @@ interface PanelState {
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void; setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void; activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void; setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
setSidebarWidth: (width: number) => void;
setExplorerWidth: (width: number) => void; setExplorerWidth: (width: number) => void;
setExplorerSortOption: (option: SortOption) => void; setExplorerSortOption: (option: SortOption) => void;
setExplorerFilesSplitRatio: (ratio: number) => 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)); 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 { function clampWidth(width: number): number {
return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH); return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH);
} }
@@ -129,6 +139,7 @@ export const usePanelStore = create<PanelState>()(
explorerTab: "changes", explorerTab: "changes",
explorerTabByCheckout: {}, explorerTabByCheckout: {},
activeExplorerCheckout: null, activeExplorerCheckout: null,
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH, explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
explorerSortOption: "name", explorerSortOption: "name",
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO, explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
@@ -244,6 +255,7 @@ export const usePanelStore = create<PanelState>()(
} }
return { activeExplorerCheckout: checkout }; return { activeExplorerCheckout: checkout };
}), }),
setSidebarWidth: (width) => set({ sidebarWidth: clampSidebarWidth(width) }),
setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }), setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }),
setExplorerSortOption: (option) => set({ explorerSortOption: option }), setExplorerSortOption: (option) => set({ explorerSortOption: option }),
setExplorerFilesSplitRatio: (ratio) => setExplorerFilesSplitRatio: (ratio) =>
@@ -255,7 +267,7 @@ export const usePanelStore = create<PanelState>()(
}), }),
{ {
name: "panel-state", name: "panel-state",
version: 5, version: 6,
storage: createJSONStorage(() => AsyncStorage), storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) => { migrate: (persistedState, version) => {
const state = persistedState as Partial<PanelState> & Record<string, unknown>; const state = persistedState as Partial<PanelState> & Record<string, unknown>;
@@ -310,6 +322,10 @@ export const usePanelStore = create<PanelState>()(
state.explorerTabByCheckout = next; state.explorerTabByCheckout = next;
} }
if (version < 6 || typeof state.sidebarWidth !== "number") {
state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
}
state.activeExplorerCheckout = null; state.activeExplorerCheckout = null;
return state as PanelState; return state as PanelState;
@@ -319,6 +335,7 @@ export const usePanelStore = create<PanelState>()(
desktop: state.desktop, desktop: state.desktop,
explorerTab: state.explorerTab, explorerTab: state.explorerTab,
explorerTabByCheckout: state.explorerTabByCheckout, explorerTabByCheckout: state.explorerTabByCheckout,
sidebarWidth: state.sidebarWidth,
explorerWidth: state.explorerWidth, explorerWidth: state.explorerWidth,
explorerSortOption: state.explorerSortOption, explorerSortOption: state.explorerSortOption,
explorerFilesSplitRatio: state.explorerFilesSplitRatio, explorerFilesSplitRatio: state.explorerFilesSplitRatio,