diff --git a/app/api/dimensions/route.ts b/app/api/dimensions/route.ts index 74e928f..4b18cda 100644 --- a/app/api/dimensions/route.ts +++ b/app/api/dimensions/route.ts @@ -59,7 +59,9 @@ export async function POST(request: NextRequest) { }, { status: 400 }); } - const descriptionError = validateDimensionDescription(description || ''); + const descriptionError = description !== null + ? validateDimensionDescription(description) + : null; if (descriptionError) { return NextResponse.json({ success: false, diff --git a/docs/6_ui.md b/docs/6_ui.md index 87d8495..0fc319a 100644 --- a/docs/6_ui.md +++ b/docs/6_ui.md @@ -2,100 +2,73 @@ > How to navigate and use RA-OS's interface. -**How it works:** RA-OS uses a 2-panel layout: browse nodes on the left, work with focused content on the right. Settings give you access to skills, database views, logs, tools, and MCP setup. +**How it works:** RA-OS uses a collapsible left navigation rail plus a flexible workspace that can show one or two panes at once. Nodes, dimensions, map, table, skills, and settings all live inside the same workspace. --- -## 2-Panel Layout +## Workspace Layout ``` -┌─────────────┬─────────────────────────┐ -│ NODES │ FOCUS │ -│ Panel │ Panel │ -│ │ │ -│ • Search │ • Tabbed workspace │ -│ • Filters │ • Node content │ -│ • Folders │ • Connections │ -│ │ │ -└─────────────┴─────────────────────────┘ +┌────────┬──────────────────────┬──────────────────────┐ +│ NAV │ PANE A │ PANE B │ +│ │ │ │ +│ Search │ Nodes / Focus │ Optional second pane │ +│ Add │ Dimensions / Map │ for compare/browse │ +│ Views │ Table / Skills │ │ +└────────┴──────────────────────┴──────────────────────┘ ``` --- -## Left Panel: Nodes +## Left Navigation -Browse and manage your knowledge base. +The left rail can stay compact or expand into a labeled navigation column. ### Features -- **Search bar** — Cmd+K opens global search modal -- **Dimension filters** — Multi-select dimension tags -- **Node list** — Scrollable list of filtered nodes -- **Folder view toggle** — Switch between list and folder views +- **Search** — Cmd+K opens global search +- **Add Stuff** — open the quick-add flow +- **Refresh** — reload pane data +- **Workspace views** — Nodes, Skills, Map, Dimensions, and Table +- **Settings** — open settings and MCP/config panels + +--- + +## Nodes Pane + +Browse and manage your knowledge base in the main feed. + +### Features + +- **Search bar** — filter nodes by text +- **Dimension filters** — filter the feed with one or more dimensions +- **Pending quick-add items** — processing placeholders appear in the feed +- **Open in other pane** — send a node to the second pane for comparison ### Node Display Each node shows: -- Title + description preview -- Dimension tags (with custom icons) +- Title and preview +- Dimension tags - Last updated timestamp - Node ID badge -### Folder View +--- -Click the folder icon to open the **Folder View Overlay**: +## Dimensions Pane -**Two Modes:** +The dimensions pane is now a dedicated browser instead of a modal-only overlay. -1. **Folders Mode** — Browse by dimension folders - - Each dimension shows as a folder card - - Drag nodes to folders to add dimensions - - Click to view nodes in that dimension +### Features -2. **Filtered View Mode** — Multi-dimension filtering with views - - Add multiple dimension filters - - Choose view layout (List, Grid, Kanban) - - Save views for quick access +- Browse dimension cards with counts and lock state +- Create new dimensions from the pane header +- Select a dimension to push that filter into the Nodes pane +- Manage dimension metadata and node grouping from one place --- -## Filtered View System - -### View Modes - -| Mode | Description | -|------|-------------| -| **List** | Nodes grouped by dimension with section headers | -| **Grid** | Cards in responsive grid, grouped by dimension | -| **Kanban** | Columns per dimension, drag to move between | - -### Compound Filters (AND Logic) - -Add secondary filters to columns: - -1. Add a filter (e.g., "inbox") -2. Click the `[+ AND]` button next to the dimension name -3. Select secondary dimension (e.g., "research") -4. Column now shows only nodes with BOTH dimensions - -### Saved Views - -Save filter + view combinations: - -1. Configure your filters and view mode -2. Click the save icon -3. Name your view -4. Access from the "Saved Views" dropdown - -### Drag-and-Drop - -- **Reorder nodes** within views -- **Move between Kanban columns** (updates dimensions) -- **Drag from nodes list** to dimension folders - ---- - -## Right Panel: Focus +## Focus Pane Active workspace for the node(s) you're working with. @@ -110,7 +83,7 @@ Active workspace for the node(s) you're working with. | Section | Content | |---------|---------| | **Header** | Title, node ID, trash icon | -| **Content** | Full markdown content with syntax highlighting | +| **Content** | Full markdown notes with node tokens and links | | **Metadata** | Created, updated, type, link | | **Dimensions** | Editable dimension tags | | **Connections** | Incoming/outgoing edges | @@ -142,7 +115,7 @@ Global search modal with 4-tier relevance: ## Settings Panel -**Access:** Settings cog icon (top-right) +**Access:** Settings item in the left navigation ### Tabs @@ -163,9 +136,11 @@ Global search modal with 4-tier relevance: Visual graph of your knowledge network. **Features:** -- Force-directed layout with pan/zoom +- Dimension View and Hub View modes +- Saved node positions per view mode +- Pan/zoom with fit controls and minimap - Node size proportional to edge count -- Top 15 nodes labeled (title + dimensions) +- Top nodes labeled by title and dimension color - Click node to highlight connections - Selection shows connected nodes in green @@ -186,6 +161,7 @@ Full table view of all nodes. - Filter by dimensions - Sort by updated/edges/created - Pagination +- Toolbar lives in the pane header for faster switching --- @@ -217,6 +193,7 @@ Each dimension can have a custom Lucide icon. | Shortcut | Action | |----------|--------| | `Cmd+K` | Open search | +| `Cmd+Shift+R` | Refresh all panes | | `Escape` | Close modals/overlays | --- diff --git a/src/components/layout/LeftToolbar.tsx b/src/components/layout/LeftToolbar.tsx index 5e255c6..2a98cc5 100644 --- a/src/components/layout/LeftToolbar.tsx +++ b/src/components/layout/LeftToolbar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from 'react'; +import { useState, type ReactNode } from 'react'; import { Search, Plus, @@ -11,6 +11,8 @@ import { Table2, BookOpen, Settings, + PanelLeftClose, + PanelLeftOpen, } from 'lucide-react'; import type { PaneType } from '../panes/types'; @@ -20,127 +22,74 @@ interface LeftToolbarProps { onRefreshClick: () => void; onSettingsClick: () => void; onPaneTypeClick: (paneType: PaneType) => void; - activePane: 'A' | 'B'; - slotAType: PaneType | null; - slotBType: PaneType | null; + isExpanded: boolean; + onToggleExpanded: () => void; + openTabTypes: Set; + activeTabType: PaneType | null; } -// Map pane types to their icons (chat removed in rah-light, guides moved to settings) -const PANE_TYPE_ICONS: Record = { - views: LayoutList, - map: Map, - dimensions: Folder, - table: Table2, - skills: BookOpen, -}; +const NAV_WIDTH_COLLAPSED = 50; +const NAV_WIDTH_EXPANDED = 280; -const PANE_TYPE_LABELS: Record = { - views: 'Feed', - map: 'Map', - dimensions: 'Dimensions', - table: 'Table', - skills: 'Skills', -}; +const VIEW_ITEMS: Array<{ paneType: PaneType; label: string; icon: typeof LayoutList }> = [ + { paneType: 'views', label: 'Nodes', icon: LayoutList }, + { paneType: 'skills', label: 'Skills', icon: BookOpen }, + { paneType: 'map', label: 'Map', icon: Map }, + { paneType: 'dimensions', label: 'Dimension', icon: Folder }, + { paneType: 'table', label: 'Table', icon: Table2 }, +]; -// Pane types shown in the toolbar center section (skills is pinned above settings) -const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'map', 'dimensions', 'table']; - -interface ToolbarButtonProps { +function NavButton({ + icon: Icon, + label, + expanded, + active, + onClick, + trailing, + activeTone = 'neutral', +}: { icon: typeof Search; label: string; - shortcut?: string; + expanded: boolean; + active?: boolean; onClick: () => void; - disabled?: boolean; - isActive?: boolean; -} - -function ToolbarButton({ icon: Icon, label, shortcut, onClick, disabled, isActive }: ToolbarButtonProps) { - const [isHovered, setIsHovered] = useState(false); + trailing?: ReactNode; + activeTone?: 'neutral' | 'green'; +}) { + const [hovered, setHovered] = useState(false); + const activeColor = activeTone === 'green' ? '#22c55e' : '#f0f0f0'; return ( - ); -} - -interface PaneTypeButtonProps { - icon: typeof LayoutList; - label: string; - paneType: PaneType; - isOpen: boolean; - isActivePane: boolean; - onClick: () => void; -} - -function PaneTypeButton({ icon: Icon, label, paneType, isOpen, isActivePane, onClick }: PaneTypeButtonProps) { - const [isHovered, setIsHovered] = useState(false); - - // Determine color: green if open, brighter if it's the active pane - const getColor = () => { - if (isOpen) { - return isActivePane ? '#4ade80' : '#22c55e'; // Brighter green for active - } - return isHovered ? '#aaa' : '#666'; - }; - - return ( - ); } @@ -151,97 +100,59 @@ export default function LeftToolbar({ onRefreshClick, onSettingsClick, onPaneTypeClick, - activePane, - slotAType, - slotBType, + isExpanded, + onToggleExpanded, + openTabTypes, + activeTabType, }: LeftToolbarProps) { - // Determine which pane types are currently open - const openPaneTypes = new Set( - [slotAType, slotBType].filter((t): t is PaneType => t !== null) - ); - - // Determine which pane type is in the active pane (null if pane is closed) - const activePaneType = activePane === 'A' ? slotAType : slotBType; - return (
- {/* Top section - Actions */} -
- - - + + +
+ + + +
+ +
+
+ {VIEW_ITEMS.map((item) => ( + onPaneTypeClick(item.paneType)} + activeTone="green" + /> + ))} +
+
- {/* Middle section - Pane Types */} -
- {TOOLBAR_PANE_TYPES.map((paneType) => { - const Icon = PANE_TYPE_ICONS[paneType]; - const label = PANE_TYPE_LABELS[paneType]; - const isOpen = openPaneTypes.has(paneType); - const isActivePane = activePaneType === paneType; - - return ( - onPaneTypeClick(paneType)} - /> - ); - })} -
- - {/* Bottom section - Skills + Settings */} -
- onPaneTypeClick('skills')} - /> - +
+
); diff --git a/src/components/layout/ThreePanelLayout.tsx b/src/components/layout/ThreePanelLayout.tsx index 9d8eb17..3968dae 100644 --- a/src/components/layout/ThreePanelLayout.tsx +++ b/src/components/layout/ThreePanelLayout.tsx @@ -45,16 +45,17 @@ export default function ThreePanelLayout() { // Slot states - the core of the flexible pane system // Default: Feed on left, closed on right (chat removed in rah-light) - const [slotA, setSlotA] = usePersistentState('ui.slotA.v4', { + const [slotA, setSlotA] = usePersistentState('ui.slotA.v5', { type: 'views', }); // SlotB can be null (closed) or a SlotState // Default: closed (chat removed in rah-light) - const [slotB, setSlotB] = usePersistentState('ui.slotB.v4', null); + const [slotB, setSlotB] = usePersistentState('ui.slotB.v5', null); // SlotB width as percentage (when open) const [slotBWidth, setSlotBWidth] = usePersistentState('ui.slotBWidth', 50); + const [leftNavExpanded, setLeftNavExpanded] = usePersistentState('ui.leftNavExpanded', false); // Migration: if a slot was persisted with type 'guides' (now moved to settings), reset it useEffect(() => { @@ -64,6 +65,12 @@ export default function ThreePanelLayout() { if (slotB && (slotB.type as string) === 'guides') { setSlotB(null); } + if (slotA?.type === 'node') { + setSlotA({ type: 'views' }); + } + if (slotB?.type === 'node') { + setSlotB(null); + } }, []); // eslint-disable-line react-hooks/exhaustive-deps // Track which pane is active (last interacted with) @@ -96,6 +103,7 @@ export default function ThreePanelLayout() { // Active dimension tracking const [activeDimension, setActiveDimension] = usePersistentState('ui.focus.activeDimension', null); + const [browseDimensionFilter, setBrowseDimensionFilter] = useState(null); // Delegations state (deprecated - kept for component compatibility) const [delegationsMap] = useState>({}); @@ -485,6 +493,37 @@ export default function ThreePanelLayout() { setNodesPanelRefresh(prev => prev + 1); }, []); + const openPaneSingleton = useCallback((paneType: PaneType) => { + if (paneType !== 'node') { + if (slotA?.type === paneType) { + setActivePane('A'); + return; + } + if (slotB?.type === paneType) { + setActivePane('B'); + return; + } + } + + if (!slotA) { + setSlotA({ type: paneType }); + setActivePane('A'); + return; + } + + if (!slotB) { + setSlotB({ type: paneType }); + setActivePane('B'); + return; + } + + if (activePane === 'A') { + setSlotA(prev => prev ? ({ ...prev, type: paneType }) : { type: paneType }); + } else { + setSlotB(prev => prev ? ({ ...prev, type: paneType }) : { type: paneType }); + } + }, [activePane, slotA, slotB, setSlotA, setSlotB]); + const handleNodeOpenFromDimensions = useCallback((nodeId: number) => { // Switch to node pane and open the node const currentTabs = slotA?.type === 'node' ? (slotA.nodeTabs || []) : []; @@ -499,35 +538,26 @@ export default function ThreePanelLayout() { setActivePane('A'); }, [slotA, setSlotA]); - // Handle pane type selection from toolbar - const handlePaneTypeClick = useCallback((paneType: PaneType) => { - // If no panes open → open in slot A - if (!slotA) { - setSlotA({ type: paneType }); - setActivePane('A'); - return; - } + const handleDimensionPaneSelect = useCallback((dimensionName: string | null) => { + setBrowseDimensionFilter(dimensionName); + setActiveDimension(dimensionName); - // If only one pane open → open second pane with this type - if (!slotB) { - setSlotB({ type: paneType }); + if (!dimensionName) return; + + if (activePane === 'B' && slotB?.type === 'dimensions') { + setSlotB({ type: 'views' }); setActivePane('B'); return; } - // Two panes open → replace the active pane - if (activePane === 'A') { - setSlotA(prev => prev ? ({ - ...prev, - type: paneType, - }) : { type: paneType }); - } else { - setSlotB(prev => prev ? ({ - ...prev, - type: paneType, - }) : { type: paneType }); - } - }, [activePane, slotA, slotB, setSlotA, setSlotB]); + setSlotA({ type: 'views' }); + setActivePane('A'); + }, [activePane, slotB, setSlotA, setSlotB, setActiveDimension]); + + // Handle pane type selection from toolbar + const handlePaneTypeClick = useCallback((paneType: PaneType) => { + openPaneSingleton(paneType); + }, [openPaneSingleton]); // Ensure the Feed pane is visible (for quick-add loading placeholders) const ensureFeedOpen = useCallback(() => { @@ -596,25 +626,19 @@ export default function ThreePanelLayout() { const handleSlotAAction = useCallback((action: PaneAction) => { switch (action.type) { case 'switch-pane-type': - setSlotA(prev => ({ - ...prev, - type: action.paneType, - })); + openPaneSingleton(action.paneType); break; case 'open-node': handleNodeSelect(action.nodeId, false); break; } - }, [handleNodeSelect, setSlotA]); + }, [handleNodeSelect, openPaneSingleton]); const handleSlotBAction = useCallback((action: PaneAction) => { if (!slotB) return; switch (action.type) { case 'switch-pane-type': - setSlotB(prev => prev ? ({ - ...prev, - type: action.paneType, - }) : null); + openPaneSingleton(action.paneType); break; case 'open-node': // Open node in slot B (if it's a node pane) @@ -636,7 +660,7 @@ export default function ThreePanelLayout() { } break; } - }, [slotB, setSlotB]); + }, [slotB, setSlotB, openPaneSingleton]); // Open a node directly in Slot B (for Alt+Click) const handleNodeOpenInSlotB = useCallback((nodeId: number) => { @@ -909,7 +933,7 @@ export default function ThreePanelLayout() { onNodeOpen={handleNodeOpenFromDimensions} refreshToken={folderViewRefresh} onDataChanged={handleFolderViewDataChanged} - onDimensionSelect={setActiveDimension} + onDimensionSelect={handleDimensionPaneSelect} /> ); @@ -942,6 +966,11 @@ export default function ThreePanelLayout() { refreshToken={nodesPanelRefresh} pendingNodes={pendingNodes} onDismissPending={(id) => setPendingNodes(prev => prev.filter(p => p.id !== id))} + externalDimensionFilter={browseDimensionFilter} + onClearExternalDimensionFilter={() => { + setBrowseDimensionFilter(null); + setActiveDimension(null); + }} /> ); @@ -998,9 +1027,10 @@ export default function ThreePanelLayout() { setShowSettings(true); }} onPaneTypeClick={handlePaneTypeClick} - activePane={activePane} - slotAType={slotA?.type ?? null} - slotBType={slotB?.type ?? null} + isExpanded={leftNavExpanded} + onToggleExpanded={() => setLeftNavExpanded(prev => !prev)} + openTabTypes={new Set([slotA?.type, slotB?.type].filter((t): t is PaneType => t != null))} + activeTabType={activePane === 'A' ? slotA?.type ?? null : slotB?.type ?? null} onRefreshClick={handleRefreshAll} /> diff --git a/src/components/nodes/FolderViewOverlay.tsx b/src/components/nodes/FolderViewOverlay.tsx index 61e0317..595d1d3 100644 --- a/src/components/nodes/FolderViewOverlay.tsx +++ b/src/components/nodes/FolderViewOverlay.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useMemo, useState, useRef, type DragEvent } from 'react'; +import { createPortal } from 'react-dom'; import { Check, X, ArrowLeft, Plus, Trash2, Edit2, Lock } from 'lucide-react'; import type { Node } from '@/types/database'; import ConfirmDialog from '../common/ConfirmDialog'; @@ -23,11 +24,12 @@ interface FolderViewOverlayProps { refreshToken: number; onDataChanged?: () => void; onDimensionSelect?: (dimensionName: string | null) => void; + toolbarHost?: HTMLDivElement | null; } const PAGE_SIZE = 100; -export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, onDataChanged, onDimensionSelect: _onDimensionSelect }: FolderViewOverlayProps) { +export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, onDataChanged, onDimensionSelect, toolbarHost }: FolderViewOverlayProps) { const [view, setView] = useState<'dimensions' | 'nodes'>('dimensions'); const [dimensions, setDimensions] = useState([]); const [dimensionsLoading, setDimensionsLoading] = useState(true); @@ -202,11 +204,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o }; const handleSelectDimension = (dimension: DimensionSummary) => { - setSelectedDimension(dimension); - setNodes([]); - setNodeOffset(0); - setHasMoreNodes(false); - setView('nodes'); + onDimensionSelect?.(dimension.dimension); }; const handleBackToDimensions = () => { @@ -215,6 +213,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o setNodes([]); setNodeOffset(0); setHasMoreNodes(false); + onDimensionSelect?.(null); }; const handleAddDimension = async (name: string) => { @@ -1563,6 +1562,96 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o ); }; + const toolbar = ( +
+
+ {view === 'nodes' && ( + + )} + {view === 'nodes' && ( +
+ {selectedDimension?.dimension ?? ''} +
+ )} +
+ +
+ {view === 'dimensions' && ( + + )} + + {!toolbarHost && ( + + )} +
+
+ ); + return ( <>
- {/* Header */} -
- {/* Top row: Mode tabs + Actions */} -
-
- {/* Back button when viewing nodes in a dimension */} - {view === 'nodes' && ( - - )} - - {view === 'dimensions' && ( -
- - Dimensions -
- )} - - {/* Title when viewing nodes in a dimension */} - {view === 'nodes' && ( -
- Nodes – - {selectedDimension?.dimension ?? ''} -
- )} - -
- -
- {/* Add Dimension button */} - {view === 'dimensions' && ( - - )} - - - -
+ {toolbarHost ? createPortal(toolbar, toolbarHost) : ( +
+ {toolbar}
- -
+ )} {/* Content */} {view === 'nodes' ? renderNodeGrid() : renderDimensionGrid()} diff --git a/src/components/panes/DimensionsPane.tsx b/src/components/panes/DimensionsPane.tsx index 67e7c6d..7e5a722 100644 --- a/src/components/panes/DimensionsPane.tsx +++ b/src/components/panes/DimensionsPane.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from 'react'; import FolderViewOverlay from '@/components/nodes/FolderViewOverlay'; import PaneHeader from './PaneHeader'; import { DimensionsPaneProps, PaneType } from './types'; @@ -10,11 +11,13 @@ export default function DimensionsPane({ onPaneAction, onCollapse, onSwapPanes, + tabBar, onNodeOpen, refreshToken, onDataChanged, onDimensionSelect, }: DimensionsPaneProps) { + const [toolbarHost, setToolbarHost] = useState(null); const handleTypeChange = (type: PaneType) => { onPaneAction?.({ type: 'switch-pane-type', paneType: type }); }; @@ -32,7 +35,13 @@ export default function DimensionsPane({ background: 'transparent', overflow: 'hidden', }}> - +
{/* FolderViewOverlay expects to be an overlay, so we wrap it in a container */} @@ -47,6 +56,7 @@ export default function DimensionsPane({ refreshToken={refreshToken} onDataChanged={onDataChanged} onDimensionSelect={onDimensionSelect} + toolbarHost={toolbarHost} />
diff --git a/src/components/panes/MapPane.tsx b/src/components/panes/MapPane.tsx index 9dc126e..b5a9f70 100644 --- a/src/components/panes/MapPane.tsx +++ b/src/components/panes/MapPane.tsx @@ -4,7 +4,6 @@ import { useEffect, useMemo, useRef, useState, useCallback, type CSSProperties } import { ReactFlow, Background, - MiniMap, useNodesState, useEdgesState, addEdge as rfAddEdge, @@ -22,10 +21,11 @@ import PaneHeader from './PaneHeader'; import type { MapPaneProps } from './types'; import { ChevronDown } from 'lucide-react'; +import { MiniMap } from '@xyflow/react'; import { RahNode } from './map/RahNode'; import { RahEdge } from './map/RahEdge'; import EdgeExplanationModal from './map/EdgeExplanationModal'; -import { toRFNodes, toRFEdges, NODE_LIMIT, type RahNodeData } from './map/utils'; +import { getPrimaryDimension, toRFNodes, toRFEdges, NODE_LIMIT, type MapViewMode, type RahNodeData } from './map/utils'; import { useDimensionIcons } from '@/context/DimensionIconsContext'; import './map/map-styles.css'; @@ -74,6 +74,7 @@ function MapPaneInner({ // --- UI state --- const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedDimension, setSelectedDimension] = useState(null); + const [viewMode, setViewMode] = useState('dimension'); const [dimensionDropdownOpen, setDimensionDropdownOpen] = useState(false); const dropdownRef = useRef(null); @@ -86,6 +87,7 @@ function MapPaneInner({ // Track current RF positions so we can preserve them across data refreshes const rfPositionsRef = useRef>(new Map()); + const hasInitialFitRef = useRef(false); // Combine base + expanded const allDbNodes = useMemo(() => { @@ -115,6 +117,26 @@ function MapPaneInner({ [lockedDimensions], ); + const clusterLabels = useMemo(() => { + if (viewMode !== 'dimension') { + return []; + } + const grouped = new Map(); + for (const node of rfNodes) { + const dimension = getPrimaryDimension((node.data as RahNodeData).dimensions); + const current = grouped.get(dimension) || { x: 0, y: 0, count: 0 }; + current.x += node.position.x; + current.y += node.position.y; + current.count += 1; + grouped.set(dimension, current); + } + return [...grouped.entries()].map(([dimension, totals]) => ({ + dimension, + x: totals.x / totals.count, + y: totals.y / totals.count - 84, + })); + }, [rfNodes, viewMode]); + // ----- Close dropdown on outside click ----- useEffect(() => { if (!dimensionDropdownOpen) return; @@ -157,9 +179,7 @@ function MapPaneInner({ if (dimsRes.ok) { const dimsPayload = await dimsRes.json(); if (dimsPayload.success && dimsPayload.data) { - setLockedDimensions( - (dimsPayload.data as DimensionInfo[]).filter(d => d.isPriority), - ); + setLockedDimensions(dimsPayload.data as DimensionInfo[]); } } } catch (err) { @@ -196,6 +216,8 @@ function MapPaneInner({ connectedNodeIds, rfPositionsRef.current, dimensionIcons, + viewMode, + dbEdges, ); const nodeIdSet = new Set(newRfNodes.map(n => n.id)); @@ -203,7 +225,47 @@ function MapPaneInner({ setRfNodes(newRfNodes); setRfEdges(newRfEdges); - }, [allDbNodes, baseNodes, expandedNodes, dbEdges, selectedNodeId, connectedNodeIds]); + }, [allDbNodes, baseNodes, expandedNodes, dbEdges, selectedNodeId, connectedNodeIds, dimensionIcons, viewMode]); + + useEffect(() => { + if (hasInitialFitRef.current || rfNodes.length === 0 || loading) return; + hasInitialFitRef.current = true; + const hubNodeIds = baseNodes + .sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0)) + .slice(0, 25) + .map(n => String(n.id)); + setTimeout(() => { + if (hubNodeIds.length > 0) { + reactFlowInstance.fitView({ + nodes: hubNodeIds.map(id => ({ id })), + padding: 0.3, + duration: 300, + }); + } + }, 100); + }, [rfNodes, loading, baseNodes, reactFlowInstance]); + + useEffect(() => { + hasInitialFitRef.current = false; + }, [selectedDimension, viewMode]); + + const fitAllNodes = useCallback(() => { + reactFlowInstance.fitView({ padding: 0.2, duration: 300 }); + }, [reactFlowInstance]); + + const fitHubNodes = useCallback(() => { + const hubNodeIds = baseNodes + .sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0)) + .slice(0, 25) + .map(n => String(n.id)); + if (hubNodeIds.length > 0) { + reactFlowInstance.fitView({ + nodes: hubNodeIds.map(id => ({ id })), + padding: 0.3, + duration: 300, + }); + } + }, [baseNodes, reactFlowInstance]); // ----- Node traversal: fetch connected nodes ----- const fetchConnectedNodes = useCallback(async (nodeId: number) => { @@ -379,7 +441,7 @@ function MapPaneInner({ // ----- Node drag → save position to metadata (debounced) ----- const savePositionRef = useRef( - debounce(async (nodeId: number, x: number, y: number) => { + debounce(async (nodeId: number, x: number, y: number, mode: MapViewMode) => { try { const res = await fetch(`/api/nodes/${nodeId}`); if (!res.ok) return; @@ -393,7 +455,14 @@ function MapPaneInner({ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - metadata: { ...mergedMeta, map_position: { x, y } }, + metadata: { + ...mergedMeta, + map_positions: { + ...(mergedMeta.map_positions || {}), + [mode]: { x, y }, + }, + [`map_position_${mode}`]: { x, y }, + }, }), }); } catch (err) { @@ -406,9 +475,21 @@ function MapPaneInner({ const nodeId = parseInt(node.id); if (!isNaN(nodeId)) { rfPositionsRef.current.set(node.id, node.position); - savePositionRef.current(nodeId, node.position.x, node.position.y); + savePositionRef.current(nodeId, node.position.x, node.position.y, viewMode); } - }, []); + }, [viewMode]); + + useEffect(() => { + rfPositionsRef.current.clear(); + }, [viewMode]); + + useEffect(() => { + if (loading || rfNodes.length === 0) return; + const timeout = setTimeout(() => { + reactFlowInstance.fitView({ padding: 0.22, duration: 300 }); + }, 80); + return () => clearTimeout(timeout); + }, [viewMode, selectedDimension, loading, rfNodes, reactFlowInstance]); // ----- Node click → select + traverse ----- const onNodeClickHandler: NodeMouseHandler> = useCallback((_event, node) => { @@ -481,6 +562,39 @@ function MapPaneInner({ return (
+
+ + +
+ {/* Dimension filter dropdown */}
+ {viewMode === 'hub' && ( + + )} +
+ {/* Selected node info panel */} {selectedDbNode && (
diff --git a/src/components/panes/PaneHeader.tsx b/src/components/panes/PaneHeader.tsx index 3161126..a6c0eaf 100644 --- a/src/components/panes/PaneHeader.tsx +++ b/src/components/panes/PaneHeader.tsx @@ -8,7 +8,9 @@ export default function PaneHeader({ slot, onCollapse, onSwapPanes, - children + tabBar, + children, + toolbarHostRef, }: PaneHeaderProps) { const [isDragging, setIsDragging] = useState(false); const [isDragOver, setIsDragOver] = useState(false); @@ -56,32 +58,53 @@ export default function PaneHeader({ style={{ display: 'flex', alignItems: 'center', - gap: '8px', - padding: '8px 12px', + gap: '10px', background: isDragOver ? 'rgba(34, 197, 94, 0.1)' : 'transparent', - minHeight: '44px', cursor: slot && onSwapPanes ? 'grab' : 'default', opacity: isDragging ? 0.5 : 1, borderRadius: isDragOver ? '6px' : '0', transition: 'background 0.15s ease', + padding: '8px 12px', + minHeight: '48px', }} > - {/* Children (tabs, etc.) - takes up available space */} -
- {children} +
+ {tabBar ? ( +
+ {tabBar} +
+ ) : null} + {children ? ( +
+ {children} +
+ ) : null} + {toolbarHostRef ? ( +
+ ) : null} + {!tabBar && !children && !toolbarHostRef ? ( +
+ ) : null}
- {/* Close button (when onCollapse is provided) */} {onCollapse && ( )}
diff --git a/src/components/panes/TablePane.tsx b/src/components/panes/TablePane.tsx index 9b78cf3..6642e24 100644 --- a/src/components/panes/TablePane.tsx +++ b/src/components/panes/TablePane.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from 'react'; import PaneHeader from './PaneHeader'; import DatabaseTableView from '../views/DatabaseTableView'; import type { BasePaneProps } from './types'; @@ -19,6 +20,7 @@ export default function TablePane({ onNodeClick, refreshToken }: TablePaneProps) { + const [toolbarHost, setToolbarHost] = useState(null); return (
- +
diff --git a/src/components/panes/ViewsPane.tsx b/src/components/panes/ViewsPane.tsx index ce9cc94..b866032 100644 --- a/src/components/panes/ViewsPane.tsx +++ b/src/components/panes/ViewsPane.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from 'react'; import PaneHeader from './PaneHeader'; import ViewsOverlay from '../views/ViewsOverlay'; import type { BasePaneProps, PaneAction, PaneType } from './types'; @@ -11,6 +12,8 @@ export interface ViewsPaneProps extends BasePaneProps { refreshToken?: number; pendingNodes?: PendingNode[]; onDismissPending?: (id: string) => void; + externalDimensionFilter?: string | null; + onClearExternalDimensionFilter?: () => void; } export default function ViewsPane({ @@ -19,12 +22,16 @@ export default function ViewsPane({ onPaneAction, onCollapse, onSwapPanes, + tabBar, onNodeClick, onNodeOpenInOtherPane, refreshToken, pendingNodes, onDismissPending, + externalDimensionFilter, + onClearExternalDimensionFilter, }: ViewsPaneProps) { + const [toolbarHost, setToolbarHost] = useState(null); const handleTypeChange = (type: PaneType) => { onPaneAction?.({ type: 'switch-pane-type', paneType: type }); }; @@ -37,7 +44,13 @@ export default function ViewsPane({ background: 'transparent', overflow: 'hidden' }}> - +
diff --git a/src/components/panes/map/utils.ts b/src/components/panes/map/utils.ts index b8fd153..ac3d96b 100644 --- a/src/components/panes/map/utils.ts +++ b/src/components/panes/map/utils.ts @@ -15,6 +15,8 @@ const DIMENSION_COLORS = [ '#a855f7', // purple ]; +export type MapViewMode = 'dimension' | 'hub'; + function hashDimensionColor(dimension: string): string { let hash = 0; for (let i = 0; i < dimension.length; i++) { @@ -23,10 +25,21 @@ function hashDimensionColor(dimension: string): string { return DIMENSION_COLORS[Math.abs(hash) % DIMENSION_COLORS.length]; } +export function getOrderedDimensions(dimensions: string[] | undefined): string[] { + if (!dimensions || dimensions.length === 0) { + return []; + } + return [...dimensions].sort((a, b) => a.localeCompare(b)); +} + +export function getPrimaryDimension(dimensions: string[] | undefined): string { + const ordered = getOrderedDimensions(dimensions); + return ordered[0] || 'Unsorted'; +} + export function getDimensionColor(dimensions: string[] | undefined): string | undefined { - if (!dimensions || dimensions.length === 0) return undefined; - // Use first dimension for border color - return hashDimensionColor(dimensions[0]); + const primary = getPrimaryDimension(dimensions); + return primary === 'Unsorted' ? '#4b5563' : hashDimensionColor(primary); } export interface RahNodeData { @@ -37,74 +50,155 @@ export interface RahNodeData { dbNode: DbNode; dimensionIcons?: Record; primaryDimensionColor?: string; + clusterKey?: string; [key: string]: unknown; } -const NODE_LIMIT = 200; -const LABEL_THRESHOLD = 15; +export const NODE_LIMIT = 200; +export const LABEL_THRESHOLD = 15; -export { NODE_LIMIT, LABEL_THRESHOLD }; - -/** - * Get node position from saved metadata or calculate using Fibonacci spiral. - */ -export function getNodePosition( +export function getSavedMapPosition( node: DbNode, - index: number, - total: number, - centerX: number, - centerY: number, - maxEdges: number, -): { x: number; y: number } { - // Check for saved position in metadata + viewMode: MapViewMode, +): { x: number; y: number } | null { const metadata = typeof node.metadata === 'string' ? safeParseJSON(node.metadata) : node.metadata; - const savedPos = metadata?.map_position; - if (savedPos?.x !== undefined && savedPos?.y !== undefined) { - return { x: savedPos.x, y: savedPos.y }; + const nested = metadata?.map_positions as Record | undefined; + const scoped = metadata?.[`map_position_${viewMode}`] as { x?: number; y?: number } | undefined; + const saved = nested?.[viewMode] || scoped; + if (saved?.x !== undefined && saved?.y !== undefined) { + return { x: saved.x, y: saved.y }; + } + return null; +} + +function getAllNodes(baseNodes: DbNode[], expandedNodes: DbNode[]): DbNode[] { + const baseIds = new Set(baseNodes.map((node) => node.id)); + return [...baseNodes, ...expandedNodes.filter((node) => !baseIds.has(node.id))]; +} + +function buildDimensionLayout(nodes: DbNode[], centerX: number, centerY: number): Map { + const positions = new Map(); + const groups = new Map(); + for (const node of nodes) { + const key = getPrimaryDimension(node.dimensions); + const existing = groups.get(key) || []; + existing.push(node); + groups.set(key, existing); } - // Fibonacci spiral layout - const edgeCount = node.edge_count ?? 0; - const edgeRatio = maxEdges > 0 ? edgeCount / maxEdges : 0; + const clusterKeys = [...groups.keys()].sort((a, b) => a.localeCompare(b)); + const columns = Math.max(1, Math.ceil(Math.sqrt(clusterKeys.length || 1))); + const clusterGapX = 360; + const clusterGapY = 280; + const originX = centerX - ((columns - 1) * clusterGapX) / 2; + const rows = Math.max(1, Math.ceil(clusterKeys.length / columns)); + const originY = centerY - ((rows - 1) * clusterGapY) / 2; - const goldenAngle = Math.PI * (3 - Math.sqrt(5)); - const angle = index * goldenAngle; + clusterKeys.forEach((clusterKey, clusterIndex) => { + const clusterNodes = (groups.get(clusterKey) || []).sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0)); + const clusterColumn = clusterIndex % columns; + const clusterRow = Math.floor(clusterIndex / columns); + const clusterCenterX = originX + clusterColumn * clusterGapX; + const clusterCenterY = originY + clusterRow * clusterGapY; + const clusterCols = Math.max(1, Math.ceil(Math.sqrt(clusterNodes.length))); - const isLabeled = index < LABEL_THRESHOLD; - const labelSpacing = isLabeled ? 60 : 0; - const containerSize = Math.min(centerX * 2, centerY * 2); - const baseDistance = 80 + labelSpacing + (1 - edgeRatio) * containerSize * 0.35; - const distance = baseDistance + index * 4; + clusterNodes.forEach((node, index) => { + const col = index % clusterCols; + const row = Math.floor(index / clusterCols); + const x = clusterCenterX + (col - (clusterCols - 1) / 2) * 120 + (row % 2 === 0 ? 0 : 18); + const y = clusterCenterY + row * 96; + positions.set(String(node.id), { x, y }); + }); + }); - return { - x: centerX + Math.cos(angle) * distance, - y: centerY + Math.sin(angle) * distance, - }; + return positions; } -/** - * Position expanded (traversal) nodes in a circle around a reference node. - */ -export function getExpandedNodePosition( - index: number, - total: number, - refX: number, - refY: number, -): { x: number; y: number } { - const angle = (index / Math.max(total, 1)) * Math.PI * 2; - const distance = 150 + (index % 3) * 40; - return { - x: refX + Math.cos(angle) * distance, - y: refY + Math.sin(angle) * distance, - }; +function buildHubLayout( + nodes: DbNode[], + dbEdges: DbEdge[], + centerX: number, + centerY: number, +): Map { + const positions = new Map(); + const sortedNodes = [...nodes].sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0)); + const hubCount = Math.max(1, Math.min(10, Math.ceil(Math.sqrt(sortedNodes.length || 1)))); + const hubs = sortedNodes.slice(0, hubCount); + const hubIds = new Set(hubs.map((node) => node.id)); + + const adjacency = new Map(); + for (const edge of dbEdges) { + const from = adjacency.get(edge.from_node_id) || []; + from.push(edge.to_node_id); + adjacency.set(edge.from_node_id, from); + + const to = adjacency.get(edge.to_node_id) || []; + to.push(edge.from_node_id); + adjacency.set(edge.to_node_id, to); + } + + const clusterMembers = new Map(); + for (const hub of hubs) { + clusterMembers.set(hub.id, [hub]); + } + + const orphanNodes: DbNode[] = []; + for (const node of sortedNodes) { + if (hubIds.has(node.id)) continue; + const neighbours = adjacency.get(node.id) || []; + const connectedHub = hubs + .filter((hub) => neighbours.includes(hub.id)) + .sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0))[0]; + + if (connectedHub) { + const members = clusterMembers.get(connectedHub.id) || [connectedHub]; + members.push(node); + clusterMembers.set(connectedHub.id, members); + } else { + orphanNodes.push(node); + } + } + + const hubRadius = Math.max(160, hubCount * 42); + hubs.forEach((hub, index) => { + const angle = (index / hubCount) * Math.PI * 2 - Math.PI / 2; + const hubX = centerX + Math.cos(angle) * hubRadius; + const hubY = centerY + Math.sin(angle) * hubRadius; + positions.set(String(hub.id), { x: hubX, y: hubY }); + + const members = (clusterMembers.get(hub.id) || []).filter((member) => member.id !== hub.id); + members.forEach((member, memberIndex) => { + const memberAngle = (memberIndex / Math.max(members.length, 1)) * Math.PI * 2; + const ringRadius = 115 + Math.floor(memberIndex / 10) * 46; + positions.set(String(member.id), { + x: hubX + Math.cos(memberAngle) * ringRadius, + y: hubY + Math.sin(memberAngle) * ringRadius, + }); + }); + }); + + orphanNodes.forEach((node, index) => { + const columns = Math.max(1, Math.ceil(Math.sqrt(orphanNodes.length))); + const col = index % columns; + const row = Math.floor(index / columns); + positions.set(String(node.id), { + x: centerX - 220 + col * 110, + y: centerY + hubRadius + 140 + row * 90, + }); + }); + + return positions; +} + +export function getClusterKey(node: DbNode, viewMode: MapViewMode, dbEdges: DbEdge[]): string { + if (viewMode === 'dimension') { + return getPrimaryDimension(node.dimensions); + } + return `hub:${node.id}`; } -/** - * Transform DB nodes into React Flow nodes. - * When a node is selected, non-connected nodes get dimmed via className. - */ export function toRFNodes( baseNodes: DbNode[], expandedNodes: DbNode[], @@ -113,20 +207,23 @@ export function toRFNodes( selectedNodeId: number | null, connectedNodeIds: Set, existingPositions: Map, - dimensionIcons?: Record, + dimensionIcons: Record | undefined, + viewMode: MapViewMode, + dbEdges: DbEdge[], ): RFNode[] { - const sortedBase = [...baseNodes].sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0)); - const maxEdges = Math.max(...sortedBase.map(n => n.edge_count ?? 0), 1); - const baseNodeIds = new Set(baseNodes.map(n => n.id)); + const allNodes = getAllNodes(baseNodes, expandedNodes); const hasSelection = selectedNodeId !== null; + const clusterLayout = viewMode === 'dimension' + ? buildDimensionLayout(allNodes, centerX, centerY) + : buildHubLayout(allNodes, dbEdges, centerX, centerY); + const baseNodeIds = new Set(baseNodes.map((node) => node.id)); - const rfNodes: RFNode[] = sortedBase.map((node, index) => { + return allNodes.map((node) => { const id = String(node.id); - // Prefer React Flow's current position (for drag state), then saved, then calculated const existingPos = existingPositions.get(id); - const pos = existingPos || getNodePosition(node, index, sortedBase.length, centerX, centerY, maxEdges); - - // Dim nodes that aren't selected or connected to selection + const savedPos = getSavedMapPosition(node, viewMode); + const fallbackPos = clusterLayout.get(id) || { x: centerX, y: centerY }; + const pos = existingPos || savedPos || fallbackPos; const isDimmed = hasSelection && node.id !== selectedNodeId && !connectedNodeIds.has(node.id); return { @@ -136,64 +233,16 @@ export function toRFNodes( className: isDimmed ? 'dimmed' : undefined, data: { label: node.title || 'Untitled', - dimensions: node.dimensions || [], + dimensions: getOrderedDimensions(node.dimensions), edgeCount: node.edge_count ?? 0, - isExpanded: false, + isExpanded: !baseNodeIds.has(node.id), dbNode: node, dimensionIcons, primaryDimensionColor: getDimensionColor(node.dimensions), + clusterKey: viewMode === 'dimension' ? getPrimaryDimension(node.dimensions) : undefined, }, }; }); - - // Add expanded nodes not already in base - const uniqueExpanded = expandedNodes.filter(n => !baseNodeIds.has(n.id)); - - // Find reference position for expanded nodes (the selected node) - let refX = centerX; - let refY = centerY; - if (selectedNodeId) { - const selectedRF = rfNodes.find(n => n.id === String(selectedNodeId)); - if (selectedRF) { - refX = selectedRF.position.x; - refY = selectedRF.position.y; - } - } - - uniqueExpanded.forEach((node, index) => { - const id = String(node.id); - const existingPos = existingPositions.get(id); - - // Check for saved metadata position - const metadata = typeof node.metadata === 'string' - ? safeParseJSON(node.metadata) - : node.metadata; - const savedPos = metadata?.map_position; - - const pos = existingPos - || (savedPos?.x !== undefined ? { x: savedPos.x, y: savedPos.y } : null) - || getExpandedNodePosition(index, uniqueExpanded.length, refX, refY); - - const isDimmed = hasSelection && node.id !== selectedNodeId && !connectedNodeIds.has(node.id); - - rfNodes.push({ - id, - type: 'rahNode', - position: pos, - className: isDimmed ? 'dimmed' : undefined, - data: { - label: node.title || 'Untitled', - dimensions: node.dimensions || [], - edgeCount: node.edge_count ?? 0, - isExpanded: true, - dbNode: node, - dimensionIcons, - primaryDimensionColor: getDimensionColor(node.dimensions), - }, - }); - }); - - return rfNodes; } /** diff --git a/src/components/panes/types.ts b/src/components/panes/types.ts index b88ca93..f9247c9 100644 --- a/src/components/panes/types.ts +++ b/src/components/panes/types.ts @@ -87,6 +87,8 @@ export interface ViewsPaneProps extends BasePaneProps { onNodeClick: (nodeId: number) => void; onNodeOpenInOtherPane?: (nodeId: number) => void; refreshToken?: number; + externalDimensionFilter?: string | null; + onClearExternalDimensionFilter?: () => void; } // TablePane specific props @@ -102,6 +104,7 @@ export interface PaneHeaderProps { onSwapPanes?: () => void; tabBar?: React.ReactNode; children?: React.ReactNode; + toolbarHostRef?: (node: HTMLDivElement | null) => void; } // Labels for pane types diff --git a/src/components/views/DatabaseTableView.tsx b/src/components/views/DatabaseTableView.tsx index 0c41e09..097fe44 100644 --- a/src/components/views/DatabaseTableView.tsx +++ b/src/components/views/DatabaseTableView.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; +import { createPortal } from 'react-dom'; import { Filter, ChevronDown, ChevronLeft, ChevronRight, X, ArrowUpDown, Search, ExternalLink } from 'lucide-react'; import type { Node } from '@/types/database'; @@ -24,6 +25,7 @@ interface DimensionSummary { interface DatabaseTableViewProps { onNodeClick: (nodeId: number) => void; refreshToken?: number; + toolbarHost?: HTMLDivElement | null; } function formatRelativeTime(dateStr: string): string { @@ -56,7 +58,7 @@ function formatDate(dateStr: string | null | undefined): string { } } -export default function DatabaseTableView({ onNodeClick, refreshToken = 0 }: DatabaseTableViewProps) { +export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolbarHost }: DatabaseTableViewProps) { const [nodes, setNodes] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); @@ -161,18 +163,15 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0 }: Dat const startItem = (page - 1) * PAGE_SIZE + 1; const endItem = Math.min(page * PAGE_SIZE, total); - return ( -
- {/* Compact toolbar */} -
- {/* Search */} -
+ const toolbar = ( +
+
+ ); + + return ( +
+ {toolbarHost ? createPortal(toolbar, toolbarHost) : ( +
+ {toolbar} +
+ )} {/* Table */}
diff --git a/src/components/views/ViewsOverlay.tsx b/src/components/views/ViewsOverlay.tsx index cf2f9a7..0caa2df 100644 --- a/src/components/views/ViewsOverlay.tsx +++ b/src/components/views/ViewsOverlay.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useMemo, useState, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; import { Filter, ChevronDown, X, ArrowUpDown, GripVertical } from 'lucide-react'; import type { Node } from '@/types/database'; import { getNodeIcon } from '@/utils/nodeIcons'; @@ -132,9 +133,21 @@ interface ViewsOverlayProps { refreshToken?: number; pendingNodes?: PendingNode[]; onDismissPending?: (id: string) => void; + externalDimensionFilter?: string | null; + onClearExternalDimensionFilter?: () => void; + toolbarHost?: HTMLDivElement | null; } -export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refreshToken = 0, pendingNodes, onDismissPending }: ViewsOverlayProps) { +export default function ViewsOverlay({ + onNodeClick, + onNodeOpenInOtherPane, + refreshToken = 0, + pendingNodes, + onDismissPending, + externalDimensionFilter = null, + onClearExternalDimensionFilter, + toolbarHost, +}: ViewsOverlayProps) { const { dimensionIcons } = useDimensionIcons(); // Dimensions for filter picker @@ -160,10 +173,12 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre const [showSortDropdown, setShowSortDropdown] = useState(false); // Derive selectedFilters for backward compatibility (unique dimensions) - const selectedFilters = useMemo(() => - [...new Set(columns.map(c => c.dimension))], - [columns] - ); + const selectedFilters = useMemo(() => { + if (externalDimensionFilter) { + return [externalDimensionFilter]; + } + return [...new Set(columns.map(c => c.dimension))]; + }, [columns, externalDimensionFilter]); // Sorted dimensions (locked first) const sortedDimensions = useMemo(() => { @@ -293,6 +308,9 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre // Column management const addColumn = (dimension: string) => { + if (externalDimensionFilter) { + return; + } const newColumn: ColumnFilter = { id: `col-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, dimension @@ -303,6 +321,10 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre }; const removeFilter = (dimension: string) => { + if (externalDimensionFilter && dimension === externalDimensionFilter) { + onClearExternalDimensionFilter?.(); + return; + } const idx = columns.findIndex(c => c.dimension === dimension); if (idx !== -1) { setColumns(columns.filter((_, i) => i !== idx)); @@ -310,6 +332,9 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre }; const clearFilters = () => { + if (externalDimensionFilter) { + onClearExternalDimensionFilter?.(); + } setColumns([]); }; @@ -552,24 +577,15 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre ); }; - return ( + const toolbar = (
- {/* Header with filters + sort */} -
- {/* Filter chips + add filter button */} -
+
{selectedFilters.map(filter => (
))} - {/* Add filter button */} + {externalDimensionFilter && ( + + Sidebar filter + + )} +
{/* Filter picker dropdown */} - {showFilterPicker && ( + {showFilterPicker && !externalDimensionFilter && (
+ ); + + return ( +
+ {toolbarHost ? createPortal(toolbar, toolbarHost) : ( +
+ {toolbar} +
+ )} {/* Content area — list view */} {filteredNodesLoading ? ( diff --git a/src/services/database/chunks.ts b/src/services/database/chunks.ts index f2b76a5..ccbf817 100644 --- a/src/services/database/chunks.ts +++ b/src/services/database/chunks.ts @@ -5,7 +5,8 @@ type RankedChunk = Chunk & { similarity: number }; function sanitizeFtsQuery(input: string): string { return input - .replace(/['"()*:^~{}[\]]/g, ' ') + .toLowerCase() + .replace(/[^a-z0-9\s]+/g, ' ') .trim() .split(/\s+/) .filter(word => word.length > 0 && !/^(AND|OR|NOT|NEAR)$/i.test(word)) diff --git a/src/services/database/nodes.ts b/src/services/database/nodes.ts index e15a339..7246865 100644 --- a/src/services/database/nodes.ts +++ b/src/services/database/nodes.ts @@ -8,7 +8,8 @@ type NodeSearchRow = NodeRow & { rank?: number; similarity?: number }; function sanitizeFtsQuery(input: string): string { return input - .replace(/['"()*:^~{}[\]]/g, ' ') + .toLowerCase() + .replace(/[^a-z0-9\s]+/g, ' ') .trim() .split(/\s+/) .filter(word => word.length > 0 && !/^(AND|OR|NOT|NEAR)$/i.test(word)) diff --git a/src/tools/database/deleteNode.ts b/src/tools/database/deleteNode.ts new file mode 100644 index 0000000..838a8c7 --- /dev/null +++ b/src/tools/database/deleteNode.ts @@ -0,0 +1,48 @@ +import { tool } from 'ai'; +import { z } from 'zod'; +import { getInternalApiBaseUrl } from '@/services/runtime/apiBase'; + +export const deleteNodeTool = tool({ + description: 'Delete a node from the graph by ID', + inputSchema: z.object({ + id: z.number().int().positive().describe('Node ID to delete'), + }), + execute: async ({ id }) => { + try { + const response = await fetch(`${getInternalApiBaseUrl()}/api/nodes/${id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + let errorMessage = 'Failed to delete node'; + try { + const errorResult = await response.json(); + errorMessage = errorResult.error || errorMessage; + } catch { + errorMessage = `Failed to delete node: ${response.status} ${response.statusText}`; + } + + return { + success: false, + error: errorMessage, + data: null, + }; + } + + const result = await response.json(); + + return { + success: true, + data: { nodeId: id, ...(result.data || {}) }, + message: result.message || `Node ${id} deleted successfully`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete node', + data: null, + }; + } + }, +}); diff --git a/src/tools/database/queryNodes.ts b/src/tools/database/queryNodes.ts index a4e248f..67f9394 100644 --- a/src/tools/database/queryNodes.ts +++ b/src/tools/database/queryNodes.ts @@ -4,8 +4,67 @@ import { nodeService } from '@/services/database/nodes'; import { formatNodeForChat } from '../infrastructure/nodeFormatter'; import type { Node } from '@/types/database'; +type QueryNodeFilters = { + dimensions?: string[]; + search?: string; + limit?: number; + createdAfter?: string; + createdBefore?: string; + eventAfter?: string; + eventBefore?: string; +}; + +function extractSearchTerms(query: string): string[] { + const stopWords = new Set(['a', 'an', 'and', 'for', 'from', 'in', 'of', 'on', 'or', 'recent', 'the', 'to', 'with']); + + const rawTerms = query + .toLowerCase() + .replace(/[^a-z0-9\s]+/g, ' ') + .split(/\s+/) + .map(term => term.trim()) + .filter(Boolean); + + const terms = new Set(); + for (const term of rawTerms) { + if (!stopWords.has(term) && term.length >= 3) { + terms.add(term); + } + const alphaParts = term.replace(/\d+/g, ' ').split(/\s+/).filter(Boolean); + for (const part of alphaParts) { + if (!stopWords.has(part) && part.length >= 3) { + terms.add(part); + } + } + } + + return Array.from(terms).slice(0, 8); +} + +function scoreNodeForSearch(node: Node, searchTerm: string): number { + const normalizedSearch = searchTerm.toLowerCase(); + const title = (node.title || '').toLowerCase(); + const description = (node.description || '').toLowerCase(); + const notes = (node.notes || '').toLowerCase(); + const terms = extractSearchTerms(searchTerm); + + let score = 0; + + if (title === normalizedSearch) score += 100; + if (title.includes(normalizedSearch)) score += 40; + if (description.includes(normalizedSearch)) score += 20; + if (notes.includes(normalizedSearch)) score += 10; + + for (const term of terms) { + if (title.includes(term)) score += 8; + if (description.includes(term)) score += 3; + if (notes.includes(term)) score += 2; + } + + return score; +} + export const queryNodesTool = tool({ - description: 'Search nodes across title, description, notes, and dimensions. Multi-word queries use FTS/tokenized fallback, and agent calls can add node-vector retrieval.', + description: 'Search nodes across title, description, and notes. For free-text lookups, search the graph broadly and prioritize title/description matches. Do not use dimensions to constrain keyword search unless the user is explicitly asking about a known dimension.', inputSchema: z.object({ filters: z.object({ dimensions: z.array(z.string()).describe('Filter by dimensions (e.g., ["research", "ai", "technology"]). Replaces old type/stage filtering.').optional(), @@ -17,7 +76,7 @@ export const queryNodesTool = tool({ eventBefore: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes with event_date before this date.'), }).optional() }), - execute: async ({ filters = {} }) => { + execute: async ({ filters = {} }: { filters?: QueryNodeFilters }) => { console.log('🔍 QueryNodes tool called with filters:', JSON.stringify(filters, null, 2)); try { const limit = filters.limit || 10; @@ -63,27 +122,40 @@ export const queryNodesTool = tool({ }; } - // Add timeout to prevent hanging - const timeoutPromise: Promise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('QueryNodes timeout after 10 seconds')), 10000); - }); + const runQuery = async (queryFilters: typeof filters): Promise => { + const timeoutPromise: Promise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('QueryNodes timeout after 10 seconds')), 10000); + }); - // Use new nodeService with dimension-based filtering - const nodesPromise: Promise = nodeService.getNodes({ - limit, - dimensions: filters.dimensions, - search: filters.search, - searchMode: searchTerm ? 'hybrid' : 'standard', - createdAfter: filters.createdAfter, - createdBefore: filters.createdBefore, - eventAfter: filters.eventAfter, - eventBefore: filters.eventBefore, - }); + const nodesPromise: Promise = nodeService.getNodes({ + limit, + dimensions: queryFilters.dimensions, + search: queryFilters.search, + searchMode: searchTerm ? 'hybrid' : 'standard', + createdAfter: queryFilters.createdAfter, + createdBefore: queryFilters.createdBefore, + eventAfter: queryFilters.eventAfter, + eventBefore: queryFilters.eventBefore, + }); - const nodes = await Promise.race([nodesPromise, timeoutPromise]); + const nodes = await Promise.race([nodesPromise, timeoutPromise]); + return Array.isArray(nodes) ? nodes : []; + }; + + const effectiveFilters = searchTerm + ? { ...filters, dimensions: undefined } + : { ...filters }; + + let safeNodes = await runQuery(effectiveFilters); + + if (searchTerm) { + safeNodes = safeNodes + .map(node => ({ node, score: scoreNodeForSearch(node, searchTerm) })) + .sort((a, b) => b.score - a.score || b.node.updated_at.localeCompare(a.node.updated_at)) + .slice(0, limit) + .map(entry => entry.node); + } - // Handle the case where nodes might be undefined/null - const safeNodes: Node[] = Array.isArray(nodes) ? nodes : []; const limitedNodes = safeNodes.slice(0, limit); // Format nodes for chat display @@ -106,14 +178,14 @@ export const queryNodesTool = tool({ // Create message with formatted node labels only (no full node payload) const formattedLabels = formattedNodes.map(node => node.formatted_display).join(', '); - const message = `Found ${safeNodes.length} nodes${filters.dimensions ? ` with dimensions: ${filters.dimensions.join(', ')}` : ''}${filters.search ? ` matching: "${filters.search}"` : ''}${formattedLabels ? `:\n${formattedLabels}` : ''}`; + const message = `Found ${safeNodes.length} nodes${effectiveFilters.dimensions ? ` with dimensions: ${effectiveFilters.dimensions.join(', ')}` : ''}${effectiveFilters.search ? ` matching: "${effectiveFilters.search}"` : ''}${formattedLabels ? `:\n${formattedLabels}` : ''}`; return { success: true, data: { nodes: formattedNodes, count: safeNodes.length, - filters_applied: filters + filters_applied: effectiveFilters }, message: message }; diff --git a/src/tools/infrastructure/groups.ts b/src/tools/infrastructure/groups.ts index bcf31ae..a158f9a 100644 --- a/src/tools/infrastructure/groups.ts +++ b/src/tools/infrastructure/groups.ts @@ -49,6 +49,7 @@ export const TOOL_GROUP_ASSIGNMENTS: Record = { // Execution: Write operations and extraction (workers only) createNode: 'execution', updateNode: 'execution', + deleteNode: 'execution', createEdge: 'execution', updateEdge: 'execution', embedContent: 'execution', diff --git a/src/tools/infrastructure/registry.ts b/src/tools/infrastructure/registry.ts index 7c26839..0ef0978 100644 --- a/src/tools/infrastructure/registry.ts +++ b/src/tools/infrastructure/registry.ts @@ -3,6 +3,7 @@ import { queryNodesTool } from '../database/queryNodes'; import { getNodesByIdTool } from '../database/getNodesById'; import { createNodeTool } from '../database/createNode'; import { updateNodeTool } from '../database/updateNode'; +import { deleteNodeTool } from '../database/deleteNode'; import { createEdgeTool } from '../database/createEdge'; import { queryEdgeTool } from '../database/queryEdge'; import { updateEdgeTool } from '../database/updateEdge'; @@ -43,6 +44,7 @@ const ORCHESTRATION_TOOLS: Record = { const EXECUTION_TOOLS: Record = { createNode: createNodeTool, updateNode: updateNodeTool, + deleteNode: deleteNodeTool, createEdge: createEdgeTool, updateEdge: updateEdgeTool, createDimension: createDimensionTool, @@ -71,6 +73,7 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([ 'think', 'createNode', 'updateNode', + 'deleteNode', 'createEdge', 'updateEdge', 'createDimension', diff --git a/tests/evals/dataset.json b/tests/evals/dataset.json index e7e13a5..c9e2710 100644 --- a/tests/evals/dataset.json +++ b/tests/evals/dataset.json @@ -1,11 +1,13 @@ { "id": "golden-ra-h-core-v2", "name": "Golden RA-H Core v2", - "description": "Slim 5-scenario eval set for core RA-H usage: focused graph writes, skill-guided writes, indexed node search, chunk-grounded insight creation, and hub traversal.", + "description": "Core RA-H eval set covering database, tools, skills, search, and ingestion behavior, including graph writes, indexed node search, retrieval regressions, chunk-grounded insight creation, and hub traversal.", "inherits": "golden-v1", "version": 2, "focus": [ "tool-call correctness", + "category-based coverage", + "search regressions", "skill usage only when appropriate", "core retrieval/write behavior", "latency/tokens/cost guards" diff --git a/tests/evals/runner.ts b/tests/evals/runner.ts index dc9caaa..9aac6c4 100644 --- a/tests/evals/runner.ts +++ b/tests/evals/runner.ts @@ -3,7 +3,7 @@ import path from 'path'; import Database from 'better-sqlite3'; import { randomUUID } from 'crypto'; import { scenarios } from './scenarios'; -import { Scenario } from './types'; +import { EvalCategory, Scenario } from './types'; type EvalChatRow = { trace_id: string; @@ -38,7 +38,7 @@ type EvalResult = { const BASE_URL = process.env.RAH_EVALS_BASE_URL || 'http://localhost:3000'; const DATASET_ENV = process.env.RAH_EVALS_DATASET_ID; -const SUITE_ENV = (process.env.RAH_EVALS_SUITE || 'all').toLowerCase(); +const CATEGORY_ENV = (process.env.RAH_EVALS_CATEGORY || process.env.RAH_EVALS_SUITE || 'all').toLowerCase(); const WAIT_TIMEOUT_MS = Number(process.env.RAH_EVALS_TIMEOUT_MS || 60000); const LOG_DB_PATH = path.join(process.cwd(), 'logs', 'evals.sqlite'); const RAH_DB_PATH = process.env.SQLITE_DB_PATH || path.join( @@ -54,7 +54,7 @@ function loadDatasetId() { return parsed.id || 'default'; } -type EvalSuite = 'all' | 'tools' | 'skills' | 'traversal' | 'internal' | 'external'; +type EvalCategoryFilter = 'all' | EvalCategory; type FocusedNodeContext = { id: number; @@ -66,25 +66,25 @@ type FocusedNodeContext = { metadata: string | null; }; -function getDefaultScenarioSuites(scenario: Scenario): string[] { - const suites = ['internal']; - if (scenario.id.startsWith('skill-trigger-')) { - suites.push('skills'); - } else { - suites.push('tools'); +function getDefaultScenarioCategories(scenario: Scenario): EvalCategory[] { + if (scenario.id.includes('search') || scenario.id.includes('quote')) { + return ['search']; } - if (scenario.id.includes('traverse')) { - suites.push('traversal'); + if (scenario.id.includes('skill')) { + return ['skills']; } - return suites; + if (scenario.id.includes('extract') || scenario.id.includes('ingest')) { + return ['ingestion', 'tools']; + } + return ['database', 'tools']; } -function shouldRunScenario(scenario: Scenario, suite: EvalSuite) { - if (suite === 'all') return true; - const suites = scenario.suites && scenario.suites.length > 0 - ? scenario.suites - : getDefaultScenarioSuites(scenario); - return suites.includes(suite); +function shouldRunScenario(scenario: Scenario, category: EvalCategoryFilter) { + if (category === 'all') return true; + const categories = scenario.categories && scenario.categories.length > 0 + ? scenario.categories + : getDefaultScenarioCategories(scenario); + return categories.includes(category); } function resolveFocusedNodeId(query: Scenario['input']['focusedNodeQuery']): number | null { @@ -424,14 +424,14 @@ async function runScenario(scenario: Scenario, datasetId: string): Promise s.enabled !== false && shouldRunScenario(s, suite)); - console.log(`Running ${runnable.length} scenarios (dataset: ${datasetId}, suite: ${suite})...\n`); + const runnable = scenarios.filter(s => s.enabled !== false && shouldRunScenario(s, category)); + console.log(`Running ${runnable.length} scenarios (dataset: ${datasetId}, category: ${category})...\n`); const results: EvalResult[] = []; for (const scenario of runnable) { diff --git a/tests/evals/scenarios/index.ts b/tests/evals/scenarios/index.ts index 9868d1d..07287fe 100644 --- a/tests/evals/scenarios/index.ts +++ b/tests/evals/scenarios/index.ts @@ -1,6 +1,7 @@ import { scenario as focusedGraphWrite } from './focused-graph-write'; import { scenario as skillGuidedWrite } from './skill-guided-write'; import { scenario as nodeIndexSearch } from './node-index-search'; +import { scenario as searchDocToLora } from './search-doc-to-lora'; import { scenario as chunkQuoteInsight } from './chunk-quote-insight'; import { scenario as hubTraversal } from './hub-traversal'; @@ -8,6 +9,7 @@ export const scenarios = [ focusedGraphWrite, skillGuidedWrite, nodeIndexSearch, + searchDocToLora, chunkQuoteInsight, hubTraversal, ]; diff --git a/tests/evals/scenarios/search-doc-to-lora.ts b/tests/evals/scenarios/search-doc-to-lora.ts new file mode 100644 index 0000000..7aa9abd --- /dev/null +++ b/tests/evals/scenarios/search-doc-to-lora.ts @@ -0,0 +1,21 @@ +import { Scenario } from '../types'; + +export const scenario: Scenario = { + id: 'search-doc-to-lora', + name: 'Search Doc-to-LoRA', + description: 'Free-text graph search should find the Doc-to-LoRA node by title without speculative dimension constraints or unnecessary web search.', + categories: ['search'], + tools: ['queryNodes'], + input: { + message: 'Find the recent Doc-to-LoRA stuff in my graph. Just return the matching graph node or nodes.', + }, + expect: { + toolsCalledSoft: ['queryNodes'], + toolsNotCalled: ['webSearch'], + responseContains: ['Doc-to-LoRA: Learning to Instantly Internalize Contexts'], + maxLatencyMs: 15000, + maxTotalTokens: 8000, + maxEstimatedCostUsd: 0.08, + }, + notes: 'Regression for the failure where free-text search was over-constrained by guessed dimensions and missed the existing Doc-to-LoRA node.', +}; diff --git a/tests/evals/types.ts b/tests/evals/types.ts index 8517c4f..cbb974e 100644 --- a/tests/evals/types.ts +++ b/tests/evals/types.ts @@ -1,3 +1,5 @@ +export type EvalCategory = 'database' | 'tools' | 'skills' | 'search' | 'ingestion'; + export type ScenarioExpectations = { skillsRead?: string[]; skillsReadSoft?: string[]; @@ -32,6 +34,7 @@ export type Scenario = { expect?: ScenarioExpectations; description?: string; tools?: string[]; + categories?: EvalCategory[]; suites?: string[]; enabled?: boolean; notes?: string;