From 8af44ac82cb2c427b5b4fd1906eb957073de34c1 Mon Sep 17 00:00:00 2001 From: Max Partenfelder Date: Fri, 23 Jan 2026 11:47:40 +0100 Subject: [PATCH] Add resizable sidebar panel with drag handle - Create useResizable hook for drag-to-resize logic with mouse events - Add sidebarWidthPercent state to Zustand store with localStorage sync - Add drag handle on left edge of sidebar with violet highlight - Width clamped between 20% and 60% of viewport, defaults to 35% - Terminal auto-resizes via existing ResizeObserver Co-Authored-By: Claude Opus 4.5 --- client/src/components/Sidebar.tsx | 19 ++++++++- client/src/hooks/useResizable.ts | 66 +++++++++++++++++++++++++++++++ client/src/stores/useStore.ts | 17 ++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 client/src/hooks/useResizable.ts diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index f78f6a0..b015bc2 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -19,6 +19,7 @@ import { } from "lucide-react"; import { useStore, AgentStatus } from "../stores/useStore"; import { Terminal } from "./Terminal"; +import { useResizable } from "../hooks/useResizable"; const statusConfig: Record = { running: { label: "Running", color: "#22C55E" }, @@ -56,8 +57,16 @@ export function Sidebar() { nodes, setNewSessionModalOpen, setNewSessionForNodeId, + sidebarWidthPercent, + setSidebarWidthPercent, } = useStore(); + const { isDragging, handleMouseDown } = useResizable({ + minPercent: 20, + maxPercent: 60, + onResize: setSidebarWidthPercent, + }); + const session = selectedNodeId ? sessions.get(selectedNodeId) : null; const node = selectedNodeId ? nodes.find(n => n.id === selectedNodeId) : null; @@ -108,8 +117,16 @@ export function Sidebar() { animate={{ x: 0, opacity: 1 }} exit={{ x: "100%", opacity: 0 }} transition={{ type: "spring", stiffness: 400, damping: 40 }} - className="fixed right-0 top-14 bottom-0 w-full max-w-lg z-50 flex flex-col bg-canvas-dark border-l border-border" + className="fixed right-0 top-14 bottom-0 z-50 flex flex-col bg-canvas-dark border-l border-border" + style={{ width: `${sidebarWidthPercent}vw` }} > + {/* Drag handle */} +
{/* Header */}
diff --git a/client/src/hooks/useResizable.ts b/client/src/hooks/useResizable.ts new file mode 100644 index 0000000..b4dac41 --- /dev/null +++ b/client/src/hooks/useResizable.ts @@ -0,0 +1,66 @@ +import { useState, useCallback, useEffect } from "react"; + +interface UseResizableOptions { + minPercent?: number; + maxPercent?: number; + onResize?: (percent: number) => void; +} + +interface UseResizableReturn { + isDragging: boolean; + handleMouseDown: (e: React.MouseEvent) => void; +} + +export function useResizable({ + minPercent = 20, + maxPercent = 60, + onResize, +}: UseResizableOptions = {}): UseResizableReturn { + const [isDragging, setIsDragging] = useState(false); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + // Calculate percentage: sidebar is on the right, so width = distance from mouse to right edge + const percent = ((window.innerWidth - e.clientX) / window.innerWidth) * 100; + const clampedPercent = Math.min(maxPercent, Math.max(minPercent, percent)); + onResize?.(clampedPercent); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging, minPercent, maxPercent, onResize]); + + // Prevent text selection and set cursor globally during drag + useEffect(() => { + if (isDragging) { + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + } else { + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + } + + return () => { + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + }, [isDragging]); + + return { isDragging, handleMouseDown }; +} diff --git a/client/src/stores/useStore.ts b/client/src/stores/useStore.ts index 155d237..552e4e8 100644 --- a/client/src/stores/useStore.ts +++ b/client/src/stores/useStore.ts @@ -35,6 +35,16 @@ export interface AgentSession { currentTool?: string; } +const getInitialSidebarWidth = (): number => { + if (typeof window === 'undefined') return 35; + const stored = localStorage.getItem('openui:sidebarWidthPercent'); + if (stored) { + const parsed = parseFloat(stored); + if (!isNaN(parsed) && parsed >= 20 && parsed <= 60) return parsed; + } + return 35; // default ~512px equivalent on 1440px screens +}; + interface AppState { // Config launchCwd: string; @@ -62,6 +72,8 @@ interface AppState { setSelectedNodeId: (id: string | null) => void; sidebarOpen: boolean; setSidebarOpen: (open: boolean) => void; + sidebarWidthPercent: number; + setSidebarWidthPercent: (percent: number) => void; addAgentModalOpen: boolean; setAddAgentModalOpen: (open: boolean) => void; newSessionModalOpen: boolean; @@ -123,6 +135,11 @@ export const useStore = create((set) => ({ setSelectedNodeId: (id) => set({ selectedNodeId: id }), sidebarOpen: false, setSidebarOpen: (open) => set({ sidebarOpen: open }), + sidebarWidthPercent: getInitialSidebarWidth(), + setSidebarWidthPercent: (percent) => { + localStorage.setItem('openui:sidebarWidthPercent', String(percent)); + set({ sidebarWidthPercent: percent }); + }, addAgentModalOpen: false, setAddAgentModalOpen: (open) => set({ addAgentModalOpen: open }), newSessionModalOpen: false,