diff --git a/src/components/agents/AgentsPanel.tsx b/src/components/agents/AgentsPanel.tsx index d11be48..36cda5b 100644 --- a/src/components/agents/AgentsPanel.tsx +++ b/src/components/agents/AgentsPanel.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import RAHChat from './RAHChat'; import QuickAddInput from './QuickAddInput'; import QuickAddStatus from './QuickAddStatus'; -import { Zap, Flame } from 'lucide-react'; +import { Zap, Flame, Minimize2 } from 'lucide-react'; import type { AgentDelegation } from '@/services/agents/delegation'; import { Node } from '@/types/database'; @@ -12,12 +12,13 @@ interface AgentsPanelProps { openTabsData: Node[]; activeTabId: number | null; onNodeClick?: (nodeId: number) => void; + onCollapse?: () => void; } type ActiveTab = 'ra-h' | string; // 'ra-h' or delegation sessionId type Mode = 'quickadd' | 'session'; -export default function AgentsPanel({ openTabsData, activeTabId, onNodeClick }: AgentsPanelProps) { +export default function AgentsPanel({ openTabsData, activeTabId, onNodeClick, onCollapse }: AgentsPanelProps) { const [delegationsMap, setDelegationsMap] = useState>({}); const [activeAgentTab, setActiveAgentTab] = useState('ra-h'); const [mode, setMode] = useState('quickadd'); @@ -186,7 +187,7 @@ export default function AgentsPanel({ openTabsData, activeTabId, onNodeClick }: }; return ( -
+
{/* Mode Header */} {mode === 'quickadd' ? (
+ {/* Collapse button - top right */} + {onCollapse && ( + + )} {/* Top spacer */}
@@ -307,7 +342,39 @@ export default function AgentsPanel({ openTabsData, activeTabId, onNodeClick }: {/* Tab Bar (only show in session mode) */} {mode === 'session' && ( -
+
+ {/* Collapse button - first item */} + {onCollapse && ( + + )} {/* Quick Add button - positioned at far right */}
{/* Tab Bar */} -
{ const handleKeyDown = (e: KeyboardEvent) => { - // Check for Cmd+1 (Mac) or Ctrl+1 (Windows/Linux) + // 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]); - - 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); - }, []); + }, [setNodesCollapsed, setChatCollapsed]); // SSE connection for real-time updates @@ -373,9 +372,16 @@ export default function ThreePanelLayout() { handleCloseTab(nodeId); }; - const rightWidth = nodesCollapsed ? (100 - middleWidth) : (100 - leftWidth - middleWidth); + // Calculate panel widths based on collapse states + const baseRightWidth = nodesCollapsed ? (100 - middleWidth) : (100 - leftWidth - middleWidth); const effectiveLeftWidth = nodesCollapsed ? 0 : leftWidth; - const effectiveMiddleWidth = nodesCollapsed ? (leftWidth + middleWidth) : middleWidth; + + // 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; @@ -476,11 +482,15 @@ export default function ThreePanelLayout() { )} {/* Middle Panel - Focus */} -
-
@@ -516,7 +526,7 @@ export default function ThreePanelLayout() {
{/* Right Resize Handle */} - {!nodesCollapsed && ( + {!nodesCollapsed && !chatCollapsed && (
setIsDraggingRight(true)} style={{ @@ -530,25 +540,68 @@ export default function ThreePanelLayout() { /> )} - {/* Right Panel - Agents */} -
- handleNodeSelect(nodeId, false)} - /> -
+ alignItems: 'center', + paddingTop: '12px' + }}> + +
+ ) : ( +
+ handleNodeSelect(nodeId, false)} + onCollapse={() => setChatCollapsed(true)} + /> +
+ )} {/* Settings Modal */} (null); + // View mode state (persisted) + const [viewMode, setViewMode] = usePersistentState('ui.dimensionViewMode', 'grid'); + + // All nodes state (for list/kanban view at top level) + const [allNodes, setAllNodes] = useState([]); + const [allNodesLoading, setAllNodesLoading] = useState(false); + + // Kanban columns state (global, persisted) + const [kanbanColumns, setKanbanColumns] = usePersistentState<{ dimension: string; order: number }[]>( + 'ui.kanbanColumns.global', + [] + ); + + // Kanban-specific drag states + const [draggedNodeId, setDraggedNodeId] = useState(null); + const [draggedFromColumn, setDraggedFromColumn] = useState(null); + const [dragOverColumn, setDragOverColumn] = useState(null); + const [showKanbanColumnPicker, setShowKanbanColumnPicker] = useState(false); + const [kanbanSearchQuery, setKanbanSearchQuery] = useState(''); + + // Filter/View system state + const [overlayMode, setOverlayMode] = useState('folders'); + const [selectedFilters, setSelectedFilters] = useState([]); + const [filteredNodes, setFilteredNodes] = useState([]); + const [filteredNodesLoading, setFilteredNodesLoading] = useState(false); + const [showFilterPicker, setShowFilterPicker] = useState(false); + const [filterSearchQuery, setFilterSearchQuery] = useState(''); + const [savedViews, setSavedViews] = usePersistentState('ui.savedViews', []); + const [activeViewId, setActiveViewId] = useState(null); + const [showSaveViewDialog, setShowSaveViewDialog] = useState(false); + const [showSavedViewsDropdown, setShowSavedViewsDropdown] = useState(false); + + // Kanban drag-and-drop state + const [draggedNode, setDraggedNode] = useState<{ id: number; fromDimension: string } | null>(null); + const [dropTargetDimension, setDropTargetDimension] = useState(null); + + // Kanban column reordering state + const [draggedColumn, setDraggedColumn] = useState(null); + const [columnDropTarget, setColumnDropTarget] = useState(null); + + // Node priority ordering within dimensions (persisted) + const [dimensionOrders, setDimensionOrders] = usePersistentState>('ui.dimensionOrders', {}); + + // Within-dimension reorder drag state + const [reorderDrag, setReorderDrag] = useState<{ nodeId: number; dimension: string; index: number } | null>(null); + const [reorderDropIndex, setReorderDropIndex] = useState(null); + useEffect(() => { fetchDimensions(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -64,6 +124,228 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedDimension?.dimension]); + // Fetch all nodes when viewMode is list or kanban (at top level) + useEffect(() => { + if (view === 'dimensions' && (viewMode === 'list' || viewMode === 'kanban')) { + fetchAllNodes(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewMode, view, refreshToken]); + + const fetchAllNodes = async () => { + setAllNodesLoading(true); + try { + const response = await fetch('/api/nodes?limit=500'); + const data = await response.json(); + if (!response.ok || !data.success) { + throw new Error(data.error || 'Failed to fetch nodes'); + } + setAllNodes(data.data || []); + } catch (error) { + console.error('Error fetching all nodes:', error); + } finally { + setAllNodesLoading(false); + } + }; + + // Fetch filtered nodes when filters change + useEffect(() => { + if (overlayMode === 'filtered' && selectedFilters.length > 0) { + fetchFilteredNodes(); + } else if (overlayMode === 'filtered' && selectedFilters.length === 0) { + setFilteredNodes([]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedFilters, overlayMode, refreshToken]); + + const fetchFilteredNodes = async () => { + if (selectedFilters.length === 0) { + setFilteredNodes([]); + return; + } + setFilteredNodesLoading(true); + try { + // Fetch nodes that match ANY of the selected dimensions + const response = await fetch(`/api/nodes?limit=500`); + const data = await response.json(); + if (!response.ok || !data.success) { + throw new Error(data.error || 'Failed to fetch nodes'); + } + // Filter client-side to get nodes matching selected dimensions + const allFetched: Node[] = data.data || []; + const filtered = allFetched.filter(node => + node.dimensions?.some(d => selectedFilters.includes(d)) + ); + setFilteredNodes(filtered); + } catch (error) { + console.error('Error fetching filtered nodes:', error); + } finally { + setFilteredNodesLoading(false); + } + }; + + // Filter helper functions + const addFilter = (dimension: string) => { + if (!selectedFilters.includes(dimension)) { + setSelectedFilters([...selectedFilters, dimension]); + } + setShowFilterPicker(false); + setFilterSearchQuery(''); + }; + + const removeFilter = (dimension: string) => { + setSelectedFilters(selectedFilters.filter(f => f !== dimension)); + }; + + const clearFilters = () => { + setSelectedFilters([]); + setActiveViewId(null); + }; + + // Kanban column reorder handler + const handleColumnReorder = (draggedDim: string, targetDim: string) => { + if (draggedDim === targetDim) return; + + const currentFilters = [...selectedFilters]; + const draggedIndex = currentFilters.indexOf(draggedDim); + const targetIndex = currentFilters.indexOf(targetDim); + + if (draggedIndex === -1 || targetIndex === -1) return; + + // Remove dragged item and insert at target position + currentFilters.splice(draggedIndex, 1); + currentFilters.splice(targetIndex, 0, draggedDim); + + setSelectedFilters(currentFilters); + }; + + // Kanban drag-and-drop handler + const handleKanbanDrop = async (nodeId: number, fromDimension: string, toDimension: string) => { + if (fromDimension === toDimension) return; + + // Find the node to get its current dimensions + const node = filteredNodes.find(n => n.id === nodeId); + if (!node) return; + + // Calculate new dimensions: remove fromDimension, add toDimension + const currentDimensions = node.dimensions || []; + const newDimensions = currentDimensions + .filter(d => d !== fromDimension) + .concat(toDimension) + .filter((d, i, arr) => arr.indexOf(d) === i); // Remove duplicates + + try { + // Update the node via API + const response = await fetch(`/api/nodes/${nodeId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dimensions: newDimensions }) + }); + + if (response.ok) { + // Refresh the filtered nodes + fetchFilteredNodes(); + // Notify parent that data changed + onDataChanged?.(); + } + } catch (error) { + console.error('Failed to update node dimensions:', error); + } + }; + + // Saved view helper functions + const saveCurrentView = (name: string) => { + const newView: SavedView = { + id: `view-${Date.now()}`, + name: name.trim(), + filters: [...selectedFilters], + viewMode, + kanbanColumns: viewMode === 'kanban' ? [...kanbanColumns] : undefined, + createdAt: new Date().toISOString() + }; + setSavedViews([...savedViews, newView]); + setActiveViewId(newView.id); + setShowSaveViewDialog(false); + }; + + const loadSavedView = (view: SavedView) => { + setOverlayMode('filtered'); + setSelectedFilters(view.filters); + setViewMode(view.viewMode); + if (view.kanbanColumns) { + setKanbanColumns(view.kanbanColumns); + } + setActiveViewId(view.id); + setShowSavedViewsDropdown(false); + }; + + const deleteSavedView = (viewId: string) => { + setSavedViews(savedViews.filter(v => v.id !== viewId)); + if (activeViewId === viewId) { + setActiveViewId(null); + } + }; + + const activeView = savedViews.find(v => v.id === activeViewId); + + // Sort nodes by their priority order within a dimension + const sortNodesByDimensionOrder = (nodes: Node[], dimension: string): Node[] => { + const order = dimensionOrders[dimension] || []; + return [...nodes].sort((a, b) => { + const aIndex = order.indexOf(a.id); + const bIndex = order.indexOf(b.id); + // Nodes in order array come first, sorted by their position + // Nodes not in order array go to end, sorted by ID + if (aIndex === -1 && bIndex === -1) return a.id - b.id; + if (aIndex === -1) return 1; + if (bIndex === -1) return -1; + return aIndex - bIndex; + }); + }; + + // Handle reordering a node within a dimension + const handleReorderDrop = (dimension: string, fromIndex: number, toIndex: number, nodes: Node[]) => { + if (fromIndex === toIndex) return; + + // Get current order or create from current node order + const currentOrder = dimensionOrders[dimension] || nodes.map(n => n.id); + const nodeIds = [...currentOrder]; + + // Ensure all nodes are in the order array + nodes.forEach(n => { + if (!nodeIds.includes(n.id)) { + nodeIds.push(n.id); + } + }); + + // Move the node from fromIndex to toIndex + const [movedId] = nodeIds.splice(fromIndex, 1); + nodeIds.splice(toIndex, 0, movedId); + + // Update the dimension orders + setDimensionOrders({ + ...dimensionOrders, + [dimension]: nodeIds + }); + + setReorderDrag(null); + setReorderDropIndex(null); + }; + + // Get nodes grouped by dimension for list view + const getNodesGroupedByDimension = () => { + const groups: { dimension: string; nodes: Node[] }[] = []; + for (const dim of selectedFilters) { + const nodesInDim = filteredNodes.filter(n => n.dimensions?.includes(dim)); + if (nodesInDim.length > 0) { + // Sort by dimension order + const sortedNodes = sortNodesByDimensionOrder(nodesInDim, dim); + groups.push({ dimension: dim, nodes: sortedNodes }); + } + } + return groups; + }; + const sortedDimensions = useMemo(() => { return [...dimensions].sort((a, b) => { if (a.isPriority !== b.isPriority) { @@ -204,6 +486,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o draggedNodeRef.current = nodeData; // Also set in dataTransfer for browser compatibility event.dataTransfer.setData('application/node-info', JSON.stringify(nodeData)); + event.dataTransfer.setData('text/plain', JSON.stringify(nodeData)); // Provide a compact drag preview so drop targets stay visible const preview = document.createElement('div'); @@ -233,14 +516,14 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o }; const handleDimensionDragOver = (event: DragEvent) => { - if (event.dataTransfer.types.includes('application/node-info')) { + if (event.dataTransfer.types.includes('application/node-info') || event.dataTransfer.types.includes('text/plain')) { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; } }; const handleDimensionDragEnter = (event: DragEvent, dimension: string) => { - if (event.dataTransfer.types.includes('application/node-info')) { + if (event.dataTransfer.types.includes('application/node-info') || event.dataTransfer.types.includes('text/plain')) { setDragHoverDimension(dimension); } }; @@ -260,7 +543,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o // Fallback to dataTransfer for browser compatibility if (!payload) { - const raw = event.dataTransfer.getData('application/node-info'); + const raw = event.dataTransfer.getData('application/node-info') || event.dataTransfer.getData('text/plain'); if (raw) { try { payload = JSON.parse(raw); @@ -438,11 +721,11 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o style={{ flex: 1, overflowY: 'auto', - padding: '24px', + padding: '20px', display: 'grid', - gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', - gap: '16px', - maxWidth: '100%' + gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', + gap: '10px', + alignContent: 'start' }} > {sortedDimensions.map((dimension) => ( @@ -462,153 +745,1327 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o onDragLeave={(event) => handleDimensionDragLeave(event, dimension.dimension)} onDrop={(event) => handleNodeDropOnDimension(event, dimension.dimension)} style={{ - background: dragHoverDimension === dimension.dimension ? '#152214' : (dimension.isPriority ? '#0a0e0b' : '#0a0a0a'), - border: dimension.isPriority ? '1px solid #1c3f28' : '1px solid #161616', - borderRadius: '16px', - padding: '20px', + background: dragHoverDimension === dimension.dimension ? '#0d1a12' : '#0a0a0a', + border: dimension.isPriority ? '1px solid #1a3a25' : '1px solid #1a1a1a', + borderRadius: '10px', + padding: '14px 16px', textAlign: 'left', display: 'flex', - flexDirection: 'column', - gap: '10px', + alignItems: 'center', + gap: '12px', cursor: 'pointer', - transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', - position: 'relative', - boxShadow: dimension.isPriority - ? '0 0 0 1px rgba(125, 232, 165, 0.1), 0 2px 8px rgba(0, 0, 0, 0.3)' - : '0 1px 4px rgba(0, 0, 0, 0.2)' + transition: 'all 0.15s ease' + }} + onMouseEnter={(e) => { + if (dragHoverDimension !== dimension.dimension) { + e.currentTarget.style.background = '#111'; + e.currentTarget.style.borderColor = dimension.isPriority ? '#22c55e' : '#333'; + } + }} + onMouseLeave={(e) => { + if (dragHoverDimension !== dimension.dimension) { + e.currentTarget.style.background = '#0a0a0a'; + e.currentTarget.style.borderColor = dimension.isPriority ? '#1a3a25' : '#1a1a1a'; + } }} - onMouseEnter={(e) => { - if (dragHoverDimension !== dimension.dimension) { - e.currentTarget.style.background = dimension.isPriority ? '#0f140f' : '#111111'; - e.currentTarget.style.transform = 'translateY(-2px)'; - e.currentTarget.style.boxShadow = dimension.isPriority - ? '0 0 0 1px rgba(125, 232, 165, 0.2), 0 8px 25px rgba(0, 0, 0, 0.4)' - : '0 4px 12px rgba(0, 0, 0, 0.3)'; - } - }} - onMouseLeave={(e) => { - if (dragHoverDimension !== dimension.dimension) { - e.currentTarget.style.background = dimension.isPriority ? '#0a0e0b' : '#0a0a0a'; - e.currentTarget.style.transform = 'translateY(0px)'; - e.currentTarget.style.boxShadow = dimension.isPriority - ? '0 0 0 1px rgba(125, 232, 165, 0.1), 0 2px 8px rgba(0, 0, 0, 0.3)' - : '0 1px 4px rgba(0, 0, 0, 0.2)'; - } - }} > -
-
- - - {dimension.count} - -
-
- - -
-
- - {/* Dimension name */} -
- {dimension.dimension} +
- - {/* Description preview (read-only) */} - {dimension.description ? ( -
+
- {dimension.description} + {dimension.dimension}
- ) : ( -
- No description -
- )} + {dimension.description && ( +
+ {dimension.description} +
+ )} +
+ + {/* Count badge */} + + {dimension.count} + + + {/* Action buttons - show on hover via CSS would be ideal, but inline for now */} +
+ + +
))}
); }; + // Kanban helper functions + const getNodesForKanbanColumn = (dimension: string) => { + return nodes.filter(node => node.dimensions?.includes(dimension)); + }; + + const handleAddKanbanColumn = (dimension: string) => { + const newColumn = { + dimension, + order: kanbanColumns.length + }; + setKanbanColumns([...kanbanColumns, newColumn]); + setShowKanbanColumnPicker(false); + setKanbanSearchQuery(''); + }; + + const handleRemoveKanbanColumn = (dimension: string) => { + setKanbanColumns(kanbanColumns.filter(c => c.dimension !== dimension)); + }; + + const handleKanbanNodeDragStart = (e: DragEvent, nodeId: number, fromColumn: string) => { + setDraggedNodeId(nodeId); + setDraggedFromColumn(fromColumn); + e.dataTransfer.effectAllowed = 'move'; + }; + + const handleKanbanNodeDragEnd = () => { + setDraggedNodeId(null); + setDraggedFromColumn(null); + setDragOverColumn(null); + }; + + const handleKanbanColumnDragOver = (e: DragEvent, columnDimension: string) => { + e.preventDefault(); + if (draggedNodeId !== null) { + setDragOverColumn(columnDimension); + } + }; + + const handleKanbanColumnDragLeave = () => { + setDragOverColumn(null); + }; + + const handleKanbanNodeDrop = async (e: DragEvent, targetDimension: string) => { + e.preventDefault(); + if (draggedNodeId === null || draggedFromColumn === targetDimension) { + handleKanbanNodeDragEnd(); + return; + } + + try { + const node = nodes.find(n => n.id === draggedNodeId); + if (!node) return; + + const currentDimensions = node.dimensions || []; + let updatedDimensions: string[]; + + if (draggedFromColumn === '__uncategorized__') { + // Adding to a new dimension + updatedDimensions = [...currentDimensions, targetDimension]; + } else { + // Replace old dimension with new one + updatedDimensions = currentDimensions.map(d => + d === draggedFromColumn ? targetDimension : d + ); + } + + const response = await fetch(`/api/nodes/${draggedNodeId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dimensions: updatedDimensions }) + }); + + if (!response.ok) { + throw new Error('Failed to update node dimensions'); + } + + fetchNodes(true); + onDataChanged?.(); + } catch (error) { + console.error('Error updating node dimension:', error); + alert('Failed to move node. Please try again.'); + } finally { + handleKanbanNodeDragEnd(); + } + }; + + const filteredKanbanDimensions = dimensions.filter(d => + d.dimension.toLowerCase().includes(kanbanSearchQuery.toLowerCase()) && + !kanbanColumns.some(c => c.dimension === d.dimension) && + d.dimension !== selectedDimension?.dimension + ); + + const sortedKanbanColumns = [...kanbanColumns].sort((a, b) => a.order - b.order); + + // Render functions for each view mode + const renderGridContent = () => ( +
+ {nodes.map((node) => ( +
handleNodeTileDragStart(event, node)} + onDragEnd={handleNodeTileDragEnd} + onClick={() => { + onNodeOpen(node.id); + onClose(); + }} + style={{ + background: '#0a0a0a', + border: '1px solid #161616', + borderRadius: '16px', + padding: '20px', + display: 'flex', + flexDirection: 'column', + gap: '12px', + cursor: 'pointer', + transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', + position: 'relative', + minHeight: '200px', + maxHeight: '200px', + overflow: 'hidden', + boxShadow: '0 1px 4px rgba(0, 0, 0, 0.2)' + }} + onMouseEnter={(e) => { + e.currentTarget.style.background = '#111111'; + e.currentTarget.style.transform = 'translateY(-2px)'; + e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.3)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = '#0a0a0a'; + e.currentTarget.style.transform = 'translateY(0px)'; + e.currentTarget.style.boxShadow = '0 1px 4px rgba(0, 0, 0, 0.2)'; + }} + > +
+
+ + {getNodeIcon(node)} + +
+ {node.title || 'Untitled'} +
+
+ + #{node.id} + +
+ {node.content && ( +
+ {getContentPreview(node.content)} +
+ )} + {node.link && ( +
+ {node.link} +
+ )} + {node.dimensions && node.dimensions.length > 0 && ( +
+ {node.dimensions.slice(0, 4).map((dimension, index) => { + const isCurrentDimension = dimension === selectedDimension?.dimension; + return ( + + {dimension} + + ); + })} + {node.dimensions.length > 4 && ( + + +{node.dimensions.length - 4} + + )} +
+ )} +
+ ))} + {nodesLoading && ( +
Loading...
+ )} + {!nodesLoading && nodes.length === 0 && ( +
+ No nodes in this dimension yet. +
+ )} +
+ ); + + const renderListContent = () => ( +
+ {nodes.map((node) => ( + + ))} + {nodesLoading && ( +
Loading...
+ )} + {!nodesLoading && nodes.length === 0 && ( +
+ No nodes in this dimension yet. +
+ )} +
+ ); + + const renderKanbanContent = () => ( +
+ {/* Kanban Column Setup Bar */} +
+ + Group by: + + + {kanbanColumns.length === 0 && ( + + Add dimensions to create columns + + )} + +
+ + + {showKanbanColumnPicker && ( +
+ setKanbanSearchQuery(e.target.value)} + style={{ + width: '100%', + padding: '8px 12px', + background: '#0a0a0a', + border: 'none', + borderBottom: '1px solid #333', + color: '#fff', + fontSize: '12px', + outline: 'none' + }} + autoFocus + /> +
+ {filteredKanbanDimensions.length === 0 ? ( +
+ No dimensions available +
+ ) : ( + filteredKanbanDimensions.map(dim => ( + + )) + )} +
+
+ )} +
+ + {showKanbanColumnPicker && ( +
setShowKanbanColumnPicker(false)} + /> + )} +
+ + {/* Kanban Board */} +
+ {sortedKanbanColumns.map(column => { + const columnNodes = getNodesForKanbanColumn(column.dimension); + const isDropTarget = dragOverColumn === column.dimension && draggedFromColumn !== column.dimension; + + return ( +
handleKanbanColumnDragOver(e, column.dimension)} + onDragLeave={handleKanbanColumnDragLeave} + onDrop={(e) => handleKanbanNodeDrop(e, column.dimension)} + > + {/* Column Header */} +
+
+ + {column.dimension} + + + {columnNodes.length} + +
+ +
+ + {/* Column Content */} +
+ {columnNodes.map(node => ( +
handleKanbanNodeDragStart(e, node.id, column.dimension)} + onDragEnd={handleKanbanNodeDragEnd} + onClick={() => { + onNodeOpen(node.id); + onClose(); + }} + style={{ + padding: '10px', + marginBottom: '6px', + background: draggedNodeId === node.id ? '#1a1a1a' : '#111', + border: '1px solid #222', + borderRadius: '8px', + cursor: 'pointer', + opacity: draggedNodeId === node.id ? 0.5 : 1, + transition: 'all 0.2s' + }} + onMouseEnter={(e) => { + if (draggedNodeId !== node.id) { + e.currentTarget.style.background = '#1a1a1a'; + e.currentTarget.style.borderColor = '#333'; + } + }} + onMouseLeave={(e) => { + if (draggedNodeId !== node.id) { + e.currentTarget.style.background = '#111'; + e.currentTarget.style.borderColor = '#222'; + } + }} + > +
+ {node.title || 'Untitled'} +
+ {node.dimensions && node.dimensions.length > 1 && ( +
+ {node.dimensions + .filter(d => d !== column.dimension && d !== selectedDimension?.dimension) + .slice(0, 2) + .map(dim => ( + + {dim} + + ))} +
+ )} +
+ ))} + + {columnNodes.length === 0 && ( +
+ Drop nodes here +
+ )} +
+
+ ); + })} + + {/* Empty State */} + {kanbanColumns.length === 0 && ( +
+ Add dimension columns to organize your nodes +
+ )} +
+
+ ); + + // Render all nodes view (list or kanban) - shown at top level when not in folder view + const renderAllNodesView = () => { + if (allNodesLoading) { + return ( +
+ Loading nodes... +
+ ); + } + + if (viewMode === 'list') { + return ( +
+
+ Showing {allNodes.length} nodes +
+
+ {allNodes.map((node) => ( + + ))} + {allNodes.length === 0 && ( +
+ No nodes yet. +
+ )} +
+
+ ); + } + + if (viewMode === 'kanban') { + return ( +
+ {/* Kanban Column Setup Bar */} +
+ + Group by: + + + {kanbanColumns.length === 0 && ( + + Add dimensions to create columns + + )} + +
+ + + {showKanbanColumnPicker && ( +
+ setKanbanSearchQuery(e.target.value)} + style={{ + width: '100%', + padding: '8px 12px', + background: '#0a0a0a', + border: 'none', + borderBottom: '1px solid #333', + color: '#fff', + fontSize: '12px', + outline: 'none' + }} + autoFocus + /> +
+ {dimensions.filter(d => + d.dimension.toLowerCase().includes(kanbanSearchQuery.toLowerCase()) && + !kanbanColumns.some(c => c.dimension === d.dimension) + ).length === 0 ? ( +
+ No dimensions available +
+ ) : ( + dimensions.filter(d => + d.dimension.toLowerCase().includes(kanbanSearchQuery.toLowerCase()) && + !kanbanColumns.some(c => c.dimension === d.dimension) + ).map(dim => ( + + )) + )} +
+
+ )} +
+ + {showKanbanColumnPicker && ( +
setShowKanbanColumnPicker(false)} + /> + )} +
+ + {/* Kanban Board */} +
+ {sortedKanbanColumns.map(column => { + const columnNodes = allNodes.filter(node => node.dimensions?.includes(column.dimension)); + const isDropTarget = dragOverColumn === column.dimension && draggedFromColumn !== column.dimension; + + return ( +
handleKanbanColumnDragOver(e, column.dimension)} + onDragLeave={handleKanbanColumnDragLeave} + onDrop={(e) => handleAllNodesKanbanDrop(e, column.dimension)} + > + {/* Column Header */} +
+
+ + {column.dimension} + + + {columnNodes.length} + +
+ +
+ + {/* Column Content */} +
+ {columnNodes.map(node => ( +
handleKanbanNodeDragStart(e, node.id, column.dimension)} + onDragEnd={handleKanbanNodeDragEnd} + onClick={() => { + onNodeOpen(node.id); + onClose(); + }} + style={{ + padding: '10px', + marginBottom: '6px', + background: draggedNodeId === node.id ? '#1a1a1a' : '#111', + border: '1px solid #222', + borderRadius: '8px', + cursor: 'pointer', + opacity: draggedNodeId === node.id ? 0.5 : 1, + transition: 'all 0.2s' + }} + onMouseEnter={(e) => { + if (draggedNodeId !== node.id) { + e.currentTarget.style.background = '#1a1a1a'; + e.currentTarget.style.borderColor = '#333'; + } + }} + onMouseLeave={(e) => { + if (draggedNodeId !== node.id) { + e.currentTarget.style.background = '#111'; + e.currentTarget.style.borderColor = '#222'; + } + }} + > +
+ {node.title || 'Untitled'} +
+ {node.dimensions && node.dimensions.length > 1 && ( +
+ {node.dimensions + .filter(d => d !== column.dimension) + .slice(0, 2) + .map(dim => ( + + {dim} + + ))} +
+ )} +
+ ))} + {columnNodes.length === 0 && ( +
+ Drop nodes here +
+ )} +
+
+ ); + })} + + {/* Empty State */} + {kanbanColumns.length === 0 && ( +
+ Add dimension columns to organize your nodes +
+ )} +
+
+ ); + } + + return null; + }; + + // Handle kanban drop for all nodes view + const handleAllNodesKanbanDrop = async (e: DragEvent, targetDimension: string) => { + e.preventDefault(); + if (draggedNodeId === null || draggedFromColumn === targetDimension) { + handleKanbanNodeDragEnd(); + return; + } + + try { + const node = allNodes.find(n => n.id === draggedNodeId); + if (!node) return; + + const currentDimensions = node.dimensions || []; + let updatedDimensions: string[]; + + if (draggedFromColumn === '__uncategorized__') { + updatedDimensions = [...currentDimensions, targetDimension]; + } else { + updatedDimensions = currentDimensions.map(d => + d === draggedFromColumn ? targetDimension : d + ); + } + + const response = await fetch(`/api/nodes/${draggedNodeId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dimensions: updatedDimensions }) + }); + + if (!response.ok) { + throw new Error('Failed to update node dimensions'); + } + + fetchAllNodes(); + onDataChanged?.(); + } catch (error) { + console.error('Error updating node dimension:', error); + alert('Failed to move node. Please try again.'); + } finally { + handleKanbanNodeDragEnd(); + } + }; + const renderNodeGrid = () => { if (!selectedDimension) return null; @@ -617,163 +2074,9 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
Showing {nodes.length} nodes tagged with {selectedDimension.dimension.toUpperCase()}
-
- {nodes.map((node) => ( -
handleNodeTileDragStart(event, node)} - onDragEnd={handleNodeTileDragEnd} - onClick={() => { - onNodeOpen(node.id); - onClose(); - }} - style={{ - background: '#0a0a0a', - border: '1px solid #161616', - borderRadius: '16px', - padding: '20px', - display: 'flex', - flexDirection: 'column', - gap: '12px', - cursor: 'pointer', - transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', - position: 'relative', - minHeight: '200px', - maxHeight: '200px', - overflow: 'hidden', - boxShadow: '0 1px 4px rgba(0, 0, 0, 0.2)' - }} - onMouseEnter={(e) => { - e.currentTarget.style.background = '#111111'; - e.currentTarget.style.transform = 'translateY(-2px)'; - e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.3)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = '#0a0a0a'; - e.currentTarget.style.transform = 'translateY(0px)'; - e.currentTarget.style.boxShadow = '0 1px 4px rgba(0, 0, 0, 0.2)'; - }} - > -
-
- - {getNodeIcon(node)} - -
- {node.title || 'Untitled'} -
-
- - #{node.id} - -
- {node.content && ( -
- {getContentPreview(node.content)} -
- )} - {node.link && ( -
- {node.link} -
- )} - {node.dimensions && node.dimensions.length > 0 && ( -
- {node.dimensions.slice(0, 4).map((dimension, index) => { - const isCurrentDimension = dimension === selectedDimension?.dimension; - return ( - - {dimension} - - ); - })} - {node.dimensions.length > 4 && ( - - +{node.dimensions.length - 4} - - )} -
- )} -
- ))} - {nodesLoading && ( -
Loading...
- )} - {!nodesLoading && nodes.length === 0 && ( -
- No nodes in this dimension yet. -
- )} -
+ + {renderGridContent()} + {nodesError && (
{nodesError} @@ -801,6 +2104,575 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o ); }; + // Render filtered view content based on viewMode + const renderFilteredView = () => { + if (filteredNodesLoading) { + return ( +
+ Loading... +
+ ); + } + + if (selectedFilters.length === 0) { + return ( +
+ Select dimension filters to view nodes +
+ ); + } + + if (filteredNodes.length === 0) { + return ( +
+ No nodes match the selected filters +
+ ); + } + + // List view with dimension grouping + if (viewMode === 'list') { + const groups = getNodesGroupedByDimension(); + return ( +
+ {groups.map(group => ( +
+
+ {group.dimension} ({group.nodes.length}) +
+ {group.nodes.map((node, index) => ( +
{ + setReorderDrag({ nodeId: node.id, dimension: group.dimension, index }); + e.dataTransfer.effectAllowed = 'move'; + }} + onDragEnd={() => { + setReorderDrag(null); + setReorderDropIndex(null); + }} + onDragOver={(e) => { + e.preventDefault(); + if (reorderDrag && reorderDrag.dimension === group.dimension && reorderDrag.nodeId !== node.id) { + setReorderDropIndex(index); + } + }} + onDrop={(e) => { + e.preventDefault(); + if (reorderDrag && reorderDrag.dimension === group.dimension) { + handleReorderDrop(group.dimension, reorderDrag.index, index, group.nodes); + } + }} + onClick={() => { + onNodeOpen(node.id); + onClose(); + }} + style={{ + width: '100%', + display: 'flex', + alignItems: 'center', + gap: '10px', + padding: '8px 10px', + marginBottom: '2px', + background: reorderDrag?.nodeId === node.id ? '#1a2a1f' : 'transparent', + border: reorderDropIndex === index && reorderDrag?.dimension === group.dimension && reorderDrag?.nodeId !== node.id + ? '1px dashed #22c55e' + : '1px solid transparent', + borderRadius: '6px', + cursor: 'grab', + textAlign: 'left', + transition: 'all 0.15s', + opacity: reorderDrag?.nodeId === node.id ? 0.5 : 1 + }} + onMouseEnter={(e) => { + if (!reorderDrag) e.currentTarget.style.background = '#111'; + }} + onMouseLeave={(e) => { + if (!reorderDrag) e.currentTarget.style.background = 'transparent'; + }} + > + ⋮⋮ + + {node.id} + +
+ {getNodeIcon(node)} +
+
+
+ {node.title || 'Untitled'} +
+
+ {node.dimensions && node.dimensions.length > 1 && ( +
+ {node.dimensions + .filter(d => d !== group.dimension) + .slice(0, 2) + .map(dim => ( + + {dim} + + ))} +
+ )} +
+ ))} +
+ ))} +
+ ); + } + + // Grid view with dimension grouping + if (viewMode === 'grid') { + const groups = getNodesGroupedByDimension(); + return ( +
+ {groups.map(group => ( +
+
+ {group.dimension} ({group.nodes.length}) +
+
+ {group.nodes.map((node, index) => ( +
{ + setReorderDrag({ nodeId: node.id, dimension: group.dimension, index }); + e.dataTransfer.effectAllowed = 'move'; + }} + onDragEnd={() => { + setReorderDrag(null); + setReorderDropIndex(null); + }} + onDragOver={(e) => { + e.preventDefault(); + if (reorderDrag && reorderDrag.dimension === group.dimension && reorderDrag.nodeId !== node.id) { + setReorderDropIndex(index); + } + }} + onDrop={(e) => { + e.preventDefault(); + if (reorderDrag && reorderDrag.dimension === group.dimension) { + handleReorderDrop(group.dimension, reorderDrag.index, index, group.nodes); + } + }} + onClick={() => { + onNodeOpen(node.id); + onClose(); + }} + style={{ + background: reorderDrag?.nodeId === node.id ? '#1a2a1f' : '#0a0a0a', + border: reorderDropIndex === index && reorderDrag?.dimension === group.dimension && reorderDrag?.nodeId !== node.id + ? '2px dashed #22c55e' + : '1px solid #161616', + borderRadius: '10px', + padding: '14px', + display: 'flex', + flexDirection: 'column', + gap: '8px', + cursor: 'grab', + transition: 'all 0.15s ease', + minHeight: '120px', + maxHeight: '140px', + overflow: 'hidden', + opacity: reorderDrag?.nodeId === node.id ? 0.5 : 1 + }} + onMouseEnter={(e) => { + if (!reorderDrag) { + e.currentTarget.style.background = '#111'; + e.currentTarget.style.borderColor = '#222'; + } + }} + onMouseLeave={(e) => { + if (!reorderDrag) { + e.currentTarget.style.background = '#0a0a0a'; + e.currentTarget.style.borderColor = '#161616'; + } + }} + > +
+ ⋮⋮ + + {node.id} + + {getNodeIcon(node)} +
+ {node.title || 'Untitled'} +
+
+ {node.content && ( +
+ {getContentPreview(node.content)} +
+ )} + {node.dimensions && node.dimensions.length > 1 && ( +
+ {node.dimensions.filter(d => d !== group.dimension).slice(0, 2).map((dim) => ( + + {dim} + + ))} +
+ )} +
+ ))} +
+
+ ))} +
+ ); + } + + // Kanban view - group by selected filters with drag-and-drop + if (viewMode === 'kanban') { + return ( +
+ {selectedFilters.map(dimension => { + const unsortedColumnNodes = filteredNodes.filter(n => n.dimensions?.includes(dimension)); + const columnNodes = sortNodesByDimensionOrder(unsortedColumnNodes, dimension); + const isDropTarget = dropTargetDimension === dimension; + const isColumnDropTarget = columnDropTarget === dimension && draggedColumn !== dimension; + const isBeingDragged = draggedColumn === dimension; + return ( +
{ + e.preventDefault(); + // If dragging a column, set column drop target + if (draggedColumn) { + setColumnDropTarget(dimension); + } else { + setDropTargetDimension(dimension); + } + }} + onDragLeave={(e) => { + // Only clear if leaving the column entirely + if (!e.currentTarget.contains(e.relatedTarget as HTMLElement)) { + setDropTargetDimension(null); + setColumnDropTarget(null); + } + }} + onDrop={(e) => { + e.preventDefault(); + // Handle column reorder + if (draggedColumn && draggedColumn !== dimension) { + handleColumnReorder(draggedColumn, dimension); + setDraggedColumn(null); + setColumnDropTarget(null); + } + // Handle node drop + else if (draggedNode && draggedNode.fromDimension !== dimension) { + handleKanbanDrop(draggedNode.id, draggedNode.fromDimension, dimension); + } + setDraggedNode(null); + setDropTargetDimension(null); + }} + style={{ + width: '280px', + minWidth: '280px', + display: 'flex', + flexDirection: 'column', + background: isColumnDropTarget ? '#1a1a2a' : (isDropTarget ? '#0d1a12' : '#0a0a0a'), + border: isColumnDropTarget ? '1px solid #6366f1' : (isDropTarget ? '1px solid #22c55e' : '1px solid #1a1a1a'), + borderRadius: '12px', + transition: 'all 0.15s ease', + opacity: isBeingDragged ? 0.5 : 1 + }} + > + {/* Column header - draggable for reordering */} +
{ + setDraggedColumn(dimension); + e.dataTransfer.effectAllowed = 'move'; + }} + onDragEnd={() => { + setDraggedColumn(null); + setColumnDropTarget(null); + }} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '12px', + borderBottom: '1px solid #1a1a1a', + cursor: 'grab', + userSelect: 'none' + }} + > +
+ ⋮⋮ + + {dimension} + + + {columnNodes.length} + +
+
+
+ {columnNodes.map((node, index) => { + const isReorderTarget = reorderDropIndex === index && reorderDrag?.dimension === dimension && reorderDrag?.nodeId !== node.id; + return ( +
{ + setDraggedNode({ id: node.id, fromDimension: dimension }); + setReorderDrag({ nodeId: node.id, dimension, index }); + e.dataTransfer.effectAllowed = 'move'; + }} + onDragEnd={() => { + setDraggedNode(null); + setDropTargetDimension(null); + setReorderDrag(null); + setReorderDropIndex(null); + }} + onDragOver={(e) => { + e.preventDefault(); + e.stopPropagation(); + // If dragging within same column, show reorder indicator + if (reorderDrag && reorderDrag.dimension === dimension && reorderDrag.nodeId !== node.id) { + setReorderDropIndex(index); + } + }} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + // Handle within-column reorder + if (reorderDrag && reorderDrag.dimension === dimension && reorderDrag.nodeId !== node.id) { + handleReorderDrop(dimension, reorderDrag.index, index, columnNodes); + } + }} + onClick={() => { + onNodeOpen(node.id); + onClose(); + }} + style={{ + padding: '10px', + marginBottom: '6px', + background: draggedNode?.id === node.id ? '#1a2a1f' : '#111', + border: isReorderTarget ? '2px dashed #22c55e' : (draggedNode?.id === node.id ? '1px solid #22c55e' : '1px solid #222'), + borderRadius: '8px', + cursor: 'grab', + transition: 'all 0.15s', + userSelect: 'none', + opacity: draggedNode?.id === node.id ? 0.5 : 1 + }} + onMouseEnter={(e) => { + if (!draggedNode) { + e.currentTarget.style.background = '#1a1a1a'; + e.currentTarget.style.borderColor = '#333'; + } + }} + onMouseLeave={(e) => { + if (!draggedNode) { + e.currentTarget.style.background = '#111'; + e.currentTarget.style.borderColor = '#222'; + } + }} + > +
+ ⋮⋮ + + {node.id} + +
+ {node.title || 'Untitled'} +
+
+ {node.dimensions && node.dimensions.length > 1 && ( +
+ {node.dimensions + .filter(d => d !== dimension) + .slice(0, 2) + .map(dim => ( + + {dim} + + ))} +
+ )} +
+ );})} + {columnNodes.length === 0 && ( +
+ {isDropTarget ? 'Drop here' : 'No nodes'} +
+ )} +
+
+ ); + })} +
+ ); + } + + return null; + }; + return ( <>
-
-
- {view === 'nodes' && ( - - )} -
-
- {view === 'dimensions' ? ( - 'Dimensions' - ) : editingDimensionName ? ( -
- Nodes – - setEditDimensionNameText(e.target.value)} - autoFocus - style={{ - padding: '4px 8px', - fontSize: '13px', - fontWeight: 600, - letterSpacing: '0.08em', - textTransform: 'uppercase', + {/* Header */} +
+ {/* Top row: Mode tabs + Actions */} +
+
+ {/* Back button when viewing nodes in a dimension */} + {view === 'nodes' && ( + + )} + + {/* Mode tabs - only show when not drilling into a specific dimension */} + {view === 'dimensions' && ( +
+ + +
+ )} + + {/* Title when viewing nodes in a dimension */} + {view === 'nodes' && ( +
+ Nodes – + {selectedDimension?.dimension ?? ''} +
+ )} + + {/* Saved views dropdown */} + {view === 'dimensions' && savedViews.length > 0 && ( +
+ + {showSavedViewsDropdown && ( + <> +
setShowSavedViewsDropdown(false)} + /> +
{ - if (e.key === 'Enter') { - e.preventDefault(); - handleSaveDimensionName(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleCancelDimensionName(); - } - }} - /> - - -
- ) : ( -
- Nodes – - { - e.currentTarget.style.borderColor = '#333'; - e.currentTarget.style.background = '#1a1a1a'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.borderColor = 'transparent'; - e.currentTarget.style.background = 'transparent'; - }} - > - {selectedDimension?.dimension ?? ''} - - -
- )} -
- {view === 'nodes' && selectedDimension && ( -
- {editingDescription ? ( -
- setEditDescriptionText(e.target.value)} - placeholder="Add a description to help with auto-assignment..." - autoFocus - style={{ - width: '100%', - padding: '8px 12px', - fontSize: '12px', - background: '#0f0f0f', - border: '1px solid #22c55e', - borderRadius: '8px', - color: '#f8fafc', - outline: 'none', - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis' - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSaveDescription(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleCancelDescription(); - } - }} - /> - - -
- ) : ( -
{ - e.currentTarget.style.borderColor = '#333'; - e.currentTarget.style.background = '#0f0f0f'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.borderColor = 'transparent'; - e.currentTarget.style.background = 'transparent'; - }} - > - - - {selectedDimension.description || 'Add description...'} - -
+ {savedViews.map(sv => ( +
{ if (activeViewId !== sv.id) e.currentTarget.style.background = '#252525'; }} + onMouseLeave={(e) => { if (activeViewId !== sv.id) e.currentTarget.style.background = 'transparent'; }} + > + loadSavedView(sv)} + style={{ flex: 1, fontSize: '12px', color: '#ccc' }} + > + {sv.name} + + +
+ ))} +
+ )}
)}
-
-
- {view === 'dimensions' && ( + +
+ {/* Add Dimension button - only in folders mode */} + {view === 'dimensions' && overlayMode === 'folders' && ( + + )} + + {/* View mode toggle - only in filtered mode with filters */} + {view === 'dimensions' && overlayMode === 'filtered' && selectedFilters.length > 0 && ( +
+ + + +
+ )} + + {/* Save View button - only in filtered mode with filters */} + {view === 'dimensions' && overlayMode === 'filtered' && selectedFilters.length > 0 && ( + + )} + - )} - +
+ + {/* Filter bar - only show in filtered mode */} + {view === 'dimensions' && overlayMode === 'filtered' && ( +
+ {/* Filter chips */} +
+ {selectedFilters.map(filter => ( + + {filter} + + + ))} + + {/* Add filter button */} +
+ + {showFilterPicker && ( + <> +
setShowFilterPicker(false)} + /> +
+ setFilterSearchQuery(e.target.value)} + style={{ + width: '100%', + padding: '8px 12px', + background: '#0a0a0a', + border: 'none', + borderBottom: '1px solid #333', + color: '#fff', + fontSize: '12px', + outline: 'none' + }} + autoFocus + /> +
+ {dimensions + .filter(d => + d.dimension.toLowerCase().includes(filterSearchQuery.toLowerCase()) && + !selectedFilters.includes(d.dimension) + ) + .map(dim => ( + + ))} + {dimensions.filter(d => + d.dimension.toLowerCase().includes(filterSearchQuery.toLowerCase()) && + !selectedFilters.includes(d.dimension) + ).length === 0 && ( +
+ No dimensions available +
+ )} +
+
+ + )} +
+ + {selectedFilters.length > 0 && ( + + )} +
+ +
+ )}
- {view === 'dimensions' ? renderDimensionGrid() : renderNodeGrid()} + + {/* Content */} + {view === 'nodes' ? ( + renderNodeGrid() + ) : overlayMode === 'folders' ? ( + renderDimensionGrid() + ) : ( + renderFilteredView() + )}
setShowAddDimensionDialog(false)} /> + setShowSaveViewDialog(false)} + /> ); } diff --git a/src/components/nodes/NodesPanel.tsx b/src/components/nodes/NodesPanel.tsx index 056adad..4e33295 100644 --- a/src/components/nodes/NodesPanel.tsx +++ b/src/components/nodes/NodesPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, type DragEvent } from 'react'; +import { useState, useEffect, useRef, type DragEvent } from 'react'; import { ChevronRight, ChevronDown, Folder, FolderOpen, Layers, List, Maximize2, Minimize2 } from 'lucide-react'; import { Node } from '@/types/database'; import AddNodeButton from './AddNodeButton'; @@ -45,6 +45,7 @@ export default function NodesPanel({ selectedNodes, onNodeSelect, onNodeCreated, const [showSearchModal, setShowSearchModal] = useState(false); const [dropFeedback, setDropFeedback] = useState(null); const [dragHoverDimension, setDragHoverDimension] = useState(null); + const draggedNodeRef = useRef<{ id: number; dimensions?: string[] } | null>(null); useEffect(() => { fetchNodes(); @@ -153,14 +154,14 @@ export default function NodesPanel({ selectedNodes, onNodeSelect, onNodeCreated, }; const handleDimensionDragOver = (event: DragEvent) => { - if (event.dataTransfer.types.includes('application/node-info')) { + if (event.dataTransfer.types.includes('application/node-info') || event.dataTransfer.types.includes('text/plain')) { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; } }; const handleDimensionDragEnter = (event: DragEvent, dimension: string) => { - if (event.dataTransfer.types.includes('application/node-info')) { + if (event.dataTransfer.types.includes('application/node-info') || event.dataTransfer.types.includes('text/plain')) { setDragHoverDimension(dimension); } }; @@ -172,17 +173,33 @@ export default function NodesPanel({ selectedNodes, onNodeSelect, onNodeCreated, }; const handleNodeDropOnDimension = async (event: DragEvent, dimension: string) => { - if (!event.dataTransfer.types.includes('application/node-info')) { + event.preventDefault(); + event.stopPropagation(); + + // Try to get data from ref first (works in Electron/Tauri webviews) + let payload: { id: number; dimensions?: string[] } | null = draggedNodeRef.current; + + // Fallback to dataTransfer for browser compatibility + if (!payload) { + const raw = event.dataTransfer.getData('application/node-info') || event.dataTransfer.getData('text/plain'); + if (raw) { + try { + payload = JSON.parse(raw); + } catch (e) { + console.error('Failed to parse drag data:', e); + } + } + } + + // Clear the ref + draggedNodeRef.current = null; + + if (!payload?.id) { + console.warn('No valid node data in drop event'); return; } - event.preventDefault(); - const raw = event.dataTransfer.getData('application/node-info'); - if (!raw) return; try { - const payload = JSON.parse(raw) as { id: number; dimensions?: string[] }; - if (!payload?.id) return; - const currentDimensions = payload.dimensions || []; if (currentDimensions.some((dim) => dim.toLowerCase() === dimension.toLowerCase())) { setDropFeedback(`Node already in ${dimension}`); @@ -368,10 +385,15 @@ export default function NodesPanel({ selectedNodes, onNodeSelect, onNodeCreated, const handleNodeDragStart = (event: DragEvent, node: Node) => { event.dataTransfer.effectAllowed = 'copy'; - event.dataTransfer.setData('application/node-info', JSON.stringify({ + const nodeData = { id: node.id, dimensions: node.dimensions || [] - })); + }; + // Store in ref for webview compatibility (dataTransfer.getData can fail in Electron/Tauri) + draggedNodeRef.current = nodeData; + // Also set in dataTransfer for browser compatibility + event.dataTransfer.setData('application/node-info', JSON.stringify(nodeData)); + event.dataTransfer.setData('text/plain', JSON.stringify(nodeData)); const preview = document.createElement('div'); preview.textContent = node.title || `Node #${node.id}`; @@ -394,11 +416,17 @@ export default function NodesPanel({ selectedNodes, onNodeSelect, onNodeCreated, }, 0); }; + const handleNodeDragEnd = () => { + // Clear ref if drag ends without a drop + draggedNodeRef.current = null; + }; + const renderNodeItem = (node: Node) => (
diff --git a/src/config/prompts/rah-easy.ts b/src/config/prompts/rah-easy.ts index f0913d4..876924f 100644 --- a/src/config/prompts/rah-easy.ts +++ b/src/config/prompts/rah-easy.ts @@ -20,6 +20,10 @@ Tool strategy: - youtubeExtract, websiteExtract, and paperExtract when outside content is required. - webSearch only when the knowledge base lacks the answer. +Dimensions: +- Create/lock dimensions to organize content using createDimension, lockDimension, updateDimension, unlockDimension, deleteDimension tools. +- Lock dimensions (isPriority=true) so they auto-assign to new nodes. + Response polish: - Default to minimal reasoning effort for speed. - Do not expose chain-of-thought; return conclusions only. diff --git a/src/config/prompts/rah-main.ts b/src/config/prompts/rah-main.ts index 91c8939..207054b 100644 --- a/src/config/prompts/rah-main.ts +++ b/src/config/prompts/rah-main.ts @@ -28,6 +28,13 @@ Tool strategy: - Extract content with youtubeExtract, websiteExtract, paperExtract as needed. - When searchContentEmbeddings highlights a chunk, hydrate the node via getNodesById (or fetch the chunk) before quoting. +Dimension management: +- Create dimensions for new knowledge areas or topics using createDimension. +- Lock dimensions (isPriority=true) to enable auto-assignment to new nodes. +- Update descriptions to help the AI understand dimension purpose. +- Use lockDimension/unlockDimension for quick lock status changes. +- Delete unused dimensions to keep the system clean. + Response style: - Limit to one or two short sentences. Reference nodes as [NODE:id:"title"]. - When answering about stored content, quote the exact wording from the chunk (verbatim, in quotation marks) and cite the node. diff --git a/src/config/prompts/rah-mini.ts b/src/config/prompts/rah-mini.ts index f882278..2df923d 100644 --- a/src/config/prompts/rah-mini.ts +++ b/src/config/prompts/rah-mini.ts @@ -22,4 +22,5 @@ Additional guidance: - If you cannot complete a step (missing node, empty content, tool failure), state that explicitly in 'Follow-up' with the precise next action needed. - Stop after success—do not run extra verification tools. - Keep the full summary under ~100 tokens. +- You can create and manage dimensions using createDimension, updateDimension, lockDimension, unlockDimension, deleteDimension tools. Lock dimensions (isPriority=true) to enable auto-assignment. `; diff --git a/src/config/prompts/wise-rah.ts b/src/config/prompts/wise-rah.ts index 319694c..9ffdd08 100644 --- a/src/config/prompts/wise-rah.ts +++ b/src/config/prompts/wise-rah.ts @@ -15,6 +15,7 @@ Available tools: - webSearch — external research when necessary - think — internal planning/reflection (use once per workflow unless plan changes) - updateNode — append content to nodes (tool handles appending automatically) +- Dimension management: createDimension, updateDimension, lockDimension, unlockDimension, deleteDimension — manage dimensions to organize knowledge base structure diff --git a/src/services/agents/executor.ts b/src/services/agents/executor.ts index 2bba692..120a251 100644 --- a/src/services/agents/executor.ts +++ b/src/services/agents/executor.ts @@ -8,7 +8,6 @@ import { calculateCost } from '@/services/analytics/pricing'; import { UsageData } from '@/types/analytics'; import { summarizeToolExecution } from '@/services/agents/toolResultUtils'; import { edgeService } from '@/services/database/edges'; -import { RequestContext } from '@/services/context/requestContext'; interface CapsuleNodeJSON { id: number; @@ -121,11 +120,7 @@ export interface MiniRAHExecutionInput { export class MiniRAHExecutor { static async execute({ sessionId, task, context, expectedOutcome, traceId, parentChatId, workflowKey, workflowNodeId }: MiniRAHExecutionInput) { try { - const requestContext = RequestContext.get(); - const delegateKey = - requestContext.apiKeys?.openai || - process.env.RAH_DELEGATE_OPENAI_API_KEY || - process.env.OPENAI_API_KEY; + const delegateKey = process.env.RAH_DELEGATE_OPENAI_API_KEY || process.env.OPENAI_API_KEY; if (!delegateKey) { throw new Error('RAH_DELEGATE_OPENAI_API_KEY (or OPENAI_API_KEY) is not set.'); } diff --git a/src/services/agents/quickAdd.ts b/src/services/agents/quickAdd.ts index 5c05cc2..341bbef 100644 --- a/src/services/agents/quickAdd.ts +++ b/src/services/agents/quickAdd.ts @@ -210,7 +210,6 @@ async function handleNoteQuickAdd(rawInput: string, task: string): Promise { + return null; + } + + static getLast(_key: string): string | null { + return null; + } + + static set(_key: string, _value: string): void { + // No-op in OS version + } + + static delete(_key: string): void { + // No-op in OS version + } +} diff --git a/src/services/helpers/contextBuilder.ts b/src/services/helpers/contextBuilder.ts index f6dd0e3..234904b 100644 --- a/src/services/helpers/contextBuilder.ts +++ b/src/services/helpers/contextBuilder.ts @@ -19,6 +19,8 @@ export interface ContextBuilderOptions { const BASE_CONTEXT = `=== RA-H BASE CONTEXT === - Nodes store content (title, content, dimensions, metadata, link, chunk) - Edges capture directed relationships between nodes +- Dimensions organize nodes; locked dimensions (isPriority=true) auto-assign to new nodes +- You can create and manage dimensions using dimension tools (createDimension, updateDimension, lockDimension, unlockDimension, deleteDimension) - When auto-context is enabled you'll see BACKGROUND CONTEXT with the 10 most-connected nodes (ID + title only) - Focused nodes show truncated content; use queryNodes, searchContentEmbeddings, or queryEdge when you need full detail - Node references must use [NODE:id:"title"] so the UI renders clickable labels diff --git a/src/services/storage/apiKeys.ts b/src/services/storage/apiKeys.ts index 65daa43..ad950f5 100644 --- a/src/services/storage/apiKeys.ts +++ b/src/services/storage/apiKeys.ts @@ -32,12 +32,6 @@ export class ApiKeyService { this.loadKeys(); } - private notifyUpdate(): void { - if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('api-keys:updated')); - } - } - // Load keys from localStorage private loadKeys(): void { try { @@ -81,7 +75,6 @@ export class ApiKeyService { if (this.validateOpenAiKey(key)) { this.keys.openai = key; this.saveKeys(); - this.notifyUpdate(); } else { throw new Error('Invalid OpenAI API key format'); } @@ -92,7 +85,6 @@ export class ApiKeyService { if (this.validateAnthropicKey(key)) { this.keys.anthropic = key; this.saveKeys(); - this.notifyUpdate(); } else { throw new Error('Invalid Anthropic API key format'); } @@ -103,14 +95,12 @@ export class ApiKeyService { delete this.keys.openai; this.saveKeys(); this.status.openai = 'not-set'; - this.notifyUpdate(); } clearAnthropicKey(): void { delete this.keys.anthropic; this.saveKeys(); this.status.anthropic = 'not-set'; - this.notifyUpdate(); } // Clear all keys @@ -121,7 +111,6 @@ export class ApiKeyService { openai: 'not-set', anthropic: 'not-set' }; - this.notifyUpdate(); } // Get masked key for display (show only last 4 characters) @@ -184,16 +173,26 @@ export class ApiKeyService { async testAnthropicConnection(key?: string): Promise { const testKey = key || this.getAnthropicKey(); if (!testKey) return false; + this.status.anthropic = 'testing'; try { - const response = await fetch('/api/local/test-anthropic', { + // Simple test call to Anthropic API + const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ apiKey: testKey }), + headers: { + 'x-api-key': testKey, + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01' + }, + body: JSON.stringify({ + model: 'claude-3-haiku-20240307', + max_tokens: 1, + messages: [{ role: 'user', content: 'test' }] + }) }); - const data = await response.json(); - const isConnected = Boolean(data?.ok); + + const isConnected = response.ok; this.status.anthropic = isConnected ? 'connected' : 'failed'; return isConnected; } catch (error) { diff --git a/src/types/database.ts b/src/types/database.ts index 31fc7ad..361da5c 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -171,3 +171,11 @@ export interface DatabaseError { code?: string; details?: any; } + +// Dimension interface for dimension management +export interface Dimension { + name: string; + description?: string | null; + is_priority: boolean; + updated_at: string; +} diff --git a/src/types/views.ts b/src/types/views.ts new file mode 100644 index 0000000..0307560 --- /dev/null +++ b/src/types/views.ts @@ -0,0 +1,45 @@ +// View system types + +export type ViewType = 'focus' | 'list' | 'kanban' | 'grid'; + +export interface ViewFilter { + dimension: string; + operator: 'includes' | 'excludes'; +} + +export interface ViewSort { + field: 'title' | 'created_at' | 'updated_at' | 'edge_count'; + direction: 'asc' | 'desc'; +} + +export interface KanbanColumn { + id: string; + dimension: string; + order: number; +} + +export interface ViewConfig { + filters: ViewFilter[]; + filterLogic: 'and' | 'or'; + sort: ViewSort; + // Kanban-specific + columns?: KanbanColumn[]; +} + +export interface SavedView { + id: number; + name: string; + type: ViewType; + config: ViewConfig; + is_default: boolean; + created_at: string; + updated_at: string; +} + +// Default view configuration +export const DEFAULT_VIEW_CONFIG: ViewConfig = { + filters: [], + filterLogic: 'and', + sort: { field: 'updated_at', direction: 'desc' }, + columns: [] +};