import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled"; import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { X } from "lucide-react"; import { usePanel } from "../context/PanelContext"; import { useClassicTaskInterfaceEnabled } from "../hooks/useClassicTaskInterfaceEnabled"; import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled"; import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { SidePanelFrame, SidePanelWindowControls } from "@/components/side-panel"; import { useDesignSandbox } from "@/context/DesignSandboxContext"; export function PropertiesPanel({ taskDetailLayout = false }: { taskDetailLayout?: boolean }) { const { panelContent, panelContentMode, panelVisible, setPanelVisible, panelMaximizeRequested, clearPanelMaximizeRequest, } = usePanel(); const { enabled: classicTaskInterfaceEnabled } = useClassicTaskInterfaceEnabled(); const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled(); const designSandbox = useDesignSandbox(); const { enabled: coordinatorWorkspaceEnabled } = useCoordinatorWorkspaceEnabled(); const streamlinedTaskDetailLayout = (streamlinedUiEnabled || coordinatorWorkspaceEnabled) && taskDetailLayout; // Keep the zero-width host mounted while route data loads. Inserting an // already-open pane skips its width transition and snaps the thread sideways. const visible = panelVisible && panelContent != null; if (classicTaskInterfaceEnabled && !coordinatorWorkspaceEnabled) { return ( ); } return ( ); } /* ------------------------------------------------------------------------- * * Production chat-style resizable/maximizable variant. Streamlined UI only * changes its task-detail dimensions; Classic Task Interface selects the * fixed panel above. * ------------------------------------------------------------------------- */ /** * Portal target in the redesigned pane's header bar: hosted content (the * Properties | Plan | Artifacts tab strip, including the single active * Properties tab) renders here, left of the window controls. See * IssueProperties' flag-ON shell. */ export const PROPERTIES_PANE_HEADER_SLOT_ID = "properties-pane-header-slot"; /** * Portal target pinned below the pane's scroll area: hosted content (the plan * confirmation action bar) renders here so it stays visible while the pane * body scrolls. */ export const PROPERTIES_PANE_FOOTER_SLOT_ID = "properties-pane-footer-slot"; const WIDTH_STORAGE_KEY = "taskChatRedesign.propertiesPaneWidth"; const TASK_DETAIL_WIDTH_STORAGE_KEY = "taskChatRedesign.taskDetailPropertiesPaneWidth"; const PROPOSAL_WIDTH_STORAGE_KEY = "paperclip.design-proposal.properties-pane-width"; const DEFAULT_PANE_WIDTH = 322; const TASK_DETAIL_DEFAULT_PANE_WIDTH = 434; const MIN_PANE_WIDTH = 260; /** ~236px sidebar + ~420px minimum center column stay usable while resizing. */ const RESERVED_LAYOUT_WIDTH = 656; /** Content cap while maximized so text doesn't span the full viewport. */ const MAXIMIZED_CONTENT_MAX_WIDTH = 840; /** * Defensive fallback (in milliseconds) for the restore glide in case * `transitionend` never fires; slightly longer than the --motion-pane-glide * token in index.css. */ const RESTORE_FALLBACK_DELAY = 400; function readProposalPaneWidth(fallback: number): number { if (typeof window === "undefined") return fallback; const tokenWidth = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--design-page-aside-width")); return Number.isFinite(tokenWidth) && tokenWidth > 0 ? tokenWidth : fallback; } function clampPaneWidth(width: number): number { const max = typeof window === "undefined" ? Number.POSITIVE_INFINITY : Math.max(MIN_PANE_WIDTH, window.innerWidth - RESERVED_LAYOUT_WIDTH); return Math.min(Math.max(Math.round(width), MIN_PANE_WIDTH), max); } function readStoredPaneWidth(storageKey: string, defaultWidth: number): number { if (typeof window === "undefined") return defaultWidth; try { const raw = window.localStorage.getItem(storageKey); const parsed = raw === null ? Number.NaN : Number(raw); return Number.isFinite(parsed) ? parsed : defaultWidth; } catch { return defaultWidth; } } function persistPaneWidth(storageKey: string, width: number) { if (typeof window === "undefined") return; try { window.localStorage.setItem(storageKey, String(width)); } catch { // Ignore storage failures. } } function clearStoredPaneWidth(storageKey: string) { if (typeof window === "undefined") return; try { window.localStorage.removeItem(storageKey); } catch { // Ignore storage failures. } } function prefersReducedMotion(): boolean { if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } /** Fixed-position geometry while the panel is maximized (or gliding). */ interface FixedPane { top: number; /** Animated by the .tc-pane-glide transition. */ left: number; /** Distance from the viewport's right edge to the panel's right edge. */ rightInset: number; /** Glide target: the layout row's left edge (flush with the sidebar). */ parentLeft: number; } interface ResizablePropertiesPanelProps { panelContent: ReactNode | null; panelContentMode: "padded" | "prose" | "full-bleed"; panelVisible: boolean; setPanelVisible: (visible: boolean) => void; taskDetailLayout: boolean; designSandbox: boolean; /** Pending `viewer=full` deep-link request (LOOA-2181); cleared once consumed. */ maximizeRequested: boolean; clearMaximizeRequest: () => void; } function ResizablePropertiesPanel({ panelContent, panelContentMode, panelVisible, setPanelVisible, taskDetailLayout, designSandbox, maximizeRequested, clearMaximizeRequest, }: ResizablePropertiesPanelProps) { const productionPaneWidth = taskDetailLayout ? TASK_DETAIL_DEFAULT_PANE_WIDTH : DEFAULT_PANE_WIDTH; const defaultPaneWidth = designSandbox ? readProposalPaneWidth(productionPaneWidth) : productionPaneWidth; const widthStorageKey = designSandbox ? `${PROPOSAL_WIDTH_STORAGE_KEY}.${taskDetailLayout ? "task-detail" : "default"}` : taskDetailLayout ? TASK_DETAIL_WIDTH_STORAGE_KEY : WIDTH_STORAGE_KEY; const [width, setWidth] = useState(() => clampPaneWidth(readStoredPaneWidth(widthStorageKey, defaultPaneWidth)), ); const [dragging, setDragging] = useState(false); const [maximized, setMaximized] = useState(false); const [fixedPane, setFixedPane] = useState(null); // The host can outlive a viewport change while its content is loading. // Clamp before revealing it, without overwriting the user's saved width. useLayoutEffect(() => { if (panelVisible) setWidth(clampPaneWidth); }, [panelVisible]); const asideRef = useRef(null); const widthRef = useRef(width); widthRef.current = width; const dragStateRef = useRef<{ pointerId: number; startX: number; startWidth: number } | null>( null, ); const previousBodyUserSelectRef = useRef(""); const restoreTimerRef = useRef(null); const clearRestoreTimer = useCallback(() => { if (restoreTimerRef.current !== null) { window.clearTimeout(restoreTimerRef.current); restoreTimerRef.current = null; } }, []); const finishRestore = useCallback(() => { clearRestoreTimer(); setFixedPane(null); }, [clearRestoreTimer]); // Hiding the panel keeps today's collapse-to-0 behavior; if it was // maximized (or mid-glide), just unmaximize instantly first. useEffect(() => { if (!panelVisible) { setMaximized(false); finishRestore(); } }, [panelVisible, finishRestore]); useEffect( () => () => { if (restoreTimerRef.current !== null) window.clearTimeout(restoreTimerRef.current); if (dragStateRef.current !== null) { document.body.style.userSelect = previousBodyUserSelectRef.current; } }, [], ); const endDrag = useCallback((persist: boolean) => { if (dragStateRef.current === null) return; dragStateRef.current = null; setDragging(false); document.body.style.userSelect = previousBodyUserSelectRef.current; if (persist) persistPaneWidth(widthStorageKey, widthRef.current); }, [widthStorageKey]); const handleGripPointerDown = useCallback((event: React.PointerEvent) => { // Primary button only (touch/pen report button 0 or -1 for down events). if (event.pointerType === "mouse" && event.button !== 0) return; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); dragStateRef.current = { pointerId: event.pointerId, startX: event.clientX, startWidth: widthRef.current, }; previousBodyUserSelectRef.current = document.body.style.userSelect; document.body.style.userSelect = "none"; setDragging(true); }, []); const handleGripPointerMove = useCallback((event: React.PointerEvent) => { const drag = dragStateRef.current; if (drag === null || drag.pointerId !== event.pointerId) return; // The grip sits on the panel's LEFT border: moving left widens the panel. setWidth(clampPaneWidth(drag.startWidth + (drag.startX - event.clientX))); }, []); const handleGripPointerUp = useCallback( (event: React.PointerEvent) => { const drag = dragStateRef.current; if (drag === null || drag.pointerId !== event.pointerId) return; endDrag(true); }, [endDrag], ); const handleGripLostPointerCapture = useCallback(() => { endDrag(true); }, [endDrag]); const handleGripDoubleClick = useCallback(() => { setWidth(designSandbox ? clampPaneWidth(readProposalPaneWidth(productionPaneWidth)) : defaultPaneWidth); clearStoredPaneWidth(widthStorageKey); }, [defaultPaneWidth, designSandbox, productionPaneWidth, widthStorageKey]); const handleMaximize = useCallback(() => { const aside = asideRef.current; const row = aside?.parentElement; if (!aside || !row) return; clearRestoreTimer(); setMaximized(true); const rowRect = row.getBoundingClientRect(); setFixedPane((pane) => { // Re-maximizing mid-restore: keep the current geometry, glide back left. if (pane) return { ...pane, left: pane.parentLeft }; const rect = aside.getBoundingClientRect(); const seeded: FixedPane = { top: rect.top, left: rect.left, rightInset: Math.max(0, window.innerWidth - rect.right), parentLeft: rowRect.left, }; if (prefersReducedMotion()) return { ...seeded, left: seeded.parentLeft }; // Seed at the current left, then glide to the row's left edge once the // fixed position has been committed (double rAF). window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { setFixedPane((current) => (current ? { ...current, left: current.parentLeft } : current)); }); }); return seeded; }); }, [clearRestoreTimer]); const handleRestore = useCallback(() => { const row = asideRef.current?.parentElement; setMaximized(false); if (prefersReducedMotion()) { finishRestore(); return; } setFixedPane((pane) => { if (!pane) return pane; const rowRight = row ? row.getBoundingClientRect().right : window.innerWidth - pane.rightInset; return { ...pane, left: rowRight - widthRef.current }; }); clearRestoreTimer(); restoreTimerRef.current = window.setTimeout(finishRestore, RESTORE_FALLBACK_DELAY); }, [clearRestoreTimer, finishRestore]); // Deep-link maximize (LOOA-2181): the request may predate this mount (the // hash routes before the panel content commits), so it lives in context and // is consumed here once the panel is actually visible and laid out — // handleMaximize measures live geometry, which needs a committed DOM. useEffect(() => { if (!maximizeRequested || !panelVisible) return; clearMaximizeRequest(); if (!maximized) handleMaximize(); }, [maximizeRequested, panelVisible, maximized, handleMaximize, clearMaximizeRequest]); const handleTransitionEnd = useCallback( (event: React.TransitionEvent) => { if (event.target !== asideRef.current || event.propertyName !== "left") return; // Only the restore glide needs to unfix on arrival. if (!maximized) finishRestore(); }, [maximized, finishRestore], ); // Route cleanup removes content before the maximize reset effect runs. // Release fixed geometry immediately so an empty pane cannot cover the next page. const isFixed = panelContent != null && fixedPane !== null; return ( <> {isFixed ? ( // Holds the panel's slot in the layout flex row while the panel is // position:fixed, so the main column never reflows. ) : null} > ); }