feat: port holistic node refinement contract

This commit is contained in:
“BeeRad”
2026-04-11 21:37:52 +10:00
parent 35f9ecf89c
commit 3ae46245ec
119 changed files with 6596 additions and 10982 deletions
+135 -133
View File
@@ -4,9 +4,7 @@ import { useEffect, useRef, useState, type DragEvent } from 'react';
import { Trash2, Loader, Database, RefreshCw, Pencil, X, Save, Plus, Link2, Tag, Share2, AlignLeft, ChevronDown, ChevronRight, Check, Folder } from 'lucide-react';
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
import { Node, NodeConnection } from '@/types/database';
import DimensionTags from './dimensions/DimensionTags';
import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import ConfirmDialog from '../common/ConfirmDialog';
import { SourceReader } from './source';
import SourceEditor from './source/SourceEditor';
@@ -39,7 +37,6 @@ export default function FocusPanel({
onTextSelect,
highlightedPassage,
}: FocusPanelProps) {
const { dimensionIcons } = useDimensionIcons();
const [nodesData, setNodesData] = useState<Record<number, Node>>({});
const [edgesData, setEdgesData] = useState<Record<number, NodeConnection[]>>({});
const [loadingNodes, setLoadingNodes] = useState<Set<number>>(new Set());
@@ -837,7 +834,7 @@ export default function FocusPanel({
{isOutgoing ? '↗' : '↙'}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>
{getNodeIcon(connection.connected_node, dimensionIcons, 12)}
{getNodeIcon(connection.connected_node, 12)}
</span>
<button
type="button"
@@ -1150,7 +1147,7 @@ export default function FocusPanel({
<div className="prop-label">node</div>
<div className="prop-value">
<span className="node-meta-value">
<span className="node-meta-icon">{getNodeIcon(currentNode, dimensionIcons, 12)}</span>
<span className="node-meta-icon">{getNodeIcon(currentNode, 12)}</span>
<span className="node-id-pill">#{currentNode.id}</span>
</span>
</div>
@@ -1235,21 +1232,24 @@ export default function FocusPanel({
</div>
</div>
<div className="prop-row prop-row-top">
<div className="prop-label">ctx</div>
<div className="prop-value" style={{ overflow: 'visible' }}>
<div ref={contextMenuRef} style={{ position: 'relative', width: '100%' }}>
<div className="prop-row">
<div className="prop-label">context</div>
<div className="prop-value context-prop-value">
<div className="context-select-shell" ref={contextMenuRef}>
<button
type="button"
onClick={() => setContextMenuOpen((prev) => !prev)}
disabled={contextSaving}
className="context-select-trigger"
disabled={contextSaving}
onClick={(event) => {
event.stopPropagation();
setContextMenuOpen((prev) => !prev);
}}
>
<span className="context-select-main">
<span className="context-select-icon">
<span className="context-select-current">
<span className="context-select-badge">
<Folder size={13} />
</span>
<span className="context-select-text">
<span className={currentNode.context ? 'context-select-name' : 'context-select-empty'}>
{currentNode.context?.name || 'No context'}
</span>
</span>
@@ -1261,7 +1261,8 @@ export default function FocusPanel({
<button
type="button"
className={`context-option ${currentNode.context_id == null ? 'active' : ''}`}
onClick={() => {
onClick={(event) => {
event.stopPropagation();
void saveContext('');
}}
>
@@ -1272,7 +1273,8 @@ export default function FocusPanel({
key={context.id}
type="button"
className={`context-option ${currentNode.context_id === context.id ? 'active' : ''}`}
onClick={() => {
onClick={(event) => {
event.stopPropagation();
void saveContext(String(context.id));
}}
>
@@ -1286,20 +1288,6 @@ export default function FocusPanel({
</div>
</div>
{/* Dimensions */}
<div className="prop-row prop-row-top">
<div className="prop-label">dime</div>
<div className="prop-value">
<DimensionTags
dimensions={currentNode.dimensions || []}
onUpdate={async (newDimensions) => {
try { await updateNode(activeTab, { dimensions: newDimensions }); }
catch (error) { console.error('Error saving dimensions:', error); window.alert('Failed to save dimensions. Please try again.'); }
}}
/>
</div>
</div>
{/* Connections */}
<div className="prop-row prop-row-top">
<div className="prop-label">edge</div>
@@ -1322,7 +1310,7 @@ export default function FocusPanel({
onMouseLeave={() => setHoveredConnectionId(null)}
>
<span className={`conn-dir ${isOut ? 'out' : 'in'}`}>{isOut ? '↗' : '↙'}</span>
<span className="conn-icon">{getNodeIcon(connection.connected_node, dimensionIcons, 12)}</span>
<span className="conn-icon">{getNodeIcon(connection.connected_node, 12)}</span>
<button
type="button"
className="conn-title-btn"
@@ -1424,7 +1412,7 @@ export default function FocusPanel({
const title = currentNode.title || 'Untitled';
e.dataTransfer.effectAllowed = 'copyMove';
e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: activeTab, title }));
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: activeTab, title, dimensions: currentNode.dimensions || [] }));
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: activeTab, title }));
e.dataTransfer.setData('text/plain', `[NODE:${activeTab}:"${title}"]`);
}}
className="node-drag-handle"
@@ -1703,7 +1691,7 @@ export default function FocusPanel({
}
.prop-label {
width: 44px;
width: 58px;
flex-shrink: 0;
padding: 7px 8px 7px 0;
color: var(--rah-text-muted);
@@ -1720,6 +1708,10 @@ export default function FocusPanel({
line-height: 1.5;
}
.context-prop-value {
overflow: visible;
}
.props-toggle {
display: inline-flex;
align-items: center;
@@ -1769,6 +1761,116 @@ export default function FocusPanel({
font-family: inherit;
}
.context-select-shell {
position: relative;
z-index: 4;
}
.context-select-trigger {
width: 100%;
min-height: 38px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 10px 0 8px;
border: 1px solid var(--rah-border-strong);
border-radius: 10px;
background: linear-gradient(180deg, var(--rah-bg-surface), var(--rah-bg-panel));
color: var(--rah-text-primary);
cursor: pointer;
}
.context-select-current {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.context-select-badge {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 7px;
border: 1px solid var(--rah-border);
background: var(--rah-bg-base);
color: var(--rah-text-muted);
flex-shrink: 0;
}
.context-select-name,
.context-select-empty {
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.context-select-name {
color: var(--rah-text-primary);
font-weight: 600;
}
.context-select-empty {
color: var(--rah-text-muted);
font-style: italic;
}
.context-select-chevron {
color: var(--rah-text-muted);
flex-shrink: 0;
transition: transform 120ms ease;
}
.context-select-chevron.open {
transform: rotate(180deg);
}
.context-select-menu {
position: relative;
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px;
border: 1px solid var(--rah-border);
border-radius: 12px;
background: var(--rah-bg-surface);
box-shadow: var(--rah-shadow-floating);
}
.context-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
min-height: 32px;
padding: 0 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--rah-text-secondary);
font-size: 12px;
text-align: left;
cursor: pointer;
}
.context-option.active {
background: color-mix(in srgb, var(--rah-accent-green) 10%, var(--rah-bg-panel));
color: var(--rah-text-primary);
}
.context-option-count {
font-size: 10px;
color: var(--rah-text-muted);
flex-shrink: 0;
}
.prop-empty-btn {
background: transparent;
border: none;
@@ -1780,106 +1882,6 @@ export default function FocusPanel({
font-family: inherit;
}
.context-select-trigger {
width: 100%;
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 7px 10px;
border-radius: 9px;
border: 1px solid var(--rah-border);
background: var(--rah-bg-panel);
color: var(--rah-text-base);
cursor: pointer;
font-family: inherit;
transition: border-color 120ms ease, background 120ms ease;
}
.context-select-trigger:hover {
border-color: var(--rah-border-strong);
background: var(--rah-bg-hover);
}
.context-select-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.context-select-icon {
display: inline-flex;
align-items: center;
color: var(--rah-text-muted);
flex-shrink: 0;
}
.context-select-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.context-select-chevron {
color: var(--rah-text-muted);
transition: transform 120ms ease;
flex-shrink: 0;
}
.context-select-chevron.open {
transform: rotate(180deg);
}
.context-select-menu {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px;
border-radius: 10px;
border: 1px solid var(--rah-border);
background: var(--rah-bg-panel);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.22);
}
.context-option {
width: 100%;
min-height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 10px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--rah-text-secondary);
cursor: pointer;
font-size: 12px;
font-family: inherit;
text-align: left;
}
.context-option:hover,
.context-option.active {
background: var(--rah-bg-hover);
color: var(--rah-text-active);
}
.context-option-count {
color: var(--rah-text-muted);
font-size: 10px;
background: var(--rah-bg-active);
padding: 1px 6px;
border-radius: 999px;
flex-shrink: 0;
}
.prop-muted {
color: var(--rah-text-muted);
font-size: 12px;
@@ -1,560 +0,0 @@
"use client";
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
interface DimensionSearchModalProps {
isOpen: boolean;
onClose: () => void;
onDimensionSelect: (dimension: string, description?: string) => void;
existingDimensions: string[];
}
interface DimensionSuggestion {
dimension: string;
count: number;
isPriority: boolean;
}
export default function DimensionSearchModal({
isOpen,
onClose,
onDimensionSelect,
existingDimensions
}: DimensionSearchModalProps) {
const [searchQuery, setSearchQuery] = useState('');
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [newDimensionDescription, setNewDimensionDescription] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const returnFocusRef = useRef<HTMLElement | null>(null);
// Store the element that triggered the modal for return focus
useEffect(() => {
if (isOpen && document.activeElement instanceof HTMLElement) {
returnFocusRef.current = document.activeElement;
}
}, [isOpen]);
// Focus trap and accessibility
useEffect(() => {
if (!isOpen) return;
// Autofocus input
inputRef.current?.focus();
// Lock body scroll
document.body.style.overflow = 'hidden';
// Handle Escape key
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
// Focus trap: keep focus within modal
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements || focusableElements.length === 0) return;
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
document.addEventListener('keydown', handleEscape);
document.addEventListener('keydown', handleTab);
return () => {
document.body.style.overflow = '';
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('keydown', handleTab);
// Return focus to trigger element
if (returnFocusRef.current) {
returnFocusRef.current.focus();
}
};
}, [isOpen, onClose]);
// Track if we're in "create new" mode to avoid re-fetching
const isCreatingNew = searchQuery.trim() &&
!suggestions.some(s => s.dimension.toLowerCase() === searchQuery.toLowerCase().trim());
// Fetch dimension suggestions
useEffect(() => {
const fetchSuggestions = async () => {
try {
const response = await fetch('/api/dimensions/popular?limit=50');
const result = await response.json();
if (result.success) {
const allDimensions: DimensionSuggestion[] = result.data;
// Filter based on search query and exclude existing dimensions
const filtered = allDimensions.filter(dim => {
const matchesQuery = !searchQuery.trim() ||
dim.dimension.toLowerCase().includes(searchQuery.toLowerCase());
const notExisting = !existingDimensions.includes(dim.dimension);
return matchesQuery && notExisting;
});
// Sort: priority first, then by count
const sorted = filtered.sort((a, b) => {
if (a.isPriority && !b.isPriority) return -1;
if (!a.isPriority && b.isPriority) return 1;
return b.count - a.count;
});
setSuggestions(sorted.slice(0, 20));
setSelectedIndex(0);
}
} catch (error) {
console.error('Error fetching dimension suggestions:', error);
setSuggestions([]);
}
};
// Only fetch when modal is open and we're not actively creating a new dimension
// (user has typed description means they're committing to create)
if (isOpen && !newDimensionDescription.trim()) {
const timeoutId = setTimeout(fetchSuggestions, 100);
return () => clearTimeout(timeoutId);
}
}, [searchQuery, existingDimensions, isOpen, newDimensionDescription]);
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
if (suggestions[selectedIndex]) {
// Select existing dimension
handleSelectDimension(suggestions[selectedIndex].dimension);
} else if (searchQuery.trim()) {
// Create new dimension with description
handleSelectDimension(searchQuery.trim(), newDimensionDescription.trim() || undefined);
}
}
};
const handleSelectDimension = (dimension: string, description?: string) => {
onDimensionSelect(dimension, description);
setSearchQuery('');
setNewDimensionDescription('');
setSuggestions([]);
onClose();
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
if (!isOpen) return null;
const canCreateNew = searchQuery.trim() &&
!suggestions.some(s => s.dimension.toLowerCase() === searchQuery.toLowerCase());
const modalContent = (
<div
className="search-backdrop"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-label="Search dimensions"
>
<div ref={modalRef} className="search-container" onClick={(e) => e.stopPropagation()}>
{/* Search Input */}
<div className="search-input-wrapper">
<svg className="search-icon" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
</svg>
<input
ref={inputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search or create dimension..."
className="search-input"
/>
<div className="search-shortcut">
<kbd>esc</kbd>
</div>
</div>
{/* Results */}
{suggestions.length > 0 && (
<div className="search-results">
{suggestions.map((suggestion, index) => (
<button
key={suggestion.dimension}
onClick={() => handleSelectDimension(suggestion.dimension)}
onMouseEnter={() => setSelectedIndex(index)}
className={`search-result-item ${index === selectedIndex ? 'selected' : ''}`}
>
<span className={`result-name ${suggestion.isPriority ? 'priority' : ''}`}>
{suggestion.dimension}
</span>
<span className="result-count">{suggestion.count}</span>
{index === selectedIndex && (
<span className="result-hint"></span>
)}
</button>
))}
</div>
)}
{/* Create New Option */}
{canCreateNew && (
<div
className="search-create"
onClick={(e) => e.stopPropagation()}
>
<div className="create-header">
<span className="create-icon">+</span>
<span className="create-title">Create &quot;{searchQuery.trim()}&quot;</span>
</div>
<div className="description-input-wrapper">
<textarea
ref={textareaRef}
value={newDimensionDescription}
onChange={(e) => setNewDimensionDescription(e.target.value.slice(0, 500))}
onFocus={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
placeholder="Describe what belongs in this dimension..."
className="description-input"
rows={2}
/>
<span className="description-counter">{newDimensionDescription.length}/500</span>
</div>
{!newDimensionDescription.trim() && (
<div className="description-warning">
Dimensions without descriptions may not auto-assign correctly
</div>
)}
<button
onClick={(e) => {
e.stopPropagation();
handleSelectDimension(searchQuery.trim(), newDimensionDescription.trim() || undefined);
}}
onMouseEnter={() => setSelectedIndex(suggestions.length)}
className={`create-button ${selectedIndex === suggestions.length ? 'selected' : ''}`}
>
Create Dimension
</button>
</div>
)}
{/* Empty state */}
{!searchQuery && suggestions.length === 0 && (
<div className="search-empty">
Start typing to search dimensions
</div>
)}
</div>
<style jsx>{`
.search-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
display: flex;
justify-content: center;
padding-top: 15vh;
z-index: 9999;
animation: backdropIn 200ms ease-out;
}
.search-container {
width: 100%;
max-width: 640px;
max-height: 70vh;
animation: containerIn 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.search-input-wrapper {
display: flex;
align-items: center;
gap: 16px;
background: #141414;
border: 1px solid #262626;
border-radius: 16px;
padding: 20px 24px;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04),
0 24px 48px -12px rgba(0, 0, 0, 0.6);
}
.search-icon {
width: 22px;
height: 22px;
color: #525252;
flex-shrink: 0;
}
.search-input {
flex: 1;
background: none;
border: none;
outline: none;
color: #fafafa;
font-size: 18px;
font-family: inherit;
font-weight: 400;
}
.search-input::placeholder {
color: #525252;
}
.search-shortcut {
display: flex;
align-items: center;
gap: 4px;
}
.search-shortcut kbd {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
background: #262626;
border-radius: 6px;
font-size: 11px;
font-family: inherit;
color: #737373;
border: 1px solid #333;
}
.search-results {
margin-top: 8px;
background: #141414;
border: 1px solid #262626;
border-radius: 16px;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04),
0 24px 48px -12px rgba(0, 0, 0, 0.6);
animation: resultsIn 150ms ease-out;
}
.search-result-item {
width: 100%;
display: flex;
align-items: center;
gap: 14px;
padding: 16px 20px;
background: transparent;
border: none;
border-bottom: 1px solid #1f1f1f;
cursor: pointer;
transition: background 100ms ease;
text-align: left;
font-family: inherit;
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item:hover,
.search-result-item.selected {
background: #1a1a1a;
}
.result-name {
flex: 1;
color: #e5e5e5;
font-size: 15px;
}
.result-name.priority {
color: #22c55e;
}
.result-count {
color: #525252;
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.result-hint {
color: #525252;
font-size: 13px;
}
.search-create {
margin-top: 8px;
background: #141414;
border: 1px solid #262626;
border-radius: 16px;
overflow: hidden;
padding: 16px 20px;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04),
0 24px 48px -12px rgba(0, 0, 0, 0.6);
}
.create-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.create-title {
color: #22c55e;
font-size: 15px;
font-weight: 500;
}
.description-input-wrapper {
position: relative;
margin-bottom: 8px;
}
.description-input {
width: 100%;
padding: 12px;
background: #0a0a0a;
border: 1px solid #333;
border-radius: 8px;
color: #e5e5e5;
font-size: 14px;
font-family: inherit;
resize: none;
outline: none;
transition: border-color 150ms ease;
}
.description-input:focus {
border-color: #525252;
}
.description-input::placeholder {
color: #525252;
}
.description-counter {
position: absolute;
bottom: 8px;
right: 12px;
font-size: 11px;
color: #525252;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.description-warning {
margin-bottom: 12px;
padding: 8px 12px;
background: rgba(234, 179, 8, 0.1);
border: 1px solid rgba(234, 179, 8, 0.2);
border-radius: 6px;
color: #eab308;
font-size: 12px;
}
.create-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 16px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
cursor: pointer;
transition: background 100ms ease;
font-family: inherit;
color: #22c55e;
font-size: 14px;
font-weight: 500;
}
.create-button:hover,
.create-button.selected {
background: #262626;
}
.create-icon {
font-size: 18px;
font-weight: 300;
color: #22c55e;
}
.search-empty {
margin-top: 8px;
padding: 32px 24px;
background: #141414;
border: 1px solid #262626;
border-radius: 16px;
color: #525252;
font-size: 14px;
text-align: center;
}
@keyframes backdropIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes containerIn {
from {
opacity: 0;
transform: scale(0.96) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes resultsIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}</style>
</div>
);
return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null;
}
@@ -1,374 +0,0 @@
"use client";
import { useState, useEffect, useRef } from 'react';
import DimensionSearchModal from './DimensionSearchModal';
interface DimensionTagsProps {
dimensions: string[];
onUpdate: (dimensions: string[]) => Promise<void>;
disabled?: boolean;
}
interface DimensionSuggestion {
dimension: string;
count: number;
}
export default function DimensionTags({
dimensions,
onUpdate,
disabled = false
}: DimensionTagsProps) {
const [isAdding, setIsAdding] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const sortedDimensions = [...dimensions];
useEffect(() => {
if (isAdding && inputRef.current) {
inputRef.current.focus();
}
}, [isAdding]);
useEffect(() => {
if (searchQuery.length > 0) {
fetchSuggestions(searchQuery);
} else if (isAdding) {
// Load popular dimensions when field is empty
fetchPopularDimensions();
} else {
setSuggestions([]);
}
}, [searchQuery, isAdding]);
const fetchSuggestions = async (query: string) => {
setIsLoading(true);
try {
const response = await fetch(`/api/dimensions/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
if (data.success) {
setSuggestions(data.data);
}
} catch (error) {
console.error('Error fetching dimension suggestions:', error);
setSuggestions([]);
} finally {
setIsLoading(false);
}
};
const fetchPopularDimensions = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/dimensions/popular');
const data = await response.json();
if (data.success) {
setSuggestions(data.data);
}
} catch (error) {
console.error('Error fetching popular dimensions:', error);
setSuggestions([]);
} finally {
setIsLoading(false);
}
};
const addDimension = async (dimension: string, description?: string) => {
if (!dimension.trim() || dimensions.includes(dimension.trim())) {
return;
}
// If description is provided, create/update the dimension in the database first
if (description) {
try {
await fetch('/api/dimensions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: dimension.trim(),
description: description.trim(),
isPriority: false
})
});
} catch (error) {
console.error('Error creating dimension with description:', error);
}
}
const newDimensions = [...dimensions, dimension.trim()];
await onUpdate(newDimensions);
setSearchQuery('');
setSuggestions([]);
setIsAdding(false);
};
const removeDimension = async (index: number) => {
const dimension = sortedDimensions[index];
const newDimensions = dimensions.filter(d => d !== dimension);
await onUpdate(newDimensions);
};
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
const draggedDimension = sortedDimensions[draggedIndex];
const targetDimension = sortedDimensions[index];
// Find original positions in unsorted array
const draggedOrigIndex = dimensions.indexOf(draggedDimension);
const targetOrigIndex = dimensions.indexOf(targetDimension);
const newDimensions = [...dimensions];
newDimensions.splice(draggedOrigIndex, 1);
newDimensions.splice(targetOrigIndex, 0, draggedDimension);
onUpdate(newDimensions);
setDraggedIndex(index);
};
const handleDragEnd = () => {
setDraggedIndex(null);
};
const moveDimension = async (fromIndex: number, direction: 'up' | 'down') => {
const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1;
if (toIndex < 0 || toIndex >= sortedDimensions.length) return;
const fromDimension = sortedDimensions[fromIndex];
const toDimension = sortedDimensions[toIndex];
// Find original positions in unsorted array
const fromOrigIndex = dimensions.indexOf(fromDimension);
const toOrigIndex = dimensions.indexOf(toDimension);
const newDimensions = [...dimensions];
[newDimensions[fromOrigIndex], newDimensions[toOrigIndex]] = [newDimensions[toOrigIndex], newDimensions[fromOrigIndex]];
await onUpdate(newDimensions);
};
// Check if dimensions overflow 2 lines (approximate)
const shouldShowExpandButton = sortedDimensions.length > 6; // Rough estimate for 2 lines
const displayedDimensions = (!isExpanded && shouldShowExpandButton)
? sortedDimensions.slice(0, 6)
: sortedDimensions;
const hiddenCount = sortedDimensions.length - 6;
return (
<div>
<div
ref={containerRef}
onClick={() => {
if (shouldShowExpandButton && !isExpanded) {
setIsExpanded(true);
}
}}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '8px',
cursor: (shouldShowExpandButton && !isExpanded) ? 'pointer' : 'default',
position: 'relative',
minHeight: dimensions.length === 0 ? '24px' : 'auto'
}}
>
{/* Show placeholder when no dimensions */}
{dimensions.length === 0 && !disabled && (
<span style={{
fontSize: '11px',
color: 'var(--rah-text-muted)',
fontStyle: 'italic',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
No dimensions
</span>
)}
{displayedDimensions.map((dimension, index) => {
return (
<div
key={`${dimension}-${index}`}
draggable={!disabled}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
fontSize: '10px',
color: 'var(--rah-text-base)',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
borderRadius: '8px',
padding: '2px 6px',
cursor: disabled ? 'default' : 'grab',
opacity: draggedIndex === index ? 0.5 : 1,
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
if (!disabled) {
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
}}
>
<span>{dimension}</span>
{/* Reorder buttons removed - no longer needed */}
{/* Remove button */}
{!disabled && (
<button
onClick={(e) => {
e.stopPropagation();
removeDimension(index);
}}
style={{
padding: '0 2px',
fontSize: '14px',
color: 'var(--rah-text-muted)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
marginLeft: '2px'
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ff6b6b'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
>
×
</button>
)}
</div>
);
})}
{/* Show "+X more" indicator */}
{shouldShowExpandButton && !isExpanded && hiddenCount > 0 && (
<div
onClick={(e) => {
e.stopPropagation();
setIsExpanded(true);
}}
style={{
display: 'inline-flex',
alignItems: 'center',
fontSize: '11px',
color: 'var(--rah-text-muted)',
background: 'transparent',
border: '1px dashed var(--rah-border-strong)',
borderRadius: '12px',
padding: '2px 8px',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--rah-text-soft)';
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--rah-text-muted)';
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
}}
>
+{hiddenCount} more
</div>
)}
{/* Collapse button when expanded */}
{isExpanded && shouldShowExpandButton && (
<div
onClick={(e) => {
e.stopPropagation();
setIsExpanded(false);
}}
style={{
display: 'inline-flex',
alignItems: 'center',
fontSize: '11px',
color: 'var(--rah-text-muted)',
background: 'transparent',
border: '1px dashed var(--rah-border-strong)',
borderRadius: '12px',
padding: '2px 8px',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--rah-text-soft)';
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--rah-text-muted)';
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
}}
>
show less
</div>
)}
{/* Add dimension button */}
{!disabled && (
<button
onClick={(e) => {
e.stopPropagation();
setIsAdding(true);
}}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '20px',
height: '20px',
fontSize: '14px',
lineHeight: 1,
color: 'var(--rah-text-muted)',
background: 'transparent',
border: '1px dashed var(--rah-border-strong)',
borderRadius: '4px',
cursor: 'pointer',
transition: 'color 120ms ease, border-color 120ms ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--rah-text-soft)';
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--rah-text-muted)';
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
}}
title="Add dimension"
>
+
</button>
)}
</div>
{/* Dimension Search Modal */}
<DimensionSearchModal
isOpen={isAdding}
onClose={() => setIsAdding(false)}
onDimensionSelect={(dim, description) => {
addDimension(dim, description);
}}
existingDimensions={dimensions}
/>
</div>
);
}
@@ -13,7 +13,6 @@ interface NodeSearchModalProps {
interface NodeSuggestion {
id: number;
title: string;
dimensions?: string[];
}
export default function NodeSearchModal({
@@ -96,7 +95,6 @@ export default function NodeSearchModal({
.map((node) => ({
id: node.id,
title: node.title,
dimensions: node.dimensions || [],
}));
setSuggestions(nextSuggestions);
setSelectedIndex(0);
@@ -258,17 +256,6 @@ export default function NodeSearchModal({
<div style={{ color: '#e0e0e0', fontSize: '13px', marginBottom: '2px' }}>
{suggestion.title}
</div>
{suggestion.dimensions && suggestion.dimensions.length > 0 && (
<div style={{
color: '#666',
fontSize: '11px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{suggestion.dimensions.join(' · ')}
</div>
)}
</div>
<span style={{ color: '#444', fontSize: '11px', fontFamily: 'monospace', flexShrink: 0 }}>
#{suggestion.id}
+2 -6
View File
@@ -6,11 +6,10 @@ import { FileText } from 'lucide-react';
interface NodeLabelProps {
id: string;
title: string;
dimensions: string[];
onNodeClick?: (nodeId: number) => void;
}
function NodeLabel({ id, title, dimensions, onNodeClick }: NodeLabelProps) {
function NodeLabel({ id, title, onNodeClick }: NodeLabelProps) {
const handleClick = (e: React.MouseEvent) => {
// Prevent bubbling into parent containers (e.g., content view onClick that toggles edit)
e.stopPropagation();
@@ -62,7 +61,7 @@ function NodeLabel({ id, title, dimensions, onNodeClick }: NodeLabelProps) {
export function parseAndRenderContent(content: string, onNodeClick?: (nodeId: number) => void): React.ReactNode[] {
if (!content) return [content];
// Pattern to match [NODE:id:"title"] (dimensions removed)
// Pattern to match [NODE:id:"title"]
// Be tolerant of spaces and curly quotes
// Use non-greedy match (.+?) to handle quotes inside titles
const nodePattern = /\[NODE:\s*(\d+)\s*:\s*["""'](.+?)["""']\s*\]/g;
@@ -80,15 +79,12 @@ export function parseAndRenderContent(content: string, onNodeClick?: (nodeId: nu
// Parse the node data
const id = match[1];
const title = match[2];
const dimensions: string[] = []; // No dimensions in new format
// Add the node label
parts.push(
<NodeLabel
key={`node-${id}-${match.index}`}
id={id}
title={title}
dimensions={dimensions}
onNodeClick={onNodeClick}
/>
);
+35 -25
View File
@@ -18,7 +18,8 @@ import {
Sun,
Moon,
} from 'lucide-react';
import type { PaneType, NavigablePaneType } from '../panes/types';
import type { PaneType } from '../panes/types';
import type { FocusedSkill } from '@/types/skills';
import type { Theme } from '@/hooks/useTheme';
import type { ContextSummary } from '@/types/database';
@@ -27,11 +28,12 @@ interface LeftToolbarProps {
onAddStuffClick: () => void;
onRefreshClick: () => void;
onSettingsClick: () => void;
onPaneTypeClick: (paneType: NavigablePaneType) => void;
onPaneTypeClick: (paneType: PaneType) => void;
isExpanded: boolean;
onToggleExpanded: () => void;
openTabTypes: Set<PaneType>;
activeTabType: PaneType | null;
focusedSkill?: FocusedSkill | null;
theme: Theme;
onThemeToggle: () => void;
contexts?: ContextSummary[];
@@ -41,11 +43,10 @@ interface LeftToolbarProps {
const NAV_WIDTH_COLLAPSED = 50;
const NAV_WIDTH_EXPANDED = 280;
const VIEW_ITEMS: Array<{ paneType: NavigablePaneType; label: string; icon: typeof LayoutList }> = [
const PRIMARY_VIEW_ITEMS: Array<{ paneType: PaneType; label: string; icon: typeof LayoutList }> = [
{ paneType: 'views', label: 'Nodes', icon: LayoutList },
{ paneType: 'skills', label: 'Skills', icon: BookOpen },
{ paneType: 'map', label: 'Map', icon: Map },
{ paneType: 'dimensions', label: 'Dimension', icon: Folder },
{ paneType: 'table', label: 'Table', icon: Table2 },
];
@@ -116,6 +117,7 @@ export default function LeftToolbar({
onToggleExpanded,
openTabTypes,
activeTabType,
focusedSkill,
theme,
onThemeToggle,
contexts = [],
@@ -123,6 +125,14 @@ export default function LeftToolbar({
}: LeftToolbarProps) {
const [contextsExpanded, setContextsExpanded] = useState(false);
const renderActionButtons = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<NavButton icon={Search} label="Search" title="Search (⌘K)" expanded={isExpanded} onClick={onSearchClick} />
<NavButton icon={Plus} label="Add Stuff" title="New node (⌘N)" expanded={isExpanded} onClick={onAddStuffClick} />
<NavButton icon={RefreshCw} label="Refresh" expanded={isExpanded} onClick={onRefreshClick} />
</div>
);
return (
<div
style={{
@@ -131,6 +141,7 @@ export default function LeftToolbar({
background: 'transparent',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '12px 8px',
flexShrink: 0,
transition: 'width 0.2s ease',
@@ -138,7 +149,6 @@ export default function LeftToolbar({
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', height: '100%' }}>
{/* Top: collapse + actions */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<NavButton
icon={isExpanded ? PanelLeftClose : PanelLeftOpen}
@@ -146,18 +156,30 @@ export default function LeftToolbar({
expanded={isExpanded}
onClick={onToggleExpanded}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<NavButton icon={Search} label="Search" title="Search (⌘K)" expanded={isExpanded} onClick={onSearchClick} />
<NavButton icon={Plus} label="Add Stuff" title="New node (⌘N)" expanded={isExpanded} onClick={onAddStuffClick} />
<NavButton icon={RefreshCw} label="Refresh" expanded={isExpanded} onClick={onRefreshClick} />
</div>
{renderActionButtons()}
</div>
{/* Middle: view items, vertically centered */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', minHeight: 0 }}>
<div style={{ borderTop: '1px solid var(--rah-border)', paddingTop: '14px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '6px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', paddingTop: '4px' }}>
{PRIMARY_VIEW_ITEMS.map((item) => (
<NavButton
key={`${item.paneType}-${item.label}`}
icon={item.icon}
label={item.label}
expanded={isExpanded}
active={
item.paneType === 'skills'
? focusedSkill != null || activeTabType === 'skills' || openTabTypes.has('skills')
: activeTabType === item.paneType || openTabTypes.has(item.paneType)
}
onClick={() => onPaneTypeClick(item.paneType)}
activeTone="green"
/>
))}
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<NavButton
@@ -227,18 +249,6 @@ export default function LeftToolbar({
</div>
) : null}
</div>
{VIEW_ITEMS.map((item) => (
<NavButton
key={`${item.paneType}-${item.label}`}
icon={item.icon}
label={item.label}
expanded={isExpanded}
active={activeTabType === item.paneType || openTabTypes.has(item.paneType)}
onClick={() => onPaneTypeClick(item.paneType)}
activeTone="green"
/>
))}
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+19 -23
View File
@@ -4,24 +4,21 @@ import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import Chip from '../common/Chip';
import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
interface SearchModalProps {
isOpen: boolean;
onClose: () => void;
onNodeSelect: (nodeId: number) => void;
existingFilters: {type: 'dimension' | 'title', value: string}[];
existingFilters: {type: 'context' | 'title' | 'tag', value: string}[];
}
interface NodeSuggestion {
id: number;
title: string;
dimensions?: string[];
link?: string;
}
export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFilters }: SearchModalProps) {
const { dimensionIcons } = useDimensionIcons();
const [searchQuery, setSearchQuery] = useState('');
const [suggestions, setSuggestions] = useState<NodeSuggestion[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -111,7 +108,6 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
const nodeSuggestions: NodeSuggestion[] = result.data.map((node: any) => ({
id: node.id,
title: node.title,
dimensions: node.dimensions || [],
link: node.link || undefined,
}));
@@ -208,7 +204,7 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
className={`search-result-item ${index === selectedIndex ? 'selected' : ''}`}
>
<span className="result-id">{suggestion.id}</span>
<span className="result-icon">{getNodeIcon(suggestion as any, dimensionIcons, 14)}</span>
<span className="result-icon">{getNodeIcon(suggestion as any, 14)}</span>
<span className="result-title">{suggestion.title}</span>
{index === selectedIndex && (
<span className="result-hint"></span>
@@ -250,7 +246,7 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
display: flex;
align-items: center;
gap: 16px;
background: var(--rah-bg-panel);
background: var(--rah-bg-modal);
border: 1px solid var(--rah-border-strong);
border-radius: 16px;
padding: 20px 24px;
@@ -262,7 +258,7 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
.search-icon {
width: 22px;
height: 22px;
color: #525252;
color: var(--rah-text-muted);
flex-shrink: 0;
}
@@ -271,14 +267,14 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
background: none;
border: none;
outline: none;
color: #fafafa;
color: var(--rah-text-active);
font-size: 18px;
font-family: inherit;
font-weight: 400;
}
.search-input::placeholder {
color: #525252;
color: var(--rah-text-muted);
}
.search-shortcut {
@@ -292,17 +288,17 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
align-items: center;
justify-content: center;
padding: 4px 8px;
background: var(--rah-border-strong);
background: var(--rah-bg-active);
border-radius: 6px;
font-size: 11px;
font-family: inherit;
color: #737373;
border: 1px solid var(--rah-border-stronger);
color: var(--rah-text-muted);
border: 1px solid var(--rah-border-strong);
}
.search-results {
margin-top: 8px;
background: var(--rah-bg-panel);
background: var(--rah-bg-modal);
border: 1px solid var(--rah-border-strong);
border-radius: 16px;
overflow-x: hidden;
@@ -322,7 +318,7 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
padding: 16px 20px;
background: transparent;
border: none;
border-bottom: 1px solid var(--rah-bg-active);
border-bottom: 1px solid var(--rah-border);
cursor: pointer;
transition: background 100ms ease;
text-align: left;
@@ -345,8 +341,8 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
font-size: 10px;
font-weight: 600;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--rah-bg-base);
background: #22c55e;
color: var(--rah-text-inverse);
background: var(--rah-accent-green);
padding: 4px 8px;
border-radius: 6px;
min-width: 28px;
@@ -369,17 +365,17 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
}
.result-hint {
color: #525252;
color: var(--rah-text-muted);
font-size: 13px;
}
.search-empty {
margin-top: 8px;
padding: 32px 24px;
background: var(--rah-bg-panel);
background: var(--rah-bg-modal);
border: 1px solid var(--rah-border-strong);
border-radius: 16px;
color: #525252;
color: var(--rah-text-muted);
font-size: 14px;
text-align: center;
}
-66
View File
@@ -1,66 +0,0 @@
"use client";
import { useState } from 'react';
import FolderViewOverlay from '@/components/nodes/FolderViewOverlay';
import PaneHeader from './PaneHeader';
import { DimensionsPaneProps, PaneType } from './types';
export default function DimensionsPane({
slot,
isActive,
onPaneAction,
onCollapse,
onSwapPanes,
tabBar,
onNodeOpen,
refreshToken,
onDataChanged,
onDimensionSelect,
}: DimensionsPaneProps) {
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
const handleTypeChange = (type: PaneType) => {
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
};
// When used as a pane, "close" means switch back to node view
const handleClose = () => {
onPaneAction?.({ type: 'switch-pane-type', paneType: 'node' });
};
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
overflow: 'hidden',
}}>
<PaneHeader
slot={slot}
onCollapse={onCollapse}
onSwapPanes={onSwapPanes}
tabBar={tabBar}
toolbarHostRef={setToolbarHost}
/>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', position: 'relative' }}>
{/* FolderViewOverlay expects to be an overlay, so we wrap it in a container */}
<div style={{
position: 'absolute',
inset: 0,
background: 'transparent',
}}>
<FolderViewOverlay
onClose={handleClose}
onNodeOpen={onNodeOpen}
refreshToken={refreshToken}
onDataChanged={onDataChanged}
onDimensionSelect={onDimensionSelect}
replaceWithViewsOnDimensionSelect={true}
toolbarHost={toolbarHost}
/>
</div>
</div>
</div>
);
}
+173 -322
View File
@@ -6,40 +6,29 @@ import {
Background,
useNodesState,
useEdgesState,
addEdge as rfAddEdge,
type Connection,
type NodeMouseHandler,
type Node as RFNode,
type Edge as RFEdge,
ReactFlowProvider,
useReactFlow,
MiniMap,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type { Edge as DbEdge, Node as DbNode } from '@/types/database';
import PaneHeader from './PaneHeader';
import type { MapPaneProps } from './types';
import { ChevronDown } from 'lucide-react';
import { MiniMap } from '@xyflow/react';
import { RahNode } from './map/RahNode';
import { RahEdge } from './map/RahEdge';
import EdgeExplanationModal from './map/EdgeExplanationModal';
import { getPrimaryDimension, toRFNodes, toRFEdges, NODE_LIMIT, type MapViewMode, type RahNodeData } from './map/utils';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import { toRFNodes, toRFEdges, NODE_LIMIT, type MapViewMode, type RahNodeData } from './map/utils';
import { useTheme } from '@/hooks/useTheme';
import './map/map-styles.css';
interface DimensionInfo {
dimension: string;
count: number;
isPriority: boolean;
description: string | null;
}
const nodeTypes = { rahNode: RahNode };
const edgeTypes = { rahEdge: RahEdge };
// Debounce helper
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
let timer: ReturnType<typeof setTimeout>;
@@ -52,8 +41,6 @@ function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
function MapPaneInner({
slot,
isActive,
onPaneAction,
onCollapse,
onSwapPanes,
tabBar,
@@ -61,111 +48,75 @@ function MapPaneInner({
activeTabId,
}: MapPaneProps) {
const reactFlowInstance = useReactFlow();
const { dimensionIcons } = useDimensionIcons();
const [theme] = useTheme();
// --- Data state (DB-level) ---
const [baseNodes, setBaseNodes] = useState<DbNode[]>([]);
const [expandedNodes, setExpandedNodes] = useState<DbNode[]>([]);
const [dbEdges, setDbEdges] = useState<DbEdge[]>([]);
const [lockedDimensions, setLockedDimensions] = useState<DimensionInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// --- UI state ---
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
const [selectedDimension, setSelectedDimension] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<MapViewMode>('dimension');
const [dimensionDropdownOpen, setDimensionDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const [viewMode, setViewMode] = useState<MapViewMode>('context');
// --- React Flow state ---
const [rfNodes, setRfNodes, onNodesChange] = useNodesState<RFNode<RahNodeData>>([]);
const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState<RFEdge>([]);
// --- Edge creation modal ---
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null);
// Track current RF positions so we can preserve them across data refreshes
const rfPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
const hasInitialFitRef = useRef(false);
// Combine base + expanded
const allDbNodes = useMemo(() => {
const baseIds = new Set(baseNodes.map(n => n.id));
return [...baseNodes, ...expandedNodes.filter(n => !baseIds.has(n.id))];
const baseIds = new Set(baseNodes.map((node) => node.id));
return [...baseNodes, ...expandedNodes.filter((node) => !baseIds.has(node.id))];
}, [baseNodes, expandedNodes]);
// Selected DB node
const selectedDbNode = useMemo(
() => allDbNodes.find(n => n.id === selectedNodeId) ?? null,
() => allDbNodes.find((node) => node.id === selectedNodeId) ?? null,
[allDbNodes, selectedNodeId],
);
// Connected node IDs for info panel
const connectedNodeIds = useMemo(() => {
if (!selectedNodeId) return new Set<number>();
const connected = new Set<number>();
dbEdges.forEach(e => {
if (e.from_node_id === selectedNodeId) connected.add(e.to_node_id);
if (e.to_node_id === selectedNodeId) connected.add(e.from_node_id);
dbEdges.forEach((edge) => {
if (edge.from_node_id === selectedNodeId) connected.add(edge.to_node_id);
if (edge.to_node_id === selectedNodeId) connected.add(edge.from_node_id);
});
return connected;
}, [selectedNodeId, dbEdges]);
const lockedDimensionNames = useMemo(
() => new Set(lockedDimensions.map(d => d.dimension)),
[lockedDimensions],
);
const clusterLabels = useMemo(() => {
if (viewMode !== 'dimension') {
return [];
}
if (viewMode !== 'context') 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 };
const clusterLabel = (node.data as RahNodeData).clusterLabel;
const current = grouped.get(clusterLabel) || { x: 0, y: 0, count: 0 };
current.x += node.position.x;
current.y += node.position.y;
current.count += 1;
grouped.set(dimension, current);
grouped.set(clusterLabel, current);
}
return [...grouped.entries()].map(([dimension, totals]) => ({
dimension,
return [...grouped.entries()].map(([clusterLabel, totals]) => ({
clusterLabel,
x: totals.x / totals.count,
y: totals.y / totals.count - 84,
}));
}, [rfNodes, viewMode]);
// ----- Close dropdown on outside click -----
useEffect(() => {
if (!dimensionDropdownOpen) return;
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as HTMLElement)) {
setDimensionDropdownOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [dimensionDropdownOpen]);
// ----- Fetch base data -----
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const nodesUrl = selectedDimension
? `/api/nodes?limit=${NODE_LIMIT}&sortBy=edges&dimensions=${encodeURIComponent(selectedDimension)}`
: `/api/nodes?limit=${NODE_LIMIT}&sortBy=edges`;
const [nodesRes, edgesRes, dimsRes] = await Promise.all([
fetch(nodesUrl),
const [nodesRes, edgesRes] = await Promise.all([
fetch(`/api/nodes?limit=${NODE_LIMIT}&sortBy=edges`),
fetch('/api/edges'),
fetch('/api/dimensions/popular'),
]);
if (!nodesRes.ok || !edgesRes.ok) throw new Error('Failed to load data');
if (!nodesRes.ok || !edgesRes.ok) throw new Error('Failed to load map data');
const nodesPayload = await nodesRes.json();
const edgesPayload = await edgesRes.json();
@@ -175,23 +126,16 @@ function MapPaneInner({
setExpandedNodes([]);
setSelectedNodeId(null);
rfPositionsRef.current.clear();
if (dimsRes.ok) {
const dimsPayload = await dimsRes.json();
if (dimsPayload.success && dimsPayload.data) {
setLockedDimensions(dimsPayload.data as DimensionInfo[]);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedDimension]);
// ----- Sync DB data → React Flow nodes/edges -----
void fetchData();
}, []);
useEffect(() => {
if (allDbNodes.length === 0) {
setRfNodes([]);
@@ -199,9 +143,8 @@ function MapPaneInner({
return;
}
// Capture current RF positions before rebuild
rfNodes.forEach(n => {
rfPositionsRef.current.set(n.id, n.position);
rfNodes.forEach((node) => {
rfPositionsRef.current.set(node.id, node.position);
});
const centerX = 600;
@@ -215,29 +158,30 @@ function MapPaneInner({
selectedNodeId,
connectedNodeIds,
rfPositionsRef.current,
dimensionIcons,
viewMode,
dbEdges,
);
const nodeIdSet = new Set(newRfNodes.map(n => n.id));
const nodeIdSet = new Set(newRfNodes.map((node) => node.id));
const newRfEdges = toRFEdges(dbEdges, nodeIdSet, selectedNodeId);
setRfNodes(newRfNodes);
setRfEdges(newRfEdges);
}, [allDbNodes, baseNodes, expandedNodes, dbEdges, selectedNodeId, connectedNodeIds, dimensionIcons, viewMode]);
}, [allDbNodes, baseNodes, expandedNodes, dbEdges, selectedNodeId, connectedNodeIds, viewMode, rfNodes, setRfEdges, setRfNodes]);
useEffect(() => {
if (hasInitialFitRef.current || rfNodes.length === 0 || loading) return;
hasInitialFitRef.current = true;
const hubNodeIds = baseNodes
const hubNodeIds = [...baseNodes]
.sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0))
.slice(0, 25)
.map(n => String(n.id));
.map((node) => String(node.id));
setTimeout(() => {
if (hubNodeIds.length > 0) {
reactFlowInstance.fitView({
nodes: hubNodeIds.map(id => ({ id })),
nodes: hubNodeIds.map((id) => ({ id })),
padding: 0.3,
duration: 300,
});
@@ -247,27 +191,26 @@ function MapPaneInner({
useEffect(() => {
hasInitialFitRef.current = false;
}, [selectedDimension, viewMode]);
}, [viewMode]);
const fitAllNodes = useCallback(() => {
reactFlowInstance.fitView({ padding: 0.2, duration: 300 });
}, [reactFlowInstance]);
const fitHubNodes = useCallback(() => {
const hubNodeIds = baseNodes
const hubNodeIds = [...baseNodes]
.sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0))
.slice(0, 25)
.map(n => String(n.id));
.map((node) => String(node.id));
if (hubNodeIds.length > 0) {
reactFlowInstance.fitView({
nodes: hubNodeIds.map(id => ({ id })),
nodes: hubNodeIds.map((id) => ({ id })),
padding: 0.3,
duration: 300,
});
}
}, [baseNodes, reactFlowInstance]);
// ----- Node traversal: fetch connected nodes -----
const fetchConnectedNodes = useCallback(async (nodeId: number) => {
try {
const edgesRes = await fetch(`/api/nodes/${nodeId}/edges`);
@@ -278,48 +221,49 @@ function MapPaneInner({
nodeEdges = edgesData.data || [];
if (nodeEdges.length > 0) {
setDbEdges(prev => {
const existing = new Set(prev.map(e => e.id));
const fresh = nodeEdges.filter(e => !existing.has(e.id));
setDbEdges((prev) => {
const existing = new Set(prev.map((edge) => edge.id));
const fresh = nodeEdges.filter((edge) => !existing.has(edge.id));
return fresh.length > 0 ? [...prev, ...fresh] : prev;
});
}
}
// Find missing connected node IDs
const connectedIds = new Set<number>();
dbEdges.forEach(e => {
if (e.from_node_id === nodeId) connectedIds.add(e.to_node_id);
if (e.to_node_id === nodeId) connectedIds.add(e.from_node_id);
dbEdges.forEach((edge) => {
if (edge.from_node_id === nodeId) connectedIds.add(edge.to_node_id);
if (edge.to_node_id === nodeId) connectedIds.add(edge.from_node_id);
});
nodeEdges.forEach(e => {
if (e.from_node_id === nodeId) connectedIds.add(e.to_node_id);
if (e.to_node_id === nodeId) connectedIds.add(e.from_node_id);
nodeEdges.forEach((edge) => {
if (edge.from_node_id === nodeId) connectedIds.add(edge.to_node_id);
if (edge.to_node_id === nodeId) connectedIds.add(edge.from_node_id);
});
const existingIds = new Set(allDbNodes.map(n => n.id));
const missingIds = Array.from(connectedIds).filter(id => !existingIds.has(id));
const existingIds = new Set(allDbNodes.map((node) => node.id));
const missingIds = Array.from(connectedIds).filter((id) => !existingIds.has(id));
if (missingIds.length === 0) return;
const fetched = (
await Promise.all(
missingIds.slice(0, 50).map(async id => {
missingIds.slice(0, 50).map(async (id) => {
try {
const res = await fetch(`/api/nodes/${id}`);
if (res.ok) {
const data = await res.json();
return data.node as DbNode;
}
} catch { /* ignore */ }
} catch {
return null;
}
return null;
}),
)
).filter((n): n is DbNode => n !== null);
).filter((node): node is DbNode => node !== null);
if (fetched.length > 0) {
setExpandedNodes(prev => {
const ids = new Set(prev.map(n => n.id));
const fresh = fetched.filter(n => !ids.has(n.id));
setExpandedNodes((prev) => {
const ids = new Set(prev.map((node) => node.id));
const fresh = fetched.filter((node) => !ids.has(node.id));
return fresh.length > 0 ? [...prev, ...fresh] : prev;
});
}
@@ -329,44 +273,41 @@ function MapPaneInner({
}, [dbEdges, allDbNodes]);
useEffect(() => {
if (selectedNodeId) fetchConnectedNodes(selectedNodeId);
if (selectedNodeId) {
void fetchConnectedNodes(selectedNodeId);
}
}, [selectedNodeId, fetchConnectedNodes]);
// ----- Focused node awareness -----
useEffect(() => {
if (!activeTabId) return;
const existing = allDbNodes.find(n => n.id === activeTabId);
const existing = allDbNodes.find((node) => node.id === activeTabId);
if (existing) {
setSelectedNodeId(activeTabId);
// Pan + zoom closer to the focused node
const rfNode = rfNodes.find(n => n.id === String(activeTabId));
const rfNode = rfNodes.find((node) => node.id === String(activeTabId));
if (rfNode) {
reactFlowInstance.setCenter(rfNode.position.x, rfNode.position.y, { duration: 400, zoom: 1.5 });
}
} else {
(async () => {
try {
const res = await fetch(`/api/nodes/${activeTabId}`);
if (res.ok) {
const data = await res.json();
const node = data.node as DbNode;
if (node) {
setExpandedNodes(prev => prev.some(n => n.id === node.id) ? prev : [...prev, node]);
setSelectedNodeId(node.id);
// After the next render cycle, zoom to the newly added node
setTimeout(() => {
reactFlowInstance.setCenter(600, 400, { duration: 400, zoom: 1.5 });
}, 100);
}
}
} catch (err) {
console.error('Failed to fetch focused node:', err);
}
})();
return;
}
}, [activeTabId]);
// ----- SSE real-time sync -----
void (async () => {
try {
const res = await fetch(`/api/nodes/${activeTabId}`);
if (!res.ok) return;
const data = await res.json();
const node = data.node as DbNode;
if (!node) return;
setExpandedNodes((prev) => (prev.some((existingNode) => existingNode.id === node.id) ? prev : [...prev, node]));
setSelectedNodeId(node.id);
setTimeout(() => {
reactFlowInstance.setCenter(600, 400, { duration: 400, zoom: 1.5 });
}, 100);
} catch (err) {
console.error('Failed to fetch focused node:', err);
}
})();
}, [activeTabId, allDbNodes, rfNodes, reactFlowInstance]);
useEffect(() => {
let eventSource: EventSource | null = null;
@@ -382,7 +323,7 @@ function MapPaneInner({
const node = payload.data?.node as DbNode | undefined;
if (node?.id) {
const updater = (prev: DbNode[]) =>
prev.map(n => n.id === node.id ? { ...n, ...node } : n);
prev.map((existingNode) => (existingNode.id === node.id ? { ...existingNode, ...node } : existingNode));
setBaseNodes(updater);
setExpandedNodes(updater);
}
@@ -391,43 +332,36 @@ function MapPaneInner({
case 'NODE_DELETED': {
const deletedId = payload.data?.nodeId;
if (deletedId) {
setBaseNodes(prev => prev.filter(n => n.id !== deletedId));
setExpandedNodes(prev => prev.filter(n => n.id !== deletedId));
setDbEdges(prev => prev.filter(e => e.from_node_id !== deletedId && e.to_node_id !== deletedId));
setSelectedNodeId(prev => prev === deletedId ? null : prev);
setBaseNodes((prev) => prev.filter((node) => node.id !== deletedId));
setExpandedNodes((prev) => prev.filter((node) => node.id !== deletedId));
setDbEdges((prev) => prev.filter((edge) => edge.from_node_id !== deletedId && edge.to_node_id !== deletedId));
setSelectedNodeId((prev) => (prev === deletedId ? null : prev));
}
break;
}
case 'NODE_CREATED': {
// If filtering by dimension and new node matches, could add it
// For now, just note it happened — user can refresh
break;
}
case 'EDGE_CREATED': {
const edge = payload.data?.edge as DbEdge | undefined;
if (edge?.id) {
setDbEdges(prev => {
if (prev.some(e => e.id === edge.id)) return prev;
return [...prev, edge];
});
setDbEdges((prev) => (prev.some((existingEdge) => existingEdge.id === edge.id) ? prev : [...prev, edge]));
}
break;
}
case 'EDGE_DELETED': {
const edgeId = payload.data?.edgeId;
if (edgeId) {
setDbEdges(prev => prev.filter(e => e.id !== edgeId));
setDbEdges((prev) => prev.filter((edge) => edge.id !== edgeId));
}
break;
}
default:
break;
}
} catch {
// Ignore parse errors (keep-alive pings, etc.)
// Ignore keep-alives and malformed payloads.
}
};
eventSource.onerror = () => {
// EventSource auto-reconnects, just log
console.error('Map SSE connection error');
};
} catch {
@@ -439,7 +373,6 @@ function MapPaneInner({
};
}, []);
// ----- Node drag → save position to metadata (debounced) -----
const savePositionRef = useRef(
debounce(async (nodeId: number, x: number, y: number, mode: MapViewMode) => {
try {
@@ -448,7 +381,13 @@ function MapPaneInner({
const { node: existing } = await res.json();
const existingMetadata = existing?.metadata ?? {};
const mergedMeta = typeof existingMetadata === 'string'
? (() => { try { return JSON.parse(existingMetadata); } catch { return {}; } })()
? (() => {
try {
return JSON.parse(existingMetadata);
} catch {
return {};
}
})()
: existingMetadata;
await fetch(`/api/nodes/${nodeId}`, {
@@ -472,8 +411,8 @@ function MapPaneInner({
);
const onNodeDragStop: NodeMouseHandler<RFNode<RahNodeData>> = useCallback((_event, node) => {
const nodeId = parseInt(node.id);
if (!isNaN(nodeId)) {
const nodeId = parseInt(node.id, 10);
if (!Number.isNaN(nodeId)) {
rfPositionsRef.current.set(node.id, node.position);
savePositionRef.current(nodeId, node.position.x, node.position.y, viewMode);
}
@@ -485,36 +424,38 @@ function MapPaneInner({
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 -----
return () => clearTimeout(timeout);
}, [viewMode, loading, rfNodes, reactFlowInstance]);
const onNodeClickHandler: NodeMouseHandler<RFNode<RahNodeData>> = useCallback((_event, node) => {
const nodeId = parseInt(node.id);
if (isNaN(nodeId)) return;
setSelectedNodeId(prev => prev === nodeId ? null : nodeId);
const nodeId = parseInt(node.id, 10);
if (!Number.isNaN(nodeId)) {
setSelectedNodeId((prev) => (prev === nodeId ? null : nodeId));
}
}, []);
// ----- Node double-click → open in other pane -----
const onNodeDoubleClick: NodeMouseHandler<RFNode<RahNodeData>> = useCallback((_event, node) => {
const nodeId = parseInt(node.id);
if (!isNaN(nodeId)) onNodeClick?.(nodeId);
const nodeId = parseInt(node.id, 10);
if (!Number.isNaN(nodeId)) {
onNodeClick?.(nodeId);
}
}, [onNodeClick]);
// ----- Edge creation via drag -----
const onConnect = useCallback((connection: Connection) => {
if (connection.source === connection.target) return; // No self-connections
if (connection.source === connection.target) return;
setPendingConnection(connection);
}, []);
const handleEdgeCreate = useCallback(async (explanation: string) => {
if (!pendingConnection?.source || !pendingConnection?.target) return;
const fromId = parseInt(pendingConnection.source);
const toId = parseInt(pendingConnection.target);
const fromId = parseInt(pendingConnection.source, 10);
const toId = parseInt(pendingConnection.target, 10);
try {
const res = await fetch('/api/edges', {
@@ -533,11 +474,7 @@ function MapPaneInner({
const payload = await res.json();
const edge = payload.data;
if (edge?.id) {
// Add to DB edges (SSE may also add it, dedup in handler)
setDbEdges(prev => {
if (prev.some(e => e.id === edge.id)) return prev;
return [...prev, edge];
});
setDbEdges((prev) => (prev.some((existingEdge) => existingEdge.id === edge.id) ? prev : [...prev, edge]));
}
}
} catch (err) {
@@ -551,12 +488,11 @@ function MapPaneInner({
setPendingConnection(null);
}, []);
// Get source/target titles for modal
const pendingSourceTitle = pendingConnection?.source
? allDbNodes.find(n => n.id === parseInt(pendingConnection.source!))?.title || 'Unknown'
? allDbNodes.find((node) => node.id === parseInt(pendingConnection.source, 10))?.title || 'Unknown'
: '';
const pendingTargetTitle = pendingConnection?.target
? allDbNodes.find(n => n.id === parseInt(pendingConnection.target!))?.title || 'Unknown'
? allDbNodes.find((node) => node.id === parseInt(pendingConnection.target, 10))?.title || 'Unknown'
: '';
return (
@@ -564,29 +500,29 @@ function MapPaneInner({
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<button
onClick={() => setViewMode('dimension')}
onClick={() => setViewMode('context')}
style={{
padding: '6px 10px',
background: viewMode === 'dimension' ? 'rgba(34, 197, 94, 0.12)' : 'transparent',
background: viewMode === 'context' ? 'var(--rah-accent-green-soft)' : 'transparent',
border: '1px solid',
borderColor: viewMode === 'dimension' ? 'rgba(34, 197, 94, 0.35)' : 'var(--rah-border-strong)',
borderColor: viewMode === 'context' ? 'var(--rah-accent-green-soft-strong)' : 'var(--rah-border-strong)',
borderRadius: '6px',
color: viewMode === 'dimension' ? '#a7f3b8' : 'var(--rah-text-muted)',
color: viewMode === 'context' ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)',
fontSize: '12px',
cursor: 'pointer',
}}
>
Dimension View
Context View
</button>
<button
onClick={() => setViewMode('hub')}
style={{
padding: '6px 10px',
background: viewMode === 'hub' ? 'rgba(34, 197, 94, 0.12)' : 'transparent',
background: viewMode === 'hub' ? 'var(--rah-accent-green-soft)' : 'transparent',
border: '1px solid',
borderColor: viewMode === 'hub' ? 'rgba(34, 197, 94, 0.35)' : 'var(--rah-border-strong)',
borderColor: viewMode === 'hub' ? 'var(--rah-accent-green-soft-strong)' : 'var(--rah-border-strong)',
borderRadius: '6px',
color: viewMode === 'hub' ? '#a7f3b8' : 'var(--rah-text-muted)',
color: viewMode === 'hub' ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)',
fontSize: '12px',
cursor: 'pointer',
}}
@@ -594,97 +530,8 @@ function MapPaneInner({
Hub View
</button>
</div>
{/* Dimension filter dropdown */}
<div ref={dropdownRef} style={{ position: 'relative' }}>
<button
onClick={() => setDimensionDropdownOpen(!dimensionDropdownOpen)}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 10px',
background: selectedDimension ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
border: '1px solid',
borderColor: selectedDimension ? 'rgba(34, 197, 94, 0.3)' : 'var(--rah-border-strong)',
borderRadius: '6px',
color: selectedDimension ? '#22c55e' : 'var(--rah-text-muted)',
fontSize: '12px',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
<span>{selectedDimension || 'All dimensions'}</span>
<ChevronDown size={12} style={{
transform: dimensionDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.15s ease',
}} />
</button>
{dimensionDropdownOpen && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
borderRadius: '8px',
padding: '4px',
minWidth: '180px',
maxHeight: '300px',
overflowY: 'auto',
zIndex: 1000,
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
}}>
<button
onClick={() => { setSelectedDimension(null); setDimensionDropdownOpen(false); }}
style={{
display: 'flex', alignItems: 'center', width: '100%', padding: '8px 12px',
background: !selectedDimension ? 'var(--rah-bg-active)' : 'transparent',
border: 'none', borderRadius: '4px',
color: !selectedDimension ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
}}
>
All dimensions
{!selectedDimension && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>&#10003;</span>}
</button>
{lockedDimensions.map(dim => (
<button
key={dim.dimension}
onClick={() => { setSelectedDimension(dim.dimension); setDimensionDropdownOpen(false); }}
style={{
display: 'flex', alignItems: 'center', width: '100%', padding: '8px 12px',
background: selectedDimension === dim.dimension ? 'var(--rah-bg-active)' : 'transparent',
border: 'none', borderRadius: '4px',
color: selectedDimension === dim.dimension ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
}}
onMouseEnter={e => {
if (selectedDimension !== dim.dimension) {
e.currentTarget.style.background = 'var(--rah-bg-active)';
e.currentTarget.style.color = 'var(--rah-text-secondary)';
}
}}
onMouseLeave={e => {
if (selectedDimension !== dim.dimension) {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--rah-text-muted)';
}
}}
>
{dim.dimension}
{selectedDimension === dim.dimension && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>&#10003;</span>}
</button>
))}
</div>
)}
</div>
</PaneHeader>
{/* Map content */}
<div style={{ position: 'relative', flex: 1, background: 'var(--rah-bg-base)' }}>
{loading ? (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--rah-text-muted)' }}>
@@ -715,24 +562,24 @@ function MapPaneInner({
maxZoom={3}
defaultEdgeOptions={{ type: 'rahEdge' }}
proOptions={{ hideAttribution: true }}
colorMode="dark"
colorMode={theme}
>
<Background color="var(--rah-border)" gap={40} size={1} />
<MiniMap
style={{ background: 'var(--rah-bg-base)', border: '1px solid var(--rah-bg-active)', borderRadius: 6 }}
maskColor="rgba(0, 0, 0, 0.7)"
style={{ background: 'var(--rah-bg-panel)', border: '1px solid var(--rah-border)', borderRadius: 6 }}
maskColor={theme === 'light' ? 'rgba(255, 255, 255, 0.72)' : 'rgba(0, 0, 0, 0.7)'}
nodeColor={(node) => {
const data = node.data as RahNodeData | undefined;
return data?.primaryDimensionColor || '#2a2a2a';
return data?.clusterColor || '#2a2a2a';
}}
pannable
zoomable
/>
</ReactFlow>
{viewMode === 'dimension' && clusterLabels.map((label) => (
{viewMode === 'context' && clusterLabels.map((label) => (
<div
key={label.dimension}
key={label.clusterLabel}
style={{
position: 'absolute',
transform: `translate(${label.x}px, ${label.y}px)`,
@@ -741,27 +588,29 @@ function MapPaneInner({
fontSize: '11px',
letterSpacing: '0.05em',
textTransform: 'uppercase',
textShadow: '0 1px 6px rgba(0,0,0,0.45)',
textShadow: theme === 'light' ? '0 1px 3px rgba(255,255,255,0.95)' : '0 1px 6px rgba(0,0,0,0.45)',
}}
>
{label.dimension}
{label.clusterLabel}
</div>
))}
<div style={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
gap: 4,
zIndex: 10,
}}>
<div
style={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
gap: 4,
zIndex: 10,
}}
>
<button
onClick={fitAllNodes}
style={{
padding: '4px 8px',
fontSize: 10,
background: 'var(--rah-bg-active)',
background: 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border-strong)',
borderRadius: 4,
color: 'var(--rah-text-muted)',
@@ -777,7 +626,7 @@ function MapPaneInner({
style={{
padding: '4px 8px',
fontSize: 10,
background: 'var(--rah-bg-active)',
background: 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border-strong)',
borderRadius: 4,
color: 'var(--rah-text-muted)',
@@ -790,7 +639,6 @@ function MapPaneInner({
)}
</div>
{/* Selected node info panel */}
{selectedDbNode && (
<div style={infoPanel}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: 8 }}>
@@ -807,31 +655,35 @@ function MapPaneInner({
<div style={{ fontSize: 12, color: 'var(--rah-text-muted)', marginBottom: 8 }}>
{connectedNodeIds.size} connected nodes
</div>
<div style={{ fontSize: 11, color: '#22c55e', marginBottom: 8 }}>
<div style={{ fontSize: 11, color: 'var(--rah-accent-green)', marginBottom: 8 }}>
Click a connected node to traverse &middot; Double-click to open
</div>
{selectedDbNode.dimensions && selectedDbNode.dimensions.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
{selectedDbNode.dimensions.slice(0, 5).map(dim => (
<span
key={dim}
style={{
padding: '2px 8px', borderRadius: 999, fontSize: 11,
background: lockedDimensionNames.has(dim) ? '#132018' : 'var(--rah-bg-active)',
color: lockedDimensionNames.has(dim) ? '#86efac' : 'var(--rah-text-muted)',
}}
>
{dim}
</span>
))}
</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
<span
style={{
padding: '2px 8px',
borderRadius: 999,
fontSize: 11,
background: selectedDbNode.context?.name ? 'var(--rah-accent-green-soft)' : 'var(--rah-bg-active)',
color: selectedDbNode.context?.name ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)',
}}
>
{selectedDbNode.context?.name || 'Unscoped'}
</span>
</div>
<button
onClick={() => onNodeClick?.(selectedDbNode.id)}
style={{
marginTop: 4, padding: '8px 12px', background: '#22c55e', color: '#052e16',
border: 'none', borderRadius: '6px', fontSize: 12, fontWeight: 500,
cursor: 'pointer', width: '100%',
marginTop: 4,
padding: '8px 12px',
background: 'var(--rah-accent-green)',
color: 'var(--rah-text-inverse)',
border: 'none',
borderRadius: '6px',
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
width: '100%',
}}
>
Open Node
@@ -842,7 +694,6 @@ function MapPaneInner({
)}
</div>
{/* Edge creation explanation modal */}
{pendingConnection && (
<EdgeExplanationModal
sourceTitle={pendingSourceTitle}
@@ -855,7 +706,6 @@ function MapPaneInner({
);
}
// Wrap with ReactFlowProvider
export default function MapPane(props: MapPaneProps) {
return (
<ReactFlowProvider>
@@ -869,9 +719,10 @@ const infoPanel: CSSProperties = {
bottom: 16,
left: 16,
width: 260,
background: 'var(--rah-bg-base)',
border: '1px solid var(--rah-bg-active)',
background: 'var(--rah-bg-modal)',
border: '1px solid var(--rah-border)',
borderRadius: 8,
padding: 14,
zIndex: 10,
boxShadow: 'var(--rah-shadow-floating)',
};
+233
View File
@@ -0,0 +1,233 @@
"use client";
import { useEffect, useRef, useState, useCallback } from 'react';
import { X, LayoutList, PanelsTopLeft, Map, FileText, Table2, BookOpen } from 'lucide-react';
import type { SlotTab, PaneType, SlotId } from './types';
const TAB_TYPE_ICONS: Record<PaneType, typeof LayoutList> = {
views: LayoutList,
contexts: PanelsTopLeft,
map: Map,
node: FileText,
table: Table2,
skills: BookOpen,
};
const TAB_TYPE_LABELS: Record<PaneType, string> = {
views: 'Feed',
contexts: 'Contexts',
map: 'Map',
node: 'Node',
table: 'Table',
skills: 'Skills',
};
interface SlotTabBarProps {
tabs: SlotTab[];
activeTabId: string;
slot: SlotId;
onTabSelect: (tabId: string) => void;
onTabClose: (tabId: string) => void;
onReorderTabs?: (fromIndex: number, toIndex: number) => void;
onCrossSlotDrop?: (tab: SlotTab, fromSlot: SlotId) => void;
}
function truncateTitle(title: string, maxLength = 18): string {
if (title.length <= maxLength) return title;
return `${title.slice(0, maxLength - 1)}\u2026`;
}
export default function SlotTabBar({
tabs,
activeTabId,
slot,
onTabSelect,
onTabClose,
onReorderTabs,
onCrossSlotDrop,
}: SlotTabBarProps) {
const [nodeTitles, setNodeTitles] = useState<Record<string, string>>({});
const fetchedRef = useRef<Set<string>>(new Set());
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
useEffect(() => {
const nodeTabs = tabs.filter((tab) => tab.type === 'node' && tab.nodeId != null);
for (const tab of nodeTabs) {
if (fetchedRef.current.has(tab.id)) continue;
fetchedRef.current.add(tab.id);
fetch(`/api/nodes/${tab.nodeId}`)
.then((response) => (response.ok ? response.json() : null))
.then((payload) => {
if (payload?.success && payload.node) {
setNodeTitles((prev) => ({ ...prev, [tab.id]: payload.node.title || 'Untitled' }));
}
})
.catch(() => {
fetchedRef.current.delete(tab.id);
});
}
}, [tabs]);
useEffect(() => {
const currentIds = new Set(tabs.map((tab) => tab.id));
fetchedRef.current.forEach((id) => {
if (!currentIds.has(id)) {
fetchedRef.current.delete(id);
}
});
}, [tabs]);
const handleDragStart = useCallback((event: React.DragEvent, index: number, tab: SlotTab) => {
event.dataTransfer.setData(
'application/x-rah-tab',
JSON.stringify({
sourceSlot: slot,
sourceIndex: index,
tab,
})
);
event.dataTransfer.effectAllowed = 'move';
}, [slot]);
const handleDrop = useCallback((event: React.DragEvent, targetIndex: number) => {
event.preventDefault();
setDragOverIndex(null);
const raw = event.dataTransfer.getData('application/x-rah-tab');
if (!raw) return;
try {
const parsed = JSON.parse(raw) as {
sourceSlot: SlotId;
sourceIndex: number;
tab: SlotTab;
};
if (parsed.sourceSlot === slot) {
onReorderTabs?.(parsed.sourceIndex, targetIndex);
return;
}
onCrossSlotDrop?.(parsed.tab, parsed.sourceSlot);
} catch {
// Ignore malformed drag payloads.
}
}, [onCrossSlotDrop, onReorderTabs, slot]);
const handleBarDrop = useCallback((event: React.DragEvent) => {
event.preventDefault();
setDragOverIndex(null);
const raw = event.dataTransfer.getData('application/x-rah-tab');
if (!raw) return;
try {
const parsed = JSON.parse(raw) as {
sourceSlot: SlotId;
tab: SlotTab;
};
if (parsed.sourceSlot !== slot) {
onCrossSlotDrop?.(parsed.tab, parsed.sourceSlot);
}
} catch {
// Ignore malformed drag payloads.
}
}, [onCrossSlotDrop, slot]);
if (tabs.length === 0) return null;
return (
<div
onDragOver={(event) => {
if (event.dataTransfer.types.includes('application/x-rah-tab')) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}
}}
onDrop={handleBarDrop}
style={{ display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0, overflow: 'hidden' }}
>
{tabs.map((tab, index) => {
const isActive = tab.id === activeTabId;
const Icon = TAB_TYPE_ICONS[tab.type];
const label = tab.type === 'node'
? truncateTitle(nodeTitles[tab.id] || 'Loading...')
: TAB_TYPE_LABELS[tab.type];
const isDragOver = dragOverIndex === index;
return (
<div
key={tab.id}
draggable={tab.type === 'node'}
onDragStart={(event) => handleDragStart(event, index, tab)}
onDragOver={(event) => {
if (!event.dataTransfer.types.includes('application/x-rah-tab')) return;
event.preventDefault();
setDragOverIndex(index);
}}
onDragLeave={() => setDragOverIndex(null)}
onDrop={(event) => handleDrop(event, index)}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
minWidth: 0,
padding: '6px 10px',
borderRadius: '10px',
border: isDragOver ? '1px dashed var(--rah-accent-green)' : '1px solid var(--rah-border)',
background: isActive ? 'var(--rah-bg-hover)' : 'var(--rah-bg-panel)',
color: isActive ? 'var(--rah-text-primary)' : 'var(--rah-text-secondary)',
cursor: tab.type === 'node' ? 'grab' : 'pointer',
}}
>
<button
type="button"
onClick={() => onTabSelect(tab.id)}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
minWidth: 0,
background: 'transparent',
border: 'none',
color: 'inherit',
cursor: 'pointer',
padding: 0,
}}
>
<Icon size={14} />
<span style={{ fontSize: '12px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{label}
</span>
</button>
{tabs.length > 1 ? (
<button
type="button"
onClick={() => onTabClose(tab.id)}
style={{
width: '16px',
height: '16px',
border: 'none',
borderRadius: '999px',
background: 'transparent',
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
padding: 0,
flexShrink: 0,
}}
>
<X size={12} />
</button>
) : null}
</div>
);
})}
</div>
);
}
+1 -6
View File
@@ -13,10 +13,8 @@ export interface ViewsPaneProps extends BasePaneProps {
pendingNodes?: PendingNode[];
onDismissPending?: (id: string) => void;
externalContextFilterId?: number | null;
externalDimensionFilter?: string | null;
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
onClearExternalContextFilter?: () => void;
onClearExternalDimensionFilter?: () => void;
}
export default function ViewsPane({
@@ -32,12 +30,11 @@ export default function ViewsPane({
pendingNodes,
onDismissPending,
externalContextFilterId,
externalDimensionFilter,
onContextFilterSelect,
onClearExternalContextFilter,
onClearExternalDimensionFilter,
}: ViewsPaneProps) {
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
const handleTypeChange = (type: PaneType) => {
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
};
@@ -65,10 +62,8 @@ export default function ViewsPane({
pendingNodes={pendingNodes}
onDismissPending={onDismissPending}
externalContextFilterId={externalContextFilterId}
externalDimensionFilter={externalDimensionFilter}
onContextFilterSelect={onContextFilterSelect}
onClearExternalContextFilter={onClearExternalContextFilter}
onClearExternalDimensionFilter={onClearExternalDimensionFilter}
toolbarHost={toolbarHost}
/>
</div>
+1 -1
View File
@@ -1,9 +1,9 @@
export { default as NodePane } from './NodePane';
export { default as ContextsPane } from './ContextsPane';
export { default as DimensionsPane } from './DimensionsPane';
export { default as MapPane } from './MapPane';
export { default as ViewsPane } from './ViewsPane';
export { default as TablePane } from './TablePane';
export { default as SkillsPane } from './SkillsPane';
export { default as PaneHeader } from './PaneHeader';
export { default as SlotTabBar } from './SlotTabBar';
export * from './types';
+5 -6
View File
@@ -3,13 +3,12 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
import type { RahNodeData } from './utils';
import { LABEL_THRESHOLD } from './utils';
import { getNodeIcon } from '@/utils/nodeIcons';
type RahNodeType = Node<RahNodeData, 'rahNode'>;
function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
const { label, dimensions, edgeCount, isExpanded, dbNode, dimensionIcons, primaryDimensionColor } = data;
const { label, clusterLabel, edgeCount, isExpanded, dbNode, clusterColor } = data;
const isTop = !isExpanded && edgeCount > 3;
return (
@@ -20,7 +19,7 @@ function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
isTop && 'rah-map-node--top',
selected && 'rah-map-node--selected',
].filter(Boolean).join(' ')}
style={primaryDimensionColor ? { borderLeftColor: primaryDimensionColor, borderLeftWidth: 3 } : undefined}
style={clusterColor ? { borderLeftColor: clusterColor, borderLeftWidth: 3 } : undefined}
>
<Handle
type="target"
@@ -29,13 +28,13 @@ function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
/>
<div className="rah-map-node__title">
<span className="rah-map-node__icon">
{getNodeIcon(dbNode, dimensionIcons, 14)}
{getNodeIcon(dbNode, 14)}
</span>
{label.length > 26 ? label.slice(0, 24) + '\u2026' : label}
</div>
{(isTop || isExpanded) && dimensions.length > 0 && (
{(isTop || isExpanded) && clusterLabel && (
<div className="rah-map-node__dims">
{dimensions.slice(0, 3).map(d => d.length > 12 ? d.slice(0, 11) + '\u2026' : d).join(' \u00b7 ')}
{clusterLabel.length > 24 ? `${clusterLabel.slice(0, 23)}\u2026` : clusterLabel}
</div>
)}
<Handle
+50 -59
View File
@@ -1,55 +1,44 @@
import type { Node as DbNode, Edge as DbEdge } from '@/types/database';
import type { Node as RFNode, Edge as RFEdge } from '@xyflow/react';
// Fixed palette for dimension border colors (muted, dark-theme-friendly)
const DIMENSION_COLORS = [
'#22c55e', // green
'#3b82f6', // blue
'#f59e0b', // amber
'#ef4444', // red
'#8b5cf6', // violet
'#ec4899', // pink
'#06b6d4', // cyan
'#f97316', // orange
'#14b8a6', // teal
'#a855f7', // purple
const CLUSTER_COLORS = [
'#22c55e',
'#3b82f6',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#06b6d4',
'#f97316',
'#14b8a6',
'#a855f7',
];
export type MapViewMode = 'dimension' | 'hub';
export type MapViewMode = 'context' | 'hub';
function hashDimensionColor(dimension: string): string {
function hashClusterColor(label: string): string {
let hash = 0;
for (let i = 0; i < dimension.length; i++) {
hash = ((hash << 5) - hash + dimension.charCodeAt(i)) | 0;
for (let i = 0; i < label.length; i++) {
hash = ((hash << 5) - hash + label.charCodeAt(i)) | 0;
}
return DIMENSION_COLORS[Math.abs(hash) % DIMENSION_COLORS.length];
return CLUSTER_COLORS[Math.abs(hash) % CLUSTER_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 getNodeClusterLabel(node: DbNode): string {
return node.context?.name?.trim() || 'Unscoped';
}
export function getPrimaryDimension(dimensions: string[] | undefined): string {
const ordered = getOrderedDimensions(dimensions);
return ordered[0] || 'Unsorted';
}
export function getDimensionColor(dimensions: string[] | undefined): string | undefined {
const primary = getPrimaryDimension(dimensions);
return primary === 'Unsorted' ? '#4b5563' : hashDimensionColor(primary);
export function getClusterColor(label: string): string {
return label === 'Unscoped' ? '#4b5563' : hashClusterColor(label);
}
export interface RahNodeData {
label: string;
dimensions: string[];
clusterLabel: string;
edgeCount: number;
isExpanded: boolean;
dbNode: DbNode;
dimensionIcons?: Record<string, string>;
primaryDimensionColor?: string;
clusterColor?: string;
clusterKey?: string;
[key: string]: unknown;
}
@@ -64,12 +53,15 @@ export function getSavedMapPosition(
const metadata = typeof node.metadata === 'string'
? safeParseJSON(node.metadata)
: node.metadata;
const nested = metadata?.map_positions as Record<string, { x?: number; y?: number }> | undefined;
const scoped = metadata?.[`map_position_${viewMode}`] as { x?: number; y?: number } | undefined;
const saved = nested?.[viewMode] || scoped;
if (saved?.x !== undefined && saved?.y !== undefined) {
return { x: saved.x, y: saved.y };
}
return null;
}
@@ -78,11 +70,12 @@ function getAllNodes(baseNodes: DbNode[], expandedNodes: DbNode[]): DbNode[] {
return [...baseNodes, ...expandedNodes.filter((node) => !baseIds.has(node.id))];
}
function buildDimensionLayout(nodes: DbNode[], centerX: number, centerY: number): Map<string, { x: number; y: number }> {
function buildContextLayout(nodes: DbNode[], centerX: number, centerY: number): Map<string, { x: number; y: number }> {
const positions = new Map<string, { x: number; y: number }>();
const groups = new Map<string, DbNode[]>();
for (const node of nodes) {
const key = getPrimaryDimension(node.dimensions);
const key = getNodeClusterLabel(node);
const existing = groups.get(key) || [];
existing.push(node);
groups.set(key, existing);
@@ -97,7 +90,10 @@ function buildDimensionLayout(nodes: DbNode[], centerX: number, centerY: number)
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 clusterNodes = (groups.get(clusterKey) || []).sort((a, b) => {
return (b.edge_count ?? 0) - (a.edge_count ?? 0);
});
const clusterColumn = clusterIndex % columns;
const clusterRow = Math.floor(clusterIndex / columns);
const clusterCenterX = originX + clusterColumn * clusterGapX;
@@ -147,6 +143,7 @@ function buildHubLayout(
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))
@@ -193,9 +190,10 @@ function buildHubLayout(
}
export function getClusterKey(node: DbNode, viewMode: MapViewMode, dbEdges: DbEdge[]): string {
if (viewMode === 'dimension') {
return getPrimaryDimension(node.dimensions);
if (viewMode === 'context') {
return getNodeClusterLabel(node);
}
return `hub:${node.id}`;
}
@@ -207,14 +205,13 @@ export function toRFNodes(
selectedNodeId: number | null,
connectedNodeIds: Set<number>,
existingPositions: Map<string, { x: number; y: number }>,
dimensionIcons: Record<string, string> | undefined,
viewMode: MapViewMode,
dbEdges: DbEdge[],
): RFNode<RahNodeData>[] {
const allNodes = getAllNodes(baseNodes, expandedNodes);
const hasSelection = selectedNodeId !== null;
const clusterLayout = viewMode === 'dimension'
? buildDimensionLayout(allNodes, centerX, centerY)
const clusterLayout = viewMode === 'context'
? buildContextLayout(allNodes, centerX, centerY)
: buildHubLayout(allNodes, dbEdges, centerX, centerY);
const baseNodeIds = new Set(baseNodes.map((node) => node.id));
@@ -225,6 +222,7 @@ export function toRFNodes(
const fallbackPos = clusterLayout.get(id) || { x: centerX, y: centerY };
const pos = existingPos || savedPos || fallbackPos;
const isDimmed = hasSelection && node.id !== selectedNodeId && !connectedNodeIds.has(node.id);
const clusterLabel = getNodeClusterLabel(node);
return {
id,
@@ -233,23 +231,17 @@ export function toRFNodes(
className: isDimmed ? 'dimmed' : undefined,
data: {
label: node.title || 'Untitled',
dimensions: getOrderedDimensions(node.dimensions),
clusterLabel,
edgeCount: node.edge_count ?? 0,
isExpanded: !baseNodeIds.has(node.id),
dbNode: node,
dimensionIcons,
primaryDimensionColor: getDimensionColor(node.dimensions),
clusterKey: viewMode === 'dimension' ? getPrimaryDimension(node.dimensions) : undefined,
clusterColor: getClusterColor(clusterLabel),
clusterKey: viewMode === 'context' ? clusterLabel : undefined,
},
};
});
}
/**
* Transform DB edges into React Flow edges, filtering to only those
* connecting nodes currently in the graph.
* When a node is selected, connected edges are highlighted and others dimmed.
*/
export function toRFEdges(
dbEdges: DbEdge[],
nodeIds: Set<string>,
@@ -258,19 +250,18 @@ export function toRFEdges(
const hasSelection = selectedNodeId !== null;
return dbEdges
.filter(e => nodeIds.has(String(e.from_node_id)) && nodeIds.has(String(e.to_node_id)))
.map(e => {
.filter((edge) => nodeIds.has(String(edge.from_node_id)) && nodeIds.has(String(edge.to_node_id)))
.map((edge) => {
const isConnected = hasSelection && (
e.from_node_id === selectedNodeId || e.to_node_id === selectedNodeId
edge.from_node_id === selectedNodeId || edge.to_node_id === selectedNodeId
);
const isDimmed = hasSelection && !isConnected;
const explanation = typeof e.context?.explanation === 'string' ? e.context.explanation : '';
const explanation = typeof edge.context?.explanation === 'string' ? edge.context.explanation : '';
return {
id: String(e.id),
source: String(e.from_node_id),
target: String(e.to_node_id),
id: String(edge.id),
source: String(edge.from_node_id),
target: String(edge.to_node_id),
type: 'rahEdge',
animated: isConnected,
data: { explanation },
@@ -284,7 +275,7 @@ export function toRFEdges(
});
}
function safeParseJSON(str: string | null | undefined): Record<string, unknown> | null {
function safeParseJSON(str: string | null | undefined): Record<string, any> | null {
if (!str || str === 'null') return null;
try {
return JSON.parse(str);
+45 -43
View File
@@ -1,41 +1,38 @@
import React from 'react';
import { Node } from '@/types/database';
import type { FocusedSkill } from '@/types/skills';
export type SlotId = 'A' | 'B' | 'C';
export type NavigablePaneType = Exclude<PaneType, 'node'>;
// Stub type for delegation (delegation system removed in rah-light)
export type AgentDelegation = {
id: number;
sessionId: string;
task: string;
context: string[];
status: 'queued' | 'in_progress' | 'completed' | 'failed';
summary?: string | null;
agentType: string;
createdAt: string;
updatedAt: string;
};
// The pane types
export type PaneType = 'node' | 'contexts' | 'map' | 'views' | 'table' | 'skills';
// Pane types (chat removed in rah-light)
export type PaneType = 'node' | 'contexts' | 'dimensions' | 'map' | 'views' | 'table' | 'skills';
// State for each slot
export interface SlotState {
// A single tab within a slot
export interface SlotTab {
id: string; // 'views', 'map', or 'node-{nodeId}'
type: PaneType;
// NodePane state
nodeTabs?: number[];
activeNodeTab?: number | null;
// DimensionsPane state
selectedDimension?: string | null;
viewMode?: 'grid' | 'list' | 'kanban';
nodeId?: number; // Only for type === 'node'
}
// State for each slot — persistent tabs
export interface SlotState {
tabs: SlotTab[];
activeTabId: string;
}
// Helper to create a consistent tab ID
export function createTabId(type: PaneType, nodeId?: number): string {
return type === 'node' && nodeId != null ? `node-${nodeId}` : type;
}
// Helper to get the active tab from a slot state
export function getActiveTab(state: SlotState): SlotTab | undefined {
return state.tabs.find(t => t.id === state.activeTabId);
}
// Actions panes can emit to the layout
export type PaneAction =
| { type: 'open-node'; nodeId: number; targetSlot?: SlotId }
| { type: 'open-context'; contextId: number | null; contextName?: string | null; targetSlot?: SlotId }
| { type: 'open-dimension'; dimension: string; targetSlot?: SlotId }
| { type: 'switch-pane-type'; paneType: PaneType }
| { type: 'close-pane' };
@@ -70,14 +67,24 @@ export interface HighlightedPassage {
selectedText: string;
}
// ChatPaneProps removed in rah-light
// DimensionsPane specific props
export interface DimensionsPaneProps extends BasePaneProps {
onNodeOpen: (nodeId: number) => void;
refreshToken: number;
onDataChanged?: () => void;
onDimensionSelect?: (dimensionName: string | null) => void;
export interface ChatPanelProps {
isOpen: boolean;
onClose: () => void;
onOpen: () => void;
openTabsData: Node[];
activeTabId: number | null;
activeContextId?: number | null;
activeContextName?: string | null;
onClearContext?: () => void;
focusedSkill?: FocusedSkill | null;
onClearFocusedSkill?: () => void;
onNodeClick?: (nodeId: number) => void;
chatMessages?: unknown[];
setChatMessages?: React.Dispatch<React.SetStateAction<unknown[]>>;
highlightedPassage?: HighlightedPassage | null;
onClearPassage?: () => void;
onboardingHint?: string | null;
onDismissOnboardingHint?: () => void;
}
// MapPane specific props
@@ -92,13 +99,12 @@ export interface ViewsPaneProps extends BasePaneProps {
onNodeOpenInOtherPane?: (nodeId: number) => void;
refreshToken?: number;
externalContextFilterId?: number | null;
externalDimensionFilter?: string | null;
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
onClearExternalContextFilter?: () => void;
onClearExternalDimensionFilter?: () => void;
}
export interface ContextsPaneProps extends BasePaneProps {
onNodeOpen: (nodeId: number) => void;
onContextSelect?: (contextId: number | null, contextName?: string | null) => void;
}
@@ -122,7 +128,6 @@ export interface PaneHeaderProps {
export const PANE_LABELS: Record<PaneType, string> = {
node: 'Nodes',
contexts: 'Contexts',
dimensions: 'Dimensions',
map: 'Map',
views: 'Feed',
table: 'Table',
@@ -131,11 +136,8 @@ export const PANE_LABELS: Record<PaneType, string> = {
// Default slot states
export const DEFAULT_SLOT_A: SlotState = {
type: 'node',
nodeTabs: [],
activeNodeTab: null,
tabs: [{ id: 'views', type: 'views' }],
activeTabId: 'views',
};
export const DEFAULT_SLOT_B: SlotState = {
type: 'views',
};
export const DEFAULT_SLOT_B: SlotState | null = null;
+1 -1
View File
@@ -23,7 +23,7 @@ export default function ApiKeysViewer() {
<div style={featuresHeaderStyle}>OpenAI API Key enables:</div>
<ul style={featuresListStyle}>
<li>Auto-generated descriptions for new nodes</li>
<li>Smart dimension assignment</li>
<li>Edge explanation inference</li>
<li>Semantic search via embeddings</li>
</ul>
<div style={noteStyle}>
+123 -110
View File
@@ -3,123 +3,126 @@
import { useEffect, useState, type CSSProperties } from 'react';
import type { Node } from '@/types/database';
interface AutoContextSettings {
autoContextEnabled: boolean;
lastPinnedMigration?: string;
}
interface NodeWithMetrics extends Node {
edge_count?: number;
}
interface CapsuleData {
userProfile: string;
agentProfile: string;
lastUpdatedAt: string;
}
export default function ContextViewer() {
const [nodes, setNodes] = useState<NodeWithMetrics[]>([]);
const [enabled, setEnabled] = useState(false);
const [capsule, setCapsule] = useState<CapsuleData | null>(null);
const [loadingNodes, setLoadingNodes] = useState(true);
const [loadingSettings, setLoadingSettings] = useState(true);
const [saving, setSaving] = useState(false);
const [loadingCapsule, setLoadingCapsule] = useState(true);
const [resetting, setResetting] = useState(false);
const loadNodes = async () => {
try {
const res = await fetch('/api/nodes?sortBy=edges&limit=5');
const payload = await res.json();
setNodes(payload.data || []);
} catch (error) {
console.error(error);
setNodes([]);
} finally {
setLoadingNodes(false);
}
};
const loadCapsule = async () => {
try {
const res = await fetch('/api/rah/memory');
const payload = await res.json();
setCapsule(payload.data?.capsule ?? null);
} catch (error) {
console.error(error);
setCapsule(null);
} finally {
setLoadingCapsule(false);
}
};
useEffect(() => {
const loadNodes = async () => {
try {
const res = await fetch('/api/nodes?sortBy=edges&limit=10');
const payload = await res.json();
setNodes(payload.data || []);
} catch (e) {
console.error(e);
} finally {
setLoadingNodes(false);
}
};
const loadSettings = async () => {
try {
const res = await fetch('/api/system/auto-context');
const payload = await res.json() as { success: boolean; data?: AutoContextSettings };
if (payload.success && payload.data) {
setEnabled(payload.data.autoContextEnabled);
}
} catch (e) {
console.error(e);
} finally {
setLoadingSettings(false);
}
};
loadNodes();
loadSettings();
void loadNodes();
void loadCapsule();
}, []);
const handleToggle = async () => {
if (saving) return;
setSaving(true);
const handleReset = async () => {
if (!confirm('Reset the context capsule to neutral defaults?')) return;
try {
const res = await fetch('/api/system/auto-context', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoContextEnabled: !enabled }),
});
const payload = await res.json() as { success: boolean; data?: AutoContextSettings };
if (payload.success && payload.data) {
setEnabled(payload.data.autoContextEnabled);
}
} catch (e) {
console.error(e);
setResetting(true);
await fetch('/api/rah/memory', { method: 'DELETE' });
await loadCapsule();
} finally {
setSaving(false);
setResetting(false);
}
};
return (
<div style={containerStyle}>
<p style={descStyle}>
Context summaries and anchor nodes are used first. Global hub nodes remain secondary diagnostics in background context.
RA-H now carries one compact context capsule into every conversation. It stays neutral by default,
updates only on meaningful changes, and sits alongside hub nodes rather than replacing them.
</p>
{/* Toggle */}
<div style={cardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={labelStyle}>Auto-Context</div>
<div style={subLabelStyle}>
{loadingSettings ? 'Loading...' : enabled ? 'Enabled' : 'Disabled'}
</div>
</div>
<button
onClick={handleToggle}
disabled={loadingSettings || saving}
style={{
...toggleStyle,
background: enabled ? 'var(--settings-active-bg)' : 'var(--settings-code-bg)',
}}
>
<span style={{
...toggleKnobStyle,
left: enabled ? 26 : 4,
}} />
</button>
<div style={capsuleHeaderStyle}>
<div>
<div style={labelStyle}>Context Capsule</div>
<div style={subLabelStyle}>Always injected into the system prompt. Max 200 words total.</div>
</div>
<button
onClick={handleReset}
disabled={resetting}
style={resetButtonStyle}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--settings-button-hover-bg)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
{resetting ? 'Resetting...' : 'Reset Capsule'}
</button>
</div>
{loadingCapsule ? (
<div style={mutedStyle}>Loading capsule...</div>
) : capsule ? (
<div style={capsuleGridStyle}>
<CapsuleSection
title="User"
value={capsule.userProfile}
footer={capsule.lastUpdatedAt ? `Updated ${new Date(capsule.lastUpdatedAt).toLocaleString()}` : 'Never updated'}
/>
<CapsuleSection title="Agent" value={capsule.agentProfile} />
</div>
) : (
<div style={mutedStyle}>Capsule unavailable.</div>
)}
<div style={{ ...labelStyle, marginTop: 28 }}>Hub Nodes</div>
<div style={subLabelStyle}>
Your 5 most-connected nodes remain the raw graph grounding and are included in every conversation.
</div>
{/* Nodes List */}
<div style={labelStyle}>Top Nodes</div>
{loadingNodes ? (
<div style={mutedStyle}>Loading...</div>
<div style={mutedStyle}>Loading hub nodes...</div>
) : nodes.length === 0 ? (
<div style={mutedStyle}>No connected nodes yet.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 12 }}>
{nodes.map((node) => (
<div key={node.id} style={nodeCardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={nodeTitleStyle}>{node.title || 'Untitled'}</span>
<span style={edgeCountStyle}>{node.edge_count ?? 0}</span>
</div>
{node.dimensions && node.dimensions.length > 0 && (
<div style={{ display: 'flex', gap: 4, marginTop: 6, flexWrap: 'wrap' }}>
{node.dimensions.slice(0, 3).map((dim) => (
<span key={dim} style={dimTagStyle}>{dim}</span>
))}
{node.description && (
<div style={nodeDescriptionStyle}>{node.description}</div>
)}
{node.context?.name && (
<div style={{ display: 'flex', gap: 4, marginTop: 8, flexWrap: 'wrap' }}>
<span style={contextTagStyle}>{node.context.name}</span>
</div>
)}
</div>
@@ -130,52 +133,62 @@ export default function ContextViewer() {
);
}
const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' };
const descStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginBottom: 20, lineHeight: 1.5 };
function CapsuleSection({
title,
value,
footer,
}: {
title: string;
value: string;
footer?: string;
}) {
return (
<div style={cardStyle}>
<div style={labelStyle}>{title}</div>
<div style={capsuleBodyStyle}>{value}</div>
{footer && <div style={capsuleFooterStyle}>{footer}</div>}
</div>
);
}
const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' };
const descStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginBottom: 20, lineHeight: 1.5, maxWidth: 780 };
const capsuleHeaderStyle: CSSProperties = { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 12 };
const capsuleGridStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 };
const cardStyle: CSSProperties = {
background: 'var(--settings-card-bg)',
border: '1px solid var(--settings-border)',
borderRadius: 8,
padding: 16,
marginBottom: 24,
minHeight: 140,
};
const labelStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: 'var(--settings-text)', marginBottom: 8 };
const subLabelStyle: CSSProperties = { fontSize: 12, color: 'var(--settings-muted)' };
const mutedStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)' };
const toggleStyle: CSSProperties = {
width: 48,
height: 26,
borderRadius: 13,
border: '1px solid var(--settings-border)',
const mutedStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginTop: 10 };
const capsuleBodyStyle: CSSProperties = { fontSize: 13, lineHeight: 1.6, color: 'var(--settings-subtext)', whiteSpace: 'pre-wrap' };
const capsuleFooterStyle: CSSProperties = { fontSize: 11, color: 'var(--settings-muted)', marginTop: 12 };
const resetButtonStyle: CSSProperties = {
padding: '8px 14px',
background: 'transparent',
border: '1px solid var(--settings-border-strong)',
borderRadius: '6px',
color: 'var(--settings-text)',
fontSize: '12px',
fontWeight: 500,
cursor: 'pointer',
position: 'relative',
transition: 'background 0.15s',
transition: 'all 0.15s ease',
whiteSpace: 'nowrap',
};
const toggleKnobStyle: CSSProperties = {
position: 'absolute',
top: 4,
width: 18,
height: 18,
borderRadius: '50%',
background: 'var(--settings-text)',
transition: 'left 0.15s',
};
const nodeCardStyle: CSSProperties = {
padding: '12px 14px',
background: 'var(--settings-card-bg)',
border: '1px solid var(--settings-border)',
borderRadius: 6,
};
const nodeTitleStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: 'var(--settings-text)' };
const nodeDescriptionStyle: CSSProperties = { fontSize: 12, lineHeight: 1.5, color: 'var(--settings-subtext)', marginTop: 8 };
const edgeCountStyle: CSSProperties = { fontSize: 12, color: 'var(--settings-muted)' };
const dimTagStyle: CSSProperties = {
const contextTagStyle: CSSProperties = {
padding: '2px 8px',
borderRadius: 4,
fontSize: 11,
+119 -273
View File
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react';
import { Folder, Link as LinkIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react';
import { Link as LinkIcon } from 'lucide-react';
import { Node } from '@/types/database';
import { openExternalUrl } from '@/utils/openExternalUrl';
@@ -11,16 +11,9 @@ interface NodeWithMetrics extends Node {
interface AppliedFilters {
search?: string;
dimensions?: string[];
sortBy: 'updated' | 'edges';
}
interface PopularDimension {
dimension: string;
count: number;
isPriority: boolean;
}
const LIMIT = 50;
export default function DatabaseViewer() {
@@ -30,16 +23,12 @@ export default function DatabaseViewer() {
const [error, setError] = useState<string | null>(null);
const [searchInput, setSearchInput] = useState('');
const [dimensionInput, setDimensionInput] = useState('');
const [dimensionFilters, setDimensionFilters] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<'updated' | 'edges'>('updated');
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>({ sortBy: 'updated' });
const [lockedDimensionSet, setLockedDimensionSet] = useState<Set<string>>(new Set());
const [contextHubIds, setContextHubIds] = useState<Set<number>>(new Set());
const [hubNodeIds, setHubNodeIds] = useState<Set<number>>(new Set());
const filtersActive = useMemo(
() => Boolean(appliedFilters.search || (appliedFilters.dimensions && appliedFilters.dimensions.length > 0)),
() => Boolean(appliedFilters.search),
[appliedFilters]
);
@@ -53,9 +42,6 @@ export default function DatabaseViewer() {
params.set('offset', ((pageNumber - 1) * LIMIT).toString());
params.set('sortBy', filters.sortBy);
if (filters.search) params.set('search', filters.search);
if (filters.dimensions && filters.dimensions.length > 0) {
params.set('dimensions', filters.dimensions.join(','));
}
const response = await fetch(`/api/nodes?${params.toString()}`);
if (!response.ok) {
@@ -74,82 +60,39 @@ export default function DatabaseViewer() {
);
useEffect(() => {
fetchNodes(page, appliedFilters);
void fetchNodes(page, appliedFilters);
}, [page, appliedFilters, fetchNodes]);
useEffect(() => {
const loadLockedDimensions = async () => {
try {
const response = await fetch('/api/dimensions/popular');
if (!response.ok) return;
const result = await response.json();
if (result.success) {
const priorityDimensions: PopularDimension[] = result.data;
setLockedDimensionSet(new Set(priorityDimensions.filter((d) => d.isPriority).map((d) => d.dimension)));
}
} catch (err) {
console.warn('Failed to load locked dimensions', err);
}
};
loadLockedDimensions();
}, []);
useEffect(() => {
const loadContextHubs = async () => {
const loadHubNodes = async () => {
try {
const response = await fetch('/api/nodes?sortBy=edges&limit=10');
if (!response.ok) return;
const payload = await response.json();
const ids = new Set<number>((payload.data || []).map((node: Node) => node.id));
setContextHubIds(ids);
setHubNodeIds(ids);
} catch (err) {
console.warn('Failed to load auto-context hubs', err);
console.warn('Failed to load graph hubs', err);
}
};
loadContextHubs();
void loadHubNodes();
}, []);
const handleApplyFilters = () => {
const payload: AppliedFilters = {
sortBy,
};
const payload: AppliedFilters = { sortBy };
if (searchInput.trim()) payload.search = searchInput.trim();
if (dimensionFilters.length > 0) payload.dimensions = dimensionFilters;
setAppliedFilters(payload);
setPage(1);
};
const handleClearFilters = () => {
setSearchInput('');
setDimensionInput('');
setDimensionFilters([]);
setSortBy('updated');
setAppliedFilters({ sortBy: 'updated' });
setPage(1);
};
const handleAddDimension = () => {
const next = dimensionInput.trim();
if (!next) return;
setDimensionFilters((prev) => (prev.includes(next) ? prev : [...prev, next]));
setDimensionInput('');
};
const handleDimensionKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
handleAddDimension();
}
};
const handleRemoveDimension = (dimension: string) => {
setDimensionFilters((prev) => prev.filter((dim) => dim !== dimension));
};
const handleSortChange = (event: ChangeEvent<HTMLSelectElement>) => {
const value = event.target.value === 'edges' ? 'edges' : 'updated';
setSortBy(value);
@@ -195,27 +138,15 @@ export default function DatabaseViewer() {
};
if (loading) {
return (
<div style={{ padding: '40px', textAlign: 'center', color: 'var(--rah-text-muted)' }}>
Loading database...
</div>
);
return <div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>Loading database...</div>;
}
if (error) {
return (
<div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>
Error: {error}
</div>
);
return <div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>Error: {error}</div>;
}
if (nodes.length === 0) {
return (
<div style={{ padding: '40px', textAlign: 'center', color: 'var(--rah-text-muted)' }}>
No nodes found
</div>
);
return <div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>No nodes found</div>;
}
return (
@@ -223,71 +154,38 @@ export default function DatabaseViewer() {
<div
style={{
padding: '16px 24px',
borderBottom: '1px solid var(--rah-border-strong)',
borderBottom: '1px solid #2a2a2a',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: 'var(--rah-text-muted)', gap: '4px' }}>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
Search
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="title or content"
style={{
background: 'var(--rah-bg-surface)',
border: '1px solid var(--rah-border-strong)',
color: 'var(--rah-text-base)',
background: '#0f0f0f',
border: '1px solid #2a2a2a',
color: '#ddd',
padding: '6px 10px',
borderRadius: '4px',
minWidth: '220px',
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: 'var(--rah-text-muted)', gap: '4px' }}>
Dimension
<div style={{ display: 'flex', gap: '8px' }}>
<input
value={dimensionInput}
onChange={(e) => setDimensionInput(e.target.value)}
onKeyDown={handleDimensionKeyDown}
placeholder="e.g. research"
style={{
background: 'var(var(--rah-bg-surface))',
border: '1px solid var(--rah-border-strong)',
color: 'var(var(--rah-text-base))',
padding: '6px 10px',
borderRadius: '4px',
minWidth: '180px',
}}
/>
<button
onClick={handleAddDimension}
style={{
padding: '6px 10px',
background: '#1f3529',
border: '1px solid #264333',
borderRadius: '4px',
color: '#c4f5d2',
fontSize: '12px',
cursor: 'pointer',
}}
>
Add
</button>
</div>
</label>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: 'var(--rah-text-muted)', gap: '4px' }}>
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
Sort by
<select
value={sortBy}
onChange={handleSortChange}
style={{
background: 'var(--rah-bg-surface)',
border: '1px solid var(--rah-border-strong)',
color: 'var(--rah-text-base)',
background: '#0f0f0f',
border: '1px solid #2a2a2a',
color: '#ddd',
padding: '6px 10px',
borderRadius: '4px',
}}
@@ -298,46 +196,8 @@ export default function DatabaseViewer() {
</label>
</div>
{dimensionFilters.length > 0 && (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{dimensionFilters.map((dimension) => (
<span
key={dimension}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '3px 10px',
borderRadius: '999px',
background: '#142817',
color: '#c4f5d2',
fontSize: '11px',
border: '1px solid #1f3b23',
}}
>
<Folder size={12} />
{dimension}
<button
onClick={() => handleRemoveDimension(dimension)}
style={{
marginLeft: '2px',
background: 'transparent',
border: 'none',
color: '#7de8a5',
cursor: 'pointer',
fontSize: '11px',
}}
aria-label={`Remove ${dimension}`}
>
×
</button>
</span>
))}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontSize: '12px', color: 'var(--rah-text-muted)' }}>{filterStatus}</div>
<div style={{ fontSize: '12px', color: '#666' }}>{filterStatus}</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handleApplyFilters}
@@ -357,10 +217,10 @@ export default function DatabaseViewer() {
onClick={handleClearFilters}
style={{
padding: '8px 16px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-stronger)',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
color: 'var(--rah-text-secondary)',
color: '#ccc',
cursor: 'pointer',
fontSize: '12px',
}}
@@ -372,10 +232,10 @@ export default function DatabaseViewer() {
disabled={isFirstPage || filtersActive}
style={{
padding: '8px 12px',
background: isFirstPage || filtersActive ? 'var(--rah-bg-active)' : 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-stronger)',
background: isFirstPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
border: '1px solid #333',
borderRadius: '4px',
color: isFirstPage || filtersActive ? 'var(--rah-text-muted)' : 'var(--rah-text-secondary)',
color: isFirstPage || filtersActive ? '#555' : '#ccc',
cursor: isFirstPage || filtersActive ? 'not-allowed' : 'pointer',
fontSize: '12px',
}}
@@ -387,10 +247,10 @@ export default function DatabaseViewer() {
disabled={isLastPage || filtersActive}
style={{
padding: '8px 12px',
background: isLastPage || filtersActive ? 'var(--rah-bg-active)' : 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-stronger)',
background: isLastPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
border: '1px solid #333',
borderRadius: '4px',
color: isLastPage || filtersActive ? 'var(--rah-text-muted)' : 'var(--rah-text-secondary)',
color: isLastPage || filtersActive ? '#555' : '#ccc',
cursor: isLastPage || filtersActive ? 'not-allowed' : 'pointer',
fontSize: '12px',
}}
@@ -403,20 +263,20 @@ export default function DatabaseViewer() {
<div style={{ flex: 1, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead style={{ position: 'sticky', top: 0, background: 'var(--rah-bg-active)', zIndex: 1 }}>
<thead style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
<tr>
{['Node', 'Categories', 'Edges', 'Highlights', 'Updated', 'Created'].map((column) => (
{['Node', 'Context', 'Edges', 'Highlights', 'Updated', 'Created'].map((column) => (
<th
key={column}
style={{
padding: '12px 16px',
textAlign: 'left',
fontSize: '11px',
color: 'var(--rah-text-muted)',
color: '#888',
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontWeight: 'normal',
borderBottom: '1px solid var(--rah-border-strong)',
borderBottom: '1px solid #2a2a2a',
}}
>
{column}
@@ -425,105 +285,91 @@ export default function DatabaseViewer() {
</tr>
</thead>
<tbody>
{nodes.map((node, index) => {
const belongsToLocked = node.dimensions?.some((dimension) => lockedDimensionSet.has(dimension));
return (
<tr
key={node.id}
style={{
background: index % 2 === 0 ? 'var(--rah-bg-base)' : 'var(--rah-bg-surface)',
borderBottom: '1px solid var(--rah-bg-surface)',
}}
>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '28%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ fontWeight: 600, color: 'var(--rah-text-base)', display: 'flex', alignItems: 'center', gap: '8px' }}>
{node.title || 'Untitled node'}
{node.link && (
<button
onClick={() => {
void openExternalUrl(node.link!).catch((error) => {
console.error('[DatabaseViewer] Failed to open node link', error);
window.alert(`Unable to open ${node.link}`);
});
}}
title="Open original link"
style={{
background: 'transparent',
border: 'none',
color: '#7de8a5',
cursor: 'pointer',
padding: 0,
display: 'flex',
alignItems: 'center',
}}
>
<LinkIcon size={14} />
</button>
)}
</div>
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)', fontFamily: 'JetBrains Mono, monospace' }}>ID: {node.id}</span>
</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '24%' }}>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{node.dimensions && node.dimensions.length > 0 ? (
node.dimensions.slice(0, 3).map((dimension) => (
<span
key={`${node.id}-${dimension}`}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
borderRadius: '999px',
background: '#111914',
border: '1px solid #1f2f24',
fontSize: '11px',
color: '#bbf7d0',
}}
>
<Folder size={11} />
{dimension}
</span>
))
) : (
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>No categories</span>
)}
{node.dimensions && node.dimensions.length > 3 && (
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>+{node.dimensions.length - 3} more</span>
{nodes.map((node, index) => (
<tr
key={node.id}
style={{
background: index % 2 === 0 ? '#080808' : '#0d0d0d',
borderBottom: '1px solid #111',
}}
>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '34%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ fontWeight: 600, color: '#f5f5f5', display: 'flex', alignItems: 'center', gap: '8px' }}>
{node.title || 'Untitled node'}
{node.link && (
<button
onClick={() => {
void openExternalUrl(node.link!).catch((openError) => {
console.error('[DatabaseViewer] Failed to open original link', openError);
window.alert(`Unable to open ${node.link}`);
});
}}
title="Open original link"
style={{
background: 'transparent',
border: 'none',
color: '#7de8a5',
cursor: 'pointer',
padding: 0,
display: 'flex',
alignItems: 'center',
}}
>
<LinkIcon size={14} />
</button>
)}
</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '10%' }}>
<div style={{ fontWeight: 600, color: 'var(--rah-text-base)' }}>{node.edge_count ?? 0}</div>
<div style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>connections</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '14%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{contextHubIds.has(node.id) ? (
<span style={{ fontSize: '11px', color: '#facc15', fontWeight: 600 }}>
Auto-context hub
</span>
) : (
<span style={{ fontSize: '11px', color: '#475569' }}></span>
)}
{belongsToLocked && (
<span style={{ fontSize: '11px', color: '#7de8a5' }}>Priority dimension</span>
)}
</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
<div style={{ fontSize: '12px', color: 'var(--rah-text-base)' }}>{formatTimestamp(node.updated_at)}</div>
<div style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>{formatRelative(node.updated_at)}</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
<div style={{ fontSize: '12px', color: 'var(--rah-text-base)' }}>{formatTimestamp(node.created_at)}</div>
<div style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>{formatRelative(node.created_at)}</div>
</td>
</tr>
);
})}
<span style={{ fontSize: '11px', color: '#666', fontFamily: 'JetBrains Mono, monospace' }}>ID: {node.id}</span>
{node.description && <span style={{ fontSize: '12px', color: '#9ca3af' }}>{node.description}</span>}
</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '18%' }}>
{node.context?.name ? (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
padding: '3px 10px',
borderRadius: '999px',
background: '#142817',
color: '#c4f5d2',
fontSize: '11px',
border: '1px solid #1f3b23',
}}
>
{node.context.name}
</span>
) : (
<span style={{ fontSize: '11px', color: '#555' }}>Unscoped</span>
)}
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '10%' }}>
<div style={{ fontWeight: 600, color: '#e2e8f0' }}>{node.edge_count ?? 0}</div>
<div style={{ fontSize: '11px', color: '#666' }}>connections</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '14%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{hubNodeIds.has(node.id) ? (
<span style={{ fontSize: '11px', color: '#facc15', fontWeight: 600 }}>Graph hub</span>
) : (
<span style={{ fontSize: '11px', color: '#475569' }}></span>
)}
{node.metadata?.state && (
<span style={{ fontSize: '11px', color: '#7de8a5' }}>{String(node.metadata.state)}</span>
)}
</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
<div style={{ fontSize: '12px', color: '#e2e8f0' }}>{formatTimestamp(node.updated_at)}</div>
<div style={{ fontSize: '11px', color: '#666' }}>{formatRelative(node.updated_at)}</div>
</td>
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
<div style={{ fontSize: '12px', color: '#cbd5f5' }}>{formatTimestamp(node.created_at)}</div>
<div style={{ fontSize: '11px', color: '#666' }}>{formatRelative(node.created_at)}</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
@@ -127,7 +127,7 @@ export default function ExternalAgentsPanel() {
<div style={{ display: 'grid', gap: '16px' }}>
<HelperCard
title="Add to RA-H"
body={`"Summarize our meeting and add it to RA-H under dimensions Strategy, Q1 Execution."`}
body={`"Summarize our meeting and add it to RA-H. If a context is obvious, use it. If not, leave context blank."`}
/>
<HelperCard
title="Search RA-H"
@@ -0,0 +1,5 @@
"use client";
export default function AutoUpdateManager() {
return null;
}
+19 -172
View File
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Filter, ChevronDown, ChevronLeft, ChevronRight, X, ArrowUpDown, Search, ExternalLink } from 'lucide-react';
import { ChevronDown, ChevronLeft, ChevronRight, X, ArrowUpDown, Search, ExternalLink } from 'lucide-react';
import type { Node } from '@/types/database';
import { formatRelativeDate } from '@/utils/formatDate';
@@ -17,12 +17,6 @@ const SORT_LABELS: Record<SortOrder, string> = {
const PAGE_SIZE = 50;
interface DimensionSummary {
dimension: string;
count: number;
isPriority: boolean;
}
interface DatabaseTableViewProps {
onNodeClick: (nodeId: number) => void;
refreshToken?: number;
@@ -46,41 +40,11 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
const [sortOrder, setSortOrder] = useState<SortOrder>('updated');
const [searchQuery, setSearchQuery] = useState('');
const [activeSearch, setActiveSearch] = useState('');
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
const [showFilterPicker, setShowFilterPicker] = useState(false);
const [filterSearchQuery, setFilterSearchQuery] = useState('');
const [showSortDropdown, setShowSortDropdown] = useState(false);
const [hoveredRow, setHoveredRow] = useState<number | null>(null);
const filterPickerRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
const sortedDimensions = useMemo(() => {
return [...dimensions].sort((a, b) => {
if (a.isPriority && !b.isPriority) return -1;
if (!a.isPriority && b.isPriority) return 1;
return a.dimension.localeCompare(b.dimension);
});
}, [dimensions]);
const filterPickerDimensions = sortedDimensions.filter(d =>
d.dimension.toLowerCase().includes(filterSearchQuery.toLowerCase())
);
// Fetch dimensions
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/dimensions');
const data = await res.json();
if (data.success) setDimensions(data.data || []);
} catch (e) {
console.error('Error fetching dimensions:', e);
}
})();
}, [refreshToken]);
// Fetch nodes
const fetchNodes = useCallback(async () => {
setLoading(true);
@@ -91,10 +55,6 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
sortBy: sortOrder,
});
if (activeSearch) params.set('search', activeSearch);
if (selectedFilters.length > 0) {
params.set('dimensions', selectedFilters.join(','));
params.set('dimensionsMatch', 'all');
}
const res = await fetch(`/api/nodes?${params}`);
const data = await res.json();
@@ -107,32 +67,27 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
} finally {
setLoading(false);
}
}, [page, sortOrder, activeSearch, selectedFilters]);
}, [page, sortOrder, activeSearch]);
useEffect(() => {
fetchNodes();
}, [fetchNodes, refreshToken]);
// Reset to page 1 when filters/sort/search change
const filtersKey = selectedFilters.join(',');
useEffect(() => {
setPage(1);
}, [sortOrder, activeSearch, filtersKey]);
}, [sortOrder, activeSearch]);
// Close dropdowns on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (showFilterPicker && filterPickerRef.current && !filterPickerRef.current.contains(e.target as HTMLElement)) {
setShowFilterPicker(false);
setFilterSearchQuery('');
}
if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) {
setShowSortDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showFilterPicker, showSortDropdown]);
}, [showSortDropdown]);
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -189,108 +144,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
</div>
</form>
{/* Filter chips + button */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flex: 1, flexWrap: 'wrap' }}>
{selectedFilters.map(f => (
<div key={f} style={{
display: 'flex', alignItems: 'center', gap: '4px',
padding: '2px 7px',
background: 'rgba(34, 197, 94, 0.06)',
border: '1px solid rgba(34, 197, 94, 0.12)',
borderRadius: '4px', fontSize: '11px', color: '#5a9'
}}>
{f}
<button
onClick={() => setSelectedFilters(selectedFilters.filter(x => x !== f))}
style={{ background: 'transparent', border: 'none', color: '#5a9', cursor: 'pointer', padding: 0, display: 'flex' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#5a9'; }}
>
<X size={10} />
</button>
</div>
))}
<div style={{ position: 'relative' }} ref={filterPickerRef}>
<button
onClick={() => setShowFilterPicker(!showFilterPicker)}
style={{
display: 'flex', alignItems: 'center', gap: '4px',
padding: '4px 7px', background: 'transparent',
border: '1px solid var(--rah-border)', borderRadius: '5px',
color: 'var(--rah-text-soft)', fontSize: '11px', cursor: 'pointer',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<Filter size={11} />
Filter
</button>
{showFilterPicker && (
<div style={{
position: 'absolute', top: '100%', left: 0, marginTop: '4px',
background: 'var(--rah-bg-panel)', border: '1px solid var(--rah-border)', borderRadius: '10px',
padding: '6px', minWidth: '220px', maxHeight: '320px', overflowY: 'auto',
zIndex: 1000, boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
}}>
<input
type="text"
value={filterSearchQuery}
onChange={(e) => setFilterSearchQuery(e.target.value)}
placeholder="Search dimensions..."
autoFocus
style={{
width: '100%', padding: '7px 10px', background: 'var(--rah-bg-base)',
border: '1px solid transparent', borderRadius: '6px',
color: 'var(--rah-text-active)', fontSize: '12px', marginBottom: '4px', outline: 'none',
}}
/>
{filterPickerDimensions.length === 0 ? (
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
No matching dimensions
</div>
) : (
filterPickerDimensions.map(d => (
<button
key={d.dimension}
onClick={() => {
if (!selectedFilters.includes(d.dimension)) {
setSelectedFilters([...selectedFilters, d.dimension]);
}
setShowFilterPicker(false);
setFilterSearchQuery('');
}}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
width: '100%', padding: '7px 10px', background: 'transparent',
border: 'none', borderRadius: '5px', color: 'var(--rah-text-secondary)',
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<span>{d.dimension}</span>
<span style={{ color: 'var(--rah-text-muted)', fontSize: '10px', background: 'var(--rah-bg-active)', padding: '1px 6px', borderRadius: '10px' }}>
{d.count}
</span>
</button>
))
)}
</div>
)}
</div>
{selectedFilters.length > 0 && (
<button
onClick={() => setSelectedFilters([])}
style={{ padding: '4px 6px', background: 'transparent', border: 'none', color: 'var(--rah-text-muted)', fontSize: '11px', cursor: 'pointer' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
>
Clear
</button>
)}
</div>
<div style={{ flex: 1 }} />
{/* Sort dropdown */}
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
@@ -393,7 +247,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
<div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>Loading...</div>
) : nodes.length === 0 ? (
<div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>
{activeSearch || selectedFilters.length > 0 ? 'No nodes match your filters.' : 'No nodes yet.'}
{activeSearch ? 'No nodes match your search.' : 'No nodes yet.'}
</div>
) : (
<table style={{ minWidth: '1600px', width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
@@ -402,9 +256,9 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
<th style={thStyle({ width: '240px' })}>TITLE</th>
<th style={thStyle({ width: '55px', textAlign: 'right' })}>ID</th>
<th style={thStyle({ width: '200px' })}>DESCRIPTION</th>
<th style={thStyle({ width: '160px' })}>NOTES</th>
<th style={thStyle({ width: '160px' })}>SOURCE</th>
<th style={thStyle({ width: '180px' })}>LINK</th>
<th style={thStyle({ width: '160px' })}>DIMENSIONS</th>
<th style={thStyle({ width: '160px' })}>CONTEXT</th>
<th style={thStyle({ width: '50px', textAlign: 'right' })}>EDGES</th>
<th style={thStyle({ width: '90px' })}>EVENT</th>
<th style={thStyle({ width: '85px' })}>UPDATED</th>
@@ -488,26 +342,19 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
</div>
</td>
{/* Dimensions */}
{/* Context */}
<td style={tdStyle()}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px', overflow: 'hidden', maxHeight: '36px' }}>
{node.dimensions && node.dimensions.length > 0 ? (
{node.context?.name ? (
<>
{node.dimensions.slice(0, 3).map(d => (
<span key={d} style={{
fontSize: '9px', padding: '1px 5px',
background: 'var(--rah-accent-green-soft)', border: '1px solid var(--rah-accent-green-soft-strong)',
color: 'var(--rah-accent-green)', borderRadius: '3px',
whiteSpace: 'nowrap',
}}>
{d}
</span>
))}
{node.dimensions.length > 3 && (
<span style={{ fontSize: '9px', color: 'var(--rah-text-muted)' }}>
+{node.dimensions.length - 3}
</span>
)}
<span style={{
fontSize: '9px', padding: '1px 5px',
background: 'var(--rah-accent-green-soft)', border: '1px solid var(--rah-accent-green-soft-strong)',
color: 'var(--rah-accent-green)', borderRadius: '3px',
whiteSpace: 'nowrap',
}}>
{node.context.name}
</span>
</>
) : (
<span style={{ fontSize: '10px', color: 'var(--rah-text-muted)' }}>{'\u2014'}</span>
+12 -26
View File
@@ -2,7 +2,6 @@
import { Node } from '@/types/database';
import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import { getNodeProcessedState } from '@/services/nodes/metadata';
interface GridViewProps {
@@ -11,7 +10,6 @@ interface GridViewProps {
}
export default function GridView({ nodes, onNodeClick }: GridViewProps) {
const { dimensionIcons } = useDimensionIcons();
const truncateContent = (content?: string, maxLength: number = 120) => {
if (!content) return '';
if (content.length <= maxLength) return content;
@@ -93,7 +91,7 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
borderRadius: '6px',
flexShrink: 0
}}>
{getNodeIcon(node, dimensionIcons, 14)}
{getNodeIcon(node, 14)}
</div>
<div style={{
fontSize: '13px',
@@ -135,37 +133,25 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
</div>
)}
{/* Footer with Dimensions */}
{node.dimensions && node.dimensions.length > 0 && (
{/* Footer with Context */}
{node.context?.name && (
<div style={{
display: 'flex',
gap: '4px',
flexWrap: 'wrap',
marginTop: 'auto'
}}>
{node.dimensions.slice(0, 3).map(dim => (
<span
key={dim}
style={{
padding: '2px 6px',
background: '#1a1a1a',
borderRadius: '3px',
fontSize: '10px',
color: '#888'
}}
>
{dim}
</span>
))}
{node.dimensions.length > 3 && (
<span style={{
<span
style={{
padding: '2px 6px',
background: '#1a1a1a',
borderRadius: '3px',
fontSize: '10px',
color: '#555'
}}>
+{node.dimensions.length - 3}
</span>
)}
color: '#888'
}}
>
{node.context.name}
</span>
</div>
)}
</button>
-536
View File
@@ -1,536 +0,0 @@
"use client";
import { useState, DragEvent } from 'react';
import { Plus, GripVertical, X } from 'lucide-react';
import { Node } from '@/types/database';
import { KanbanColumn } from '@/types/views';
interface KanbanViewProps {
nodes: Node[];
columns: KanbanColumn[];
dimensions: string[];
onNodeClick: (nodeId: number) => void;
onColumnChange: (columns: KanbanColumn[]) => void;
onNodeDimensionUpdate: (nodeId: number, newDimension: string, oldDimension?: string) => void;
}
export default function KanbanView({
nodes,
columns,
dimensions,
onNodeClick,
onColumnChange,
onNodeDimensionUpdate
}: KanbanViewProps) {
const [showColumnPicker, setShowColumnPicker] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [draggedNodeId, setDraggedNodeId] = useState<number | null>(null);
const [draggedFromColumn, setDraggedFromColumn] = useState<string | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null);
const [draggingColumnId, setDraggingColumnId] = useState<string | null>(null);
const [dragOverColumnId, setDragOverColumnId] = useState<string | null>(null);
// Get nodes for a specific column (dimension)
const getNodesForColumn = (dimension: string) => {
return nodes.filter(node => node.dimensions?.includes(dimension));
};
// Get uncategorized nodes (not in any column)
const getUncategorizedNodes = () => {
const columnDimensions = columns.map(c => c.dimension);
return nodes.filter(node =>
!node.dimensions || !node.dimensions.some(d => columnDimensions.includes(d))
);
};
const handleAddColumn = (dimension: string) => {
const newColumn: KanbanColumn = {
id: `col-${Date.now()}`,
dimension,
order: columns.length
};
onColumnChange([...columns, newColumn]);
setShowColumnPicker(false);
setSearchQuery('');
};
const handleRemoveColumn = (columnId: string) => {
onColumnChange(columns.filter(c => c.id !== columnId));
};
// Node drag handlers
const handleNodeDragStart = (e: DragEvent, nodeId: number, fromColumn: string) => {
setDraggedNodeId(nodeId);
setDraggedFromColumn(fromColumn);
e.dataTransfer.effectAllowed = 'copyMove';
// Find the node to get its title for chat drop
const node = nodes.find(n => n.id === nodeId);
const title = node?.title || 'Untitled';
// Set MIME types for chat input drops
e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: nodeId, title }));
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: nodeId, title, dimensions: node?.dimensions || [] }));
e.dataTransfer.setData('text/plain', `[NODE:${nodeId}:"${title}"]`);
};
const handleNodeDragEnd = () => {
setDraggedNodeId(null);
setDraggedFromColumn(null);
setDragOverColumn(null);
};
const handleColumnDragOver = (e: DragEvent, columnDimension: string) => {
e.preventDefault();
if (draggedNodeId !== null) {
setDragOverColumn(columnDimension);
}
};
const handleColumnDragLeave = () => {
setDragOverColumn(null);
};
const handleColumnDrop = (e: DragEvent, targetDimension: string) => {
e.preventDefault();
if (draggedNodeId !== null && draggedFromColumn !== targetDimension) {
onNodeDimensionUpdate(
draggedNodeId,
targetDimension,
draggedFromColumn || undefined
);
}
handleNodeDragEnd();
};
// Column reorder drag handlers
const handleColumnDragStart = (e: DragEvent, columnId: string) => {
setDraggingColumnId(columnId);
e.dataTransfer.effectAllowed = 'move';
};
const handleColumnDragEnd = () => {
setDraggingColumnId(null);
setDragOverColumnId(null);
};
const handleColumnReorderDragOver = (e: DragEvent, columnId: string) => {
e.preventDefault();
if (draggingColumnId && draggingColumnId !== columnId) {
setDragOverColumnId(columnId);
}
};
const handleColumnReorderDrop = (e: DragEvent, targetColumnId: string) => {
e.preventDefault();
if (!draggingColumnId || draggingColumnId === targetColumnId) return;
const newColumns = [...columns];
const dragIndex = newColumns.findIndex(c => c.id === draggingColumnId);
const dropIndex = newColumns.findIndex(c => c.id === targetColumnId);
if (dragIndex !== -1 && dropIndex !== -1) {
const [removed] = newColumns.splice(dragIndex, 1);
newColumns.splice(dropIndex, 0, removed);
// Update order values
newColumns.forEach((col, idx) => { col.order = idx; });
onColumnChange(newColumns);
}
handleColumnDragEnd();
};
const filteredDimensions = dimensions.filter(d =>
d.toLowerCase().includes(searchQuery.toLowerCase()) &&
!columns.some(c => c.dimension === d)
);
const sortedColumns = [...columns].sort((a, b) => a.order - b.order);
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
background: '#000'
}}>
{/* Column Setup Bar */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderBottom: '1px solid #222',
background: '#0a0a0a',
flexShrink: 0
}}>
<span style={{ fontSize: '11px', color: '#666', fontWeight: 500 }}>
Columns:
</span>
{columns.length === 0 && (
<span style={{ fontSize: '11px', color: '#555', fontStyle: 'italic' }}>
Add dimensions to create columns
</span>
)}
{/* Add Column Button */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowColumnPicker(!showColumnPicker)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
fontSize: '11px',
color: '#888',
cursor: 'pointer'
}}
>
<Plus size={12} />
Add Column
</button>
{/* Dimension Picker */}
{showColumnPicker && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
width: '200px',
maxHeight: '300px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
overflow: 'hidden',
zIndex: 100,
boxShadow: '0 4px 12px rgba(0,0,0,0.5)'
}}>
<input
type="text"
placeholder="Search dimensions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
background: '#0a0a0a',
border: 'none',
borderBottom: '1px solid #333',
color: '#fff',
fontSize: '12px',
outline: 'none'
}}
autoFocus
/>
<div style={{ maxHeight: '250px', overflowY: 'auto' }}>
{filteredDimensions.length === 0 ? (
<div style={{
padding: '12px',
fontSize: '12px',
color: '#666',
textAlign: 'center'
}}>
No dimensions available
</div>
) : (
filteredDimensions.map(dim => (
<button
key={dim}
onClick={() => handleAddColumn(dim)}
style={{
width: '100%',
padding: '8px 12px',
background: 'transparent',
border: 'none',
color: '#ccc',
fontSize: '12px',
textAlign: 'left',
cursor: 'pointer'
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#2a2a2a'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
{dim}
</button>
))
)}
</div>
</div>
)}
</div>
{/* Click outside to close */}
{showColumnPicker && (
<div
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
onClick={() => setShowColumnPicker(false)}
/>
)}
</div>
{/* Kanban Board */}
<div style={{
flex: 1,
display: 'flex',
gap: '12px',
padding: '12px',
overflowX: 'auto',
overflowY: 'hidden'
}}>
{sortedColumns.map(column => {
const columnNodes = getNodesForColumn(column.dimension);
const isDropTarget = dragOverColumn === column.dimension && draggedFromColumn !== column.dimension;
const isReorderTarget = dragOverColumnId === column.id;
return (
<div
key={column.id}
style={{
width: '280px',
minWidth: '280px',
display: 'flex',
flexDirection: 'column',
background: isDropTarget ? '#0f2417' : '#0a0a0a',
border: isReorderTarget ? '2px dashed #22c55e' : '1px solid #1a1a1a',
borderRadius: '8px',
transition: 'all 0.2s'
}}
onDragOver={(e) => handleColumnDragOver(e, column.dimension)}
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, column.dimension)}
>
{/* Column Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
borderBottom: '1px solid #1a1a1a',
cursor: 'grab'
}}
draggable
onDragStart={(e) => handleColumnDragStart(e, column.id)}
onDragEnd={handleColumnDragEnd}
onDragOver={(e) => handleColumnReorderDragOver(e, column.id)}
onDrop={(e) => handleColumnReorderDrop(e, column.id)}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<GripVertical size={14} color="#444" />
<span style={{
fontSize: '12px',
fontWeight: 600,
color: '#e5e5e5',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
{column.dimension}
</span>
<span style={{
fontSize: '11px',
color: '#666',
background: '#1a1a1a',
padding: '2px 6px',
borderRadius: '10px'
}}>
{columnNodes.length}
</span>
</div>
<button
onClick={() => handleRemoveColumn(column.id)}
style={{
background: 'none',
border: 'none',
padding: '4px',
cursor: 'pointer',
color: '#666',
display: 'flex',
alignItems: 'center'
}}
>
<X size={14} />
</button>
</div>
{/* Column Content */}
<div style={{
flex: 1,
overflowY: 'auto',
padding: '8px'
}}>
{columnNodes.map(node => (
<div
key={node.id}
draggable
onDragStart={(e) => handleNodeDragStart(e, node.id, column.dimension)}
onDragEnd={handleNodeDragEnd}
onClick={() => onNodeClick(node.id)}
style={{
padding: '10px',
marginBottom: '6px',
background: draggedNodeId === node.id ? '#1a1a1a' : '#111',
border: '1px solid #222',
borderRadius: '6px',
cursor: 'pointer',
opacity: draggedNodeId === node.id ? 0.5 : 1,
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
if (draggedNodeId !== node.id) {
e.currentTarget.style.background = '#1a1a1a';
e.currentTarget.style.borderColor = '#333';
}
}}
onMouseLeave={(e) => {
if (draggedNodeId !== node.id) {
e.currentTarget.style.background = '#111';
e.currentTarget.style.borderColor = '#222';
}
}}
>
<div style={{
fontSize: '12px',
fontWeight: 500,
color: '#e5e5e5',
marginBottom: '4px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{node.title || 'Untitled'}
</div>
{node.dimensions && node.dimensions.length > 1 && (
<div style={{
display: 'flex',
gap: '4px',
flexWrap: 'wrap',
marginTop: '6px'
}}>
{node.dimensions
.filter(d => d !== column.dimension)
.slice(0, 2)
.map(dim => (
<span
key={dim}
style={{
padding: '2px 6px',
background: '#1a1a1a',
borderRadius: '3px',
fontSize: '10px',
color: '#666'
}}
>
{dim}
</span>
))}
</div>
)}
</div>
))}
{columnNodes.length === 0 && (
<div style={{
padding: '20px',
textAlign: 'center',
color: '#444',
fontSize: '11px'
}}>
Drop nodes here
</div>
)}
</div>
</div>
);
})}
{/* Uncategorized Column (if nodes exist without column dimensions) */}
{columns.length > 0 && getUncategorizedNodes().length > 0 && (
<div
style={{
width: '280px',
minWidth: '280px',
display: 'flex',
flexDirection: 'column',
background: '#0a0a0a',
border: '1px dashed #333',
borderRadius: '8px'
}}
>
<div style={{
padding: '10px 12px',
borderBottom: '1px solid #1a1a1a'
}}>
<span style={{
fontSize: '12px',
fontWeight: 600,
color: '#666',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
Uncategorized ({getUncategorizedNodes().length})
</span>
</div>
<div style={{
flex: 1,
overflowY: 'auto',
padding: '8px'
}}>
{getUncategorizedNodes().map(node => (
<div
key={node.id}
draggable
onDragStart={(e) => handleNodeDragStart(e, node.id, '__uncategorized__')}
onDragEnd={handleNodeDragEnd}
onClick={() => onNodeClick(node.id)}
style={{
padding: '10px',
marginBottom: '6px',
background: '#111',
border: '1px solid #222',
borderRadius: '6px',
cursor: 'pointer'
}}
>
<div style={{
fontSize: '12px',
fontWeight: 500,
color: '#888',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{node.title || 'Untitled'}
</div>
</div>
))}
</div>
</div>
)}
{/* Empty State */}
{columns.length === 0 && (
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#666',
fontSize: '13px'
}}>
Add dimension columns to organize your nodes
</div>
)}
</div>
</div>
);
}
+14 -35
View File
@@ -3,7 +3,6 @@
import { Inbox } from 'lucide-react';
import { Node } from '@/types/database';
import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import { formatRelativeDate } from '@/utils/formatDate';
import { getNodeProcessedState } from '@/services/nodes/metadata';
@@ -13,7 +12,6 @@ interface ListViewProps {
}
export default function ListView({ nodes, onNodeClick }: ListViewProps) {
const { dimensionIcons } = useDimensionIcons();
const truncateContent = (content?: string, maxLength: number = 100) => {
if (!content) return '';
@@ -97,7 +95,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
borderRadius: '6px',
flexShrink: 0
}}>
{getNodeIcon(node, dimensionIcons, 16)}
{getNodeIcon(node, 16)}
</div>
{/* Content */}
@@ -148,38 +146,19 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
}}>
{processedState}
</span>
{/* Dimensions */}
{node.dimensions && node.dimensions.length > 0 && (
<div style={{
display: 'flex',
gap: '4px',
flexWrap: 'wrap'
}}>
{node.dimensions.slice(0, 3).map(dim => (
<span
key={dim}
style={{
padding: '2px 8px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
borderRadius: '8px',
fontSize: '11px',
color: 'var(--rah-text-base)'
}}
>
{dim}
</span>
))}
{node.dimensions.length > 3 && (
<span style={{
padding: '2px 6px',
fontSize: '11px',
color: 'var(--rah-text-muted)'
}}>
+{node.dimensions.length - 3}
</span>
)}
</div>
{node.context?.name && (
<span
style={{
padding: '2px 8px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
borderRadius: '8px',
fontSize: '11px',
color: 'var(--rah-text-base)'
}}
>
{node.context.name}
</span>
)}
{/* Date */}
-242
View File
@@ -1,242 +0,0 @@
"use client";
import { useState } from 'react';
import { X, Plus, ChevronDown } from 'lucide-react';
import { ViewFilter } from '@/types/views';
interface ViewFiltersProps {
filters: ViewFilter[];
filterLogic: 'and' | 'or';
dimensions: string[];
onFilterChange: (filters: ViewFilter[]) => void;
onFilterLogicChange: (logic: 'and' | 'or') => void;
}
export default function ViewFilters({
filters,
filterLogic,
dimensions,
onFilterChange,
onFilterLogicChange
}: ViewFiltersProps) {
const [showDimensionPicker, setShowDimensionPicker] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const handleAddFilter = (dimension: string) => {
if (!filters.some(f => f.dimension === dimension)) {
onFilterChange([...filters, { dimension, operator: 'includes' }]);
}
setShowDimensionPicker(false);
setSearchQuery('');
};
const handleRemoveFilter = (dimension: string) => {
onFilterChange(filters.filter(f => f.dimension !== dimension));
};
const handleToggleOperator = (dimension: string) => {
onFilterChange(filters.map(f =>
f.dimension === dimension
? { ...f, operator: f.operator === 'includes' ? 'excludes' : 'includes' }
: f
));
};
const filteredDimensions = dimensions.filter(d =>
d.toLowerCase().includes(searchQuery.toLowerCase()) &&
!filters.some(f => f.dimension === d)
);
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderBottom: '1px solid #222',
background: '#0a0a0a',
flexWrap: 'wrap',
minHeight: '40px'
}}>
<span style={{ fontSize: '11px', color: '#666', fontWeight: 500 }}>
Filters:
</span>
{/* Filter Chips */}
{filters.map(filter => (
<div
key={filter.dimension}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: filter.operator === 'includes' ? '#0f2417' : '#2a1515',
border: `1px solid ${filter.operator === 'includes' ? '#166534' : '#7f1d1d'}`,
borderRadius: '4px',
fontSize: '11px',
color: filter.operator === 'includes' ? '#22c55e' : '#ef4444'
}}
>
<button
onClick={() => handleToggleOperator(filter.dimension)}
style={{
background: 'none',
border: 'none',
padding: '0 2px',
cursor: 'pointer',
color: 'inherit',
fontSize: '10px',
opacity: 0.7
}}
title={filter.operator === 'includes' ? 'Click to exclude' : 'Click to include'}
>
{filter.operator === 'includes' ? '+' : '-'}
</button>
<span>{filter.dimension}</span>
<button
onClick={() => handleRemoveFilter(filter.dimension)}
style={{
background: 'none',
border: 'none',
padding: '0',
cursor: 'pointer',
color: 'inherit',
display: 'flex',
alignItems: 'center'
}}
>
<X size={12} />
</button>
</div>
))}
{/* Add Filter Button */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowDimensionPicker(!showDimensionPicker)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
fontSize: '11px',
color: '#888',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
<Plus size={12} />
Add
</button>
{/* Dimension Picker Dropdown */}
{showDimensionPicker && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
width: '200px',
maxHeight: '300px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
overflow: 'hidden',
zIndex: 100,
boxShadow: '0 4px 12px rgba(0,0,0,0.5)'
}}>
<input
type="text"
placeholder="Search dimensions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
background: '#0a0a0a',
border: 'none',
borderBottom: '1px solid #333',
color: '#fff',
fontSize: '12px',
outline: 'none'
}}
autoFocus
/>
<div style={{ maxHeight: '250px', overflowY: 'auto' }}>
{filteredDimensions.length === 0 ? (
<div style={{
padding: '12px',
fontSize: '12px',
color: '#666',
textAlign: 'center'
}}>
No dimensions found
</div>
) : (
filteredDimensions.map(dim => (
<button
key={dim}
onClick={() => handleAddFilter(dim)}
style={{
width: '100%',
padding: '8px 12px',
background: 'transparent',
border: 'none',
color: '#ccc',
fontSize: '12px',
textAlign: 'left',
cursor: 'pointer',
transition: 'background 0.2s'
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#2a2a2a'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
{dim}
</button>
))
)}
</div>
</div>
)}
</div>
{/* Logic Toggle */}
{filters.length > 1 && (
<button
onClick={() => onFilterLogicChange(filterLogic === 'and' ? 'or' : 'and')}
style={{
padding: '4px 8px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
fontSize: '10px',
fontWeight: 600,
color: filterLogic === 'and' ? '#22c55e' : '#3b82f6',
cursor: 'pointer',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}
title={filterLogic === 'and' ? 'Match ALL filters' : 'Match ANY filter'}
>
{filterLogic}
</button>
)}
{/* Click outside to close */}
{showDimensionPicker && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 99
}}
onClick={() => setShowDimensionPicker(false)}
/>
)}
</div>
);
}
+155 -507
View File
@@ -1,11 +1,10 @@
"use client";
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Filter, ChevronDown, X, ArrowUpDown, GripVertical, Inbox, Check } from 'lucide-react';
import type { ContextSummary, Node } from '@/types/database';
import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import { usePersistentState } from '@/hooks/usePersistentState';
import type { PendingNode } from '@/components/layout/ThreePanelLayout';
import { getNodeProcessedState } from '@/services/nodes/metadata';
@@ -24,41 +23,6 @@ const SORT_LABELS: Record<SortOrder, string> = {
const DOCUMENT_MAX_WIDTH = '980px';
const pickerRowStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '7px 10px',
background: 'transparent',
border: 'none',
borderRadius: '5px',
color: 'var(--rah-text-secondary)',
fontSize: '12px',
cursor: 'pointer',
textAlign: 'left',
};
const pickerCountStyle: React.CSSProperties = {
color: 'var(--rah-text-muted)',
fontSize: '10px',
background: 'var(--rah-bg-active)',
padding: '1px 6px',
borderRadius: '10px',
};
interface ColumnFilter {
id: string;
dimension: string;
}
interface DimensionSummary {
dimension: string;
count: number;
isPriority: boolean;
description?: string | null;
}
interface ViewsOverlayProps {
onNodeClick: (nodeId: number) => void;
onNodeOpenInOtherPane?: (nodeId: number) => void;
@@ -66,10 +30,8 @@ interface ViewsOverlayProps {
pendingNodes?: PendingNode[];
onDismissPending?: (id: string) => void;
externalContextFilterId?: number | null;
externalDimensionFilter?: string | null;
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
onClearExternalContextFilter?: () => void;
onClearExternalDimensionFilter?: () => void;
toolbarHost?: HTMLDivElement | null;
}
@@ -225,6 +187,29 @@ function SkeletonCard() {
);
}
const pickerRowStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '7px 10px',
background: 'transparent',
border: 'none',
borderRadius: '5px',
color: 'var(--rah-text-secondary)',
fontSize: '12px',
cursor: 'pointer',
textAlign: 'left',
};
const pickerCountStyle: React.CSSProperties = {
color: 'var(--rah-text-muted)',
fontSize: '10px',
background: 'var(--rah-bg-active)',
padding: '1px 6px',
borderRadius: '10px',
};
export default function ViewsOverlay({
onNodeClick,
onNodeOpenInOtherPane,
@@ -232,17 +217,11 @@ export default function ViewsOverlay({
pendingNodes,
onDismissPending,
externalContextFilterId = null,
externalDimensionFilter = null,
onContextFilterSelect,
onClearExternalContextFilter,
onClearExternalDimensionFilter,
toolbarHost,
}: ViewsOverlayProps) {
const { dimensionIcons } = useDimensionIcons();
// Dimensions for filter picker
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
const [dimensionsLoading, setDimensionsLoading] = useState(true);
const [contexts, setContexts] = useState<ContextSummary[]>([]);
const [contextsLoading, setContextsLoading] = useState(true);
@@ -256,13 +235,8 @@ export default function ViewsOverlay({
const [reorderDragIndex, setReorderDragIndex] = useState<number | null>(null);
const [reorderDropIndex, setReorderDropIndex] = useState<number | null>(null);
// Filter system state
const [columns, setColumns] = useState<ColumnFilter[]>([]);
const [filteredNodes, setFilteredNodes] = useState<Node[]>([]);
const [filteredNodesLoading, setFilteredNodesLoading] = useState(false);
const [showFilterPicker, setShowFilterPicker] = useState(false);
const [showDimensionPicker, setShowDimensionPicker] = useState(false);
const [filterSearchQuery, setFilterSearchQuery] = useState('');
const [showSortDropdown, setShowSortDropdown] = useState(false);
const processedFilter: ProcessedFilter = sortOrder === 'processed'
@@ -271,41 +245,6 @@ export default function ViewsOverlay({
? 'not_processed'
: 'all';
// Derive selectedFilters for backward compatibility (unique dimensions)
const selectedFilters = useMemo(() => {
if (externalDimensionFilter) {
return [externalDimensionFilter];
}
return [...new Set(columns.map(c => c.dimension))];
}, [columns, externalDimensionFilter]);
// Sorted dimensions (locked first)
const sortedDimensions = useMemo(() => {
return [...dimensions].sort((a, b) => {
if (a.isPriority && !b.isPriority) return -1;
if (!a.isPriority && b.isPriority) return 1;
return a.dimension.localeCompare(b.dimension);
});
}, [dimensions]);
// Fetch functions
const fetchDimensions = useCallback(async () => {
setDimensionsLoading(true);
try {
const response = await fetch('/api/dimensions');
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to fetch dimensions');
}
setDimensions(data.data || []);
} catch (error) {
console.error('Error fetching dimensions:', error);
} finally {
setDimensionsLoading(false);
}
}, []);
const fetchContexts = useCallback(async () => {
setContextsLoading(true);
try {
@@ -364,135 +303,35 @@ export default function ViewsOverlay({
}
}, [sortOrder, customOrder, applyProcessedFilter, externalContextFilterId]);
const fetchFilteredNodes = useCallback(async (filters: string[]) => {
if (filters.length === 0) {
fetchAllNodes();
return;
}
setFilteredNodesLoading(true);
try {
const apiSort = sortOrder === 'custom' || sortOrder === 'processed' || sortOrder === 'not_processed'
? 'updated'
: sortOrder;
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}&dimensions=${encodeURIComponent(filters.join(','))}&dimensionsMatch=all${externalContextFilterId ? `&contextId=${externalContextFilterId}` : ''}`);
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to fetch nodes');
}
const nodes: Node[] = data.data || [];
if (sortOrder === 'custom' && customOrder.length > 0) {
const orderMap = new Map(customOrder.map((id, idx) => [id, idx]));
const ordered: Node[] = [];
const unordered: Node[] = [];
for (const node of nodes) {
if (orderMap.has(node.id)) {
ordered.push(node);
} else {
unordered.push(node);
}
}
ordered.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
setFilteredNodes(applyProcessedFilter([...ordered, ...unordered]));
} else {
setFilteredNodes(applyProcessedFilter(nodes));
}
} catch (error) {
console.error('Error fetching filtered nodes:', error);
} finally {
setFilteredNodesLoading(false);
}
}, [fetchAllNodes, sortOrder, customOrder, applyProcessedFilter, externalContextFilterId]);
// Stringify filters for stable dependency
const filtersKey = selectedFilters.join(',');
// Fetch dimensions on mount
useEffect(() => {
fetchDimensions();
}, [fetchDimensions]);
// Fetch contexts on mount
useEffect(() => {
fetchContexts();
}, [fetchContexts]);
// Fetch nodes on mount and when filters/sort/refreshToken change
// Fetch nodes on mount and when sort/refreshToken/context filter change
useEffect(() => {
if (refreshToken > 0) {
console.log('🔄 Feed refreshing due to SSE event (refreshToken:', refreshToken, ')');
}
if (selectedFilters.length > 0) {
fetchFilteredNodes(selectedFilters);
} else {
fetchAllNodes();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filtersKey, sortOrder, refreshToken, externalContextFilterId]);
fetchAllNodes();
}, [fetchAllNodes, refreshToken, sortOrder, externalContextFilterId]);
// Also refresh dimensions when data changes (for filter picker counts)
// Refresh contexts when data changes
useEffect(() => {
if (refreshToken > 0) {
fetchDimensions();
fetchContexts();
}
}, [refreshToken, fetchDimensions, fetchContexts]);
// Column management
const addColumn = (dimension: string) => {
if (externalDimensionFilter) {
return;
}
const newColumn: ColumnFilter = {
id: `col-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
dimension
};
setColumns([...columns, newColumn]);
setShowFilterPicker(false);
setFilterSearchQuery('');
};
const removeFilter = (dimension: string) => {
if (externalDimensionFilter && dimension === externalDimensionFilter) {
onClearExternalDimensionFilter?.();
return;
}
const idx = columns.findIndex(c => c.dimension === dimension);
if (idx !== -1) {
setColumns(columns.filter((_, i) => i !== idx));
}
};
const clearFilters = () => {
if (externalContextFilterId) {
onClearExternalContextFilter?.();
}
if (externalDimensionFilter) {
onClearExternalDimensionFilter?.();
}
setColumns([]);
};
// Filter dimensions for picker
const filterPickerDimensions = sortedDimensions.filter(d =>
d.dimension.toLowerCase().includes(filterSearchQuery.toLowerCase())
);
}, [refreshToken, fetchContexts]);
// Close dropdowns on outside click
const filterPickerRef = useRef<HTMLDivElement>(null);
const dimensionPickerRef = useRef<HTMLDivElement>(null);
const contextPickerRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
const [showContextPicker, setShowContextPicker] = useState(false);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (showFilterPicker && filterPickerRef.current && !filterPickerRef.current.contains(e.target as HTMLElement)) {
setShowFilterPicker(false);
setFilterSearchQuery('');
}
if (showDimensionPicker && dimensionPickerRef.current && !dimensionPickerRef.current.contains(e.target as HTMLElement)) {
setShowDimensionPicker(false);
if (showContextPicker && contextPickerRef.current && !contextPickerRef.current.contains(e.target as HTMLElement)) {
setShowContextPicker(false);
}
if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) {
setShowSortDropdown(false);
@@ -500,7 +339,7 @@ export default function ViewsOverlay({
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showDimensionPicker, showFilterPicker, showSortDropdown]);
}, [showContextPicker, showSortDropdown]);
// Reorder handlers
const handleReorderDrop = useCallback((dropIdx: number) => {
@@ -557,17 +396,13 @@ export default function ViewsOverlay({
}
} catch (error) {
console.error('Error updating processed state from feed:', error);
if (selectedFilters.length > 0) {
void fetchFilteredNodes(selectedFilters);
} else {
void fetchAllNodes();
}
void fetchAllNodes();
}
}, [fetchAllNodes, fetchFilteredNodes, selectedFilters]);
}, [fetchAllNodes]);
// Render node card
const renderNodeCard = (node: Node, index: number) => {
const nodeIcon = getNodeIcon(node, dimensionIcons, 14);
const nodeIcon = getNodeIcon(node, 14);
const isCustomSort = sortOrder === 'custom';
const isDragSource = reorderDragIndex === index;
const isDropTarget = reorderDropIndex === index;
@@ -587,7 +422,7 @@ export default function ViewsOverlay({
onDragStart={(e) => {
const title = node.title || 'Untitled';
e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: node.id, title }));
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: node.id, title, dimensions: node.dimensions || [] }));
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: node.id, title }));
e.dataTransfer.setData('text/plain', `[NODE:${node.id}:"${title}"]`);
}}
onDragOver={(e) => {
@@ -636,8 +471,7 @@ export default function ViewsOverlay({
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
{/* Grip handle — only in custom sort mode */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', minWidth: 0 }}>
{isCustomSort && (
<div
draggable
@@ -695,137 +529,112 @@ export default function ViewsOverlay({
</button>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
flexDirection: 'column',
gap: '4px',
minWidth: 0,
flex: 1,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
<span style={{
fontSize: '13px',
fontWeight: 500,
fontWeight: 600,
color: 'var(--rah-text-active)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: 1,
minWidth: 0,
flex: 1,
}}>
{node.title || 'Untitled'}
</span>
{node.context?.name ? (
<span style={{
fontSize: '10px',
lineHeight: 1.2,
padding: '3px 7px',
borderRadius: '999px',
background: 'rgba(34, 197, 94, 0.08)',
border: '1px solid rgba(34, 197, 94, 0.18)',
color: 'var(--rah-accent-green)',
flexShrink: 0,
maxWidth: '40%',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
<span
style={{
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: 'var(--rah-accent-green)',
background: 'color-mix(in srgb, var(--rah-accent-green) 12%, var(--rah-bg-panel))',
border: '1px solid color-mix(in srgb, var(--rah-accent-green) 32%, var(--rah-border))',
padding: '3px 7px',
borderRadius: '999px',
lineHeight: 1.2,
flexShrink: 0,
}}
>
{node.context.name}
</span>
) : null}
</div>
<div style={{
width: '20px',
height: '20px',
borderRadius: '6px',
background: 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
{nodeIcon}
</div>
<span style={{
fontSize: '10px',
color: 'var(--rah-text-muted)',
background: 'var(--rah-bg-panel)',
padding: '2px 6px',
borderRadius: '999px',
fontFamily: 'monospace',
flexShrink: 0,
lineHeight: 1.2,
}}>
#{node.id}
</span>
{node.edge_count != null && node.edge_count > 0 && (
<span style={{
minWidth: '18px',
height: '18px',
padding: '0 5px',
borderRadius: '999px',
background: 'var(--rah-accent-green-soft)',
border: '1px solid var(--rah-accent-green-soft-strong)',
color: 'var(--rah-accent-green)',
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
<div style={{
fontSize: '12px',
color: 'var(--rah-text-muted)',
lineHeight: '1.35',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}>
{descPreview || 'No description'}
</div>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
minWidth: 0,
overflow: 'hidden',
flexShrink: 0,
fontSize: '11px',
fontWeight: 600,
}}>
{node.edge_count}
</span>
)}
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
minWidth: 0,
}}>
<div style={{
fontSize: '12px',
color: 'var(--rah-text-muted)',
lineHeight: '1.35',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}>
{descPreview || 'No description'}
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '3px',
minWidth: 0,
maxWidth: '46%',
overflow: 'hidden',
flexShrink: 1,
whiteSpace: 'nowrap',
}}>
{node.dimensions && node.dimensions.length > 0 ? (
<>
{node.dimensions.map(d => (
<span
key={d}
style={{
fontSize: '10px',
padding: '2px 7px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
color: 'var(--rah-text-base)',
borderRadius: '999px',
flexShrink: 0,
}}
>
{d}
<div style={{
width: '20px',
height: '20px',
borderRadius: '6px',
background: 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
{nodeIcon}
</div>
<span style={{
fontSize: '10px',
color: 'var(--rah-text-muted)',
background: 'var(--rah-bg-panel)',
padding: '2px 6px',
borderRadius: '999px',
fontFamily: 'monospace',
flexShrink: 0,
lineHeight: 1.2,
}}>
#{node.id}
</span>
{node.edge_count != null && node.edge_count > 0 ? (
<span style={{
minWidth: '18px',
height: '18px',
padding: '0 5px',
borderRadius: '999px',
background: 'var(--rah-accent-green-soft)',
border: '1px solid var(--rah-accent-green-soft-strong)',
color: 'var(--rah-accent-green)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
fontSize: '11px',
fontWeight: 600,
}}>
{node.edge_count}
</span>
))}
</>
) : null}
) : null}
</div>
</div>
</div>
</div>
</div>
@@ -843,87 +652,6 @@ export default function ViewsOverlay({
flexWrap: 'wrap'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', flex: 1 }}>
<div style={{ position: 'relative' }} ref={filterPickerRef}>
<button
onClick={() => setShowFilterPicker(!showFilterPicker)}
title="Context"
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: 'transparent',
border: '1px solid var(--rah-border)',
borderRadius: '5px',
color: 'var(--rah-text-soft)',
fontSize: '11px',
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
>
<Filter size={11} />
{externalContextFilterId
? (contexts.find((context) => context.id === externalContextFilterId)?.name || 'Context')
: 'Context'}
<ChevronDown size={10} />
</button>
{showFilterPicker && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
background: 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border)',
borderRadius: '10px',
padding: '6px',
minWidth: '220px',
maxHeight: '320px',
overflowY: 'auto',
zIndex: 1000,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)'
}}>
{contextsLoading ? (
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
Loading contexts...
</div>
) : (
<>
<button
onClick={() => {
onContextFilterSelect?.(null, null);
setShowFilterPicker(false);
}}
style={pickerRowStyle}
>
<span>All contexts</span>
</button>
{contexts.map((context) => (
<button
key={context.id}
onClick={() => {
onContextFilterSelect?.(context.id, context.name);
setShowFilterPicker(false);
}}
style={{
...pickerRowStyle,
background: externalContextFilterId === context.id ? 'rgba(255,255,255,0.04)' : 'transparent',
color: externalContextFilterId === context.id ? 'var(--rah-text-active)' : 'var(--rah-text-secondary)',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', paddingRight: '8px' }}>
{context.name}
</span>
<span style={pickerCountStyle}>{context.count}</span>
</button>
))}
</>
)}
</div>
)}
</div>
{externalContextFilterId ? (
<div
style={{
@@ -938,7 +666,7 @@ export default function ViewsOverlay({
color: '#5a9'
}}
>
{contexts.find((context) => context.id === externalContextFilterId)?.name || 'Context'}
{contexts.find((context) => context.id === externalContextFilterId)?.name ?? 'Context'}
<button
onClick={() => onClearExternalContextFilter?.()}
style={{
@@ -956,53 +684,16 @@ export default function ViewsOverlay({
</div>
) : null}
{selectedFilters.map(filter => (
<div
key={filter}
style={{
display: 'flex',
alignItems: 'center',
gap: '5px',
padding: '3px 8px',
background: 'rgba(34, 197, 94, 0.06)',
border: '1px solid rgba(34, 197, 94, 0.12)',
borderRadius: '5px',
fontSize: '11px',
color: '#5a9'
}}
>
{filter}
<button
onClick={() => removeFilter(filter)}
style={{
background: 'transparent',
border: 'none',
color: '#5a9',
cursor: 'pointer',
padding: '0',
display: 'flex',
alignItems: 'center'
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#5a9'; }}
>
<X size={11} />
</button>
</div>
))}
<div style={{ position: 'relative' }} ref={dimensionPickerRef}>
<div style={{ position: 'relative' }} ref={contextPickerRef}>
<button
onClick={() => {
setShowDimensionPicker(!showDimensionPicker);
}}
title="Dimensions"
onClick={() => setShowContextPicker(!showContextPicker)}
title="Context filter"
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: 'transparent',
background: externalContextFilterId ? 'rgba(34, 197, 94, 0.06)' : 'transparent',
border: '1px solid var(--rah-border)',
borderRadius: '5px',
color: 'var(--rah-text-soft)',
@@ -1010,21 +701,12 @@ export default function ViewsOverlay({
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.borderColor = 'var(--rah-border)';
}}
>
<Filter size={11} />
Dimension
<ChevronDown size={10} />
Context
</button>
{showDimensionPicker && (
{showContextPicker && (
<div style={{
position: 'absolute',
top: '100%',
@@ -1040,71 +722,37 @@ export default function ViewsOverlay({
zIndex: 1000,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)'
}}>
<input
type="text"
value={filterSearchQuery}
onChange={(e) => setFilterSearchQuery(e.target.value)}
placeholder="Search dimensions..."
autoFocus
style={{
width: '100%',
padding: '7px 10px',
background: 'var(--rah-bg-base)',
border: '1px solid transparent',
borderRadius: '6px',
color: 'var(--rah-text-active)',
fontSize: '12px',
marginBottom: '4px',
outline: 'none',
<button
onClick={() => {
onClearExternalContextFilter?.();
setShowContextPicker(false);
}}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--rah-border-strong)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
/>
{dimensionsLoading ? (
style={pickerRowStyle}
>
All contexts
</button>
{contextsLoading ? (
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
Loading dimensions...
Loading contexts...
</div>
) : filterPickerDimensions.length === 0 ? (
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
{filterSearchQuery ? 'No matching dimensions' : 'No dimensions available'}
</div>
) : (
filterPickerDimensions.map(d => (
<button
key={d.dimension}
onClick={() => addColumn(d.dimension)}
style={pickerRowStyle}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<span>{d.dimension}</span>
<span style={pickerCountStyle}>{d.count}</span>
</button>
))
)}
) : contexts.map((context) => (
<button
key={context.id}
onClick={() => {
onContextFilterSelect?.(context.id, context.name);
setShowContextPicker(false);
}}
style={pickerRowStyle}
>
<span>{context.name}</span>
<span style={pickerCountStyle}>{context.count}</span>
</button>
))}
</div>
)}
</div>
</div>
{(selectedFilters.length > 0 || externalContextFilterId) && (
<button
onClick={clearFilters}
style={{
padding: '4px 8px',
background: 'transparent',
border: 'none',
color: 'var(--rah-text-muted)',
fontSize: '11px',
cursor: 'pointer'
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
>
Clear all
</button>
)}
{/* Sort dropdown */}
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
<button
@@ -1234,10 +882,10 @@ export default function ViewsOverlay({
<div style={{ width: '100%', maxWidth: DOCUMENT_MAX_WIDTH, margin: '0 auto', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<Inbox size={28} strokeWidth={1.5} style={{ opacity: 0.4 }} />
<span style={{ fontSize: '14px', color: 'var(--rah-text-secondary)' }}>
{selectedFilters.length > 0 ? 'Nothing matches these filters' : 'Nothing here yet'}
Nothing here yet
</span>
<span style={{ fontSize: '12px', opacity: 0.7 }}>
{selectedFilters.length > 0 ? 'Try adjusting your filters, or add a node with ⌘N' : 'Add a node with ⌘N to get started'}
Add a node with N to get started
</span>
</div>
</div>