Files
ra-h-os/src/components/layout/ThreePanelLayout.tsx
T
“BeeRad” 57a20899a5 sync: UI polish - welcome screen, connections modal, search modals
- Redesigned welcome screen with centered Start button and CAPTURE quick add
- Improved QuickAddInput with elegant mode tabs and circular submit button
- Restored existing connections view in connections modal with edit/delete
- Spotlight-style search modals across all search experiences
- Three-panel color system and neutral design updates
- Fixed textarea resize jump and node ID badge styling
2025-12-24 16:55:10 +11:00

632 lines
22 KiB
TypeScript

"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<SettingsTab>();
const handleCloseSettings = useCallback(() => {
setShowSettings(false);
setSettingsInitialTab(undefined);
}, []);
// Dragging states
const [isDraggingLeft, setIsDraggingLeft] = useState(false);
const [isDraggingRight, setIsDraggingRight] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Track selected nodes and open tabs
const [selectedNodes, setSelectedNodes] = useState<Set<number>>(new Set<number>());
const [openTabs, setOpenTabs] = usePersistentState<number[]>('ui.focus.openTabs', []);
const [activeTab, setActiveTab] = usePersistentState<number | null>('ui.focus.activeTab', null);
const [openTabsData, setOpenTabsData] = useState<Node[]>([]);
// 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<number[]>([]);
// 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<number>([nodeId]));
if (!openTabs.includes(nodeId)) {
setOpenTabs([...openTabs, nodeId]);
}
setActiveTab(nodeId);
}
};
const handleTabSelect = (tabId: number) => {
setSelectedNodes(new Set<number>([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<number>([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 (
<div
ref={containerRef}
style={{
display: 'flex',
height: '100vh',
width: '100vw',
background: '#0a0a0a',
overflow: 'hidden'
}}
>
{/* Left Panel - Nodes (collapsible) */}
{nodesCollapsed ? (
// Collapsed rail
<div style={{
width: '40px',
flexShrink: 0,
borderRight: '1px solid #2a2a2a',
background: '#0a0a0a',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
paddingTop: '12px'
}}>
<button
onClick={() => setNodesCollapsed(false)}
style={{
padding: '8px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
color: '#888',
cursor: 'pointer',
fontSize: '12px',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2a2a2a';
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#1a1a1a';
e.currentTarget.style.color = '#888';
}}
title="Expand Nodes (⌘1)"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
) : (
<>
<div
style={{
width: `${effectiveLeftWidth}%`,
flexShrink: 0,
borderRight: '1px solid #1a1a1a',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
background: '#0a0a0a',
position: 'relative',
padding: '4px'
}}
>
<NodesPanel
selectedNodes={selectedNodes}
onNodeSelect={handleNodeSelect}
onNodeCreated={handleNodeCreated}
onNodeDeleted={handleNodeDeleted}
refreshTrigger={nodesPanelRefresh}
onToggleFolderView={handleToggleFolderView}
folderViewOpen={folderViewOpen}
/>
<SettingsCog onClick={() => {
setSettingsInitialTab(undefined);
setShowSettings(true);
}} />
</div>
{/* Left Resize Handle */}
<div
onMouseDown={() => 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 */}
<div
style={{
width: chatCollapsed
? (nodesCollapsed
? `calc(100% - 40px)`
: `calc(100% - ${effectiveLeftWidth}% - 3px)`)
: `${effectiveMiddleWidth}%`,
flexShrink: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
background: '#0a0a0a',
padding: '8px',
paddingRight: chatCollapsed ? '8px' : '4px'
}}
>
<div style={{
flex: 1,
background: '#141414',
borderRadius: '8px',
border: '1px solid #1f1f1f',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
padding: chatCollapsed ? '20px' : '0',
position: 'relative'
}}>
{/* Expand chat button - shown when collapsed */}
{chatCollapsed && (
<button
onClick={() => setChatCollapsed(false)}
style={{
position: 'absolute',
top: '12px',
right: '12px',
width: '28px',
height: '28px',
borderRadius: '6px',
border: '1px solid #1f1f1f',
background: 'transparent',
color: '#666',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.15s ease',
zIndex: 10
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#1a1a1a';
e.currentTarget.style.color = '#999';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = '#666';
}}
title="Expand Chat (⌘\)"
>
<Maximize2 size={14} />
</button>
)}
<div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
<div style={{
height: '100%',
visibility: folderViewOpen ? 'hidden' : 'visible',
pointerEvents: folderViewOpen ? 'none' : 'auto'
}}>
<FocusPanel
openTabs={openTabs}
activeTab={activeTab}
onTabSelect={handleTabSelect}
onNodeClick={(nodeId) => handleNodeSelect(nodeId, false)}
onTabClose={handleCloseTab}
refreshTrigger={focusPanelRefresh}
onReorderTabs={handleReorderTabs}
/>
</div>
{folderViewOpen && (
<FolderViewOverlay
onClose={() => handleToggleFolderView(false)}
onNodeOpen={handleNodeOpenFromFolderView}
refreshToken={folderViewRefresh}
onDataChanged={handleFolderViewDataChanged}
/>
)}
</div>
</div>
</div>
{/* Right Resize Handle */}
{!nodesCollapsed && !chatCollapsed && (
<div
onMouseDown={() => 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 && (
<div
style={{
width: `${effectiveRightWidth}%`,
flexShrink: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
background: '#0a0a0a',
position: 'relative',
padding: '8px',
paddingLeft: '4px'
}}
>
<div style={{
flex: 1,
background: '#141414',
borderRadius: '8px',
border: '1px solid #1f1f1f',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}>
<AgentsPanel
openTabsData={openTabsData}
activeTabId={activeNodeId}
onNodeClick={(nodeId) => handleNodeSelect(nodeId, false)}
onCollapse={() => setChatCollapsed(true)}
/>
</div>
</div>
)}
{/* Settings Modal */}
<SettingsModal
isOpen={showSettings}
onClose={handleCloseSettings}
initialTab={settingsInitialTab}
/>
</div>
);
}