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:
@@ -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
@@ -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 │
|
||||||
│ Panel │ Panel │
|
│ │ │ │
|
||||||
│ │ │
|
│ 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,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',
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user