"use client"; import { motion, useReducedMotion } from "motion/react"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import useMeasure from "react-use-measure"; import { EASE_OUT, EASE_OUT_CSS, SPRING_PANEL } from "@/lib/ease"; import { cn } from "@/lib/utils"; import { ResizeHandle } from "./resize-handle"; /** Height of the overlay toolbar (`WorkbenchHeader`), in pixels. */ export const HEADER_HEIGHT = 46; /** Sidebar width when `defaultSidebarWidth` is unset, and its reset target. */ export const SIDEBAR_DEFAULT_WIDTH = 275; export const SIDEBAR_MIN_WIDTH = 240; export const SIDEBAR_MAX_WIDTH = 520; /** Panel width when `defaultPanelWidth` is unset, and its reset target. */ export const PANEL_DEFAULT_WIDTH = 384; export const PANEL_MIN_WIDTH = 320; /** Floor kept for the main column — sidebar/panel can never squeeze past it. */ export const MAIN_MIN_WIDTH = 320; // Static ceiling used for the panel's aria-valuemax before the container has // been measured (first paint only — react-use-measure reports 0 until its // ResizeObserver fires). const PANEL_MAX_FALLBACK = 960; export type WorkbenchLayoutMode = "desktop" | "tablet" | "mobile"; // Drag (or arrow-key nudge) a pane this far past its own minimum and it // collapses instead of clamping at the minimum — the "drag past the edge to // close" gesture native three-pane app shells use. const COLLAPSE_MARGIN = 40; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); interface WorkbenchContextValue { layoutMode: WorkbenchLayoutMode; sidebarOpen: boolean; panelOpen: boolean; toggleSidebar: () => void; togglePanel: () => void; setSidebarOpen: (open: boolean) => void; setPanelOpen: (open: boolean) => void; sidebarWidth: number; panelWidth: number; // Internal wiring for WorkbenchSidebar / WorkbenchPanel / WorkbenchHeader. // Not part of the public useWorkbench() contract. containerWidth: number; sidebarDefault: number; panelDefault: number; sidebarDragging: boolean; panelDragging: boolean; setSidebarDragging: (dragging: boolean) => void; setPanelDragging: (dragging: boolean) => void; resizeSidebar: (rawWidth: number) => void; resizePanel: (rawWidth: number) => void; resetSidebarWidth: () => void; resetPanelWidth: () => void; } const WorkbenchContext = createContext(null); function useWorkbenchInternal(component: string): WorkbenchContextValue { const ctx = useContext(WorkbenchContext); if (!ctx) throw new Error(`${component} must be used inside `); return ctx; } /** Reads the workbench's open/close and width state. Must be used inside ``. */ export function useWorkbench() { const { layoutMode, sidebarOpen, panelOpen, toggleSidebar, togglePanel, setSidebarOpen, setPanelOpen, sidebarWidth, panelWidth, } = useWorkbenchInternal("useWorkbench"); return { layoutMode, sidebarOpen, panelOpen, toggleSidebar, togglePanel, setSidebarOpen, setPanelOpen, sidebarWidth, panelWidth, }; } export interface WorkbenchProps { sidebarOpen?: boolean; defaultSidebarOpen?: boolean; onSidebarOpenChange?: (open: boolean) => void; panelOpen?: boolean; defaultPanelOpen?: boolean; onPanelOpenChange?: (open: boolean) => void; /** Initial sidebar width, and its double-click/Enter reset target. Defaults to `SIDEBAR_DEFAULT_WIDTH`. */ defaultSidebarWidth?: number; /** Initial panel width, and its double-click/Enter reset target. Defaults to `PANEL_DEFAULT_WIDTH`. */ defaultPanelWidth?: number; /** Width at which the inline three-column layout begins. Defaults to 1200px. */ desktopBreakpoint?: number; /** Width below which the workbench shows one task surface at a time. Defaults to 720px. */ tabletBreakpoint?: number; className?: string; children?: ReactNode; } /** * Three-pane app shell root: a resizable sidebar, a flexible main column and * a resizable utility panel, laid out as a single flex row. Compose it with * `WorkbenchHeader` (an absolutely-positioned overlay), `WorkbenchSidebar`, * `WorkbenchMain` and `WorkbenchPanel` as children, in whatever DOM order — * only the header's position is order-independent by design. */ export function Workbench({ sidebarOpen: sidebarOpenProp, defaultSidebarOpen = true, onSidebarOpenChange, panelOpen: panelOpenProp, defaultPanelOpen = false, onPanelOpenChange, defaultSidebarWidth, defaultPanelWidth, desktopBreakpoint = 1200, tabletBreakpoint = 720, className, children, }: WorkbenchProps) { const [containerRef, bounds] = useMeasure(); const containerWidth = bounds.width; const layoutMode: WorkbenchLayoutMode = containerWidth > 0 && containerWidth < tabletBreakpoint ? "mobile" : containerWidth > 0 && containerWidth < desktopBreakpoint ? "tablet" : "desktop"; const sidebarDefault = defaultSidebarWidth ?? SIDEBAR_DEFAULT_WIDTH; const panelDefault = defaultPanelWidth ?? PANEL_DEFAULT_WIDTH; const [internalSidebarOpen, setInternalSidebarOpen] = useState(defaultSidebarOpen); const [internalPanelOpen, setInternalPanelOpen] = useState(defaultPanelOpen); const sidebarControlled = sidebarOpenProp !== undefined; const panelControlled = panelOpenProp !== undefined; const sidebarOpen = sidebarControlled ? sidebarOpenProp : internalSidebarOpen; const panelOpen = panelControlled ? panelOpenProp : internalPanelOpen; // Which side pane the user opened last — the other one yields when the // desktop container can't fit both (see the auto-yield effect below). const lastOpenedRef = useRef<"sidebar" | "panel">("sidebar"); const setSidebarOpen = useCallback( (open: boolean) => { if (open) lastOpenedRef.current = "sidebar"; if (open && layoutMode !== "desktop") { if (!panelControlled) setInternalPanelOpen(false); onPanelOpenChange?.(false); } if (!sidebarControlled) setInternalSidebarOpen(open); onSidebarOpenChange?.(open); }, [ layoutMode, panelControlled, onPanelOpenChange, sidebarControlled, onSidebarOpenChange, ], ); const setPanelOpen = useCallback( (open: boolean) => { if (open) lastOpenedRef.current = "panel"; if (open && layoutMode !== "desktop") { if (!sidebarControlled) setInternalSidebarOpen(false); onSidebarOpenChange?.(false); } if (!panelControlled) setInternalPanelOpen(open); onPanelOpenChange?.(open); }, [ layoutMode, sidebarControlled, onSidebarOpenChange, panelControlled, onPanelOpenChange, ], ); const toggleSidebar = useCallback( () => setSidebarOpen(!sidebarOpen), [setSidebarOpen, sidebarOpen], ); const togglePanel = useCallback(() => setPanelOpen(!panelOpen), [setPanelOpen, panelOpen]); const [sidebarWidth, setSidebarWidth] = useState(sidebarDefault); const [panelWidth, setPanelWidth] = useState(panelDefault); const [sidebarDragging, setSidebarDragging] = useState(false); const [panelDragging, setPanelDragging] = useState(false); const resizeSidebar = useCallback( (rawWidth: number) => { if (rawWidth < SIDEBAR_MIN_WIDTH - COLLAPSE_MARGIN) { setSidebarOpen(false); return; } const spaceLimit = containerWidth > 0 ? containerWidth - MAIN_MIN_WIDTH : SIDEBAR_MAX_WIDTH; const max = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, spaceLimit)); setSidebarWidth(clamp(rawWidth, SIDEBAR_MIN_WIDTH, max)); }, [setSidebarOpen, containerWidth], ); const resizePanel = useCallback( (rawWidth: number) => { if (rawWidth < PANEL_MIN_WIDTH - COLLAPSE_MARGIN) { setPanelOpen(false); return; } const reserved = sidebarOpen ? sidebarWidth : 0; const max = containerWidth > 0 ? Math.max(PANEL_MIN_WIDTH, containerWidth - reserved - MAIN_MIN_WIDTH) : PANEL_MAX_FALLBACK; setPanelWidth(clamp(rawWidth, PANEL_MIN_WIDTH, max)); }, [setPanelOpen, sidebarOpen, sidebarWidth, containerWidth], ); const resetSidebarWidth = useCallback(() => setSidebarWidth(sidebarDefault), [sidebarDefault]); const resetPanelWidth = useCallback(() => setPanelWidth(panelDefault), [panelDefault]); const previousLayoutModeRef = useRef(layoutMode); // A narrow workbench starts with its navigation out of the way. This is an // intentional mode transition rather than a width-fitting fallback: the // panel remains open when an application controls it, and can be shown as an // overlay without squeezing the task surface. useEffect(() => { const previous = previousLayoutModeRef.current; previousLayoutModeRef.current = layoutMode; if (layoutMode === "desktop" || previous === layoutMode) return; setSidebarOpen(false); }, [layoutMode, setSidebarOpen]); // Re-clamp (never collapse) whenever the measured container shrinks, so a // narrower window can't leave the sidebar/panel wider than there's room for. useEffect(() => { if (containerWidth <= 0 || layoutMode !== "desktop") return; setSidebarWidth((w) => clamp(w, SIDEBAR_MIN_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, containerWidth - MAIN_MIN_WIDTH)), ); }, [containerWidth, layoutMode]); useEffect(() => { if (containerWidth <= 0 || layoutMode !== "desktop") return; const reserved = sidebarOpen ? sidebarWidth : 0; setPanelWidth((w) => clamp(w, PANEL_MIN_WIDTH, Math.max(PANEL_MIN_WIDTH, containerWidth - reserved - MAIN_MIN_WIDTH)), ); }, [containerWidth, layoutMode, sidebarOpen, sidebarWidth]); // Both side panes plus the main floor can exceed a narrow container even at // their minimum widths. When they do, the pane opened less recently yields — // the narrow-window rule desktop three-pane shells use — so opening one side // swaps the other out instead of crushing the main column. useEffect(() => { if (layoutMode !== "desktop" || containerWidth <= 0 || !sidebarOpen || !panelOpen) return; if (sidebarWidth + panelWidth + MAIN_MIN_WIDTH <= containerWidth) return; if (lastOpenedRef.current === "panel") setSidebarOpen(false); else setPanelOpen(false); }, [layoutMode, containerWidth, sidebarOpen, panelOpen, sidebarWidth, panelWidth, setSidebarOpen, setPanelOpen]); const value = useMemo( () => ({ layoutMode, sidebarOpen, panelOpen, toggleSidebar, togglePanel, setSidebarOpen, setPanelOpen, sidebarWidth, panelWidth, containerWidth, sidebarDefault, panelDefault, sidebarDragging, panelDragging, setSidebarDragging, setPanelDragging, resizeSidebar, resizePanel, resetSidebarWidth, resetPanelWidth, }), [ layoutMode, sidebarOpen, panelOpen, toggleSidebar, togglePanel, setSidebarOpen, setPanelOpen, sidebarWidth, panelWidth, containerWidth, sidebarDefault, panelDefault, sidebarDragging, panelDragging, resizeSidebar, resizePanel, resetSidebarWidth, resetPanelWidth, ], ); return (
{children}
); } export interface WorkbenchSidebarProps { className?: string; children?: ReactNode; } /** Translucent, resizable left pane. Drag its right-edge handle past the * minimum to collapse it; double-click the handle to reset its width. */ export function WorkbenchSidebar({ className, children }: WorkbenchSidebarProps) { const { layoutMode, sidebarOpen, sidebarWidth, sidebarDragging, containerWidth, resizeSidebar, resetSidebarWidth, setSidebarDragging, setSidebarOpen, } = useWorkbenchInternal("WorkbenchSidebar"); const reduce = useReducedMotion() ?? false; const ariaMax = containerWidth > 0 ? Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, containerWidth - MAIN_MIN_WIDTH)) : SIDEBAR_MAX_WIDTH; if (layoutMode !== "desktop") { const mobile = layoutMode === "mobile"; return ( <> {sidebarOpen ? (