import { useCallback, useEffect, useRef, useState } from 'react'; import type { DragEvent } from 'react'; import { toast } from 'sonner'; import { api } from '@/api/client'; import type { WorkspacePane } from '@/api/types'; import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane'; import { sessionEvents } from '@/hooks/sessionEvents'; export const MAX_PANES = 5; // v1.12.1: legacy localStorage key. Read once on mount to seed the server // for sessions still on per-device state, then deleted. Server is now // authoritative via sessions.workspace_panes. const LEGACY_STORAGE_KEY = 'boocode.workspace.panes'; const SAVE_DEBOUNCE_MS = 300; function generateId(): string { return crypto.randomUUID(); } // v1.10.3: optional id arg lets addSplitPane lift id generation out of the // setPanes updater so the new pane's id can be returned synchronously to the // caller (needed for mobile URL state). function emptyPane(id: string = generateId()): WorkspacePane { return { id, kind: 'empty', chatIds: [], activeChatIdx: -1 }; } function chatPane(chatId: string): WorkspacePane { return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 }; } // v1.10 booterm: terminal panes carry no chats. Their `id` is used as the // tmux window key on booterm — see apps/booterm/src/pty/manager.ts. They // persist in localStorage along with chat panes so a refresh resumes the // same tmux window via the idempotent start endpoint. function terminalPane(id: string = generateId()): WorkspacePane { return { id, kind: 'terminal', chatIds: [], activeChatIdx: -1 }; } // 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); } // v1.12.1: read legacy per-device localStorage. If present, the caller seeds // the server then deletes the key. One-time migration per session. function readLegacyPanes(sessionId: string): WorkspacePane[] | null { try { const raw = localStorage.getItem(`${LEGACY_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; } } 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; // v1.10.3: returns the new pane's id (or null if the operation was a no-op: // 'agent' kind is a toast stub, or max panes reached). Callers can use the // id to update mobile URL state so the URL-sync effect doesn't fight the // freshly-set activePaneIdx. addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => string | null; // Open-on-first-click, close-on-second-click. Singleton — settings panes // don't count toward MAX_PANES. Closing the only remaining pane (edge case) // falls back to an empty pane to preserve the "always one pane" invariant. toggleSettingsPane: () => void; removePane: (idx: number) => void; removeChatFromPanes: (chatId: string) => void; initializeFirstChatIfEmpty: (chatId: string) => void; validatePanes: (validChatIds: Set) => 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(() => [emptyPane()]); const [activePaneIdx, setActivePaneIdx] = useState(0); const draggingIdxRef = useRef(null); const [dragOverIdx, setDragOverIdx] = useState(null); // v1.12.1: skip PATCH while hydrating from the server. Without this, the // initial [emptyPane()] would be saved over the server's real state before // the GET resolves. const hydratedRef = useRef(false); // Tracks the last value broadcast by another device (or this one's own // round-trip). If a PATCH would echo this exact payload, we skip the call. const lastRemoteJsonRef = useRef('[]'); // v1.12.1: hydrate from server on mount, then subscribe to remote updates. useEffect(() => { hydratedRef.current = false; let cancelled = false; void (async () => { try { const session = await api.sessions.get(sessionId); if (cancelled) return; let initial: WorkspacePane[] = Array.isArray(session.workspace_panes) ? session.workspace_panes : []; // One-time migration: if server is empty but legacy localStorage has // a layout, seed the server and delete the local key. if (initial.length === 0) { const legacy = readLegacyPanes(sessionId); if (legacy && legacy.length > 0) { try { const updated = await api.sessions.updateWorkspacePanes(sessionId, legacy); if (cancelled) return; initial = updated.workspace_panes; localStorage.removeItem(`${LEGACY_STORAGE_KEY}.${sessionId}`); } catch { initial = legacy; } } } const next = initial.length > 0 ? initial : [emptyPane()]; lastRemoteJsonRef.current = JSON.stringify(persistablePanes(next)); setPanes(next); setActivePaneIdx(0); } finally { if (!cancelled) hydratedRef.current = true; } })(); return () => { cancelled = true; }; }, [sessionId]); // v1.12.1: live cross-device sync. Replace local state when another device // (or our own write echo) lands a session_workspace_updated frame. useEffect(() => { return sessionEvents.subscribe((ev) => { if (ev.type !== 'session_workspace_updated') return; if (ev.session_id !== sessionId) return; const incoming = Array.isArray(ev.workspace_panes) ? ev.workspace_panes : []; const json = JSON.stringify(incoming); if (json === lastRemoteJsonRef.current) return; lastRemoteJsonRef.current = json; setPanes(incoming.length > 0 ? incoming : [emptyPane()]); setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1))); }); }, [sessionId]); // v1.12.1: debounced PATCH on every change. Settings panes are stripped // before saving (ephemeral per v1.9). useEffect(() => { if (!hydratedRef.current) return; const payload = persistablePanes(panes); const json = JSON.stringify(payload); if (json === lastRemoteJsonRef.current) return; const timer = setTimeout(() => { lastRemoteJsonRef.current = json; api.sessions.updateWorkspacePanes(sessionId, payload).catch(() => { // Non-fatal: next change retries. Persistent failures surface via // the network layer's existing reconnect toast. }); }, SAVE_DEBOUNCE_MS); return () => clearTimeout(timer); }, [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'): string | null => { if (kind === 'agent') { toast('Agent panes coming in BooCoder'); return null; } // Generate the id outside the updater so we can return it deterministically. // setPanes's updater can be invoked twice in strict mode; using a fixed id // ensures both invocations agree and the returned id matches what landed. const newPaneId = generateId(); let success = false; 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 newPane = kind === 'terminal' ? terminalPane(newPaneId) : emptyPane(newPaneId); const next = [...prev, newPane]; setActivePaneIdx(next.length - 1); success = true; return next; }); return success ? newPaneId : null; }, []); const toggleSettingsPane = useCallback(() => { setPanes((prev) => { const existingIdx = prev.findIndex((p) => p.kind === 'settings'); if (existingIdx < 0) { const next = [...prev, settingsPane()]; setActivePaneIdx(next.length - 1); return next; } if (prev.length <= 1) { setActivePaneIdx(0); return [emptyPane()]; } const next = prev.filter((_, i) => i !== existingIdx); setActivePaneIdx((ai) => Math.min(ai, next.length - 1)); return next; }); }, []); const removePane = useCallback((idx: number) => { setPanes((prev) => { if (prev.length <= 1) { // Settings is the only kind that can be the last pane and still need // closing (X / Esc / sidebar toggle). Fall back to empty. if (prev[idx]?.kind === 'settings') { setActivePaneIdx(0); return [emptyPane()]; } return prev; } // v1.10.8c: with per-pane tmux sessions, an unkilled session leaks until // the next `tmux kill-server`. Fire-and-forget /kill on terminal removal. // The endpoint is idempotent (404 on missing session) so a strict-mode // double-invoke of the updater is safe. const removed = prev[idx]; if (removed?.kind === 'terminal') { api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ }); } const next = prev.filter((_, i) => i !== idx); setActivePaneIdx((ai) => Math.min(ai, next.length - 1)); return next; }); }, [sessionId]); // 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 validatePanes = useCallback((validChatIds: Set) => { setPanes((prev) => { const cleaned = prev.map((pane) => { if (pane.kind !== 'chat' || pane.chatIds.length === 0) return pane; const nextIds = pane.chatIds.filter((id) => validChatIds.has(id)); if (nextIds.length === pane.chatIds.length) return pane; if (nextIds.length === 0) { return { ...pane, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 }; } const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1); return { ...pane, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx] }; }); const unchanged = cleaned.every((p, i) => p === prev[i]); return unchanged ? prev : cleaned; }); }, []); 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, switchTab, removeTab, closeOtherTabs, closeTabsToRight, closeAllTabs, showLandingPage, addSplitPane, toggleSettingsPane, removePane, removeChatFromPanes, initializeFirstChatIfEmpty, validatePanes, handlePaneDragStart, handlePaneDragOver, handlePaneDragLeave, handlePaneDrop, handlePaneDragEnd, dragOverIdx, draggingIdxRef, }; }