import { useCallback, useEffect, useRef, useState } from 'react'; import type { DragEvent } from 'react'; import { toast } from 'sonner'; import type { WorkspacePane } from '@/api/types'; import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane'; export const MAX_PANES = 5; const STORAGE_KEY = 'boocode.workspace.panes'; function generateId(): string { return crypto.randomUUID(); } function emptyPane(): WorkspacePane { return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 }; } function chatPane(chatId: string): WorkspacePane { return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 }; } // v1.9: settings pane factory. No chats, no state beyond identity — the // SettingsPane component renders Session/Project sections from the // surrounding session/project. function settingsPane(): WorkspacePane { return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 }; } // v1.9: settings panes are ephemeral. Filter them out before persisting so a // page reload always returns to a clean workspace; the user re-opens via the // sidebar Settings button when needed. function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] { return panes.filter((p) => p.kind !== 'settings'); } // v1.9: per recon decision (c), settings panes don't count toward MAX_PANES. // Helper used at every pane-insertion site so the rule lives in one place. function nonSettingsCount(panes: WorkspacePane[]): number { return panes.reduce((n, p) => n + (p.kind === 'settings' ? 0 : 1), 0); } function loadPanes(sessionId: string): WorkspacePane[] | null { try { const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`); if (!raw) return null; const parsed = JSON.parse(raw) as WorkspacePane[]; if (!Array.isArray(parsed) || parsed.length === 0) return null; return parsed; } catch { return null; } } function savePanes(sessionId: string, panes: WorkspacePane[]): void { try { localStorage.setItem( `${STORAGE_KEY}.${sessionId}`, JSON.stringify(persistablePanes(panes)), ); } catch { /* quota or disabled */ } } export interface UseWorkspacePanesResult { panes: WorkspacePane[]; activePaneIdx: number; setActivePaneIdx: React.Dispatch>; activePaneIdxRef: React.MutableRefObject; openChatInPane: (paneIdx: number, chatId: string) => void; switchTab: (paneIdx: number, tabIdx: number) => void; removeTab: (paneIdx: number, chatId: string) => void; closeOtherTabs: (paneIdx: number, keepChatId: string) => void; closeTabsToRight: (paneIdx: number, pivotChatId: string) => void; closeAllTabs: (paneIdx: number) => void; showLandingPage: (paneIdx: number) => void; addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void; // v1.9: idempotent open-or-focus for the settings pane singleton. Appends // a new settings pane if none exists, otherwise just focuses the existing // one. Always succeeds — settings panes don't count toward MAX_PANES. openOrFocusSettingsPane: () => void; removePane: (idx: number) => void; removeChatFromPanes: (chatId: string) => void; initializeFirstChatIfEmpty: (chatId: string) => void; handlePaneDragStart: (idx: number) => (e: DragEvent) => void; handlePaneDragOver: (idx: number) => (e: DragEvent) => void; handlePaneDragLeave: () => void; handlePaneDrop: (targetIdx: number) => (e: DragEvent) => void; handlePaneDragEnd: () => void; dragOverIdx: number | null; draggingIdxRef: React.MutableRefObject; } export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { const [panes, setPanes] = useState(() => { return loadPanes(sessionId) ?? [emptyPane()]; }); const [activePaneIdx, setActivePaneIdx] = useState(0); const draggingIdxRef = useRef(null); const [dragOverIdx, setDragOverIdx] = useState(null); useEffect(() => { savePanes(sessionId, panes); }, [sessionId, panes]); useEffect(() => { const active = panes[activePaneIdx]; if (!active) { clearActivePane(); return; } setActivePaneInfo({ sessionId, paneId: active.id, kind: active.kind, activeFile: null, }); }, [sessionId, panes, activePaneIdx]); useEffect(() => { return () => { clearActivePane(); }; }, []); const activePaneIdxRef = useRef(activePaneIdx); activePaneIdxRef.current = activePaneIdx; const openChatInPane = useCallback((paneIdx: number, chatId: string) => { setPanes((prev) => { const next = [...prev]; const pane = next[paneIdx]!; const existing = pane.chatIds.indexOf(chatId); if (existing >= 0) { next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing }; } else { const newIds = [...pane.chatIds, chatId]; next[paneIdx] = { ...pane, kind: 'chat', chatId, chatIds: newIds, activeChatIdx: newIds.length - 1, }; } return next; }); setActivePaneIdx(paneIdx); }, []); const switchTab = useCallback((paneIdx: number, tabIdx: number) => { setPanes((prev) => { const next = [...prev]; const pane = next[paneIdx]!; const chatId = pane.chatIds[tabIdx]; if (!chatId) return prev; next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx }; return next; }); }, []); const removeTab = useCallback((paneIdx: number, chatId: string) => { setPanes((prev) => { const next = [...prev]; const pane = next[paneIdx]!; const nextIds = pane.chatIds.filter((id) => id !== chatId); if (nextIds.length === 0) { next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 }; } else { const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1); next[paneIdx] = { ...pane, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx], }; } return next; }); }, []); // Keep only the right-clicked tab open in this pane. const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => { setPanes((prev) => { const next = [...prev]; const pane = next[paneIdx]!; const keepIdx = pane.chatIds.indexOf(keepChatId); if (keepIdx < 0) return prev; next[paneIdx] = { ...pane, kind: 'chat', chatId: keepChatId, chatIds: [keepChatId], activeChatIdx: 0, }; return next; }); }, []); // Close every tab to the right of the right-clicked one. const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => { setPanes((prev) => { const next = [...prev]; const pane = next[paneIdx]!; const pivotIdx = pane.chatIds.indexOf(pivotChatId); if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev; const nextIds = pane.chatIds.slice(0, pivotIdx + 1); const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1); next[paneIdx] = { ...pane, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx], }; return next; }); }, []); // Close every tab in this pane; land on landing page. const closeAllTabs = useCallback((paneIdx: number) => { setPanes((prev) => { const next = [...prev]; const pane = next[paneIdx]!; next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 }; return next; }); }, []); const showLandingPage = useCallback((paneIdx: number) => { setPanes((prev) => { const next = [...prev]; const pane = next[paneIdx]!; next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined }; return next; }); }, []); const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => { if (kind === 'terminal') { toast('Terminal panes coming in BooTerm'); return; } if (kind === 'agent') { toast('Agent panes coming in BooCoder'); return; } setPanes((prev) => { // v1.9: settings panes are excluded from the MAX cap (decision c). if (nonSettingsCount(prev) >= MAX_PANES) { toast.error(`Maximum ${MAX_PANES} panes`); return prev; } const next = [...prev, emptyPane()]; setActivePaneIdx(next.length - 1); return next; }); }, []); const openOrFocusSettingsPane = useCallback(() => { setPanes((prev) => { const existingIdx = prev.findIndex((p) => p.kind === 'settings'); if (existingIdx >= 0) { setActivePaneIdx(existingIdx); return prev; } const next = [...prev, settingsPane()]; setActivePaneIdx(next.length - 1); return next; }); }, []); const removePane = useCallback((idx: number) => { setPanes((prev) => { if (prev.length <= 1) return prev; const next = prev.filter((_, i) => i !== idx); setActivePaneIdx((ai) => Math.min(ai, next.length - 1)); return next; }); }, []); // Replaces a single empty default pane with a chat pane. Used by the initial // chat fetch to land on the most-recent open chat if no saved pane state. const initializeFirstChatIfEmpty = useCallback((chatId: string) => { setPanes((prev) => { if (prev.length === 1 && prev[0]!.kind === 'empty') { return [chatPane(chatId)]; } return prev; }); }, []); const removeChatFromPanes = useCallback((chatId: string) => { setPanes((prev) => prev.map((p) => { const idx = p.chatIds.indexOf(chatId); if (idx < 0) return p; const nextIds = p.chatIds.filter((id) => id !== chatId); if (nextIds.length === 0) { return { ...p, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 }; } const nextActiveIdx = Math.min(p.activeChatIdx, nextIds.length - 1); return { ...p, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx], }; })); }, []); const handlePaneDragStart = useCallback( (idx: number) => (e: DragEvent) => { draggingIdxRef.current = idx; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(idx)); }, [] ); const handlePaneDragOver = useCallback( (idx: number) => (e: DragEvent) => { if (draggingIdxRef.current === null) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (dragOverIdx !== idx) setDragOverIdx(idx); }, [dragOverIdx] ); const handlePaneDragLeave = useCallback(() => { setDragOverIdx(null); }, []); const handlePaneDrop = useCallback( (targetIdx: number) => (e: DragEvent) => { e.preventDefault(); const fromIdx = draggingIdxRef.current; draggingIdxRef.current = null; setDragOverIdx(null); if (fromIdx === null || fromIdx === targetIdx) return; setPanes((prev) => { const next = [...prev]; const [moved] = next.splice(fromIdx, 1); if (!moved) return prev; next.splice(targetIdx, 0, moved); // Keep active selection on the same logical pane (the one being dragged). setActivePaneIdx(targetIdx); return next; }); }, [] ); const handlePaneDragEnd = useCallback(() => { draggingIdxRef.current = null; setDragOverIdx(null); }, []); return { panes, activePaneIdx, setActivePaneIdx, activePaneIdxRef, openChatInPane, openOrFocusSettingsPane, switchTab, removeTab, closeOtherTabs, closeTabsToRight, closeAllTabs, showLandingPage, addSplitPane, removePane, removeChatFromPanes, initializeFirstChatIfEmpty, handlePaneDragStart, handlePaneDragOver, handlePaneDragLeave, handlePaneDrop, handlePaneDragEnd, dragOverIdx, draggingIdxRef, }; }