"use client"; import { useState, useCallback, useEffect, useRef } from 'react'; import NodesPanel from '../nodes/NodesPanel'; import FocusPanel from '../focus/FocusPanel'; import AgentsPanel from '../agents/AgentsPanel'; import SettingsCog from '../settings/SettingsCog'; import SettingsModal, { SettingsTab } from '../settings/SettingsModal'; import { Node } from '@/types/database'; import { DatabaseEvent } from '@/services/events'; import { usePersistentState } from '@/hooks/usePersistentState'; import FolderViewOverlay from '../nodes/FolderViewOverlay'; import { Maximize2 } from 'lucide-react'; export default function ThreePanelLayout() { // Panel widths as percentages (20% | 40% | 40%) const [leftWidth, setLeftWidth] = useState(20); const [middleWidth, setMiddleWidth] = useState(40); // Collapsible state for nodes panel const [nodesCollapsed, setNodesCollapsed] = usePersistentState('ui.nodesCollapsed', false); // Collapsible state for chat/agents panel const [chatCollapsed, setChatCollapsed] = usePersistentState('ui.chatCollapsed', false); // Settings dropdown state const [showSettings, setShowSettings] = useState(false); const [settingsInitialTab, setSettingsInitialTab] = useState(); const handleCloseSettings = useCallback(() => { setShowSettings(false); setSettingsInitialTab(undefined); }, []); // Dragging states const [isDraggingLeft, setIsDraggingLeft] = useState(false); const [isDraggingRight, setIsDraggingRight] = useState(false); const containerRef = useRef(null); // Track selected nodes and open tabs const [selectedNodes, setSelectedNodes] = useState>(new Set()); const [openTabs, setOpenTabs] = usePersistentState('ui.focus.openTabs', []); const [activeTab, setActiveTab] = usePersistentState('ui.focus.activeTab', null); const [openTabsData, setOpenTabsData] = useState([]); // Event handlers for SSE events const [nodesPanelRefresh, setNodesPanelRefresh] = useState(0); const [focusPanelRefresh, setFocusPanelRefresh] = useState(0); const [folderViewOpen, setFolderViewOpen] = useState(false); const [folderViewRefresh, setFolderViewRefresh] = useState(0); // Ref to get current openTabs value in SSE handler const openTabsRef = useRef([]); // Fetch full node data for open tabs 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, content: node.content, dimensions: node.dimensions, created_at: node.created_at, updated_at: node.updated_at })); setOpenTabsData(validNodes); } catch (error) { console.error('Failed to fetch tab data:', error); setOpenTabsData([]); } }; // Update tab data whenever openTabs changes useEffect(() => { openTabsRef.current = openTabs; // Keep ref updated fetchOpenTabsData(openTabs); }, [openTabs]); // Keyboard shortcut handler useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Check for Cmd+1 (Mac) or Ctrl+1 (Windows/Linux) - toggle nodes panel if ((e.metaKey || e.ctrlKey) && e.key === '1') { e.preventDefault(); setNodesCollapsed(prev => !prev); } // Check for Cmd+\ (Mac) or Ctrl+\ (Windows/Linux) - toggle chat panel if ((e.metaKey || e.ctrlKey) && e.key === '\\') { e.preventDefault(); setChatCollapsed(prev => !prev); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [setNodesCollapsed, setChatCollapsed]); // SSE connection for real-time updates useEffect(() => { let eventSource: EventSource | null = null; try { eventSource = new EventSource('/api/events'); eventSource.onopen = () => { console.log('🔌 SSE connected for real-time updates'); }; eventSource.onmessage = (event) => { try { const data: DatabaseEvent = JSON.parse(event.data); switch (data.type) { case 'NODE_CREATED': // Trigger NodesPanel refresh setNodesPanelRefresh(prev => prev + 1); console.log('📥 Node created via helper:', data.data.node.title); break; case 'NODE_UPDATED': const currentOpenTabs = openTabsRef.current; const updatedNodeId = Number(data.data.nodeId); console.log('🔍 NODE_UPDATED - nodeId:', updatedNodeId, 'openTabs:', currentOpenTabs); // Trigger FocusPanel refresh for open tabs if (currentOpenTabs.includes(updatedNodeId)) { console.log('🔄 Triggering FocusPanel refresh for node:', updatedNodeId); setFocusPanelRefresh(prev => prev + 1); } else { console.log('⚠️ Node not in open tabs, skipping FocusPanel refresh'); } // Also refresh NodesPanel in case title changed setNodesPanelRefresh(prev => prev + 1); console.log('✏️ Node updated via helper:', updatedNodeId); break; case 'NODE_DELETED': // Remove from tabs and selection if open handleNodeDeleted(data.data.nodeId); // Trigger NodesPanel refresh setNodesPanelRefresh(prev => prev + 1); console.log('🗑️ Node deleted via helper:', data.data.nodeId); break; case 'EDGE_CREATED': case 'EDGE_DELETED': // Trigger FocusPanel refresh for affected nodes const currentOpenTabsForEdge = openTabsRef.current; console.log('🔗 Edge changed - fromNodeId:', data.data.fromNodeId, 'toNodeId:', data.data.toNodeId, 'openTabs:', currentOpenTabsForEdge); if (currentOpenTabsForEdge.includes(data.data.fromNodeId) || currentOpenTabsForEdge.includes(data.data.toNodeId)) { console.log('🔄 Triggering FocusPanel refresh for edge change'); setFocusPanelRefresh(prev => prev + 1); } else { console.log('⚠️ Neither edge node in open tabs, skipping FocusPanel refresh'); } console.log('🔗 Edge changed via helper'); break; case 'DIMENSION_UPDATED': console.log('📁 Dimension updated event received:', data.data?.dimension); 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 'AGENT_DELEGATION_CREATED': console.log('[ThreePanelLayout] AGENT_DELEGATION_CREATED received:', data.data); if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('delegations:created', { detail: data.data })); console.log('[ThreePanelLayout] Dispatched delegations:created event'); } break; case 'AGENT_DELEGATION_UPDATED': console.log('[ThreePanelLayout] AGENT_DELEGATION_UPDATED received:', data.data); if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('delegations:updated', { detail: data.data })); console.log('[ThreePanelLayout] Dispatched delegations:updated event'); } break; case 'WORKFLOW_PROGRESS': console.log('[ThreePanelLayout] WORKFLOW_PROGRESS received:', data.data); if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('workflow:progress', { detail: data.data })); } break; case 'CONNECTION_ESTABLISHED': console.log('✅ SSE connection established'); break; default: console.log('📡 Unknown SSE event:', data.type); } } catch (error) { console.error('Failed to parse SSE event:', error); } }; eventSource.onerror = (error) => { console.error('SSE connection error:', error); }; } catch (error) { console.error('Failed to establish SSE connection:', error); } // Cleanup on unmount return () => { if (eventSource) { eventSource.close(); console.log('🔌 SSE connection closed'); } }; }, []); // Empty dependency array - connect once on mount // Handle panel resizing const handleMouseMove = useCallback((e: MouseEvent) => { if (!containerRef.current) return; const containerRect = containerRef.current.getBoundingClientRect(); const mouseX = ((e.clientX - containerRect.left) / containerRect.width) * 100; if (isDraggingLeft) { const newLeftWidth = Math.max(15, Math.min(35, mouseX)); const diff = newLeftWidth - leftWidth; setLeftWidth(newLeftWidth); setMiddleWidth(prev => Math.max(20, prev - diff)); } else if (isDraggingRight) { const rightEdge = leftWidth + middleWidth; const newMiddleWidth = Math.max(20, Math.min(65, mouseX - leftWidth)); setMiddleWidth(newMiddleWidth); } }, [isDraggingLeft, isDraggingRight, leftWidth, middleWidth]); const handleMouseUp = useCallback(() => { setIsDraggingLeft(false); setIsDraggingRight(false); }, []); useEffect(() => { if (isDraggingLeft || isDraggingRight) { document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; } }, [isDraggingLeft, isDraggingRight, handleMouseMove, handleMouseUp]); const handleToggleFolderView = (nextState?: boolean) => { setFolderViewOpen(prev => typeof nextState === 'boolean' ? nextState : !prev); }; const handleFolderViewDataChanged = useCallback(() => { setFolderViewRefresh(prev => prev + 1); setNodesPanelRefresh(prev => prev + 1); }, []); const handleNodeOpenFromFolderView = (nodeId: number) => { handleNodeSelect(nodeId, false); setFolderViewOpen(false); }; const handleReorderTabs = (fromIndex: number, toIndex: number) => { if (fromIndex === toIndex) return; setOpenTabs(prev => { if (fromIndex < 0 || toIndex < 0 || fromIndex >= prev.length || toIndex >= prev.length) { return prev; } const updated = [...prev]; const [moved] = updated.splice(fromIndex, 1); updated.splice(toIndex, 0, moved); return updated; }); }; // Handle node selection const handleNodeSelect = (nodeId: number, multiSelect: boolean) => { if (folderViewOpen) { setFolderViewOpen(false); } if (multiSelect) { const newSelection = new Set(selectedNodes); if (newSelection.has(nodeId)) { newSelection.delete(nodeId); } else { newSelection.add(nodeId); } setSelectedNodes(newSelection); const newTabs = Array.from(newSelection); setOpenTabs(newTabs); if (newTabs.length > 0 && !activeTab) { setActiveTab(newTabs[0]); } } else { setSelectedNodes(new Set([nodeId])); if (!openTabs.includes(nodeId)) { setOpenTabs([...openTabs, nodeId]); } setActiveTab(nodeId); } }; const handleTabSelect = (tabId: number) => { setSelectedNodes(new Set([tabId])); setActiveTab(tabId); }; // Handle tab close const handleCloseTab = (tabId: number) => { const newTabs = openTabs.filter(id => id !== tabId); setOpenTabs(newTabs); if (activeTab === tabId) { const currentIndex = openTabs.indexOf(tabId); if (newTabs.length > 0) { const newIndex = Math.min(currentIndex, newTabs.length - 1); setActiveTab(newTabs[newIndex]); } else { setActiveTab(null); } } const newSelection = new Set(selectedNodes); newSelection.delete(tabId); setSelectedNodes(newSelection); }; // Handle node creation - auto-open the new node in Focus panel const handleNodeCreated = (newNode: Node) => { // Auto-select the new node setSelectedNodes(new Set([newNode.id])); // Auto-open the new node as a tab if (!openTabs.includes(newNode.id)) { setOpenTabs([...openTabs, newNode.id]); } // Set as active tab setActiveTab(newNode.id); }; // Handle node deletion - close tab and remove from selection const handleNodeDeleted = (nodeId: number) => { // Close tab if open handleCloseTab(nodeId); }; // Calculate panel widths based on collapse states const baseRightWidth = nodesCollapsed ? (100 - middleWidth) : (100 - leftWidth - middleWidth); const effectiveLeftWidth = nodesCollapsed ? 0 : leftWidth; // When chat is collapsed, middle panel takes its space (but leave room for 64px rail) const effectiveRightWidth = chatCollapsed ? 0 : baseRightWidth; // Note: When chatCollapsed, we use calc() to leave room for the 64px collapsed rail const effectiveMiddleWidth = chatCollapsed ? (nodesCollapsed ? 100 : (middleWidth + baseRightWidth)) : (nodesCollapsed ? (leftWidth + middleWidth) : middleWidth); const activeNodeId = activeTab; return (
{/* Left Panel - Nodes (collapsible) */} {nodesCollapsed ? ( // Collapsed rail
) : ( <>
{ setSettingsInitialTab(undefined); setShowSettings(true); }} />
{/* Left Resize Handle */}
setIsDraggingLeft(true)} style={{ width: '3px', cursor: 'col-resize', background: isDraggingLeft ? '#353535' : 'transparent', transition: isDraggingLeft ? 'none' : 'background 0.2s' }} onMouseEnter={(e) => { if (!isDraggingLeft) e.currentTarget.style.background = '#1f1f1f'; }} onMouseLeave={(e) => { if (!isDraggingLeft) e.currentTarget.style.background = 'transparent'; }} /> )} {/* Middle Panel - Focus */}
{/* Expand chat button - shown when collapsed */} {chatCollapsed && ( )}
handleNodeSelect(nodeId, false)} onTabClose={handleCloseTab} refreshTrigger={focusPanelRefresh} onReorderTabs={handleReorderTabs} />
{folderViewOpen && ( handleToggleFolderView(false)} onNodeOpen={handleNodeOpenFromFolderView} refreshToken={folderViewRefresh} onDataChanged={handleFolderViewDataChanged} /> )}
{/* Right Resize Handle */} {!nodesCollapsed && !chatCollapsed && (
setIsDraggingRight(true)} style={{ width: '3px', cursor: 'col-resize', background: isDraggingRight ? '#353535' : 'transparent', transition: isDraggingRight ? 'none' : 'background 0.2s' }} onMouseEnter={(e) => { if (!isDraggingRight) e.currentTarget.style.background = '#1f1f1f'; }} onMouseLeave={(e) => { if (!isDraggingRight) e.currentTarget.style.background = 'transparent'; }} /> )} {/* Right Panel - Agents (collapsible) */} {!chatCollapsed && (
handleNodeSelect(nodeId, false)} onCollapse={() => setChatCollapsed(true)} />
)} {/* Settings Modal */}
); }