From 80037fa168c1e624a839178959606f12ce63711f Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 31 Mar 2026 21:31:54 -0700 Subject: [PATCH] =?UTF-8?q?feat(web-ui):=20SplitPane=20layout=20component?= =?UTF-8?q?=20=E2=80=94=20draggable,=20collapsible,=20persistent=20(#507)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Draggable divider updates pane widths in real-time via direct DOM mutation during mousemove (no setState jank), flush to state on mouseup - Collapse/expand buttons on each pane edge (ArrowLeft01Icon/ArrowRight01Icon) - Split position persists to localStorage; restored on mount - minPanePercent enforced (default 15%) during drag - Smooth CSS transition on collapse/expand, disabled during drag - Mobile (<768px): vertical stack layout, divider hidden - 15 tests covering all acceptance criteria - Adds ArrowLeft01Icon to @hugeicons/react mock --- web-ui/__mocks__/@hugeicons/react.js | 2 + .../components/sessions/SplitPane.test.tsx | 201 +++++++++++++ web-ui/src/components/sessions/SplitPane.tsx | 263 ++++++++++++++++++ 3 files changed, 466 insertions(+) create mode 100644 web-ui/src/__tests__/components/sessions/SplitPane.test.tsx create mode 100644 web-ui/src/components/sessions/SplitPane.tsx diff --git a/web-ui/__mocks__/@hugeicons/react.js b/web-ui/__mocks__/@hugeicons/react.js index 16d2bcec..09ef8629 100644 --- a/web-ui/__mocks__/@hugeicons/react.js +++ b/web-ui/__mocks__/@hugeicons/react.js @@ -58,4 +58,6 @@ module.exports = { // AgentChatPanel ArrowRight01Icon: createIconMock('ArrowRight01Icon'), Alert01Icon: createIconMock('Alert01Icon'), + // SplitPane + ArrowLeft01Icon: createIconMock('ArrowLeft01Icon'), }; diff --git a/web-ui/src/__tests__/components/sessions/SplitPane.test.tsx b/web-ui/src/__tests__/components/sessions/SplitPane.test.tsx new file mode 100644 index 00000000..a73df5a1 --- /dev/null +++ b/web-ui/src/__tests__/components/sessions/SplitPane.test.tsx @@ -0,0 +1,201 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { SplitPane } from '@/components/sessions/SplitPane'; + +// ── localStorage mock ──────────────────────────────────────────────────── + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + }; +})(); + +Object.defineProperty(window, 'localStorage', { value: localStorageMock }); + +// ── matchMedia mock ────────────────────────────────────────────────────── + +function mockMatchMedia(matches: boolean) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); +} + +// ── getBoundingClientRect mock ─────────────────────────────────────────── + +function mockContainerRect(left = 0, width = 1000) { + jest.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ + left, + width, + top: 0, + right: left + width, + bottom: 100, + height: 100, + x: left, + y: 0, + toJSON: () => ({}), + }); +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +beforeEach(() => { + localStorageMock.clear(); + mockMatchMedia(true); // desktop by default +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('SplitPane', () => { + it('renders left and right children', () => { + render(Left content} right={
Right content
} />); + expect(screen.getByText('Left content')).toBeInTheDocument(); + expect(screen.getByText('Right content')).toBeInTheDocument(); + }); + + it('uses defaultSplit=45 when no localStorage value exists', () => { + render(L} right={
R
} defaultSplit={45} />); + const leftPane = screen.getByTestId('split-pane-left'); + expect(leftPane).toHaveStyle({ width: '45%' }); + }); + + it('restores split position from localStorage on mount', () => { + localStorageMock.setItem('split-pane-position', '60'); + render(L} right={
R
} />); + const leftPane = screen.getByTestId('split-pane-left'); + expect(leftPane).toHaveStyle({ width: '60%' }); + }); + + it('uses custom storageKey for localStorage', () => { + localStorageMock.setItem('my-custom-key', '70'); + render(L} right={
R
} storageKey="my-custom-key" />); + const leftPane = screen.getByTestId('split-pane-left'); + expect(leftPane).toHaveStyle({ width: '70%' }); + }); + + it('persists position to localStorage on drag end', () => { + mockContainerRect(0, 1000); + render(L} right={
R
} storageKey="test-key" />); + const divider = screen.getByTestId('split-pane-divider'); + + fireEvent.mouseDown(divider); + fireEvent.mouseMove(document, { clientX: 600 }); + fireEvent.mouseUp(document); + + expect(localStorageMock.getItem('test-key')).toBe('60'); + }); + + it('enforces minPanePercent during drag (default 15%)', () => { + mockContainerRect(0, 1000); + render(L} right={
R
} storageKey="test-key" />); + const divider = screen.getByTestId('split-pane-divider'); + + fireEvent.mouseDown(divider); + fireEvent.mouseMove(document, { clientX: 50 }); // 5% — below min + fireEvent.mouseUp(document); + + // Should be clamped to 15% + expect(localStorageMock.getItem('test-key')).toBe('15'); + }); + + it('enforces minPanePercent on the right side during drag', () => { + mockContainerRect(0, 1000); + render(L} right={
R
} storageKey="test-key" />); + const divider = screen.getByTestId('split-pane-divider'); + + fireEvent.mouseDown(divider); + fireEvent.mouseMove(document, { clientX: 980 }); // 98% — right pane below min + fireEvent.mouseUp(document); + + // Should be clamped to 85% (100 - 15) + expect(localStorageMock.getItem('test-key')).toBe('85'); + }); + + it('collapses left pane when left collapse button is clicked', () => { + render(L} right={
R
} />); + const collapseLeft = screen.getByTestId('collapse-left'); + fireEvent.click(collapseLeft); + const leftPane = screen.getByTestId('split-pane-left'); + expect(leftPane).toHaveStyle({ width: '0%' }); + }); + + it('expands left pane when collapse button is clicked again', () => { + render(L} right={
R
} defaultSplit={45} />); + const collapseLeft = screen.getByTestId('collapse-left'); + fireEvent.click(collapseLeft); // collapse + fireEvent.click(collapseLeft); // expand + const leftPane = screen.getByTestId('split-pane-left'); + expect(leftPane).toHaveStyle({ width: '45%' }); + }); + + it('collapses right pane when right collapse button is clicked', () => { + render(L} right={
R
} />); + const collapseRight = screen.getByTestId('collapse-right'); + fireEvent.click(collapseRight); + const rightPane = screen.getByTestId('split-pane-right'); + expect(rightPane).toHaveStyle({ width: '0%' }); + }); + + it('expands right pane when collapse button is clicked again', () => { + render(L} right={
R
} defaultSplit={45} />); + const collapseRight = screen.getByTestId('collapse-right'); + fireEvent.click(collapseRight); // collapse + fireEvent.click(collapseRight); // expand + const rightPane = screen.getByTestId('split-pane-right'); + expect(rightPane).toHaveStyle({ width: '55%' }); + }); + + it('does not apply inline width styles on mobile', () => { + mockMatchMedia(false); // mobile + render(L} right={
R
} />); + const container = screen.getByTestId('split-pane-container'); + expect(container.className).toContain('flex-col'); + }); + + it('hides divider on mobile', () => { + mockMatchMedia(false); + render(L} right={
R
} />); + const divider = screen.getByTestId('split-pane-divider'); + expect(divider.className).toContain('hidden'); + }); + + it('applies custom className to outer container', () => { + render( + L} right={
R
} className="my-custom-class" />, + ); + const container = screen.getByTestId('split-pane-container'); + expect(container.className).toContain('my-custom-class'); + }); + + it('does not move divider if mouse was not pressed (no drag started)', () => { + localStorageMock.setItem('split-pane-position', '45'); + mockContainerRect(0, 1000); + render(L} right={
R
} storageKey="split-pane-position" />); + + // Move without mousedown + fireEvent.mouseMove(document, { clientX: 700 }); + fireEvent.mouseUp(document); + + expect(localStorageMock.getItem('split-pane-position')).toBe('45'); + }); +}); diff --git a/web-ui/src/components/sessions/SplitPane.tsx b/web-ui/src/components/sessions/SplitPane.tsx new file mode 100644 index 00000000..ec57ed32 --- /dev/null +++ b/web-ui/src/components/sessions/SplitPane.tsx @@ -0,0 +1,263 @@ +'use client'; + +import { useRef, useEffect, useState, useCallback } from 'react'; +import { ArrowLeft01Icon, ArrowRight01Icon } from '@hugeicons/react'; +import { cn } from '@/lib/utils'; + +// ── Types ──────────────────────────────────────────────────────────────── + +interface SplitPaneProps { + left: React.ReactNode; + right: React.ReactNode; + defaultSplit?: number; + minPanePercent?: number; + storageKey?: string; + className?: string; +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function readStorage(key: string, fallback: number): number { + try { + const raw = localStorage.getItem(key); + if (raw !== null) { + const parsed = parseFloat(raw); + if (!isNaN(parsed)) return parsed; + } + } catch { + // localStorage unavailable (SSR, private browsing, etc.) + } + return fallback; +} + +function writeStorage(key: string, value: number): void { + try { + localStorage.setItem(key, String(value)); + } catch { + // ignore + } +} + +// ── Component ──────────────────────────────────────────────────────────── + +export function SplitPane({ + left, + right, + defaultSplit = 45, + minPanePercent = 15, + storageKey = 'split-pane-position', + className, +}: SplitPaneProps) { + const [splitPct, setSplitPct] = useState(() => + readStorage(storageKey, defaultSplit), + ); + const [isLeftCollapsed, setIsLeftCollapsed] = useState(false); + const [isRightCollapsed, setIsRightCollapsed] = useState(false); + const [isMobile, setIsMobile] = useState(false); + + const containerRef = useRef(null); + const leftPaneRef = useRef(null); + const rightPaneRef = useRef(null); + const isDragging = useRef(false); + const livePercent = useRef(splitPct); + const lastNonCollapsed = useRef(splitPct); + const transitionEnabled = useRef(false); + + // ── Mobile detection ────────────────────────────────────────────────── + + useEffect(() => { + const mq = window.matchMedia('(min-width: 768px)'); + setIsMobile(!mq.matches); + const handler = (e: { matches: boolean }) => setIsMobile(!e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + + // ── Sync DOM width refs when splitPct or transition state changes ────── + + const applyWidths = useCallback( + (pct: number) => { + if (!leftPaneRef.current || !rightPaneRef.current) return; + const transition = transitionEnabled.current ? 'width 200ms ease' : ''; + leftPaneRef.current.style.transition = transition; + rightPaneRef.current.style.transition = transition; + leftPaneRef.current.style.width = `${pct}%`; + rightPaneRef.current.style.width = `${100 - pct}%`; + }, + [], + ); + + // Apply widths whenever splitPct changes (collapse/expand triggers re-render) + useEffect(() => { + applyWidths(splitPct); + }, [splitPct, applyWidths]); + + // ── Drag logic ──────────────────────────────────────────────────────── + + useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + if (!isDragging.current || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + const rawPct = ((e.clientX - rect.left) / rect.width) * 100; + const clamped = clamp(rawPct, minPanePercent, 100 - minPanePercent); + livePercent.current = clamped; + // Direct DOM mutation — no setState during mousemove + if (leftPaneRef.current) leftPaneRef.current.style.width = `${clamped}%`; + if (rightPaneRef.current) rightPaneRef.current.style.width = `${100 - clamped}%`; + }; + + const onMouseUp = () => { + if (!isDragging.current) return; + isDragging.current = false; + const committed = livePercent.current; + lastNonCollapsed.current = committed; + setSplitPct(committed); + writeStorage(storageKey, committed); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + return () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + }, [minPanePercent, storageKey]); + + const onDividerMouseDown = () => { + isDragging.current = true; + transitionEnabled.current = false; + // Remove transitions immediately so drag is instant + if (leftPaneRef.current) leftPaneRef.current.style.transition = ''; + if (rightPaneRef.current) rightPaneRef.current.style.transition = ''; + }; + + // ── Collapse logic ──────────────────────────────────────────────────── + + const collapseLeft = () => { + transitionEnabled.current = true; + if (isLeftCollapsed) { + const restore = lastNonCollapsed.current; + setIsLeftCollapsed(false); + setSplitPct(restore); + writeStorage(storageKey, restore); + } else { + lastNonCollapsed.current = splitPct; + setIsLeftCollapsed(true); + setIsRightCollapsed(false); + setSplitPct(0); + writeStorage(storageKey, 0); + } + }; + + const collapseRight = () => { + transitionEnabled.current = true; + if (isRightCollapsed) { + const restore = lastNonCollapsed.current; + setIsRightCollapsed(false); + setSplitPct(restore); + writeStorage(storageKey, restore); + } else { + lastNonCollapsed.current = splitPct; + setIsRightCollapsed(true); + setIsLeftCollapsed(false); + setSplitPct(100); + writeStorage(storageKey, 100); + } + }; + + // ── Render ──────────────────────────────────────────────────────────── + + return ( +
+ {/* Left pane */} +
+ {left} +
+ + {/* Divider */} + + + {/* Right pane */} +
+ {right} +
+
+ ); +}