import { useCallback, useLayoutEffect, useRef, useState } from "react"; import type { KeyboardEventHandler, PointerEventHandler, RefObject } from "react"; export const ACTIVITY_PANEL_DEFAULT_WIDTH = 384; export const ACTIVITY_PANEL_MIN_WIDTH = 288; /** Pre-measurement fallback ceiling only; the measured bound is 50% of the container (see below). */ export const ACTIVITY_PANEL_MAX_WIDTH = 576; export const ACTIVITY_CENTRAL_MIN_WIDTH = 512; export const ACTIVITY_PANEL_STORAGE_KEY = "thothii:model-activity-panel-width"; export type ActivityPanelBounds = { min: number; max: number }; export function getActivityPanelBounds(containerWidth: number): ActivityPanelBounds { // The separator may be dragged out to a full 50/50 split. On containers too narrow // for that (below ~1024px) the central-min guard binds first, keeping the chat usable. return { min: ACTIVITY_PANEL_MIN_WIDTH, max: Math.max( ACTIVITY_PANEL_MIN_WIDTH, Math.min(containerWidth * 0.5, containerWidth - ACTIVITY_CENTRAL_MIN_WIDTH), ), }; } export function clampActivityPanelWidth(width: number, bounds: ActivityPanelBounds): number { const candidate = Number.isFinite(width) ? width : ACTIVITY_PANEL_DEFAULT_WIDTH; return Math.min(bounds.max, Math.max(bounds.min, candidate)); } export function loadActivityPanelWidth(storage: Pick | null): number { if (!storage) return ACTIVITY_PANEL_DEFAULT_WIDTH; try { const raw = storage.getItem(ACTIVITY_PANEL_STORAGE_KEY); if (raw === null || raw.trim() === "") return ACTIVITY_PANEL_DEFAULT_WIDTH; const parsed = Number(raw); return Number.isFinite(parsed) ? parsed : ACTIVITY_PANEL_DEFAULT_WIDTH; } catch { return ACTIVITY_PANEL_DEFAULT_WIDTH; } } export function saveActivityPanelWidth( width: number, storage: Pick | null, ): void { if (!storage) return; try { storage.setItem(ACTIVITY_PANEL_STORAGE_KEY, String(Math.round(width))); } catch { // Storage can be unavailable in privacy-restricted browser contexts. } } function getBrowserStorage(): Storage | null { if (typeof window === "undefined") return null; try { return window.localStorage; } catch { return null; } } export function resizeWidthFromKey( width: number, key: string, shiftKey: boolean, bounds: ActivityPanelBounds, ): number | null { const step = shiftKey ? 48 : 16; if (key === "ArrowLeft") return clampActivityPanelWidth(width - step, bounds); if (key === "ArrowRight") return clampActivityPanelWidth(width + step, bounds); if (key === "Home") return bounds.min; if (key === "End") return bounds.max; return null; } type DragState = { pointerId: number; startX: number; startWidth: number; target: HTMLDivElement; }; export function useActivityPanelResize(): { containerRef: RefObject; panelWidth: number; desktopSplit: boolean; resizing: boolean; separatorProps: { role: "separator"; tabIndex: 0; "aria-label": string; "aria-orientation": "vertical"; "aria-valuemin": number; "aria-valuemax": number; "aria-valuenow": number; onPointerDown: PointerEventHandler; onPointerMove: PointerEventHandler; onPointerUp: PointerEventHandler; onPointerCancel: PointerEventHandler; onKeyDown: KeyboardEventHandler; }; } { const storageRef = useRef(getBrowserStorage()); const initialWidthRef = useRef(loadActivityPanelWidth(storageRef.current)); const containerRef = useRef(null); const widthRef = useRef(initialWidthRef.current); const boundsRef = useRef({ min: ACTIVITY_PANEL_MIN_WIDTH, max: ACTIVITY_PANEL_MAX_WIDTH, }); const dragRef = useRef(null); const [panelWidth, setPanelWidth] = useState(initialWidthRef.current); const [bounds, setBounds] = useState(boundsRef.current); const [desktopSplit, setDesktopSplit] = useState(false); const [resizing, setResizing] = useState(false); const applyWidth = useCallback((rawWidth: number, persist: boolean) => { const next = clampActivityPanelWidth(rawWidth, boundsRef.current); widthRef.current = next; setPanelWidth(next); if (persist) saveActivityPanelWidth(next, storageRef.current); return next; }, []); useLayoutEffect(() => { const container = containerRef.current; if (!container) return; const cancelDrag = () => { const drag = dragRef.current; if (drag?.target.hasPointerCapture?.(drag.pointerId)) { drag.target.releasePointerCapture?.(drag.pointerId); } dragRef.current = null; setResizing(false); }; const syncLayout = () => { const viewportIsDesktop = window.matchMedia?.("(min-width: 1024px)").matches ?? window.innerWidth >= 1024; const hasUsableContainer = container.clientWidth >= ACTIVITY_PANEL_MIN_WIDTH + ACTIVITY_CENTRAL_MIN_WIDTH; const nextDesktopSplit = viewportIsDesktop && hasUsableContainer; setDesktopSplit(nextDesktopSplit); if (!nextDesktopSplit) { cancelDrag(); const savedWidth = loadActivityPanelWidth(storageRef.current); widthRef.current = savedWidth; setPanelWidth(savedWidth); return; } const nextBounds = getActivityPanelBounds(container.clientWidth); boundsRef.current = nextBounds; setBounds(nextBounds); applyWidth(widthRef.current, true); }; syncLayout(); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(syncLayout); observer?.observe(container); const media = window.matchMedia?.("(min-width: 1024px)"); media?.addEventListener?.("change", syncLayout); window.addEventListener("resize", syncLayout); return () => { observer?.disconnect(); media?.removeEventListener?.("change", syncLayout); window.removeEventListener("resize", syncLayout); }; }, [applyWidth]); const onPointerDown: PointerEventHandler = (event) => { dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startWidth: widthRef.current, target: event.currentTarget, }; event.currentTarget.setPointerCapture?.(event.pointerId); setResizing(true); }; const onPointerMove: PointerEventHandler = (event) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) return; applyWidth(drag.startWidth + event.clientX - drag.startX, false); }; const onPointerUp: PointerEventHandler = (event) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) return; applyWidth(drag.startWidth + event.clientX - drag.startX, true); if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { event.currentTarget.releasePointerCapture?.(event.pointerId); } dragRef.current = null; setResizing(false); }; const onPointerCancel: PointerEventHandler = (event) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) return; applyWidth(drag.startWidth, false); dragRef.current = null; setResizing(false); }; const onKeyDown: KeyboardEventHandler = (event) => { const next = resizeWidthFromKey(widthRef.current, event.key, event.shiftKey, boundsRef.current); if (next === null) return; event.preventDefault(); applyWidth(next, true); }; return { containerRef, panelWidth, desktopSplit, resizing, separatorProps: { role: "separator", tabIndex: 0, "aria-label": "Resize model activity", "aria-orientation": "vertical", "aria-valuemin": bounds.min, "aria-valuemax": bounds.max, "aria-valuenow": panelWidth, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onKeyDown, }, }; }