Initial commit: RA-H Open Source Edition
Local-first knowledge management system with BYO API keys. Features: - 3-panel UI (Nodes | Focus | Helpers) - SQLite + sqlite-vec for vector search - Agent system (Easy/Hard mode orchestrators) - Content extraction (YouTube, PDF, web) - Integrate workflow for connection discovery - Dimension system with auto-assignment Tech stack: - Next.js 15 + TypeScript + Tailwind CSS - Anthropic (Claude) + OpenAI (GPT) via Vercel AI SDK Setup: npm install && npm rebuild better-sqlite3 scripts/dev/bootstrap-local.sh npm run dev MIT License
This commit is contained in:
@@ -0,0 +1,561 @@
|
||||
"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';
|
||||
|
||||
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);
|
||||
|
||||
// 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)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === '1') {
|
||||
e.preventDefault();
|
||||
setNodesCollapsed(prev => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [setNodesCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSettingsOpen = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ tab?: SettingsTab }>).detail;
|
||||
setSettingsInitialTab(detail?.tab);
|
||||
setShowSettings(true);
|
||||
};
|
||||
window.addEventListener('settings:open', handleSettingsOpen as EventListener);
|
||||
return () => window.removeEventListener('settings:open', handleSettingsOpen as EventListener);
|
||||
}, []);
|
||||
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
const rightWidth = nodesCollapsed ? (100 - middleWidth) : (100 - leftWidth - middleWidth);
|
||||
const effectiveLeftWidth = nodesCollapsed ? 0 : leftWidth;
|
||||
const effectiveMiddleWidth = 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: '#0f0f0f',
|
||||
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: '#0f0f0f',
|
||||
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: `${effectiveMiddleWidth}%`,
|
||||
flexShrink: 0,
|
||||
borderRight: '1px solid #1a1a1a',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0f0f0f',
|
||||
padding: '4px'
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Right Resize Handle */}
|
||||
{!nodesCollapsed && (
|
||||
<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 */}
|
||||
<div
|
||||
style={{
|
||||
width: `${rightWidth}%`,
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0a0a0a',
|
||||
position: 'relative',
|
||||
padding: '4px'
|
||||
}}
|
||||
>
|
||||
<AgentsPanel
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeNodeId}
|
||||
onNodeClick={(nodeId) => handleNodeSelect(nodeId, false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings Modal */}
|
||||
<SettingsModal
|
||||
isOpen={showSettings}
|
||||
onClose={handleCloseSettings}
|
||||
initialTab={settingsInitialTab}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user