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)
This commit is contained in:
zenchantlive 2026-02-26 10:22:33 -08:00
parent ebd3ffcbbe
commit 6250335dc8
4 changed files with 157 additions and 26 deletions

View file

@ -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 (
<div
className={`
w-1 h-full cursor-col-resize
bg-transparent hover:bg-[var(--ui-accent-info)]/30
transition-colors duration-150
flex-shrink-0 z-10
group relative
`}
onMouseDown={handleMouseDown}
>
<div className="absolute inset-y-0 -left-1 -right-1 group-hover:bg-[var(--ui-accent-info)]/10" />
</div>
);
}

View file

@ -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 (
<div
className="ui-shell-panel flex overflow-hidden transition-all duration-300"
className="ui-shell-panel flex overflow-hidden h-full"
style={{
width: panelWidth,
boxShadow: isOpen ? '-24px 0 40px -26px rgba(0,0,0,0.95), inset 1px 0 0 rgba(91,168,160,0.22)' : 'none',
}}
data-testid="right-panel-desktop"

View file

@ -9,7 +9,9 @@ import { LeftPanel, type LeftPanelFilters } from './left-panel';
import { RightPanel } from './right-panel';
import { MobileNav } from './mobile-nav';
import { ThreadDrawer } from './thread-drawer';
import { ResizeHandle } from './resize-handle';
import { useUrlState } from '../../hooks/use-url-state';
import { usePanelResize } from '../../hooks/use-panel-resize';
import { SmartDag } from '../graph/smart-dag';
import { SocialPage } from '../social/social-page';
import { buildSocialCards } from '../../lib/social-cards';
@ -91,8 +93,8 @@ export function UnifiedShell({
const drawerTitle = selectedSocialCard?.title || selectedSwarmCard?.title || '';
const drawerId = taskId || swarmId || '';
// Grid Layout: Fixed width for right panel to match right-panel.tsx
const rightPanelWidth = panel === 'open' ? '20.75rem' : '0rem';
// Panel resize hook
const { leftWidth, rightWidth, handleLeftResize, handleRightResize } = usePanelResize();
const renderMiddleContent = () => {
// Filter issues by Epic if selected (Global Filter)
@ -160,31 +162,41 @@ export function UnifiedShell({
{/* TOP BAR: 3rem fixed */}
<TopBar />
{/* MAIN AREA: CSS Grid [18rem | 1fr | RightPanel] */}
{/* Increased Left Panel width to 18rem per redesign request */}
{/* MAIN AREA: Flex layout for resizable panels */}
<div
className="flex-1 grid overflow-hidden transition-all duration-300"
style={{ gridTemplateColumns: `20rem 1fr ${rightPanelWidth}` }}
className="flex-1 flex overflow-hidden"
data-testid="main-area"
>
{/* LEFT PANEL: 20rem unified Epic/Task tree */}
<LeftPanel
issues={issues}
selectedEpicId={epicId}
onEpicSelect={setEpicId}
filters={filters}
onFiltersChange={setFilters}
/>
{/* LEFT PANEL */}
<div style={{ width: leftWidth }} className="flex-shrink-0 overflow-hidden">
<LeftPanel
issues={issues}
selectedEpicId={epicId}
onEpicSelect={setEpicId}
filters={filters}
onFiltersChange={setFilters}
/>
</div>
{/* RESIZE HANDLE: Left */}
<ResizeHandle direction="left" onResize={handleLeftResize} />
{/* MIDDLE CONTENT: flex-1 */}
<div className="relative overflow-hidden bg-black/10 shadow-inner" data-testid="middle-content">
<div className="flex-1 relative overflow-hidden bg-black/10 shadow-inner" data-testid="middle-content">
{renderMiddleContent()}
</div>
{/* RIGHT PANEL: Activity or Assignment */}
<RightPanel isOpen={panel === 'open'}>
{renderRightPanelContent()}
</RightPanel>
{/* RESIZE HANDLE: Right (only when panel open) */}
{panel === 'open' && <ResizeHandle direction="right" onResize={handleRightResize} />}
{/* RIGHT PANEL */}
{panel === 'open' && (
<div style={{ width: rightWidth }} className="flex-shrink-0 overflow-hidden">
<RightPanel isOpen={true}>
{renderRightPanelContent()}
</RightPanel>
</div>
)}
</div>
{/* THREAD DRAWER: Popup overlay when a task is selected */}

View file

@ -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
};
}