"use client"; import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { PanelLeftOpen, GripVertical, X } from 'lucide-react'; import SettingsModal, { SettingsTab } from '../settings/SettingsModal'; import SearchModal from '../nodes/SearchModal'; import { ContextSummary, Node } from '@/types/database'; import { DatabaseEvent } from '@/services/events'; import { usePersistentState } from '@/hooks/usePersistentState'; import { useTheme } from '@/hooks/useTheme'; import LeftToolbar from './LeftToolbar'; import SplitHandle from './SplitHandle'; import { NodePane, ContextsPane, DimensionsPane, MapPane, ViewsPane, TablePane, SkillsPane } from '../panes'; import QuickAddInput from '../agents/QuickAddInput'; import type { PaneType, SlotState, PaneAction, SlotId } from '../panes/types'; export interface PendingNode { id: string; input: string; inputType: string; submittedAt: number; status: 'processing' | 'error'; error?: string; } const SLOT_A_KEY = 'ui.slotA.v6'; const SLOT_B_KEY = 'ui.slotB.v6'; const SLOT_C_KEY = 'ui.slotC.v6'; const PANEL_A_EXPANDED_KEY = 'ui.panelA.expanded.v1'; const PANEL_B_EXPANDED_KEY = 'ui.panelB.expanded.v1'; const PANEL_C_EXPANDED_KEY = 'ui.panelC.expanded.v1'; const PANEL_A_WEIGHT_KEY = 'ui.panelA.weight.v1'; const PANEL_B_WEIGHT_KEY = 'ui.panelB.weight.v1'; const PANEL_C_WEIGHT_KEY = 'ui.panelC.weight.v1'; const LEFT_NAV_EXPANDED_KEY = 'ui.leftNavExpanded'; const ACTIVE_CONTEXT_KEY = 'ui.focus.activeContextId'; const ACTIVE_DIMENSION_KEY = 'ui.focus.activeDimension'; const DEFAULT_SLOT_A: SlotState = { type: 'views' }; const VALID_PANE_TYPES = new Set(['node', 'contexts', 'dimensions', 'map', 'views', 'table', 'skills']); function normalizeSlotState(raw: SlotState | null): SlotState | null { if (!raw) return null; const rawType = raw.type as string; if (rawType === 'guides') { return { ...raw, type: 'skills' }; } if (!VALID_PANE_TYPES.has(raw.type)) { return null; } return raw; } export default function ThreePanelLayout() { const containerRef = useRef(null); const panelRefs = useRef>({ A: null, B: null, C: null }); const [theme, toggleTheme] = useTheme(); const [slotA, setSlotA] = usePersistentState(SLOT_A_KEY, DEFAULT_SLOT_A); const [slotB, setSlotB] = usePersistentState(SLOT_B_KEY, null); const [slotC, setSlotC] = usePersistentState(SLOT_C_KEY, null); const [panelAExpanded, setPanelAExpanded] = usePersistentState(PANEL_A_EXPANDED_KEY, true); const [panelBExpanded, setPanelBExpanded] = usePersistentState(PANEL_B_EXPANDED_KEY, false); const [panelCExpanded, setPanelCExpanded] = usePersistentState(PANEL_C_EXPANDED_KEY, false); const [panelAWeight, setPanelAWeight] = usePersistentState(PANEL_A_WEIGHT_KEY, 1); const [panelBWeight, setPanelBWeight] = usePersistentState(PANEL_B_WEIGHT_KEY, 1); const [panelCWeight, setPanelCWeight] = usePersistentState(PANEL_C_WEIGHT_KEY, 1); const [leftNavExpanded, setLeftNavExpanded] = usePersistentState(LEFT_NAV_EXPANDED_KEY, false); const [activePane, setActivePane] = useState('A'); const [showSettings, setShowSettings] = useState(false); const [settingsInitialTab, setSettingsInitialTab] = useState(); const handleCloseSettings = useCallback(() => { setShowSettings(false); setSettingsInitialTab(undefined); }, []); const [showSearchModal, setShowSearchModal] = useState(false); const [showAddStuff, setShowAddStuff] = useState(false); const [selectedNodes, setSelectedNodes] = useState>(new Set()); const [openTabsData, setOpenTabsData] = useState([]); const [nodesPanelRefresh, setNodesPanelRefresh] = useState(0); const [focusPanelRefresh, setFocusPanelRefresh] = useState(0); const [folderViewRefresh, setFolderViewRefresh] = useState(0); const [availableContexts, setAvailableContexts] = useState([]); const [activeContextId, setActiveContextId] = usePersistentState(ACTIVE_CONTEXT_KEY, null); const [activeDimension, setActiveDimension] = usePersistentState(ACTIVE_DIMENSION_KEY, null); const [browseContextFilters, setBrowseContextFilters] = useState>({ A: null, B: null, C: null, }); const [browseDimensionFilters, setBrowseDimensionFilters] = useState>({ A: null, B: null, C: null, }); const [highlightedPassage, setHighlightedPassage] = useState<{ nodeId: number; nodeTitle: string; selectedText: string; } | null>(null); const [pendingNodes, setPendingNodes] = useState([]); const openTabsRef = useRef([]); useEffect(() => { setSlotA((prev) => normalizeSlotState(prev) ?? DEFAULT_SLOT_A); setSlotB((prev) => normalizeSlotState(prev)); setSlotC((prev) => normalizeSlotState(prev)); }, [setSlotA, setSlotB, setSlotC]); useEffect(() => { void (async () => { try { const response = await fetch('/api/contexts'); const data = await response.json(); if (response.ok && data.success) { setAvailableContexts(data.data || []); } } catch (error) { console.error('Failed to fetch contexts:', error); } })(); }, [nodesPanelRefresh, folderViewRefresh]); const getSlotState = useCallback((slot: SlotId): SlotState | null => { switch (slot) { case 'A': return slotA; case 'B': return slotB; case 'C': return slotC; } }, [slotA, slotB, slotC]); const getSlotSetter = useCallback((slot: SlotId) => { switch (slot) { case 'A': return setSlotA; case 'B': return setSlotB; case 'C': return setSlotC; } }, [setSlotA, setSlotB, setSlotC]); const isPanelExpanded = useCallback((slot: SlotId) => { switch (slot) { case 'A': return panelAExpanded; case 'B': return panelBExpanded; case 'C': return panelCExpanded; } }, [panelAExpanded, panelBExpanded, panelCExpanded]); const setPanelExpanded = useCallback((slot: SlotId, expanded: boolean) => { switch (slot) { case 'A': setPanelAExpanded(expanded); break; case 'B': setPanelBExpanded(expanded); break; case 'C': setPanelCExpanded(expanded); break; } }, [setPanelAExpanded, setPanelBExpanded, setPanelCExpanded]); const getPanelWeight = useCallback((slot: SlotId) => { switch (slot) { case 'A': return panelAWeight; case 'B': return panelBWeight; case 'C': return panelCWeight; } }, [panelAWeight, panelBWeight, panelCWeight]); const setPanelWeight = useCallback((slot: SlotId, weight: number) => { const next = Math.max(0.6, Math.min(3, weight)); switch (slot) { case 'A': setPanelAWeight(next); break; case 'B': setPanelBWeight(next); break; case 'C': setPanelCWeight(next); break; } }, [setPanelAWeight, setPanelBWeight, setPanelCWeight]); const isSlotEmpty = useCallback((slot: SlotId) => { return !getSlotState(slot); }, [getSlotState]); const { openTabs, activeTab } = useMemo(() => { const collectTabs = (state: SlotState | null): number[] => state?.type === 'node' ? (state.nodeTabs ?? []) : []; const slotStates: Record = { A: slotA, B: slotB, C: slotC }; const activeSlotState = slotStates[activePane]; const activeNodes = collectTabs(activeSlotState); const otherNodes = (['A', 'B', 'C'] as SlotId[]) .filter((slot) => slot !== activePane) .flatMap((slot) => collectTabs(slotStates[slot])); const allNodes = [...new Set([...activeNodes, ...otherNodes])]; let active: number | null = null; if (activeSlotState?.type === 'node' && activeSlotState.activeNodeTab != null) { active = activeSlotState.activeNodeTab; } if (active == null) { for (const slot of ['A', 'B', 'C'] as SlotId[]) { const state = slotStates[slot]; if (slot !== activePane && state?.type === 'node' && state.activeNodeTab != null) { active = state.activeNodeTab; break; } } } if (active == null && allNodes.length > 0) { active = allNodes[0]; } return { openTabs: allNodes, activeTab: active }; }, [slotA, slotB, slotC, activePane]); const fetchOpenTabsData = async (tabIds: number[]) => { if (tabIds.length === 0) { setOpenTabsData([]); return; } try { const nodePromises = tabIds.map(async (id) => { const response = await fetch(`/api/nodes/${id}`); if (response.ok) { const data = await response.json(); return data.node as Node; } return null; }); const nodes = await Promise.all(nodePromises); const validNodes = nodes.filter((node): node is Node => Boolean(node)).map(node => ({ id: node.id, title: node.title, link: node.link, source: node.source, dimensions: node.dimensions, created_at: node.created_at, updated_at: node.updated_at, chunk_status: node.chunk_status, metadata: node.metadata, })); setOpenTabsData(validNodes); } catch (error) { console.error('Failed to fetch tab data:', error); setOpenTabsData([]); } }; const openTabsKey = openTabs.join(','); useEffect(() => { openTabsRef.current = openTabs; fetchOpenTabsData(openTabs); // eslint-disable-next-line react-hooks/exhaustive-deps }, [openTabsKey, focusPanelRefresh]); const handleRefreshAll = useCallback(() => { setNodesPanelRefresh(prev => prev + 1); setFolderViewRefresh(prev => prev + 1); setFocusPanelRefresh(prev => prev + 1); }, []); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); setShowSearchModal(true); } if ((e.metaKey || e.ctrlKey) && e.key === '\\') { e.preventDefault(); const collapsed = (['A', 'B', 'C'] as SlotId[]).find((slot) => !isPanelExpanded(slot)); if (collapsed) { setPanelExpanded(collapsed, true); setActivePane(collapsed); } } if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'r') { e.preventDefault(); handleRefreshAll(); } if ((e.metaKey || e.ctrlKey) && e.key === 'n') { if (document.activeElement?.closest('[data-rah-app]')) { e.preventDefault(); setShowAddStuff(true); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleRefreshAll, isPanelExpanded, setPanelExpanded]); useEffect(() => { let eventSource: EventSource | null = null; try { eventSource = new EventSource('/api/events'); eventSource.onmessage = (event) => { try { const data: DatabaseEvent = JSON.parse(event.data); switch (data.type) { case 'NODE_CREATED': setNodesPanelRefresh(prev => prev + 1); break; case 'NODE_UPDATED': { const currentOpenTabs = openTabsRef.current; const updatedNodeId = Number(data.data.nodeId); if (currentOpenTabs.includes(updatedNodeId)) { setFocusPanelRefresh(prev => prev + 1); } setNodesPanelRefresh(prev => prev + 1); break; } case 'NODE_DELETED': handleNodeDeleted(data.data.nodeId); setNodesPanelRefresh(prev => prev + 1); break; case 'EDGE_CREATED': case 'EDGE_DELETED': { const currentOpenTabsForEdge = openTabsRef.current; if (currentOpenTabsForEdge.includes(data.data.fromNodeId) || currentOpenTabsForEdge.includes(data.data.toNodeId)) { setFocusPanelRefresh(prev => prev + 1); } break; } case 'DIMENSION_UPDATED': setNodesPanelRefresh(prev => prev + 1); setFolderViewRefresh(prev => prev + 1); break; case 'HELPER_UPDATED': case 'AGENT_UPDATED': if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('agents:updated', { detail: data.data })); } break; case 'GUIDE_UPDATED': if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('guides:updated', { detail: data.data })); window.dispatchEvent(new CustomEvent('skills:updated', { detail: data.data })); } break; case 'QUICK_ADD_COMPLETED': if (data.data?.quickAddId) { setPendingNodes(prev => prev.filter(p => p.id !== data.data.quickAddId)); } break; case 'QUICK_ADD_FAILED': if (data.data?.quickAddId) { setPendingNodes(prev => prev.map(p => p.id === data.data.quickAddId ? { ...p, status: 'error' as const, error: data.data.error || 'Unknown error' } : p )); } break; } } catch (error) { console.error('Failed to parse SSE event:', error); } }; } catch (error) { console.error('Failed to establish SSE connection:', error); } return () => { eventSource?.close(); }; }, []); useEffect(() => { if (pendingNodes.length === 0) return; const interval = setInterval(() => { const now = Date.now(); setPendingNodes(prev => prev.filter(p => { const age = now - p.submittedAt; if (p.status === 'processing' && age > 90_000) return false; if (p.status === 'error' && age > 120_000) return false; return true; })); }, 5000); return () => clearInterval(interval); }, [pendingNodes.length]); const setSingletonPaneInSlot = useCallback((slot: SlotId, paneType: Exclude) => { setPanelExpanded(slot, true); getSlotSetter(slot)({ type: paneType }); }, [getSlotSetter, setPanelExpanded]); const focusPaneIfOpen = useCallback((paneType: Exclude): SlotId | null => { for (const slot of ['A', 'B', 'C'] as SlotId[]) { if (getSlotState(slot)?.type === paneType) { setActivePane(slot); return slot; } } return null; }, [getSlotState]); const openPaneSingleton = useCallback((paneType: Exclude, preferredSlot?: SlotId) => { const existing = focusPaneIfOpen(paneType); if (existing) return existing; const orderedSlots = preferredSlot ? [preferredSlot, ...(['A', 'B', 'C'] as SlotId[]).filter((slot) => slot !== preferredSlot)] : (['A', 'B', 'C'] as SlotId[]); const expandedEmpty = orderedSlots.find((slot) => isPanelExpanded(slot) && isSlotEmpty(slot)); if (expandedEmpty) { setSingletonPaneInSlot(expandedEmpty, paneType); setActivePane(expandedEmpty); return expandedEmpty; } const collapsedFree = orderedSlots.find((slot) => !isPanelExpanded(slot)); if (collapsedFree) { setPanelExpanded(collapsedFree, true); setSingletonPaneInSlot(collapsedFree, paneType); setActivePane(collapsedFree); return collapsedFree; } const activeHasContext = isPanelExpanded(activePane) && !isSlotEmpty(activePane); const replacementTarget = activeHasContext ? activePane : orderedSlots.find((slot) => isPanelExpanded(slot) && !isSlotEmpty(slot)) ?? orderedSlots.find((slot) => isPanelExpanded(slot)) ?? orderedSlots[0]; setSingletonPaneInSlot(replacementTarget, paneType); setActivePane(replacementTarget); return replacementTarget; }, [activePane, focusPaneIfOpen, isPanelExpanded, isSlotEmpty, setPanelExpanded, setSingletonPaneInSlot]); const addNodeTabToSlot = useCallback((slot: SlotId, nodeId: number) => { setPanelExpanded(slot, true); const state = getSlotState(slot); if (state?.type === 'node') { const currentTabs = state.nodeTabs || []; const newTabs = currentTabs.includes(nodeId) ? currentTabs : [...currentTabs, nodeId]; getSlotSetter(slot)({ ...state, nodeTabs: newTabs, activeNodeTab: nodeId, }); return; } getSlotSetter(slot)({ type: 'node', nodeTabs: [nodeId], activeNodeTab: nodeId, }); }, [getSlotSetter, getSlotState, setPanelExpanded]); const openNodeFromSlot = useCallback((nodeId: number, fromSlot?: SlotId) => { const targetOrder: SlotId[] = fromSlot === 'A' ? ['B', 'C'] : fromSlot === 'B' ? ['C', 'A'] : fromSlot === 'C' ? ['B', 'A'] : ['B', 'C', 'A']; for (const slot of ['A', 'B', 'C'] as SlotId[]) { const state = getSlotState(slot); if (state?.type === 'node' && (state.nodeTabs || []).includes(nodeId)) { getSlotSetter(slot)({ ...state, activeNodeTab: nodeId }); setSelectedNodes(new Set([nodeId])); setActivePane(slot); return; } } const emptyTarget = targetOrder.find((slot) => slot !== fromSlot && isSlotEmpty(slot)); const fallbackTarget = targetOrder.find((slot) => slot !== fromSlot) ?? 'B'; const target = emptyTarget ?? fallbackTarget; addNodeTabToSlot(target, nodeId); setSelectedNodes(new Set([nodeId])); setActivePane(target); }, [addNodeTabToSlot, getSlotSetter, getSlotState, isSlotEmpty]); const handleNodeSelect = useCallback((nodeId: number, _multiSelect: boolean) => { openNodeFromSlot(nodeId); }, [openNodeFromSlot]); const handleTabSelect = useCallback((slot: SlotId, tabId: number) => { const state = getSlotState(slot); if (state?.type !== 'node') return; getSlotSetter(slot)({ ...state, activeNodeTab: tabId }); setSelectedNodes(new Set([tabId])); setActivePane(slot); }, [getSlotSetter, getSlotState]); const handleCloseTab = useCallback((slot: SlotId, tabId: number) => { const state = getSlotState(slot); if (state?.type !== 'node') return; const currentTabs = state.nodeTabs || []; const newTabs = currentTabs.filter(id => id !== tabId); const newActiveTab = state.activeNodeTab === tabId ? (newTabs.length > 0 ? newTabs[Math.min(currentTabs.indexOf(tabId), newTabs.length - 1)] : null) : state.activeNodeTab ?? null; if (newTabs.length === 0) { getSlotSetter(slot)(null); return; } getSlotSetter(slot)({ ...state, nodeTabs: newTabs, activeNodeTab: newActiveTab, }); }, [getSlotSetter, getSlotState]); const handleNodeDeleted = useCallback((nodeId: number) => { for (const slot of ['A', 'B', 'C'] as SlotId[]) { handleCloseTab(slot, nodeId); } }, [handleCloseTab]); const handleReorderTabs = useCallback((slot: SlotId, fromIndex: number, toIndex: number) => { const state = getSlotState(slot); if (state?.type !== 'node') return; const currentTabs = state.nodeTabs || []; if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= currentTabs.length || toIndex >= currentTabs.length) { return; } const updated = [...currentTabs]; const [moved] = updated.splice(fromIndex, 1); updated.splice(toIndex, 0, moved); getSlotSetter(slot)({ ...state, nodeTabs: updated }); }, [getSlotSetter, getSlotState]); const handleFolderViewDataChanged = useCallback(() => { setFolderViewRefresh(prev => prev + 1); setNodesPanelRefresh(prev => prev + 1); }, []); const handleNodeOpenFromDimensions = useCallback((nodeId: number) => { openNodeFromSlot(nodeId, 'A'); }, [openNodeFromSlot]); const handleContextSelect = useCallback((slot: SlotId, contextId: number | null, _contextName?: string | null) => { setBrowseContextFilters((prev) => ({ ...prev, [slot]: contextId })); setActiveContextId(contextId); if (contextId == null) return; setPanelExpanded(slot, true); getSlotSetter(slot)({ type: 'views' }); setActivePane(slot); }, [getSlotSetter, setActiveContextId, setPanelExpanded]); const handleDimensionPaneSelect = useCallback((slot: SlotId, dimensionName: string | null) => { setBrowseDimensionFilters((prev) => ({ ...prev, [slot]: dimensionName })); setActiveDimension(dimensionName); if (!dimensionName) return; setPanelExpanded(slot, true); getSlotSetter(slot)({ type: 'views' }); setActivePane(slot); }, [getSlotSetter, setActiveDimension, setPanelExpanded]); const handleQuickAddSubmit = useCallback(async ({ input, mode, description }: { input: string; mode: 'link' | 'text'; description?: string }) => { try { const response = await fetch('/api/quick-add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input, mode, description, contextId: activeContextId }), }); if (!response.ok) { const data = await response.json(); throw new Error(data.error || 'Failed to submit Quick Add'); } const data = await response.json(); const result = data.result as { id: string; inputType: string } | undefined; if (result?.id) { setPendingNodes(prev => [{ id: result.id, input: input.trim(), inputType: result.inputType || 'note', submittedAt: Date.now(), status: 'processing', }, ...prev]); } openPaneSingleton('views', 'A'); setShowAddStuff(false); } catch (error) { console.error('[ThreePanelLayout] Quick Add error:', error); } }, [activeContextId, openPaneSingleton]); const handleCloseSlotA = useCallback(() => { setSlotA(null); setPanelAExpanded(false); setActivePane(panelBExpanded ? 'B' : panelCExpanded ? 'C' : 'A'); }, [panelBExpanded, panelCExpanded, setPanelAExpanded, setSlotA]); const handleCloseSlotB = useCallback(() => { setSlotB(null); setPanelBExpanded(false); setActivePane(panelAExpanded ? 'A' : panelCExpanded ? 'C' : 'B'); }, [panelAExpanded, panelCExpanded, setPanelBExpanded, setSlotB]); const handleCloseSlotC = useCallback(() => { setSlotC(null); setPanelCExpanded(false); setActivePane(panelBExpanded ? 'B' : panelAExpanded ? 'A' : 'C'); }, [panelAExpanded, panelBExpanded, setPanelCExpanded, setSlotC]); const handleSlotAction = useCallback((slot: SlotId, action: PaneAction) => { switch (action.type) { case 'switch-pane-type': if (action.paneType !== 'node') { setSingletonPaneInSlot(slot, action.paneType); setActivePane(slot); } break; case 'open-context': handleContextSelect(action.targetSlot ?? slot, action.contextId, action.contextName); break; case 'open-dimension': setBrowseDimensionFilters((prev) => ({ ...prev, [action.targetSlot ?? slot]: action.dimension })); setActiveDimension(action.dimension); setSingletonPaneInSlot(action.targetSlot ?? slot, 'views'); setActivePane(action.targetSlot ?? slot); break; case 'open-node': openNodeFromSlot(action.nodeId, slot); break; } }, [handleContextSelect, openNodeFromSlot, setActiveDimension, setSingletonPaneInSlot]); const handleSearchNodeSelect = useCallback((nodeId: number) => { handleNodeSelect(nodeId, false); setShowSearchModal(false); }, [handleNodeSelect]); const handleSwapPanes = useCallback((source: SlotId, target: SlotId) => { if (source === target) return; const sourceState = getSlotState(source); const targetState = getSlotState(target); const sourceExpanded = isPanelExpanded(source); const targetExpanded = isPanelExpanded(target); getSlotSetter(source)(targetState); getSlotSetter(target)(sourceState); setPanelExpanded(source, targetExpanded); setPanelExpanded(target, sourceExpanded); const sourceWeight = getPanelWeight(source); const targetWeight = getPanelWeight(target); setPanelWeight(source, targetWeight); setPanelWeight(target, sourceWeight); if (activePane === source) setActivePane(target); else if (activePane === target) setActivePane(source); }, [activePane, getPanelWeight, getSlotSetter, getSlotState, isPanelExpanded, setPanelExpanded, setPanelWeight]); const [dragOverSlot, setDragOverSlot] = useState(null); const handleSlotDragOver = useCallback((e: React.DragEvent, slot: SlotId) => { if (e.dataTransfer.types.includes('application/x-rah-pane') || e.dataTransfer.types.includes('application/x-rah-tab') || e.dataTransfer.types.includes('application/node-info')) { e.preventDefault(); e.dataTransfer.dropEffect = e.dataTransfer.types.includes('application/x-rah-pane') ? 'move' : 'copy'; setDragOverSlot(slot); } }, []); const handleSlotDragLeave = useCallback(() => { setDragOverSlot(null); }, []); const handleSlotDrop = useCallback((e: React.DragEvent, targetSlot: SlotId) => { setDragOverSlot(null); const paneData = e.dataTransfer.getData('application/x-rah-pane'); if (paneData) { const sourceSlot = paneData as SlotId; if (sourceSlot !== targetSlot) handleSwapPanes(sourceSlot, targetSlot); return; } let tabData = e.dataTransfer.getData('application/x-rah-tab'); if (!tabData) { tabData = e.dataTransfer.getData('application/node-info'); } if (!tabData) return; try { const parsed = JSON.parse(tabData); const nodeId = parsed.id as number | undefined; const sourceSlot = parsed.sourceSlot as SlotId | undefined; if (typeof nodeId !== 'number') return; if (sourceSlot && sourceSlot === targetSlot) { handleTabSelect(targetSlot, nodeId); return; } if (sourceSlot) { handleCloseTab(sourceSlot, nodeId); } addNodeTabToSlot(targetSlot, nodeId); setActivePane(targetSlot); } catch (err) { console.error('Failed to parse dropped tab data:', err); } }, [addNodeTabToSlot, handleCloseTab, handleSwapPanes, handleTabSelect]); const handleResizePanels = useCallback((left: SlotId, right: SlotId, clientX: number) => { const leftEl = panelRefs.current[left]; const rightEl = panelRefs.current[right]; if (!leftEl || !rightEl) return; const leftRect = leftEl.getBoundingClientRect(); const rightRect = rightEl.getBoundingClientRect(); const combinedWidth = leftRect.width + rightRect.width; if (combinedWidth <= 0) return; const minWidth = 220; const rawLeftWidth = clientX - leftRect.left; const nextLeftWidth = Math.max(minWidth, Math.min(combinedWidth - minWidth, rawLeftWidth)); const nextRightWidth = combinedWidth - nextLeftWidth; const totalWeight = getPanelWeight(left) + getPanelWeight(right); setPanelWeight(left, (nextLeftWidth / combinedWidth) * totalWeight); setPanelWeight(right, (nextRightWidth / combinedWidth) * totalWeight); }, [getPanelWeight, setPanelWeight]); const renderSlot = (slot: SlotId, state: SlotState) => { const isActive = activePane === slot; const onCollapse = slot === 'A' ? handleCloseSlotA : slot === 'B' ? handleCloseSlotB : handleCloseSlotC; switch (state.type) { case 'node': return ( handleSlotAction(slot, action)} onCollapse={onCollapse} onSwapPanes={handleSwapPanes} openTabs={state.nodeTabs || []} activeTab={state.activeNodeTab || null} onTabSelect={(tabId) => handleTabSelect(slot, tabId)} onTabClose={(tabId) => handleCloseTab(slot, tabId)} onNodeClick={(nodeId) => { addNodeTabToSlot(slot, nodeId); setSelectedNodes(new Set([nodeId])); setActivePane(slot); }} onReorderTabs={(fromIndex, toIndex) => handleReorderTabs(slot, fromIndex, toIndex)} refreshTrigger={focusPanelRefresh} onOpenInOtherSlot={(nodeId) => openNodeFromSlot(nodeId, slot)} onTextSelect={(nodeId, nodeTitle, text) => { setHighlightedPassage({ nodeId, nodeTitle, selectedText: text }); }} highlightedPassage={highlightedPassage} /> ); case 'contexts': return ( handleSlotAction(slot, action)} onCollapse={onCollapse} onSwapPanes={handleSwapPanes} onContextSelect={(contextId, contextName) => handleContextSelect(slot, contextId, contextName)} /> ); case 'dimensions': return ( handleSlotAction(slot, action)} onCollapse={onCollapse} onSwapPanes={handleSwapPanes} onNodeOpen={handleNodeOpenFromDimensions} refreshToken={folderViewRefresh} onDataChanged={handleFolderViewDataChanged} onDimensionSelect={(dimension) => handleDimensionPaneSelect(slot, dimension)} /> ); case 'map': return ( handleSlotAction(slot, action)} onCollapse={onCollapse} onSwapPanes={handleSwapPanes} onNodeClick={(nodeId) => openNodeFromSlot(nodeId, slot)} activeTabId={activeTab} /> ); case 'views': return ( handleSlotAction(slot, action)} onCollapse={onCollapse} onSwapPanes={handleSwapPanes} onNodeClick={(nodeId) => { openNodeFromSlot(nodeId, slot); setActivePane(slot); }} onNodeOpenInOtherPane={(nodeId) => openNodeFromSlot(nodeId, slot)} refreshToken={nodesPanelRefresh} pendingNodes={pendingNodes} onDismissPending={(id) => setPendingNodes(prev => prev.filter(p => p.id !== id))} externalContextFilterId={browseContextFilters[slot]} externalDimensionFilter={browseDimensionFilters[slot]} onContextFilterSelect={(contextId) => { setBrowseContextFilters((prev) => ({ ...prev, [slot]: contextId })); setActiveContextId(contextId); }} onClearExternalContextFilter={() => { setBrowseContextFilters((prev) => ({ ...prev, [slot]: null })); setActiveContextId(null); }} onClearExternalDimensionFilter={() => { setBrowseDimensionFilters((prev) => ({ ...prev, [slot]: null })); setActiveDimension(null); }} /> ); case 'table': return ( handleSlotAction(slot, action)} onCollapse={onCollapse} onSwapPanes={handleSwapPanes} onNodeClick={(nodeId) => { openNodeFromSlot(nodeId, slot); setActivePane(slot); }} refreshToken={nodesPanelRefresh} /> ); case 'skills': return ( handleSlotAction(slot, action)} onCollapse={onCollapse} onSwapPanes={handleSwapPanes} /> ); default: return null; } }; const slotStates: Record = { A: slotA, B: slotB, C: slotC }; const panelExpandedState: Record = { A: panelAExpanded, B: panelBExpanded, C: panelCExpanded }; const getSlotContainerStyle = (slot: SlotId) => { const state = slotStates[slot]; const expanded = panelExpandedState[slot]; const hasContent = Boolean(state); const weight = getPanelWeight(slot); return { flex: expanded ? `${weight} ${weight} 0` : '0 0 44px', minWidth: expanded ? 0 : '44px', overflow: 'hidden', display: 'flex', flexDirection: 'column' as const, background: expanded ? 'var(--rah-bg-surface)' : 'var(--rah-bg-subtle)', borderRadius: '10px', border: expanded && hasContent ? '1px solid transparent' : '1px dashed var(--rah-border)', outline: dragOverSlot === slot ? '2px dashed var(--rah-accent-green)' : 'none', outlineOffset: '-4px', transition: 'outline 0.15s ease, background 0.15s ease', }; }; const renderCollapsedPanel = (slot: SlotId) => (
); const renderExpandedEmptyPanel = (slot: SlotId) => (
{ e.dataTransfer.setData('application/x-rah-pane', slot); e.dataTransfer.effectAllowed = 'move'; }} style={{ minHeight: '48px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', cursor: 'grab', }} >
Select a pane from the nav
); return (
setShowSearchModal(true)} onAddStuffClick={() => setShowAddStuff(true)} onSettingsClick={() => { setSettingsInitialTab(undefined); setShowSettings(true); }} onPaneTypeClick={openPaneSingleton} isExpanded={leftNavExpanded} onToggleExpanded={() => setLeftNavExpanded(prev => !prev)} openTabTypes={new Set([slotA?.type, slotB?.type, slotC?.type].filter((t): t is PaneType => t != null))} activeTabType={getSlotState(activePane)?.type ?? null} onRefreshClick={handleRefreshAll} theme={theme} onThemeToggle={toggleTheme} contexts={availableContexts} onContextQuickSelect={(contextId, contextName) => { const target = openPaneSingleton('views', 'A'); setBrowseContextFilters((prev) => ({ ...prev, [target]: contextId })); setBrowseDimensionFilters((prev) => ({ ...prev, [target]: null })); setActiveContextId(contextId); setActivePane(target); void contextName; }} />
{(['A', 'B', 'C'] as SlotId[]).flatMap((slot, index, allSlots) => { const state = slotStates[slot]; const expanded = panelExpandedState[slot]; const items: React.ReactNode[] = []; items.push(
{ panelRefs.current[slot] = node; }} onClick={() => setActivePane(slot)} onDragOver={(e) => handleSlotDragOver(e, slot)} onDragLeave={handleSlotDragLeave} onDrop={(e) => handleSlotDrop(e, slot)} style={getSlotContainerStyle(slot)} > {!expanded ? renderCollapsedPanel(slot) : state ? renderSlot(slot, state) : renderExpandedEmptyPanel(slot)}
); const nextSlot = allSlots[index + 1]; if (nextSlot && expanded && panelExpandedState[nextSlot]) { items.push( handleResizePanels(slot, nextSlot, clientX)} title="Drag to resize panels" /> ); } return items; })}
setShowSearchModal(false)} onNodeSelect={handleSearchNodeSelect} existingFilters={[]} /> setShowAddStuff(false)} onSubmit={handleQuickAddSubmit} />
); }