From 6250335dc83de420a9a4137bc1a786b3d1139cb8 Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Thu, 26 Feb 2026 10:22:33 -0800 Subject: [PATCH] feat(ui): add resizable sidebar panels with persistence - use-panel-resize hook: Drag-to-resize with localStorage persistence - ResizeHandle component: Visual drag handle with hover effects - UnifiedShell: Flexbox layout with resizable left/right panels - RightPanel: Removed internal width handling (now controlled by shell) --- src/components/shared/resize-handle.tsx | 64 +++++++++++++++++++++++++ src/components/shared/right-panel.tsx | 8 +--- src/components/shared/unified-shell.tsx | 50 +++++++++++-------- src/hooks/use-panel-resize.ts | 61 +++++++++++++++++++++++ 4 files changed, 157 insertions(+), 26 deletions(-) create mode 100644 src/components/shared/resize-handle.tsx create mode 100644 src/hooks/use-panel-resize.ts diff --git a/src/components/shared/resize-handle.tsx b/src/components/shared/resize-handle.tsx new file mode 100644 index 0000000..ee83f2e --- /dev/null +++ b/src/components/shared/resize-handle.tsx @@ -0,0 +1,64 @@ +"use client"; + +import React, { useCallback, useRef, useEffect } from 'react'; + +interface ResizeHandleProps { + direction: 'left' | 'right'; + onResize: (delta: number) => void; + onResizeEnd?: () => void; +} + +export function ResizeHandle({ direction, onResize, onResizeEnd }: ResizeHandleProps) { + const isDragging = useRef(false); + const startX = useRef(0); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isDragging.current = true; + startX.current = e.clientX; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, []); + + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!isDragging.current) return; + const delta = direction === 'left' + ? e.clientX - startX.current + : startX.current - e.clientX; + startX.current = e.clientX; + onResize(delta); + }, [direction, onResize]); + + const handleMouseUp = useCallback(() => { + if (isDragging.current) { + isDragging.current = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + onResizeEnd?.(); + } + }, [onResizeEnd]); + + useEffect(() => { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [handleMouseMove, handleMouseUp]); + + return ( +
+
+
+ ); +} diff --git a/src/components/shared/right-panel.tsx b/src/components/shared/right-panel.tsx index 7895789..2424602 100644 --- a/src/components/shared/right-panel.tsx +++ b/src/components/shared/right-panel.tsx @@ -16,18 +16,12 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane const { rightPanel, toggleRightPanel } = useUrlState(); const isOpen = externalIsOpen ?? (rightPanel === 'open'); - - // Calculate width based on content (Standard 17rem vs Chat Mode ~26rem) - // If rail is present, we are in "Chat Mode" (Wide Panel + Rail) - // If no rail, we are in "Activity Mode" (Standard Panel) - const panelWidth = isOpen ? '20.75rem' : '0'; if (isDesktop) { return (
{ // Filter issues by Epic if selected (Global Filter) @@ -160,31 +162,41 @@ export function UnifiedShell({ {/* TOP BAR: 3rem fixed */} - {/* MAIN AREA: CSS Grid [18rem | 1fr | RightPanel] */} - {/* Increased Left Panel width to 18rem per redesign request */} + {/* MAIN AREA: Flex layout for resizable panels */}
- {/* LEFT PANEL: 20rem unified Epic/Task tree */} - + {/* LEFT PANEL */} +
+ +
+ + {/* RESIZE HANDLE: Left */} + {/* MIDDLE CONTENT: flex-1 */} -
+
{renderMiddleContent()}
- {/* RIGHT PANEL: Activity or Assignment */} - - {renderRightPanelContent()} - + {/* RESIZE HANDLE: Right (only when panel open) */} + {panel === 'open' && } + + {/* RIGHT PANEL */} + {panel === 'open' && ( +
+ + {renderRightPanelContent()} + +
+ )}
{/* THREAD DRAWER: Popup overlay when a task is selected */} diff --git a/src/hooks/use-panel-resize.ts b/src/hooks/use-panel-resize.ts new file mode 100644 index 0000000..1c74386 --- /dev/null +++ b/src/hooks/use-panel-resize.ts @@ -0,0 +1,61 @@ +import { useState, useEffect, useCallback } from 'react'; + +const LEFT_PANEL_KEY = 'bb.ui.leftPanelWidth'; +const RIGHT_PANEL_KEY = 'bb.ui.rightPanelWidth'; + +const DEFAULT_LEFT_WIDTH = 320; +const DEFAULT_RIGHT_WIDTH = 332; + +export const MIN_LEFT_WIDTH = 192; +export const MIN_RIGHT_WIDTH = 256; + +export function usePanelResize() { + const [leftWidth, setLeftWidth] = useState(() => { + if (typeof window === 'undefined') return DEFAULT_LEFT_WIDTH; + const saved = localStorage.getItem(LEFT_PANEL_KEY); + return saved ? parseInt(saved, 10) : DEFAULT_LEFT_WIDTH; + }); + + const [rightWidth, setRightWidth] = useState(() => { + if (typeof window === 'undefined') return DEFAULT_RIGHT_WIDTH; + const saved = localStorage.getItem(RIGHT_PANEL_KEY); + return saved ? parseInt(saved, 10) : DEFAULT_RIGHT_WIDTH; + }); + + useEffect(() => { + localStorage.setItem(LEFT_PANEL_KEY, String(leftWidth)); + }, [leftWidth]); + + useEffect(() => { + localStorage.setItem(RIGHT_PANEL_KEY, String(rightWidth)); + }, [rightWidth]); + + const clampLeftWidth = useCallback((width: number) => { + const maxWidth = Math.floor(window.innerWidth * 0.30); + return Math.max(MIN_LEFT_WIDTH, Math.min(width, maxWidth)); + }, []); + + const clampRightWidth = useCallback((width: number) => { + const maxWidth = Math.floor(window.innerWidth * 0.35); + return Math.max(MIN_RIGHT_WIDTH, Math.min(width, maxWidth)); + }, []); + + const handleLeftResize = useCallback((delta: number) => { + setLeftWidth(prev => clampLeftWidth(prev + delta)); + }, [clampLeftWidth]); + + const handleRightResize = useCallback((delta: number) => { + setRightWidth(prev => clampRightWidth(prev + delta)); + }, [clampRightWidth]); + + return { + leftWidth, + rightWidth, + handleLeftResize, + handleRightResize, + clampLeftWidth, + clampRightWidth, + MIN_LEFT_WIDTH, + MIN_RIGHT_WIDTH + }; +}