feat: sync pane workspace overhaul from private repo

- ship the expanded left-nav and pane header workspace updates
- add dimension-driven browse flow, map view upgrades, and delete-node tooling
- expand eval coverage and refresh the public UI docs for the new layout

Generated with Codex
This commit is contained in:
“BeeRad”
2026-03-17 18:20:20 +11:00
parent 0776bccdbd
commit 213c054c9f
25 changed files with 1050 additions and 630 deletions
+3 -1
View File
@@ -59,7 +59,9 @@ export async function POST(request: NextRequest) {
}, { status: 400 }); }, { status: 400 });
} }
const descriptionError = validateDimensionDescription(description || ''); const descriptionError = description !== null
? validateDimensionDescription(description)
: null;
if (descriptionError) { if (descriptionError) {
return NextResponse.json({ return NextResponse.json({
success: false, success: false,
+48 -71
View File
@@ -2,100 +2,73 @@
> How to navigate and use RA-OS's interface. > 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 NAV │ PANE A PANE B
PanelPanel
Search │ Nodes / Focus │ Optional second pane
• Search │ • Tabbed workspace Add │ Dimensions / Map │ for compare/browse
• Filters │ • Node content Views │ Table / Skills │
│ • Folders │ • Connections │ └────────┴──────────────────────┴──────────────────────┘
│ │ │
└─────────────┴─────────────────────────┘
``` ```
--- ---
## 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 ### Features
- **Search bar** — Cmd+K opens global search modal - **Search** — Cmd+K opens global search
- **Dimension filters** — Multi-select dimension tags - **Add Stuff** — open the quick-add flow
- **Node list** — Scrollable list of filtered nodes - **Refresh** — reload pane data
- **Folder view toggle** — Switch between list and folder views - **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 ### Node Display
Each node shows: Each node shows:
- Title + description preview - Title and preview
- Dimension tags (with custom icons) - Dimension tags
- Last updated timestamp - Last updated timestamp
- Node ID badge - 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 ### Features
- Each dimension shows as a folder card
- Drag nodes to folders to add dimensions
- Click to view nodes in that dimension
2. **Filtered View Mode** — Multi-dimension filtering with views - Browse dimension cards with counts and lock state
- Add multiple dimension filters - Create new dimensions from the pane header
- Choose view layout (List, Grid, Kanban) - Select a dimension to push that filter into the Nodes pane
- Save views for quick access - Manage dimension metadata and node grouping from one place
--- ---
## Filtered View System ## Focus Pane
### 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
Active workspace for the node(s) you're working with. 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 | | Section | Content |
|---------|---------| |---------|---------|
| **Header** | Title, node ID, trash icon | | **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 | | **Metadata** | Created, updated, type, link |
| **Dimensions** | Editable dimension tags | | **Dimensions** | Editable dimension tags |
| **Connections** | Incoming/outgoing edges | | **Connections** | Incoming/outgoing edges |
@@ -142,7 +115,7 @@ Global search modal with 4-tier relevance:
## Settings Panel ## Settings Panel
**Access:** Settings cog icon (top-right) **Access:** Settings item in the left navigation
### Tabs ### Tabs
@@ -163,9 +136,11 @@ Global search modal with 4-tier relevance:
Visual graph of your knowledge network. Visual graph of your knowledge network.
**Features:** **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 - 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 - Click node to highlight connections
- Selection shows connected nodes in green - Selection shows connected nodes in green
@@ -186,6 +161,7 @@ Full table view of all nodes.
- Filter by dimensions - Filter by dimensions
- Sort by updated/edges/created - Sort by updated/edges/created
- Pagination - Pagination
- Toolbar lives in the pane header for faster switching
--- ---
@@ -217,6 +193,7 @@ Each dimension can have a custom Lucide icon.
| Shortcut | Action | | Shortcut | Action |
|----------|--------| |----------|--------|
| `Cmd+K` | Open search | | `Cmd+K` | Open search |
| `Cmd+Shift+R` | Refresh all panes |
| `Escape` | Close modals/overlays | | `Escape` | Close modals/overlays |
--- ---
+85 -174
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useCallback } from 'react'; import { useState, type ReactNode } from 'react';
import { import {
Search, Search,
Plus, Plus,
@@ -11,6 +11,8 @@ import {
Table2, Table2,
BookOpen, BookOpen,
Settings, Settings,
PanelLeftClose,
PanelLeftOpen,
} from 'lucide-react'; } from 'lucide-react';
import type { PaneType } from '../panes/types'; import type { PaneType } from '../panes/types';
@@ -20,127 +22,74 @@ interface LeftToolbarProps {
onRefreshClick: () => void; onRefreshClick: () => void;
onSettingsClick: () => void; onSettingsClick: () => void;
onPaneTypeClick: (paneType: PaneType) => void; onPaneTypeClick: (paneType: PaneType) => void;
activePane: 'A' | 'B'; isExpanded: boolean;
slotAType: PaneType | null; onToggleExpanded: () => void;
slotBType: PaneType | null; openTabTypes: Set<PaneType>;
activeTabType: PaneType | null;
} }
// Map pane types to their icons (chat removed in rah-light, guides moved to settings) const NAV_WIDTH_COLLAPSED = 50;
const PANE_TYPE_ICONS: Record<string, typeof LayoutList> = { const NAV_WIDTH_EXPANDED = 280;
views: LayoutList,
map: Map,
dimensions: Folder,
table: Table2,
skills: BookOpen,
};
const PANE_TYPE_LABELS: Record<string, string> = { const VIEW_ITEMS: Array<{ paneType: PaneType; label: string; icon: typeof LayoutList }> = [
views: 'Feed', { paneType: 'views', label: 'Nodes', icon: LayoutList },
map: 'Map', { paneType: 'skills', label: 'Skills', icon: BookOpen },
dimensions: 'Dimensions', { paneType: 'map', label: 'Map', icon: Map },
table: 'Table', { paneType: 'dimensions', label: 'Dimension', icon: Folder },
skills: 'Skills', { paneType: 'table', label: 'Table', icon: Table2 },
}; ];
// Pane types shown in the toolbar center section (skills is pinned above settings) function NavButton({
const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'map', 'dimensions', 'table']; icon: Icon,
label,
interface ToolbarButtonProps { expanded,
active,
onClick,
trailing,
activeTone = 'neutral',
}: {
icon: typeof Search; icon: typeof Search;
label: string; label: string;
shortcut?: string; expanded: boolean;
active?: boolean;
onClick: () => void; onClick: () => void;
disabled?: boolean; trailing?: ReactNode;
isActive?: boolean; activeTone?: 'neutral' | 'green';
} }) {
const [hovered, setHovered] = useState(false);
function ToolbarButton({ icon: Icon, label, shortcut, onClick, disabled, isActive }: ToolbarButtonProps) { const activeColor = activeTone === 'green' ? '#22c55e' : '#f0f0f0';
const [isHovered, setIsHovered] = useState(false);
return ( return (
<button <button
onClick={onClick} onClick={onClick}
disabled={disabled} onMouseEnter={() => setHovered(true)}
onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setHovered(false)}
onMouseLeave={() => setIsHovered(false)} title={expanded ? undefined : label}
title={shortcut ? `${label} (${shortcut})` : label}
style={{ style={{
width: '36px', width: '100%',
height: '36px', height: '36px',
borderRadius: '8px', borderRadius: '8px',
border: 'none', border: 'none',
background: isHovered ? '#1a1a1a' : 'transparent', background: active ? '#151515' : (hovered ? '#121212' : 'transparent'),
color: isActive ? '#22c55e' : (isHovered ? '#aaa' : '#666'), color: active ? activeColor : (hovered ? '#c7c7c7' : '#7a7a7a'),
cursor: disabled ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.15s ease',
opacity: disabled ? 0.5 : 1,
}}
>
<Icon size={18} />
</button>
);
}
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 (
<button
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
title={label}
style={{
width: '36px',
height: '36px',
borderRadius: '8px',
border: 'none',
background: isOpen ? '#1a1a1a' : (isHovered ? '#151515' : 'transparent'),
color: getColor(),
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: expanded ? 'space-between' : 'center',
gap: '10px',
padding: expanded ? '0 10px' : '0',
transition: 'all 0.15s ease', transition: 'all 0.15s ease',
position: 'relative',
}} }}
> >
<span style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0 }}>
<Icon size={18} /> <Icon size={18} />
{/* Active pane indicator dot */} {expanded ? (
{isActivePane && isOpen && ( <span style={{ fontSize: '13px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
<div {label}
style={{ </span>
position: 'absolute', ) : null}
bottom: '4px', </span>
left: '50%', {expanded ? trailing : null}
transform: 'translateX(-50%)',
width: '4px',
height: '4px',
borderRadius: '50%',
background: '#4ade80',
}}
/>
)}
</button> </button>
); );
} }
@@ -151,97 +100,59 @@ export default function LeftToolbar({
onRefreshClick, onRefreshClick,
onSettingsClick, onSettingsClick,
onPaneTypeClick, onPaneTypeClick,
activePane, isExpanded,
slotAType, onToggleExpanded,
slotBType, openTabTypes,
activeTabType,
}: LeftToolbarProps) { }: LeftToolbarProps) {
// Determine which pane types are currently open
const openPaneTypes = new Set<PaneType>(
[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 ( return (
<div <div
style={{ style={{
width: '50px', width: isExpanded ? `${NAV_WIDTH_EXPANDED}px` : `${NAV_WIDTH_COLLAPSED}px`,
height: '100%', height: '100%',
background: '#0a0a0a', background: 'transparent',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'space-between', justifyContent: 'space-between',
padding: '12px 0', padding: '12px 8px',
flexShrink: 0, flexShrink: 0,
transition: 'width 0.2s ease',
overflow: 'hidden',
}} }}
> >
{/* Top section - Actions */} <div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}> <NavButton
<ToolbarButton icon={isExpanded ? PanelLeftClose : PanelLeftOpen}
icon={Search} label={isExpanded ? 'Collapse' : 'Expand'}
label="Search" expanded={isExpanded}
shortcut="⌘K" onClick={onToggleExpanded}
onClick={onSearchClick}
/>
<ToolbarButton
icon={Plus}
label="Add Stuff"
onClick={onAddStuffClick}
/>
<ToolbarButton
icon={RefreshCw}
label="Refresh"
shortcut="⌘⇧R"
onClick={onRefreshClick}
/> />
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<NavButton icon={Search} label="Search" expanded={isExpanded} onClick={onSearchClick} />
<NavButton icon={Plus} label="Add Stuff" expanded={isExpanded} onClick={onAddStuffClick} />
<NavButton icon={RefreshCw} label="Refresh" expanded={isExpanded} onClick={onRefreshClick} />
</div> </div>
{/* Middle section - Pane Types */} <div style={{ borderTop: '1px solid #1a1a1a', paddingTop: '14px', marginTop: '10px' }}>
<div <div style={{ display: 'flex', flexDirection: 'column', gap: '4px', paddingTop: '4px' }}>
style={{ {VIEW_ITEMS.map((item) => (
display: 'flex', <NavButton
flexDirection: 'column', key={`${item.paneType}-${item.label}`}
alignItems: 'center', icon={item.icon}
gap: '4px', label={item.label}
padding: '8px 0', expanded={isExpanded}
}} active={activeTabType === item.paneType || openTabTypes.has(item.paneType)}
> onClick={() => onPaneTypeClick(item.paneType)}
{TOOLBAR_PANE_TYPES.map((paneType) => { activeTone="green"
const Icon = PANE_TYPE_ICONS[paneType];
const label = PANE_TYPE_LABELS[paneType];
const isOpen = openPaneTypes.has(paneType);
const isActivePane = activePaneType === paneType;
return (
<PaneTypeButton
key={paneType}
icon={Icon}
label={label}
paneType={paneType}
isOpen={isOpen}
isActivePane={isActivePane}
onClick={() => onPaneTypeClick(paneType)}
/> />
); ))}
})} </div>
</div>
</div> </div>
{/* Bottom section - Skills + Settings */} <div style={{ borderTop: '1px solid #1a1a1a', paddingTop: '8px' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}> <NavButton icon={Settings} label="Settings" expanded={isExpanded} onClick={onSettingsClick} />
<PaneTypeButton
icon={BookOpen}
label="Skills"
paneType="skills"
isOpen={openPaneTypes.has('skills')}
isActivePane={activePaneType === 'skills'}
onClick={() => onPaneTypeClick('skills')}
/>
<ToolbarButton
icon={Settings}
label="Settings"
onClick={onSettingsClick}
/>
</div> </div>
</div> </div>
); );
+70 -40
View File
@@ -45,16 +45,17 @@ export default function ThreePanelLayout() {
// Slot states - the core of the flexible pane system // Slot states - the core of the flexible pane system
// Default: Feed on left, closed on right (chat removed in rah-light) // Default: Feed on left, closed on right (chat removed in rah-light)
const [slotA, setSlotA] = usePersistentState<SlotState | null>('ui.slotA.v4', { const [slotA, setSlotA] = usePersistentState<SlotState | null>('ui.slotA.v5', {
type: 'views', type: 'views',
}); });
// SlotB can be null (closed) or a SlotState // SlotB can be null (closed) or a SlotState
// Default: closed (chat removed in rah-light) // Default: closed (chat removed in rah-light)
const [slotB, setSlotB] = usePersistentState<SlotState | null>('ui.slotB.v4', null); const [slotB, setSlotB] = usePersistentState<SlotState | null>('ui.slotB.v5', null);
// SlotB width as percentage (when open) // SlotB width as percentage (when open)
const [slotBWidth, setSlotBWidth] = usePersistentState<number>('ui.slotBWidth', 50); const [slotBWidth, setSlotBWidth] = usePersistentState<number>('ui.slotBWidth', 50);
const [leftNavExpanded, setLeftNavExpanded] = usePersistentState<boolean>('ui.leftNavExpanded', false);
// Migration: if a slot was persisted with type 'guides' (now moved to settings), reset it // Migration: if a slot was persisted with type 'guides' (now moved to settings), reset it
useEffect(() => { useEffect(() => {
@@ -64,6 +65,12 @@ export default function ThreePanelLayout() {
if (slotB && (slotB.type as string) === 'guides') { if (slotB && (slotB.type as string) === 'guides') {
setSlotB(null); setSlotB(null);
} }
if (slotA?.type === 'node') {
setSlotA({ type: 'views' });
}
if (slotB?.type === 'node') {
setSlotB(null);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
// Track which pane is active (last interacted with) // Track which pane is active (last interacted with)
@@ -96,6 +103,7 @@ export default function ThreePanelLayout() {
// Active dimension tracking // Active dimension tracking
const [activeDimension, setActiveDimension] = usePersistentState<string | null>('ui.focus.activeDimension', null); const [activeDimension, setActiveDimension] = usePersistentState<string | null>('ui.focus.activeDimension', null);
const [browseDimensionFilter, setBrowseDimensionFilter] = useState<string | null>(null);
// Delegations state (deprecated - kept for component compatibility) // Delegations state (deprecated - kept for component compatibility)
const [delegationsMap] = useState<Record<string, AgentDelegation>>({}); const [delegationsMap] = useState<Record<string, AgentDelegation>>({});
@@ -485,6 +493,37 @@ export default function ThreePanelLayout() {
setNodesPanelRefresh(prev => prev + 1); 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) => { const handleNodeOpenFromDimensions = useCallback((nodeId: number) => {
// Switch to node pane and open the node // Switch to node pane and open the node
const currentTabs = slotA?.type === 'node' ? (slotA.nodeTabs || []) : []; const currentTabs = slotA?.type === 'node' ? (slotA.nodeTabs || []) : [];
@@ -499,35 +538,26 @@ export default function ThreePanelLayout() {
setActivePane('A'); setActivePane('A');
}, [slotA, setSlotA]); }, [slotA, setSlotA]);
// Handle pane type selection from toolbar const handleDimensionPaneSelect = useCallback((dimensionName: string | null) => {
const handlePaneTypeClick = useCallback((paneType: PaneType) => { setBrowseDimensionFilter(dimensionName);
// If no panes open → open in slot A setActiveDimension(dimensionName);
if (!slotA) {
setSlotA({ type: paneType });
setActivePane('A');
return;
}
// If only one pane open → open second pane with this type if (!dimensionName) return;
if (!slotB) {
setSlotB({ type: paneType }); if (activePane === 'B' && slotB?.type === 'dimensions') {
setSlotB({ type: 'views' });
setActivePane('B'); setActivePane('B');
return; return;
} }
// Two panes open → replace the active pane setSlotA({ type: 'views' });
if (activePane === 'A') { setActivePane('A');
setSlotA(prev => prev ? ({ }, [activePane, slotB, setSlotA, setSlotB, setActiveDimension]);
...prev,
type: paneType, // Handle pane type selection from toolbar
}) : { type: paneType }); const handlePaneTypeClick = useCallback((paneType: PaneType) => {
} else { openPaneSingleton(paneType);
setSlotB(prev => prev ? ({ }, [openPaneSingleton]);
...prev,
type: paneType,
}) : { type: paneType });
}
}, [activePane, slotA, slotB, setSlotA, setSlotB]);
// Ensure the Feed pane is visible (for quick-add loading placeholders) // Ensure the Feed pane is visible (for quick-add loading placeholders)
const ensureFeedOpen = useCallback(() => { const ensureFeedOpen = useCallback(() => {
@@ -596,25 +626,19 @@ export default function ThreePanelLayout() {
const handleSlotAAction = useCallback((action: PaneAction) => { const handleSlotAAction = useCallback((action: PaneAction) => {
switch (action.type) { switch (action.type) {
case 'switch-pane-type': case 'switch-pane-type':
setSlotA(prev => ({ openPaneSingleton(action.paneType);
...prev,
type: action.paneType,
}));
break; break;
case 'open-node': case 'open-node':
handleNodeSelect(action.nodeId, false); handleNodeSelect(action.nodeId, false);
break; break;
} }
}, [handleNodeSelect, setSlotA]); }, [handleNodeSelect, openPaneSingleton]);
const handleSlotBAction = useCallback((action: PaneAction) => { const handleSlotBAction = useCallback((action: PaneAction) => {
if (!slotB) return; if (!slotB) return;
switch (action.type) { switch (action.type) {
case 'switch-pane-type': case 'switch-pane-type':
setSlotB(prev => prev ? ({ openPaneSingleton(action.paneType);
...prev,
type: action.paneType,
}) : null);
break; break;
case 'open-node': case 'open-node':
// Open node in slot B (if it's a node pane) // Open node in slot B (if it's a node pane)
@@ -636,7 +660,7 @@ export default function ThreePanelLayout() {
} }
break; break;
} }
}, [slotB, setSlotB]); }, [slotB, setSlotB, openPaneSingleton]);
// Open a node directly in Slot B (for Alt+Click) // Open a node directly in Slot B (for Alt+Click)
const handleNodeOpenInSlotB = useCallback((nodeId: number) => { const handleNodeOpenInSlotB = useCallback((nodeId: number) => {
@@ -909,7 +933,7 @@ export default function ThreePanelLayout() {
onNodeOpen={handleNodeOpenFromDimensions} onNodeOpen={handleNodeOpenFromDimensions}
refreshToken={folderViewRefresh} refreshToken={folderViewRefresh}
onDataChanged={handleFolderViewDataChanged} onDataChanged={handleFolderViewDataChanged}
onDimensionSelect={setActiveDimension} onDimensionSelect={handleDimensionPaneSelect}
/> />
); );
@@ -942,6 +966,11 @@ export default function ThreePanelLayout() {
refreshToken={nodesPanelRefresh} refreshToken={nodesPanelRefresh}
pendingNodes={pendingNodes} pendingNodes={pendingNodes}
onDismissPending={(id) => setPendingNodes(prev => prev.filter(p => p.id !== id))} 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); setShowSettings(true);
}} }}
onPaneTypeClick={handlePaneTypeClick} onPaneTypeClick={handlePaneTypeClick}
activePane={activePane} isExpanded={leftNavExpanded}
slotAType={slotA?.type ?? null} onToggleExpanded={() => setLeftNavExpanded(prev => !prev)}
slotBType={slotB?.type ?? null} 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} onRefreshClick={handleRefreshAll}
/> />
+44 -43
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useState, useRef, type DragEvent } from 'react'; 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 { Check, X, ArrowLeft, Plus, Trash2, Edit2, Lock } from 'lucide-react';
import type { Node } from '@/types/database'; import type { Node } from '@/types/database';
import ConfirmDialog from '../common/ConfirmDialog'; import ConfirmDialog from '../common/ConfirmDialog';
@@ -23,11 +24,12 @@ interface FolderViewOverlayProps {
refreshToken: number; refreshToken: number;
onDataChanged?: () => void; onDataChanged?: () => void;
onDimensionSelect?: (dimensionName: string | null) => void; onDimensionSelect?: (dimensionName: string | null) => void;
toolbarHost?: HTMLDivElement | null;
} }
const PAGE_SIZE = 100; 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 [view, setView] = useState<'dimensions' | 'nodes'>('dimensions');
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]); const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
const [dimensionsLoading, setDimensionsLoading] = useState(true); const [dimensionsLoading, setDimensionsLoading] = useState(true);
@@ -202,11 +204,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
}; };
const handleSelectDimension = (dimension: DimensionSummary) => { const handleSelectDimension = (dimension: DimensionSummary) => {
setSelectedDimension(dimension); onDimensionSelect?.(dimension.dimension);
setNodes([]);
setNodeOffset(0);
setHasMoreNodes(false);
setView('nodes');
}; };
const handleBackToDimensions = () => { const handleBackToDimensions = () => {
@@ -215,6 +213,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
setNodes([]); setNodes([]);
setNodeOffset(0); setNodeOffset(0);
setHasMoreNodes(false); setHasMoreNodes(false);
onDimensionSelect?.(null);
}; };
const handleAddDimension = async (name: string) => { const handleAddDimension = async (name: string) => {
@@ -1563,29 +1562,16 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
); );
}; };
return ( const toolbar = (
<> <div style={{
<div width: '100%',
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: '#050505',
borderRadius: '4px',
border: '1px solid #1a1a1a',
display: 'flex', display: 'flex',
flexDirection: 'column', alignItems: 'center',
zIndex: 5 justifyContent: 'space-between',
}} gap: '8px',
> minWidth: 0
{/* Header */} }}>
<div style={{ borderBottom: '1px solid #1a1a1a' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
{/* Top row: Mode tabs + Actions */}
<div style={{ padding: '12px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{/* Back button when viewing nodes in a dimension */}
{view === 'nodes' && ( {view === 'nodes' && (
<button <button
onClick={handleBackToDimensions} onClick={handleBackToDimensions}
@@ -1601,26 +1587,20 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
<ArrowLeft size={16} /> <ArrowLeft size={16} />
</button> </button>
)} )}
{view === 'dimensions' && (
<div style={{ fontSize: '13px', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#f8fafc', display: 'flex', alignItems: 'center', gap: '8px' }}>
<DynamicIcon name="Folder" size={14} style={{ color: '#22c55e' }} />
Dimensions
</div>
)}
{/* Title when viewing nodes in a dimension */}
{view === 'nodes' && ( {view === 'nodes' && (
<div style={{ fontSize: '13px', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#f8fafc', display: 'flex', alignItems: 'center', gap: '8px' }}> <div style={{
<span>Nodes </span> fontSize: '12px',
<span style={{ color: '#22c55e' }}>{selectedDimension?.dimension ?? ''}</span> color: '#7de8a5',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{selectedDimension?.dimension ?? ''}
</div> </div>
)} )}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{/* Add Dimension button */}
{view === 'dimensions' && ( {view === 'dimensions' && (
<button <button
onClick={() => setShowAddDimensionDialog(true)} onClick={() => setShowAddDimensionDialog(true)}
@@ -1645,7 +1625,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
</button> </button>
)} )}
{!toolbarHost && (
<button <button
onClick={onClose} onClick={onClose}
title="Close" title="Close"
@@ -1667,10 +1647,31 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
> >
<X size={14} /> <X size={14} />
</button> </button>
)}
</div> </div>
</div> </div>
);
return (
<>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'transparent',
display: 'flex',
flexDirection: 'column',
zIndex: 5
}}
>
{toolbarHost ? createPortal(toolbar, toolbarHost) : (
<div style={{ borderBottom: '1px solid #1a1a1a', padding: '12px 16px' }}>
{toolbar}
</div> </div>
)}
{/* Content */} {/* Content */}
{view === 'nodes' ? renderNodeGrid() : renderDimensionGrid()} {view === 'nodes' ? renderNodeGrid() : renderDimensionGrid()}
+11 -1
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useState } from 'react';
import FolderViewOverlay from '@/components/nodes/FolderViewOverlay'; import FolderViewOverlay from '@/components/nodes/FolderViewOverlay';
import PaneHeader from './PaneHeader'; import PaneHeader from './PaneHeader';
import { DimensionsPaneProps, PaneType } from './types'; import { DimensionsPaneProps, PaneType } from './types';
@@ -10,11 +11,13 @@ export default function DimensionsPane({
onPaneAction, onPaneAction,
onCollapse, onCollapse,
onSwapPanes, onSwapPanes,
tabBar,
onNodeOpen, onNodeOpen,
refreshToken, refreshToken,
onDataChanged, onDataChanged,
onDimensionSelect, onDimensionSelect,
}: DimensionsPaneProps) { }: DimensionsPaneProps) {
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
const handleTypeChange = (type: PaneType) => { const handleTypeChange = (type: PaneType) => {
onPaneAction?.({ type: 'switch-pane-type', paneType: type }); onPaneAction?.({ type: 'switch-pane-type', paneType: type });
}; };
@@ -32,7 +35,13 @@ export default function DimensionsPane({
background: 'transparent', background: 'transparent',
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} /> <PaneHeader
slot={slot}
onCollapse={onCollapse}
onSwapPanes={onSwapPanes}
tabBar={tabBar}
toolbarHostRef={setToolbarHost}
/>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', position: 'relative' }}> <div style={{ flex: 1, minHeight: 0, overflow: 'hidden', position: 'relative' }}>
{/* FolderViewOverlay expects to be an overlay, so we wrap it in a container */} {/* FolderViewOverlay expects to be an overlay, so we wrap it in a container */}
@@ -47,6 +56,7 @@ export default function DimensionsPane({
refreshToken={refreshToken} refreshToken={refreshToken}
onDataChanged={onDataChanged} onDataChanged={onDataChanged}
onDimensionSelect={onDimensionSelect} onDimensionSelect={onDimensionSelect}
toolbarHost={toolbarHost}
/> />
</div> </div>
</div> </div>
+188 -16
View File
@@ -4,7 +4,6 @@ import { useEffect, useMemo, useRef, useState, useCallback, type CSSProperties }
import { import {
ReactFlow, ReactFlow,
Background, Background,
MiniMap,
useNodesState, useNodesState,
useEdgesState, useEdgesState,
addEdge as rfAddEdge, addEdge as rfAddEdge,
@@ -22,10 +21,11 @@ import PaneHeader from './PaneHeader';
import type { MapPaneProps } from './types'; import type { MapPaneProps } from './types';
import { ChevronDown } from 'lucide-react'; import { ChevronDown } from 'lucide-react';
import { MiniMap } from '@xyflow/react';
import { RahNode } from './map/RahNode'; import { RahNode } from './map/RahNode';
import { RahEdge } from './map/RahEdge'; import { RahEdge } from './map/RahEdge';
import EdgeExplanationModal from './map/EdgeExplanationModal'; 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 { useDimensionIcons } from '@/context/DimensionIconsContext';
import './map/map-styles.css'; import './map/map-styles.css';
@@ -74,6 +74,7 @@ function MapPaneInner({
// --- UI state --- // --- UI state ---
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null); const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
const [selectedDimension, setSelectedDimension] = useState<string | null>(null); const [selectedDimension, setSelectedDimension] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<MapViewMode>('dimension');
const [dimensionDropdownOpen, setDimensionDropdownOpen] = useState(false); const [dimensionDropdownOpen, setDimensionDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@@ -86,6 +87,7 @@ function MapPaneInner({
// Track current RF positions so we can preserve them across data refreshes // Track current RF positions so we can preserve them across data refreshes
const rfPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map()); const rfPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
const hasInitialFitRef = useRef(false);
// Combine base + expanded // Combine base + expanded
const allDbNodes = useMemo(() => { const allDbNodes = useMemo(() => {
@@ -115,6 +117,26 @@ function MapPaneInner({
[lockedDimensions], [lockedDimensions],
); );
const clusterLabels = useMemo(() => {
if (viewMode !== 'dimension') {
return [];
}
const grouped = new Map<string, { x: number; y: number; count: number }>();
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 ----- // ----- Close dropdown on outside click -----
useEffect(() => { useEffect(() => {
if (!dimensionDropdownOpen) return; if (!dimensionDropdownOpen) return;
@@ -157,9 +179,7 @@ function MapPaneInner({
if (dimsRes.ok) { if (dimsRes.ok) {
const dimsPayload = await dimsRes.json(); const dimsPayload = await dimsRes.json();
if (dimsPayload.success && dimsPayload.data) { if (dimsPayload.success && dimsPayload.data) {
setLockedDimensions( setLockedDimensions(dimsPayload.data as DimensionInfo[]);
(dimsPayload.data as DimensionInfo[]).filter(d => d.isPriority),
);
} }
} }
} catch (err) { } catch (err) {
@@ -196,6 +216,8 @@ function MapPaneInner({
connectedNodeIds, connectedNodeIds,
rfPositionsRef.current, rfPositionsRef.current,
dimensionIcons, dimensionIcons,
viewMode,
dbEdges,
); );
const nodeIdSet = new Set(newRfNodes.map(n => n.id)); const nodeIdSet = new Set(newRfNodes.map(n => n.id));
@@ -203,7 +225,47 @@ function MapPaneInner({
setRfNodes(newRfNodes); setRfNodes(newRfNodes);
setRfEdges(newRfEdges); 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 ----- // ----- Node traversal: fetch connected nodes -----
const fetchConnectedNodes = useCallback(async (nodeId: number) => { const fetchConnectedNodes = useCallback(async (nodeId: number) => {
@@ -379,7 +441,7 @@ function MapPaneInner({
// ----- Node drag → save position to metadata (debounced) ----- // ----- Node drag → save position to metadata (debounced) -----
const savePositionRef = useRef( const savePositionRef = useRef(
debounce(async (nodeId: number, x: number, y: number) => { debounce(async (nodeId: number, x: number, y: number, mode: MapViewMode) => {
try { try {
const res = await fetch(`/api/nodes/${nodeId}`); const res = await fetch(`/api/nodes/${nodeId}`);
if (!res.ok) return; if (!res.ok) return;
@@ -393,7 +455,14 @@ function MapPaneInner({
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 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) { } catch (err) {
@@ -406,9 +475,21 @@ function MapPaneInner({
const nodeId = parseInt(node.id); const nodeId = parseInt(node.id);
if (!isNaN(nodeId)) { if (!isNaN(nodeId)) {
rfPositionsRef.current.set(node.id, node.position); 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 ----- // ----- Node click → select + traverse -----
const onNodeClickHandler: NodeMouseHandler<RFNode<RahNodeData>> = useCallback((_event, node) => { const onNodeClickHandler: NodeMouseHandler<RFNode<RahNodeData>> = useCallback((_event, node) => {
@@ -481,6 +562,39 @@ function MapPaneInner({
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'transparent', overflow: 'hidden' }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'transparent', overflow: 'hidden' }}>
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar}> <PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<button
onClick={() => setViewMode('dimension')}
style={{
padding: '6px 10px',
background: viewMode === 'dimension' ? 'rgba(34, 197, 94, 0.12)' : 'transparent',
border: '1px solid',
borderColor: viewMode === 'dimension' ? 'rgba(34, 197, 94, 0.35)' : '#2a2a2a',
borderRadius: '6px',
color: viewMode === 'dimension' ? '#a7f3b8' : '#888',
fontSize: '12px',
cursor: 'pointer',
}}
>
Dimension View
</button>
<button
onClick={() => setViewMode('hub')}
style={{
padding: '6px 10px',
background: viewMode === 'hub' ? 'rgba(34, 197, 94, 0.12)' : 'transparent',
border: '1px solid',
borderColor: viewMode === 'hub' ? 'rgba(34, 197, 94, 0.35)' : '#2a2a2a',
borderRadius: '6px',
color: viewMode === 'hub' ? '#a7f3b8' : '#888',
fontSize: '12px',
cursor: 'pointer',
}}
>
Hub View
</button>
</div>
{/* Dimension filter dropdown */} {/* Dimension filter dropdown */}
<div ref={dropdownRef} style={{ position: 'relative' }}> <div ref={dropdownRef} style={{ position: 'relative' }}>
<button <button
@@ -597,8 +711,6 @@ function MapPaneInner({
onConnect={onConnect} onConnect={onConnect}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1} minZoom={0.1}
maxZoom={3} maxZoom={3}
defaultEdgeOptions={{ type: 'rahEdge' }} defaultEdgeOptions={{ type: 'rahEdge' }}
@@ -608,16 +720,76 @@ function MapPaneInner({
<Background color="#1a1a1a" gap={40} size={1} /> <Background color="#1a1a1a" gap={40} size={1} />
<MiniMap <MiniMap
style={{ background: '#0a0a0a', border: '1px solid #1f1f1f', borderRadius: 6 }} style={{ background: '#0a0a0a', border: '1px solid #1f1f1f', borderRadius: 6 }}
maskColor="rgba(0,0,0,0.6)" maskColor="rgba(0, 0, 0, 0.7)"
nodeColor={(n) => { nodeColor={(node) => {
const data = n.data as RahNodeData | undefined; const data = node.data as RahNodeData | undefined;
return data?.primaryDimensionColor || '#374151'; return data?.primaryDimensionColor || '#2a2a2a';
}} }}
pannable pannable
zoomable zoomable
/> />
</ReactFlow> </ReactFlow>
{viewMode === 'dimension' && clusterLabels.map((label) => (
<div
key={label.dimension}
style={{
position: 'absolute',
transform: `translate(${label.x}px, ${label.y}px)`,
pointerEvents: 'none',
color: '#7a7a7a',
fontSize: '11px',
letterSpacing: '0.05em',
textTransform: 'uppercase',
textShadow: '0 1px 6px rgba(0,0,0,0.45)',
}}
>
{label.dimension}
</div>
))}
<div style={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
gap: 4,
zIndex: 10,
}}>
<button
onClick={fitAllNodes}
style={{
padding: '4px 8px',
fontSize: 10,
background: '#1a1a1a',
border: '1px solid #2a2a2a',
borderRadius: 4,
color: '#888',
cursor: 'pointer',
}}
title="Fit all nodes"
>
Fit
</button>
{viewMode === 'hub' && (
<button
onClick={fitHubNodes}
style={{
padding: '4px 8px',
fontSize: 10,
background: '#1a1a1a',
border: '1px solid #2a2a2a',
borderRadius: 4,
color: '#888',
cursor: 'pointer',
}}
title="Fit to hub nodes"
>
Hubs
</button>
)}
</div>
{/* Selected node info panel */} {/* Selected node info panel */}
{selectedDbNode && ( {selectedDbNode && (
<div style={infoPanel}> <div style={infoPanel}>
+43 -20
View File
@@ -8,7 +8,9 @@ export default function PaneHeader({
slot, slot,
onCollapse, onCollapse,
onSwapPanes, onSwapPanes,
children tabBar,
children,
toolbarHostRef,
}: PaneHeaderProps) { }: PaneHeaderProps) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
@@ -56,32 +58,53 @@ export default function PaneHeader({
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '8px', gap: '10px',
padding: '8px 12px',
background: isDragOver ? 'rgba(34, 197, 94, 0.1)' : 'transparent', background: isDragOver ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
minHeight: '44px',
cursor: slot && onSwapPanes ? 'grab' : 'default', cursor: slot && onSwapPanes ? 'grab' : 'default',
opacity: isDragging ? 0.5 : 1, opacity: isDragging ? 0.5 : 1,
borderRadius: isDragOver ? '6px' : '0', borderRadius: isDragOver ? '6px' : '0',
transition: 'background 0.15s ease', transition: 'background 0.15s ease',
padding: '8px 12px',
minHeight: '48px',
}} }}
> >
{/* Children (tabs, etc.) - takes up available space */} <div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0 }}> {tabBar ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0 }}>
{tabBar}
</div>
) : null}
{children ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0, flexWrap: 'wrap' }}>
{children} {children}
</div> </div>
) : null}
{toolbarHostRef ? (
<div
ref={toolbarHostRef}
style={{
flex: 1,
minWidth: 0,
display: 'flex',
alignItems: 'center',
}}
/>
) : null}
{!tabBar && !children && !toolbarHostRef ? (
<div style={{ color: '#666', fontSize: '12px' }} />
) : null}
</div>
{/* Close button (when onCollapse is provided) */}
{onCollapse && ( {onCollapse && (
<button <button
onClick={onCollapse} onClick={onCollapse}
style={{ style={{
width: '28px', width: '32px',
height: '28px', height: '32px',
borderRadius: '6px', borderRadius: '8px',
border: '1px solid #2a2a2a', border: '1px solid #3a3a3a',
background: '#111', background: '#161616',
color: '#777', color: '#c2c2c2',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -91,17 +114,17 @@ export default function PaneHeader({
}} }}
title="Close pane" title="Close pane"
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = '#1a1a1a'; e.currentTarget.style.background = 'rgba(127, 29, 29, 0.22)';
e.currentTarget.style.borderColor = '#3a3a3a'; e.currentTarget.style.borderColor = 'rgba(248, 113, 113, 0.5)';
e.currentTarget.style.color = '#aaa'; e.currentTarget.style.color = '#fca5a5';
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.background = '#111'; e.currentTarget.style.background = '#161616';
e.currentTarget.style.borderColor = '#2a2a2a'; e.currentTarget.style.borderColor = '#3a3a3a';
e.currentTarget.style.color = '#777'; e.currentTarget.style.color = '#c2c2c2';
}} }}
> >
<X size={14} /> <X size={15} strokeWidth={2.25} />
</button> </button>
)} )}
</div> </div>
+10 -1
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useState } from 'react';
import PaneHeader from './PaneHeader'; import PaneHeader from './PaneHeader';
import DatabaseTableView from '../views/DatabaseTableView'; import DatabaseTableView from '../views/DatabaseTableView';
import type { BasePaneProps } from './types'; import type { BasePaneProps } from './types';
@@ -19,6 +20,7 @@ export default function TablePane({
onNodeClick, onNodeClick,
refreshToken refreshToken
}: TablePaneProps) { }: TablePaneProps) {
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
return ( return (
<div style={{ <div style={{
display: 'flex', display: 'flex',
@@ -27,11 +29,18 @@ export default function TablePane({
background: 'transparent', background: 'transparent',
overflow: 'hidden' overflow: 'hidden'
}}> }}>
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar} /> <PaneHeader
slot={slot}
onCollapse={onCollapse}
onSwapPanes={onSwapPanes}
tabBar={tabBar}
toolbarHostRef={setToolbarHost}
/>
<div style={{ flex: 1, overflow: 'hidden' }}> <div style={{ flex: 1, overflow: 'hidden' }}>
<DatabaseTableView <DatabaseTableView
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
refreshToken={refreshToken} refreshToken={refreshToken}
toolbarHost={toolbarHost}
/> />
</div> </div>
</div> </div>
+17 -1
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useState } from 'react';
import PaneHeader from './PaneHeader'; import PaneHeader from './PaneHeader';
import ViewsOverlay from '../views/ViewsOverlay'; import ViewsOverlay from '../views/ViewsOverlay';
import type { BasePaneProps, PaneAction, PaneType } from './types'; import type { BasePaneProps, PaneAction, PaneType } from './types';
@@ -11,6 +12,8 @@ export interface ViewsPaneProps extends BasePaneProps {
refreshToken?: number; refreshToken?: number;
pendingNodes?: PendingNode[]; pendingNodes?: PendingNode[];
onDismissPending?: (id: string) => void; onDismissPending?: (id: string) => void;
externalDimensionFilter?: string | null;
onClearExternalDimensionFilter?: () => void;
} }
export default function ViewsPane({ export default function ViewsPane({
@@ -19,12 +22,16 @@ export default function ViewsPane({
onPaneAction, onPaneAction,
onCollapse, onCollapse,
onSwapPanes, onSwapPanes,
tabBar,
onNodeClick, onNodeClick,
onNodeOpenInOtherPane, onNodeOpenInOtherPane,
refreshToken, refreshToken,
pendingNodes, pendingNodes,
onDismissPending, onDismissPending,
externalDimensionFilter,
onClearExternalDimensionFilter,
}: ViewsPaneProps) { }: ViewsPaneProps) {
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
const handleTypeChange = (type: PaneType) => { const handleTypeChange = (type: PaneType) => {
onPaneAction?.({ type: 'switch-pane-type', paneType: type }); onPaneAction?.({ type: 'switch-pane-type', paneType: type });
}; };
@@ -37,7 +44,13 @@ export default function ViewsPane({
background: 'transparent', background: 'transparent',
overflow: 'hidden' overflow: 'hidden'
}}> }}>
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} /> <PaneHeader
slot={slot}
onCollapse={onCollapse}
onSwapPanes={onSwapPanes}
tabBar={tabBar}
toolbarHostRef={setToolbarHost}
/>
<div style={{ flex: 1, overflow: 'hidden' }}> <div style={{ flex: 1, overflow: 'hidden' }}>
<ViewsOverlay <ViewsOverlay
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
@@ -45,6 +58,9 @@ export default function ViewsPane({
refreshToken={refreshToken} refreshToken={refreshToken}
pendingNodes={pendingNodes} pendingNodes={pendingNodes}
onDismissPending={onDismissPending} onDismissPending={onDismissPending}
externalDimensionFilter={externalDimensionFilter}
onClearExternalDimensionFilter={onClearExternalDimensionFilter}
toolbarHost={toolbarHost}
/> />
</div> </div>
</div> </div>
+166 -117
View File
@@ -15,6 +15,8 @@ const DIMENSION_COLORS = [
'#a855f7', // purple '#a855f7', // purple
]; ];
export type MapViewMode = 'dimension' | 'hub';
function hashDimensionColor(dimension: string): string { function hashDimensionColor(dimension: string): string {
let hash = 0; let hash = 0;
for (let i = 0; i < dimension.length; i++) { 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]; 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 { export function getDimensionColor(dimensions: string[] | undefined): string | undefined {
if (!dimensions || dimensions.length === 0) return undefined; const primary = getPrimaryDimension(dimensions);
// Use first dimension for border color return primary === 'Unsorted' ? '#4b5563' : hashDimensionColor(primary);
return hashDimensionColor(dimensions[0]);
} }
export interface RahNodeData { export interface RahNodeData {
@@ -37,74 +50,155 @@ export interface RahNodeData {
dbNode: DbNode; dbNode: DbNode;
dimensionIcons?: Record<string, string>; dimensionIcons?: Record<string, string>;
primaryDimensionColor?: string; primaryDimensionColor?: string;
clusterKey?: string;
[key: string]: unknown; [key: string]: unknown;
} }
const NODE_LIMIT = 200; export const NODE_LIMIT = 200;
const LABEL_THRESHOLD = 15; export const LABEL_THRESHOLD = 15;
export { NODE_LIMIT, LABEL_THRESHOLD }; export function getSavedMapPosition(
/**
* Get node position from saved metadata or calculate using Fibonacci spiral.
*/
export function getNodePosition(
node: DbNode, node: DbNode,
index: number, viewMode: MapViewMode,
total: number, ): { x: number; y: number } | null {
centerX: number,
centerY: number,
maxEdges: number,
): { x: number; y: number } {
// Check for saved position in metadata
const metadata = typeof node.metadata === 'string' const metadata = typeof node.metadata === 'string'
? safeParseJSON(node.metadata) ? safeParseJSON(node.metadata)
: node.metadata; : node.metadata;
const savedPos = metadata?.map_position; const nested = metadata?.map_positions as Record<string, { x?: number; y?: number }> | undefined;
if (savedPos?.x !== undefined && savedPos?.y !== undefined) { const scoped = metadata?.[`map_position_${viewMode}`] as { x?: number; y?: number } | undefined;
return { x: savedPos.x, y: savedPos.y }; const saved = nested?.[viewMode] || scoped;
if (saved?.x !== undefined && saved?.y !== undefined) {
return { x: saved.x, y: saved.y };
}
return null;
} }
// Fibonacci spiral layout function getAllNodes(baseNodes: DbNode[], expandedNodes: DbNode[]): DbNode[] {
const edgeCount = node.edge_count ?? 0; const baseIds = new Set(baseNodes.map((node) => node.id));
const edgeRatio = maxEdges > 0 ? edgeCount / maxEdges : 0; return [...baseNodes, ...expandedNodes.filter((node) => !baseIds.has(node.id))];
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
const angle = index * goldenAngle;
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;
return {
x: centerX + Math.cos(angle) * distance,
y: centerY + Math.sin(angle) * distance,
};
} }
/** function buildDimensionLayout(nodes: DbNode[], centerX: number, centerY: number): Map<string, { x: number; y: number }> {
* Position expanded (traversal) nodes in a circle around a reference node. const positions = new Map<string, { x: number; y: number }>();
*/ const groups = new Map<string, DbNode[]>();
export function getExpandedNodePosition( for (const node of nodes) {
index: number, const key = getPrimaryDimension(node.dimensions);
total: number, const existing = groups.get(key) || [];
refX: number, existing.push(node);
refY: number, groups.set(key, existing);
): { x: number; y: number } { }
const angle = (index / Math.max(total, 1)) * Math.PI * 2;
const distance = 150 + (index % 3) * 40; const clusterKeys = [...groups.keys()].sort((a, b) => a.localeCompare(b));
return { const columns = Math.max(1, Math.ceil(Math.sqrt(clusterKeys.length || 1)));
x: refX + Math.cos(angle) * distance, const clusterGapX = 360;
y: refY + Math.sin(angle) * distance, 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;
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)));
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 positions;
}
function buildHubLayout(
nodes: DbNode[],
dbEdges: DbEdge[],
centerX: number,
centerY: number,
): Map<string, { x: number; y: number }> {
const positions = new Map<string, { x: number; y: number }>();
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<number, number[]>();
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<number, DbNode[]>();
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( export function toRFNodes(
baseNodes: DbNode[], baseNodes: DbNode[],
expandedNodes: DbNode[], expandedNodes: DbNode[],
@@ -113,20 +207,23 @@ export function toRFNodes(
selectedNodeId: number | null, selectedNodeId: number | null,
connectedNodeIds: Set<number>, connectedNodeIds: Set<number>,
existingPositions: Map<string, { x: number; y: number }>, existingPositions: Map<string, { x: number; y: number }>,
dimensionIcons?: Record<string, string>, dimensionIcons: Record<string, string> | undefined,
viewMode: MapViewMode,
dbEdges: DbEdge[],
): RFNode<RahNodeData>[] { ): RFNode<RahNodeData>[] {
const sortedBase = [...baseNodes].sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0)); const allNodes = getAllNodes(baseNodes, expandedNodes);
const maxEdges = Math.max(...sortedBase.map(n => n.edge_count ?? 0), 1);
const baseNodeIds = new Set(baseNodes.map(n => n.id));
const hasSelection = selectedNodeId !== null; 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<RahNodeData>[] = sortedBase.map((node, index) => { return allNodes.map((node) => {
const id = String(node.id); const id = String(node.id);
// Prefer React Flow's current position (for drag state), then saved, then calculated
const existingPos = existingPositions.get(id); const existingPos = existingPositions.get(id);
const pos = existingPos || getNodePosition(node, index, sortedBase.length, centerX, centerY, maxEdges); const savedPos = getSavedMapPosition(node, viewMode);
const fallbackPos = clusterLayout.get(id) || { x: centerX, y: centerY };
// Dim nodes that aren't selected or connected to selection const pos = existingPos || savedPos || fallbackPos;
const isDimmed = hasSelection && node.id !== selectedNodeId && !connectedNodeIds.has(node.id); const isDimmed = hasSelection && node.id !== selectedNodeId && !connectedNodeIds.has(node.id);
return { return {
@@ -136,64 +233,16 @@ export function toRFNodes(
className: isDimmed ? 'dimmed' : undefined, className: isDimmed ? 'dimmed' : undefined,
data: { data: {
label: node.title || 'Untitled', label: node.title || 'Untitled',
dimensions: node.dimensions || [], dimensions: getOrderedDimensions(node.dimensions),
edgeCount: node.edge_count ?? 0, edgeCount: node.edge_count ?? 0,
isExpanded: false, isExpanded: !baseNodeIds.has(node.id),
dbNode: node, dbNode: node,
dimensionIcons, dimensionIcons,
primaryDimensionColor: getDimensionColor(node.dimensions), 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;
} }
/** /**
+3
View File
@@ -87,6 +87,8 @@ export interface ViewsPaneProps extends BasePaneProps {
onNodeClick: (nodeId: number) => void; onNodeClick: (nodeId: number) => void;
onNodeOpenInOtherPane?: (nodeId: number) => void; onNodeOpenInOtherPane?: (nodeId: number) => void;
refreshToken?: number; refreshToken?: number;
externalDimensionFilter?: string | null;
onClearExternalDimensionFilter?: () => void;
} }
// TablePane specific props // TablePane specific props
@@ -102,6 +104,7 @@ export interface PaneHeaderProps {
onSwapPanes?: () => void; onSwapPanes?: () => void;
tabBar?: React.ReactNode; tabBar?: React.ReactNode;
children?: React.ReactNode; children?: React.ReactNode;
toolbarHostRef?: (node: HTMLDivElement | null) => void;
} }
// Labels for pane types // Labels for pane types
+21 -7
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; 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 { Filter, ChevronDown, ChevronLeft, ChevronRight, X, ArrowUpDown, Search, ExternalLink } from 'lucide-react';
import type { Node } from '@/types/database'; import type { Node } from '@/types/database';
@@ -24,6 +25,7 @@ interface DimensionSummary {
interface DatabaseTableViewProps { interface DatabaseTableViewProps {
onNodeClick: (nodeId: number) => void; onNodeClick: (nodeId: number) => void;
refreshToken?: number; refreshToken?: number;
toolbarHost?: HTMLDivElement | null;
} }
function formatRelativeTime(dateStr: string): string { 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<Node[]>([]); const [nodes, setNodes] = useState<Node[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -161,17 +163,14 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0 }: Dat
const startItem = (page - 1) * PAGE_SIZE + 1; const startItem = (page - 1) * PAGE_SIZE + 1;
const endItem = Math.min(page * PAGE_SIZE, total); const endItem = Math.min(page * PAGE_SIZE, total);
return ( const toolbar = (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'transparent' }}>
{/* Compact toolbar */}
<div style={{ <div style={{
padding: '8px 12px', width: '100%',
borderBottom: '1px solid #1a1a1a',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '8px', gap: '8px',
minWidth: 0,
}}> }}>
{/* Search */}
<form onSubmit={handleSearchSubmit} style={{ display: 'flex', alignItems: 'center', gap: '0' }}> <form onSubmit={handleSearchSubmit} style={{ display: 'flex', alignItems: 'center', gap: '0' }}>
<div style={{ <div style={{
display: 'flex', display: 'flex',
@@ -392,6 +391,21 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0 }: Dat
</button> </button>
</div> </div>
</div> </div>
);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'transparent' }}>
{toolbarHost ? createPortal(toolbar, toolbarHost) : (
<div style={{
padding: '8px 12px',
borderBottom: '1px solid #1a1a1a',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
{toolbar}
</div>
)}
{/* Table */} {/* Table */}
<div style={{ flex: 1, overflow: 'auto' }}> <div style={{ flex: 1, overflow: 'auto' }}>
+70 -21
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Filter, ChevronDown, X, ArrowUpDown, GripVertical } from 'lucide-react'; import { Filter, ChevronDown, X, ArrowUpDown, GripVertical } from 'lucide-react';
import type { Node } from '@/types/database'; import type { Node } from '@/types/database';
import { getNodeIcon } from '@/utils/nodeIcons'; import { getNodeIcon } from '@/utils/nodeIcons';
@@ -132,9 +133,21 @@ interface ViewsOverlayProps {
refreshToken?: number; refreshToken?: number;
pendingNodes?: PendingNode[]; pendingNodes?: PendingNode[];
onDismissPending?: (id: string) => void; 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(); const { dimensionIcons } = useDimensionIcons();
// Dimensions for filter picker // Dimensions for filter picker
@@ -160,10 +173,12 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
const [showSortDropdown, setShowSortDropdown] = useState(false); const [showSortDropdown, setShowSortDropdown] = useState(false);
// Derive selectedFilters for backward compatibility (unique dimensions) // Derive selectedFilters for backward compatibility (unique dimensions)
const selectedFilters = useMemo(() => const selectedFilters = useMemo(() => {
[...new Set(columns.map(c => c.dimension))], if (externalDimensionFilter) {
[columns] return [externalDimensionFilter];
); }
return [...new Set(columns.map(c => c.dimension))];
}, [columns, externalDimensionFilter]);
// Sorted dimensions (locked first) // Sorted dimensions (locked first)
const sortedDimensions = useMemo(() => { const sortedDimensions = useMemo(() => {
@@ -293,6 +308,9 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
// Column management // Column management
const addColumn = (dimension: string) => { const addColumn = (dimension: string) => {
if (externalDimensionFilter) {
return;
}
const newColumn: ColumnFilter = { const newColumn: ColumnFilter = {
id: `col-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, id: `col-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
dimension dimension
@@ -303,6 +321,10 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
}; };
const removeFilter = (dimension: string) => { const removeFilter = (dimension: string) => {
if (externalDimensionFilter && dimension === externalDimensionFilter) {
onClearExternalDimensionFilter?.();
return;
}
const idx = columns.findIndex(c => c.dimension === dimension); const idx = columns.findIndex(c => c.dimension === dimension);
if (idx !== -1) { if (idx !== -1) {
setColumns(columns.filter((_, i) => i !== idx)); setColumns(columns.filter((_, i) => i !== idx));
@@ -310,6 +332,9 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
}; };
const clearFilters = () => { const clearFilters = () => {
if (externalDimensionFilter) {
onClearExternalDimensionFilter?.();
}
setColumns([]); setColumns([]);
}; };
@@ -552,23 +577,14 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
); );
}; };
return ( const toolbar = (
<div style={{ <div style={{
display: 'flex', width: '100%',
flexDirection: 'column',
height: '100%',
background: 'transparent'
}}>
{/* Header with filters + sort */}
<div style={{
padding: '10px 16px',
borderBottom: '1px solid #1a1a1a',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '10px', gap: '10px',
flexWrap: 'wrap' flexWrap: 'wrap'
}}> }}>
{/* Filter chips + add filter button */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', flex: 1 }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', flex: 1 }}>
{selectedFilters.map(filter => ( {selectedFilters.map(filter => (
<div <div
@@ -605,10 +621,20 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
</div> </div>
))} ))}
{/* Add filter button */} {externalDimensionFilter && (
<span style={{ fontSize: '11px', color: '#666' }}>
Sidebar filter
</span>
)}
<div style={{ position: 'relative' }} ref={filterPickerRef}> <div style={{ position: 'relative' }} ref={filterPickerRef}>
<button <button
onClick={() => setShowFilterPicker(!showFilterPicker)} onClick={() => {
if (!externalDimensionFilter) {
setShowFilterPicker(!showFilterPicker);
}
}}
disabled={!!externalDimensionFilter}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -617,14 +643,16 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
background: 'transparent', background: 'transparent',
border: '1px solid #222', border: '1px solid #222',
borderRadius: '5px', borderRadius: '5px',
color: '#888', color: externalDimensionFilter ? '#4b4b4b' : '#888',
fontSize: '11px', fontSize: '11px',
cursor: 'pointer', cursor: externalDimensionFilter ? 'not-allowed' : 'pointer',
transition: 'all 0.15s ease' transition: 'all 0.15s ease'
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!externalDimensionFilter) {
e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
e.currentTarget.style.borderColor = '#333'; e.currentTarget.style.borderColor = '#333';
}
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'; e.currentTarget.style.background = 'transparent';
@@ -636,7 +664,7 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
</button> </button>
{/* Filter picker dropdown */} {/* Filter picker dropdown */}
{showFilterPicker && ( {showFilterPicker && !externalDimensionFilter && (
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
top: '100%', top: '100%',
@@ -816,6 +844,27 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
)} )}
</div> </div>
</div> </div>
);
return (
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
background: 'transparent'
}}>
{toolbarHost ? createPortal(toolbar, toolbarHost) : (
<div style={{
padding: '10px 16px',
borderBottom: '1px solid #1a1a1a',
display: 'flex',
alignItems: 'center',
gap: '10px',
flexWrap: 'wrap'
}}>
{toolbar}
</div>
)}
{/* Content area — list view */} {/* Content area — list view */}
{filteredNodesLoading ? ( {filteredNodesLoading ? (
+2 -1
View File
@@ -5,7 +5,8 @@ type RankedChunk = Chunk & { similarity: number };
function sanitizeFtsQuery(input: string): string { function sanitizeFtsQuery(input: string): string {
return input return input
.replace(/['"()*:^~{}[\]]/g, ' ') .toLowerCase()
.replace(/[^a-z0-9\s]+/g, ' ')
.trim() .trim()
.split(/\s+/) .split(/\s+/)
.filter(word => word.length > 0 && !/^(AND|OR|NOT|NEAR)$/i.test(word)) .filter(word => word.length > 0 && !/^(AND|OR|NOT|NEAR)$/i.test(word))
+2 -1
View File
@@ -8,7 +8,8 @@ type NodeSearchRow = NodeRow & { rank?: number; similarity?: number };
function sanitizeFtsQuery(input: string): string { function sanitizeFtsQuery(input: string): string {
return input return input
.replace(/['"()*:^~{}[\]]/g, ' ') .toLowerCase()
.replace(/[^a-z0-9\s]+/g, ' ')
.trim() .trim()
.split(/\s+/) .split(/\s+/)
.filter(word => word.length > 0 && !/^(AND|OR|NOT|NEAR)$/i.test(word)) .filter(word => word.length > 0 && !/^(AND|OR|NOT|NEAR)$/i.test(word))
+48
View File
@@ -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,
};
}
},
});
+86 -14
View File
@@ -4,8 +4,67 @@ import { nodeService } from '@/services/database/nodes';
import { formatNodeForChat } from '../infrastructure/nodeFormatter'; import { formatNodeForChat } from '../infrastructure/nodeFormatter';
import type { Node } from '@/types/database'; 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<string>();
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({ 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({ inputSchema: z.object({
filters: 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(), 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.'), eventBefore: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes with event_date before this date.'),
}).optional() }).optional()
}), }),
execute: async ({ filters = {} }) => { execute: async ({ filters = {} }: { filters?: QueryNodeFilters }) => {
console.log('🔍 QueryNodes tool called with filters:', JSON.stringify(filters, null, 2)); console.log('🔍 QueryNodes tool called with filters:', JSON.stringify(filters, null, 2));
try { try {
const limit = filters.limit || 10; const limit = filters.limit || 10;
@@ -63,27 +122,40 @@ export const queryNodesTool = tool({
}; };
} }
// Add timeout to prevent hanging const runQuery = async (queryFilters: typeof filters): Promise<Node[]> => {
const timeoutPromise: Promise<Node[] | undefined> = new Promise((_, reject) => { const timeoutPromise: Promise<Node[] | undefined> = new Promise((_, reject) => {
setTimeout(() => reject(new Error('QueryNodes timeout after 10 seconds')), 10000); setTimeout(() => reject(new Error('QueryNodes timeout after 10 seconds')), 10000);
}); });
// Use new nodeService with dimension-based filtering
const nodesPromise: Promise<Node[] | undefined> = nodeService.getNodes({ const nodesPromise: Promise<Node[] | undefined> = nodeService.getNodes({
limit, limit,
dimensions: filters.dimensions, dimensions: queryFilters.dimensions,
search: filters.search, search: queryFilters.search,
searchMode: searchTerm ? 'hybrid' : 'standard', searchMode: searchTerm ? 'hybrid' : 'standard',
createdAfter: filters.createdAfter, createdAfter: queryFilters.createdAfter,
createdBefore: filters.createdBefore, createdBefore: queryFilters.createdBefore,
eventAfter: filters.eventAfter, eventAfter: queryFilters.eventAfter,
eventBefore: filters.eventBefore, eventBefore: queryFilters.eventBefore,
}); });
const nodes = await Promise.race<Node[] | undefined>([nodesPromise, timeoutPromise]); const nodes = await Promise.race<Node[] | undefined>([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); const limitedNodes = safeNodes.slice(0, limit);
// Format nodes for chat display // Format nodes for chat display
@@ -106,14 +178,14 @@ export const queryNodesTool = tool({
// Create message with formatted node labels only (no full node payload) // Create message with formatted node labels only (no full node payload)
const formattedLabels = formattedNodes.map(node => node.formatted_display).join(', '); 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 { return {
success: true, success: true,
data: { data: {
nodes: formattedNodes, nodes: formattedNodes,
count: safeNodes.length, count: safeNodes.length,
filters_applied: filters filters_applied: effectiveFilters
}, },
message: message message: message
}; };
+1
View File
@@ -49,6 +49,7 @@ export const TOOL_GROUP_ASSIGNMENTS: Record<string, string> = {
// Execution: Write operations and extraction (workers only) // Execution: Write operations and extraction (workers only)
createNode: 'execution', createNode: 'execution',
updateNode: 'execution', updateNode: 'execution',
deleteNode: 'execution',
createEdge: 'execution', createEdge: 'execution',
updateEdge: 'execution', updateEdge: 'execution',
embedContent: 'execution', embedContent: 'execution',
+3
View File
@@ -3,6 +3,7 @@ import { queryNodesTool } from '../database/queryNodes';
import { getNodesByIdTool } from '../database/getNodesById'; import { getNodesByIdTool } from '../database/getNodesById';
import { createNodeTool } from '../database/createNode'; import { createNodeTool } from '../database/createNode';
import { updateNodeTool } from '../database/updateNode'; import { updateNodeTool } from '../database/updateNode';
import { deleteNodeTool } from '../database/deleteNode';
import { createEdgeTool } from '../database/createEdge'; import { createEdgeTool } from '../database/createEdge';
import { queryEdgeTool } from '../database/queryEdge'; import { queryEdgeTool } from '../database/queryEdge';
import { updateEdgeTool } from '../database/updateEdge'; import { updateEdgeTool } from '../database/updateEdge';
@@ -43,6 +44,7 @@ const ORCHESTRATION_TOOLS: Record<string, any> = {
const EXECUTION_TOOLS: Record<string, any> = { const EXECUTION_TOOLS: Record<string, any> = {
createNode: createNodeTool, createNode: createNodeTool,
updateNode: updateNodeTool, updateNode: updateNodeTool,
deleteNode: deleteNodeTool,
createEdge: createEdgeTool, createEdge: createEdgeTool,
updateEdge: updateEdgeTool, updateEdge: updateEdgeTool,
createDimension: createDimensionTool, createDimension: createDimensionTool,
@@ -71,6 +73,7 @@ const ORCHESTRATOR_TOOL_NAMES = Array.from(new Set([
'think', 'think',
'createNode', 'createNode',
'updateNode', 'updateNode',
'deleteNode',
'createEdge', 'createEdge',
'updateEdge', 'updateEdge',
'createDimension', 'createDimension',
+3 -1
View File
@@ -1,11 +1,13 @@
{ {
"id": "golden-ra-h-core-v2", "id": "golden-ra-h-core-v2",
"name": "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", "inherits": "golden-v1",
"version": 2, "version": 2,
"focus": [ "focus": [
"tool-call correctness", "tool-call correctness",
"category-based coverage",
"search regressions",
"skill usage only when appropriate", "skill usage only when appropriate",
"core retrieval/write behavior", "core retrieval/write behavior",
"latency/tokens/cost guards" "latency/tokens/cost guards"
+22 -22
View File
@@ -3,7 +3,7 @@ import path from 'path';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { scenarios } from './scenarios'; import { scenarios } from './scenarios';
import { Scenario } from './types'; import { EvalCategory, Scenario } from './types';
type EvalChatRow = { type EvalChatRow = {
trace_id: string; trace_id: string;
@@ -38,7 +38,7 @@ type EvalResult = {
const BASE_URL = process.env.RAH_EVALS_BASE_URL || 'http://localhost:3000'; const BASE_URL = process.env.RAH_EVALS_BASE_URL || 'http://localhost:3000';
const DATASET_ENV = process.env.RAH_EVALS_DATASET_ID; 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 WAIT_TIMEOUT_MS = Number(process.env.RAH_EVALS_TIMEOUT_MS || 60000);
const LOG_DB_PATH = path.join(process.cwd(), 'logs', 'evals.sqlite'); const LOG_DB_PATH = path.join(process.cwd(), 'logs', 'evals.sqlite');
const RAH_DB_PATH = process.env.SQLITE_DB_PATH || path.join( const RAH_DB_PATH = process.env.SQLITE_DB_PATH || path.join(
@@ -54,7 +54,7 @@ function loadDatasetId() {
return parsed.id || 'default'; return parsed.id || 'default';
} }
type EvalSuite = 'all' | 'tools' | 'skills' | 'traversal' | 'internal' | 'external'; type EvalCategoryFilter = 'all' | EvalCategory;
type FocusedNodeContext = { type FocusedNodeContext = {
id: number; id: number;
@@ -66,25 +66,25 @@ type FocusedNodeContext = {
metadata: string | null; metadata: string | null;
}; };
function getDefaultScenarioSuites(scenario: Scenario): string[] { function getDefaultScenarioCategories(scenario: Scenario): EvalCategory[] {
const suites = ['internal']; if (scenario.id.includes('search') || scenario.id.includes('quote')) {
if (scenario.id.startsWith('skill-trigger-')) { return ['search'];
suites.push('skills');
} else {
suites.push('tools');
} }
if (scenario.id.includes('traverse')) { if (scenario.id.includes('skill')) {
suites.push('traversal'); 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) { function shouldRunScenario(scenario: Scenario, category: EvalCategoryFilter) {
if (suite === 'all') return true; if (category === 'all') return true;
const suites = scenario.suites && scenario.suites.length > 0 const categories = scenario.categories && scenario.categories.length > 0
? scenario.suites ? scenario.categories
: getDefaultScenarioSuites(scenario); : getDefaultScenarioCategories(scenario);
return suites.includes(suite); return categories.includes(category);
} }
function resolveFocusedNodeId(query: Scenario['input']['focusedNodeQuery']): number | null { function resolveFocusedNodeId(query: Scenario['input']['focusedNodeQuery']): number | null {
@@ -424,14 +424,14 @@ async function runScenario(scenario: Scenario, datasetId: string): Promise<EvalR
} }
async function runAll() { async function runAll() {
const suite: EvalSuite = ['skills', 'tools', 'traversal', 'internal', 'external'].includes(SUITE_ENV) const category: EvalCategoryFilter = ['database', 'tools', 'skills', 'search', 'ingestion'].includes(CATEGORY_ENV)
? (SUITE_ENV as EvalSuite) ? (CATEGORY_ENV as EvalCategoryFilter)
: 'all'; : 'all';
const datasetId = loadDatasetId(); const datasetId = loadDatasetId();
await ensureServerReady(); await ensureServerReady();
await ensureEvalsEnabled(); await ensureEvalsEnabled();
const runnable = scenarios.filter(s => s.enabled !== false && shouldRunScenario(s, suite)); const runnable = scenarios.filter(s => s.enabled !== false && shouldRunScenario(s, category));
console.log(`Running ${runnable.length} scenarios (dataset: ${datasetId}, suite: ${suite})...\n`); console.log(`Running ${runnable.length} scenarios (dataset: ${datasetId}, category: ${category})...\n`);
const results: EvalResult[] = []; const results: EvalResult[] = [];
for (const scenario of runnable) { for (const scenario of runnable) {
+2
View File
@@ -1,6 +1,7 @@
import { scenario as focusedGraphWrite } from './focused-graph-write'; import { scenario as focusedGraphWrite } from './focused-graph-write';
import { scenario as skillGuidedWrite } from './skill-guided-write'; import { scenario as skillGuidedWrite } from './skill-guided-write';
import { scenario as nodeIndexSearch } from './node-index-search'; 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 chunkQuoteInsight } from './chunk-quote-insight';
import { scenario as hubTraversal } from './hub-traversal'; import { scenario as hubTraversal } from './hub-traversal';
@@ -8,6 +9,7 @@ export const scenarios = [
focusedGraphWrite, focusedGraphWrite,
skillGuidedWrite, skillGuidedWrite,
nodeIndexSearch, nodeIndexSearch,
searchDocToLora,
chunkQuoteInsight, chunkQuoteInsight,
hubTraversal, hubTraversal,
]; ];
@@ -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.',
};
+3
View File
@@ -1,3 +1,5 @@
export type EvalCategory = 'database' | 'tools' | 'skills' | 'search' | 'ingestion';
export type ScenarioExpectations = { export type ScenarioExpectations = {
skillsRead?: string[]; skillsRead?: string[];
skillsReadSoft?: string[]; skillsReadSoft?: string[];
@@ -32,6 +34,7 @@ export type Scenario = {
expect?: ScenarioExpectations; expect?: ScenarioExpectations;
description?: string; description?: string;
tools?: string[]; tools?: string[];
categories?: EvalCategory[];
suites?: string[]; suites?: string[];
enabled?: boolean; enabled?: boolean;
notes?: string; notes?: string;