import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { ensurePanelLayout, type PanelLayout, selectPanelLayout, setSidebarVisible, setSidebarWidth, toggleSidebar, } from '../../store/layoutSlice'; const namespace = 'two-panel-layout'; function debug(message: string, payload?: Record) { if (import.meta.env.DEV) { console.debug(`[${namespace}] ${message}`, payload ?? {}); } } function clampWidth(width: number, min: number, max: number): number { return Math.min(Math.max(width, min), max); } /** * Subscribe to a two-pane layout's persisted geometry and get back the * helpers external chrome needs to drive it (e.g. a hamburger button living * in some other header). Reads the SAME slice state `TwoPanelLayout` renders * from, so toggles stay in sync. */ export function useTwoPanelLayout(id: string, defaults?: Partial) { const dispatch = useAppDispatch(); const layout = useAppSelector(selectPanelLayout(id, defaults)); const show = useCallback( (visible: boolean) => dispatch(setSidebarVisible({ id, visible })), [dispatch, id] ); const toggle = useCallback(() => dispatch(toggleSidebar({ id })), [dispatch, id]); return { sidebarVisible: layout.sidebarVisible, sidebarWidth: layout.sidebarWidth, showSidebar: show, toggleSidebar: toggle, }; } interface TwoPanelLayoutProps { /** Stable id used as the persistence key for this layout's geometry. */ id: string; /** Content of the mini sidebar (left pane). */ sidebar: ReactNode; /** Main content (right pane). */ children: ReactNode; /** Sidebar visibility on first ever mount (before any persisted state). */ defaultSidebarVisible?: boolean; /** Sidebar width in px on first ever mount. */ defaultSidebarWidth?: number; /** Minimum sidebar width while dragging. */ minSidebarWidth?: number; /** Maximum sidebar width while dragging. */ maxSidebarWidth?: number; /** * Force the sidebar open regardless of persisted state (e.g. an onboarding * lockdown where the sidebar must always show). The persisted preference is * untouched, so it restores once the force is lifted. */ forceSidebarVisible?: boolean; /** Step (px) the keyboard divider moves per arrow press. */ keyboardStep?: number; className?: string; sidebarClassName?: string; contentClassName?: string; /** * Shared appearance applied to BOTH panes — the card background, rounded * corners, border and shadow live here (not in the panes' own content) so * every two-pane screen gets a consistent look for free. Pass `''` to opt * out (e.g. a flush, borderless layout). */ paneClassName?: string; /** * Show a thin rail with a reopen button when the sidebar is hidden. Defaults * to false because chat surfaces its own toggle in the header; standalone * uses can opt in. */ showCollapsedRail?: boolean; /** * Show the visible grab handle on the resize divider. When false the divider * is still draggable (and shows a faint line on hover/focus) but renders no * resting holder — a cleaner look for screens that don't want the affordance * front-and-center. Defaults to true. */ showDividerHandle?: boolean; /** * Join the two panes into a single bordered card with no gap between them: the * shared edge becomes a flush, hairline drag divider. This is the default for * every two-pane surface; pass `false` for the legacy split-card look with a * gutter divider (no current callers). */ seamless?: boolean; } /** Default card look shared by both panes. */ const DEFAULT_PANE_CLASS = 'bg-surface rounded-2xl shadow-soft border border-line'; const DEFAULT_MIN_WIDTH = 180; const DEFAULT_MAX_WIDTH = 480; const DEFAULT_KEYBOARD_STEP = 16; /** * A reusable two-pane shell: a resizable mini sidebar on the left and main * content on the right. Visibility and the dragged width persist per `id` via * the Redux `layout` slice, so the layout is remembered across reloads. * * Resize: drag the divider between the panes (pointer) or focus it and use the * arrow keys. Width is clamped to [minSidebarWidth, maxSidebarWidth] and only * committed to the store on drag end to avoid thrashing redux-persist. */ export default function TwoPanelLayout({ id, sidebar, children, defaultSidebarVisible = false, defaultSidebarWidth, minSidebarWidth = DEFAULT_MIN_WIDTH, maxSidebarWidth = DEFAULT_MAX_WIDTH, forceSidebarVisible = false, keyboardStep = DEFAULT_KEYBOARD_STEP, className = '', sidebarClassName = '', contentClassName = '', paneClassName = DEFAULT_PANE_CLASS, showCollapsedRail = false, showDividerHandle = true, seamless = true, }: TwoPanelLayoutProps) { const { t } = useT(); const dispatch = useAppDispatch(); const layout = useAppSelector( selectPanelLayout(id, { sidebarVisible: defaultSidebarVisible, ...(defaultSidebarWidth != null ? { sidebarWidth: defaultSidebarWidth } : {}), }) ); // Seed persisted geometry from this component's defaults exactly once per id. useEffect(() => { dispatch( ensurePanelLayout({ id, defaults: { sidebarVisible: defaultSidebarVisible, ...(defaultSidebarWidth != null ? { sidebarWidth: defaultSidebarWidth } : {}), }, }) ); // Intentionally only on id change — defaults are a first-mount seed. // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); const isOpen = forceSidebarVisible || layout.sidebarVisible; // Live width while dragging is kept local (and applied via inline style) so // we don't dispatch — and re-persist — on every pointer move. const [dragWidth, setDragWidth] = useState(null); const dragWidthRef = useRef(null); const persistedWidth = clampWidth(layout.sidebarWidth, minSidebarWidth, maxSidebarWidth); const width = dragWidth ?? persistedWidth; const commitWidth = useCallback( (next: number) => { const clamped = clampWidth(Math.round(next), minSidebarWidth, maxSidebarWidth); dispatch(setSidebarWidth({ id, width: clamped })); debug('commit width', { id, width: clamped }); }, [dispatch, id, minSidebarWidth, maxSidebarWidth] ); // Active-drag teardown, stashed so an unmount mid-drag can detach the global // listeners. Each drag installs locally-scoped `pointermove`/`pointerup` // handlers (hoisted function declarations so they can reference each other), // keeping the resize self-contained without inter-callback dependencies. const dragCleanupRef = useRef<(() => void) | null>(null); const onPointerDown = useCallback( (e: React.PointerEvent) => { e.preventDefault(); const startX = e.clientX; const startWidth = width; dragWidthRef.current = startWidth; setDragWidth(startWidth); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; function handleMove(ev: PointerEvent) { const next = clampWidth( startWidth + (ev.clientX - startX), minSidebarWidth, maxSidebarWidth ); dragWidthRef.current = next; setDragWidth(next); } function detach() { window.removeEventListener('pointermove', handleMove); window.removeEventListener('pointerup', stop); document.body.style.removeProperty('cursor'); document.body.style.removeProperty('user-select'); dragCleanupRef.current = null; } function stop() { detach(); const finalWidth = dragWidthRef.current; dragWidthRef.current = null; setDragWidth(null); if (finalWidth != null) commitWidth(finalWidth); } dragCleanupRef.current = detach; window.addEventListener('pointermove', handleMove); window.addEventListener('pointerup', stop); debug('drag start', { id, startWidth }); }, [width, minSidebarWidth, maxSidebarWidth, commitWidth, id] ); // Detach global listeners if we unmount mid-drag. useLayoutEffect(() => { return () => { dragCleanupRef.current?.(); }; }, []); const onDividerKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'ArrowLeft') { e.preventDefault(); commitWidth(persistedWidth - keyboardStep); } else if (e.key === 'ArrowRight') { e.preventDefault(); commitWidth(persistedWidth + keyboardStep); } }, [commitWidth, persistedWidth, keyboardStep] ); // In seamless mode the card lives on the wrapper that holds both panes, so the // panes themselves carry no border/rounding and sit flush against the divider. const paneCard = seamless ? '' : paneClassName; const panes = ( <> {isOpen && ( <>
{sidebar}
{/* Drag handle / divider */}
{seamless ? ( <> {/* Wider transparent grab strip straddling the 1px seam; z-10 keeps it above the adjacent panes so it stays grabbable. */} {/* The seam line itself, brightened on hover/focus. */} ) : ( /* Transparent hit area (full height) with a short grab handle centered vertically. When the handle is hidden it stays transparent at rest and only surfaces on hover/focus. */ )}
)} {!isOpen && showCollapsedRail && ( )}
{children}
); return (
{seamless ? (
{panes}
) : ( panes )}
); }