sync: Major UI updates from private repo (Jan 16-24)

Features synced:
- UI Panels Refactor: flexible pane system with ChatPane, NodePane,
  DimensionsPane, WorkflowsPane, ViewsPane, MapPane
- Source Content Reader: content type detection + 4 formatters
  (transcript, book, markdown, raw)
- Source Content Search: Cmd+F search in Source Reader
- Feed Layout Overhaul: LeftToolbar, SplitHandle, CollapsedRail
- Map Panel: promoted to first-class pane
- Edge policy simplification: auto-inference + direction correction

New files:
- src/components/panes/* (pane system)
- src/components/layout/CollapsedRail.tsx
- src/components/layout/LeftToolbar.tsx
- src/components/layout/SplitHandle.tsx
- src/components/focus/source/* (Source Reader + formatters)
- src/components/views/ViewsOverlay.tsx

Updated:
- ThreePanelLayout.tsx (complete refactor)
- FocusPanel.tsx (Source tab integration)
- API routes (dimensions GET, edges auto-inference)
- Database services (nodes, edges, dimensions)

Dependencies added:
- react-markdown
- remark-gfm

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-01-24 21:54:21 +11:00
co-authored by Claude Opus 4.5
parent cea1df7f1f
commit 05523b7cb2
34 changed files with 7895 additions and 991 deletions
+183
View File
@@ -0,0 +1,183 @@
"use client";
import { useState, useEffect } from 'react';
import RAHChat from '@/components/agents/RAHChat';
import PaneHeader from './PaneHeader';
import { ChatPaneProps } from './types';
import type { ChatMessage } from '@/components/agents/hooks/useSSEChat';
export default function ChatPane({
slot,
isActive,
onPaneAction,
onCollapse,
onSwapPanes,
openTabsData,
activeTabId,
activeDimension,
onClearDimension,
onNodeClick,
delegations,
chatMessages: externalMessages,
setChatMessages: externalSetMessages,
highlightedPassage,
onClearPassage,
}: ChatPaneProps) {
const [rahMode, setRahMode] = useState<'easy' | 'hard'>('easy');
const [hasStarted, setHasStarted] = useState(false);
// Use lifted state if provided, otherwise fall back to local state
const [internalMessages, setInternalMessages] = useState<ChatMessage[]>([]);
const rahMessages = (externalMessages as ChatMessage[]) ?? internalMessages;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const setRahMessages = (externalSetMessages as any) ?? setInternalMessages;
// Load mode from localStorage
useEffect(() => {
if (typeof window === 'undefined') return;
const stored = window.localStorage.getItem('rah-mode');
if (stored === 'easy' || stored === 'hard') {
setRahMode(stored);
}
}, []);
// Persist mode to localStorage
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem('rah-mode', rahMode);
}, [rahMode]);
// Listen for mode toggle events
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ mode: 'easy' | 'hard' }>).detail;
if (detail?.mode) {
setRahMode(detail.mode);
}
};
window.addEventListener('rah:mode-toggle', handler as EventListener);
return () => {
window.removeEventListener('rah:mode-toggle', handler as EventListener);
};
}, []);
// Auto-start if there are existing messages
useEffect(() => {
if (rahMessages.length > 0 && !hasStarted) {
setHasStarted(true);
}
}, [rahMessages.length, hasStarted]);
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
overflow: 'hidden',
}}>
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} />
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
{!hasStarted ? (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
padding: '20px',
}}>
{/* Start Chat button */}
<div style={{
flex: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}>
<button
onClick={() => {
setHasStarted(true);
setRahMode('easy');
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '14px',
padding: '0',
background: 'transparent',
border: 'none',
cursor: 'pointer'
}}
onMouseEnter={(e) => {
const text = e.currentTarget.querySelector('.start-text') as HTMLElement;
const icon = e.currentTarget.querySelector('.start-icon') as HTMLElement;
if (text) text.style.color = '#22c55e';
if (icon) {
icon.style.transform = 'translateY(-3px)';
icon.style.boxShadow = '0 8px 20px rgba(34, 197, 94, 0.3), 0 0 0 4px rgba(34, 197, 94, 0.1)';
}
}}
onMouseLeave={(e) => {
const text = e.currentTarget.querySelector('.start-text') as HTMLElement;
const icon = e.currentTarget.querySelector('.start-icon') as HTMLElement;
if (text) text.style.color = '#737373';
if (icon) {
icon.style.transform = 'translateY(0)';
icon.style.boxShadow = '0 0 0 0 rgba(34, 197, 94, 0)';
}
}}
>
<span
className="start-text"
style={{
color: '#737373',
fontSize: '11px',
fontWeight: 500,
letterSpacing: '0.15em',
textTransform: 'uppercase',
transition: 'color 0.2s ease'
}}
>
Start
</span>
<div
className="start-icon"
style={{
width: '44px',
height: '44px',
borderRadius: '50%',
background: '#22c55e',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: '0 0 0 0 rgba(34, 197, 94, 0)'
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#0a0a0a" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 19V5"/>
<path d="M5 12l7-7 7 7"/>
</svg>
</div>
</button>
</div>
</div>
) : (
<RAHChat
openTabsData={openTabsData}
activeTabId={activeTabId}
activeDimension={activeDimension}
onClearDimension={onClearDimension}
onNodeClick={onNodeClick}
delegations={delegations}
messages={rahMessages}
setMessages={setRahMessages}
mode={rahMode}
highlightedPassage={highlightedPassage}
onClearPassage={onClearPassage}
/>
)}
</div>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import FolderViewOverlay from '@/components/nodes/FolderViewOverlay';
import PaneHeader from './PaneHeader';
import { DimensionsPaneProps, PaneType } from './types';
export default function DimensionsPane({
slot,
isActive,
onPaneAction,
onCollapse,
onSwapPanes,
onNodeOpen,
refreshToken,
onDataChanged,
onDimensionSelect,
}: DimensionsPaneProps) {
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} />
<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}
/>
</div>
</div>
</div>
);
}
+791
View File
@@ -0,0 +1,791 @@
"use client";
import { useEffect, useMemo, useRef, useState, useCallback, type CSSProperties } from 'react';
import type { Edge, Node as DbNode } from '@/types/database';
import PaneHeader from './PaneHeader';
import type { MapPaneProps } from './types';
import { ChevronDown } from 'lucide-react';
interface GraphNode extends DbNode {
edge_count?: number;
x: number;
y: number;
radius: number;
isExpanded?: boolean; // Node was dynamically loaded as a connection
}
interface DimensionInfo {
dimension: string;
count: number;
isPriority: boolean;
description: string | null;
}
const NODE_LIMIT = 200;
const LABEL_THRESHOLD = 15;
export default function MapPane({
slot,
isActive,
onPaneAction,
onCollapse,
onSwapPanes,
onNodeClick,
activeTabId,
}: MapPaneProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerSize, setContainerSize] = useState({ width: 800, height: 600 });
const [baseNodes, setBaseNodes] = useState<DbNode[]>([]); // Nodes from dimension filter
const [expandedNodes, setExpandedNodes] = useState<DbNode[]>([]); // Connected nodes loaded dynamically
const [edges, setEdges] = useState<Edge[]>([]);
const [lockedDimensions, setLockedDimensions] = useState<DimensionInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<DbNode | null>(null);
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
// Dimension filter state
const [selectedDimension, setSelectedDimension] = useState<string | null>(null);
const [dimensionDropdownOpen, setDimensionDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Combine base nodes + expanded nodes
const allNodes = useMemo(() => {
const baseNodeIds = new Set(baseNodes.map(n => n.id));
// Add expanded nodes that aren't already in base nodes
const uniqueExpanded = expandedNodes.filter(n => !baseNodeIds.has(n.id));
return [...baseNodes, ...uniqueExpanded];
}, [baseNodes, expandedNodes]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as HTMLElement)) {
setDimensionDropdownOpen(false);
}
};
if (dimensionDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [dimensionDropdownOpen]);
// Resize observer
useEffect(() => {
const observer = new ResizeObserver(entries => {
const entry = entries[0];
if (entry?.contentRect) {
setContainerSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, []);
// Fetch base data (dimension-filtered nodes)
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
// Build nodes URL with optional dimension filter
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),
fetch('/api/edges'),
fetch('/api/dimensions/popular'),
]);
if (!nodesRes.ok || !edgesRes.ok) {
throw new Error('Failed to load data');
}
const nodesPayload = await nodesRes.json();
const edgesPayload = await edgesRes.json();
setBaseNodes(nodesPayload.data || []);
setEdges(edgesPayload.data || []);
setExpandedNodes([]); // Clear expanded nodes when filter changes
setSelectedNode(null); // Clear selection when filter changes
// Get locked (priority) dimensions
if (dimsRes.ok) {
const dimsPayload = await dimsRes.json();
if (dimsPayload.success && dimsPayload.data) {
const locked = (dimsPayload.data as DimensionInfo[])
.filter((d) => d.isPriority);
setLockedDimensions(locked);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedDimension]);
// Fetch edges and connected nodes when a node is selected
const fetchConnectedNodes = useCallback(async (nodeId: number) => {
try {
// First, fetch edges for this specific node (in case we don't have them)
const edgesRes = await fetch(`/api/nodes/${nodeId}/edges`);
let nodeEdges: Edge[] = [];
if (edgesRes.ok) {
const edgesData = await edgesRes.json();
nodeEdges = edgesData.data || [];
// Add new edges to our edges state
if (nodeEdges.length > 0) {
setEdges(prev => {
const existingEdgeIds = new Set(prev.map(e => e.id));
const newEdges = nodeEdges.filter(e => !existingEdgeIds.has(e.id));
if (newEdges.length > 0) {
return [...prev, ...newEdges];
}
return prev;
});
}
}
// Get connected node IDs from both existing edges and newly fetched edges
const connectedIds = new Set<number>();
// From existing edges
edges.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);
});
// From newly fetched edges
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);
});
// Find which connected nodes we don't have yet
const existingIds = new Set(allNodes.map(n => n.id));
const missingIds = Array.from(connectedIds).filter(id => !existingIds.has(id));
if (missingIds.length === 0) return;
// Fetch missing nodes
const fetchPromises = missingIds.slice(0, 50).map(async (id) => { // Limit to 50 to prevent overload
try {
const res = await fetch(`/api/nodes/${id}`);
if (res.ok) {
const data = await res.json();
return data.node as DbNode;
}
} catch {
// Ignore individual fetch errors
}
return null;
});
const fetchedNodes = (await Promise.all(fetchPromises)).filter((n): n is DbNode => n !== null);
if (fetchedNodes.length > 0) {
setExpandedNodes(prev => {
const existingExpandedIds = new Set(prev.map(n => n.id));
const newNodes = fetchedNodes.filter(n => !existingExpandedIds.has(n.id));
return [...prev, ...newNodes];
});
}
} catch (err) {
console.error('Failed to fetch connected nodes:', err);
}
}, [edges, allNodes]);
// When selection changes, fetch connected nodes
useEffect(() => {
if (selectedNode) {
fetchConnectedNodes(selectedNode.id);
}
}, [selectedNode, fetchConnectedNodes]);
// Sync with focused node from other panes (focused node awareness)
useEffect(() => {
if (!activeTabId) return;
// Check if this node is already in our nodes
const existingNode = allNodes.find(n => n.id === activeTabId);
if (existingNode) {
// Node is visible, just select it
setSelectedNode(existingNode);
} else {
// Node not in current view - fetch it and add as expanded
const fetchFocusedNode = 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) {
// Add to expanded nodes
setExpandedNodes(prev => {
if (prev.some(n => n.id === node.id)) return prev;
return [...prev, node];
});
// Select it
setSelectedNode(node);
}
}
} catch (err) {
console.error('Failed to fetch focused node:', err);
}
};
fetchFocusedNode();
}
}, [activeTabId, allNodes]);
// Position nodes in a cluster layout
const graphNodes = useMemo<GraphNode[]>(() => {
if (allNodes.length === 0) return [];
const { width, height } = containerSize;
const centerX = width / 2;
const centerY = height / 2;
// Separate base nodes and expanded nodes
const baseNodeIds = new Set(baseNodes.map(n => n.id));
// Sort base nodes by edge count
const sortedBase = [...baseNodes].sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0));
const maxEdges = Math.max(...sortedBase.map(n => n.edge_count ?? 0), 1);
// Position base nodes using spiral layout
const positioned: GraphNode[] = sortedBase.map((node, index) => {
const edgeCount = node.edge_count ?? 0;
const edgeRatio = edgeCount / maxEdges;
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
const angle = index * goldenAngle;
const isLabeled = index < LABEL_THRESHOLD;
const labelSpacing = isLabeled ? 60 : 0;
const baseDistance = 80 + labelSpacing + (1 - edgeRatio) * Math.min(width, height) * 0.35;
const distance = baseDistance + (index * 4);
const x = centerX + Math.cos(angle) * distance;
const y = centerY + Math.sin(angle) * distance;
const minRadius = 3;
const maxRadius = 18;
const radius = minRadius + edgeRatio * (maxRadius - minRadius);
return { ...node, x, y, radius, isExpanded: false };
});
// Track positioned node IDs for quick lookup
const positionedMap = new Map<number, GraphNode>();
positioned.forEach(n => positionedMap.set(n.id, n));
// Position expanded nodes
if (selectedNode) {
// Find the selected node's position (could be in base or already expanded)
let selectedGraphNode = positionedMap.get(selectedNode.id);
// If selected node is an expanded node not yet positioned, position it first
if (!selectedGraphNode && !baseNodeIds.has(selectedNode.id)) {
// Position the selected expanded node at a reasonable location
// Use the center or find a reference point
const selectedExpanded: GraphNode = {
...selectedNode,
x: centerX,
y: centerY,
radius: 10,
isExpanded: true,
};
positioned.push(selectedExpanded);
positionedMap.set(selectedNode.id, selectedExpanded);
selectedGraphNode = selectedExpanded;
}
if (selectedGraphNode) {
// Get all expanded nodes that need positioning (not in base and not already positioned)
const expandedToPosition = expandedNodes.filter(n =>
!baseNodeIds.has(n.id) && !positionedMap.has(n.id)
);
expandedToPosition.forEach((node, index) => {
// Position in a circle around the selected node
const angle = (index / Math.max(expandedToPosition.length, 1)) * Math.PI * 2;
const distance = 120 + (index % 3) * 30; // Vary distance slightly
const x = selectedGraphNode!.x + Math.cos(angle) * distance;
const y = selectedGraphNode!.y + Math.sin(angle) * distance;
const newNode: GraphNode = {
...node,
x,
y,
radius: 8, // Fixed smaller radius for expanded nodes
isExpanded: true,
};
positioned.push(newNode);
positionedMap.set(node.id, newNode);
});
}
}
return positioned;
}, [allNodes, baseNodes, expandedNodes, containerSize, selectedNode]);
// Get edges between visible nodes
const graphEdges = useMemo(() => {
if (graphNodes.length === 0 || edges.length === 0) return [];
const nodeMap = new Map<number, GraphNode>();
graphNodes.forEach(node => nodeMap.set(node.id, node));
return edges
.map(edge => {
const source = nodeMap.get(edge.from_node_id);
const target = nodeMap.get(edge.to_node_id);
if (!source || !target) return null;
return { id: edge.id, source, target };
})
.filter(Boolean) as Array<{ id: number; source: GraphNode; target: GraphNode }>;
}, [edges, graphNodes]);
// Get connected node IDs for selected node
const connectedNodeIds = useMemo(() => {
if (!selectedNode) return new Set<number>();
const connected = new Set<number>();
edges.forEach(edge => {
if (edge.from_node_id === selectedNode.id) connected.add(edge.to_node_id);
if (edge.to_node_id === selectedNode.id) connected.add(edge.from_node_id);
});
return connected;
}, [selectedNode, edges]);
// Set of locked dimension names for styling
const lockedDimensionNames = useMemo(() =>
new Set(lockedDimensions.map(d => d.dimension)),
[lockedDimensions]
);
// Pan handling
const handlePanStart = (event: React.PointerEvent<SVGRectElement>) => {
const startX = event.clientX;
const startY = event.clientY;
const originX = transform.x;
const originY = transform.y;
const handleMove = (moveEvent: PointerEvent) => {
setTransform(prev => ({
...prev,
x: originX + (moveEvent.clientX - startX),
y: originY + (moveEvent.clientY - startY),
}));
};
const handleUp = () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
};
const handleZoom = (direction: 'in' | 'out' | 'reset') => {
if (direction === 'reset') {
setTransform({ x: 0, y: 0, scale: 1 });
return;
}
setTransform(prev => ({
...prev,
scale: direction === 'in'
? Math.min(prev.scale + 0.2, 3)
: Math.max(prev.scale - 0.2, 0.5),
}));
};
// Handle node click - supports traversal
const handleNodeClick = (node: GraphNode) => {
const isCurrentlySelected = selectedNode?.id === node.id;
if (isCurrentlySelected) {
// Clicking selected node deselects it
setSelectedNode(null);
} else {
// Select this node - will trigger fetch of its connections
setSelectedNode(node);
}
};
const handlePaneTypeChange = (type: typeof slot extends 'A' | 'B' ? import('./types').PaneType : never) => {
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'transparent', overflow: 'hidden' }}>
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes}>
{/* 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)' : '#2a2a2a',
borderRadius: '6px',
color: selectedDimension ? '#22c55e' : '#888',
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: '#1a1a1a',
border: '1px solid #2a2a2a',
borderRadius: '8px',
padding: '4px',
minWidth: '180px',
maxHeight: '300px',
overflowY: 'auto',
zIndex: 1000,
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
}}>
{/* All dimensions option */}
<button
onClick={() => {
setSelectedDimension(null);
setDimensionDropdownOpen(false);
}}
style={{
display: 'flex',
alignItems: 'center',
width: '100%',
padding: '8px 12px',
background: !selectedDimension ? '#2a2a2a' : 'transparent',
border: 'none',
borderRadius: '4px',
color: !selectedDimension ? '#fff' : '#888',
fontSize: '12px',
cursor: 'pointer',
transition: 'all 0.1s ease',
textAlign: 'left',
}}
>
All dimensions
{!selectedDimension && <span style={{ marginLeft: 'auto', color: '#22c55e' }}></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 ? '#2a2a2a' : 'transparent',
border: 'none',
borderRadius: '4px',
color: selectedDimension === dim.dimension ? '#fff' : '#888',
fontSize: '12px',
cursor: 'pointer',
transition: 'all 0.1s ease',
textAlign: 'left',
}}
onMouseEnter={(e) => {
if (selectedDimension !== dim.dimension) {
e.currentTarget.style.background = '#222';
e.currentTarget.style.color = '#ccc';
}
}}
onMouseLeave={(e) => {
if (selectedDimension !== dim.dimension) {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = '#888';
}
}}
>
{dim.dimension}
{selectedDimension === dim.dimension && <span style={{ marginLeft: 'auto', color: '#22c55e' }}></span>}
</button>
))}
</div>
)}
</div>
</PaneHeader>
{/* Map content */}
<div ref={containerRef} style={{ position: 'relative', flex: 1, background: '#080808' }}>
{loading ? (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' }}>
Loading map...
</div>
) : error ? (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ef4444' }}>
{error}
</div>
) : graphNodes.length === 0 ? (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' }}>
No nodes to display
</div>
) : (
<>
{/* Zoom controls */}
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 8, zIndex: 10 }}>
<button onClick={() => handleZoom('in')} style={controlBtn} title="Zoom in">+</button>
<button onClick={() => handleZoom('out')} style={controlBtn} title="Zoom out"></button>
<button onClick={() => handleZoom('reset')} style={controlBtn} title="Reset"></button>
</div>
{/* Selected node info */}
{selectedNode && (
<div style={infoPanel}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: 8 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>
{selectedNode.title || 'Untitled'}
</div>
<button
onClick={() => setSelectedNode(null)}
style={{ background: 'none', border: 'none', color: '#666', cursor: 'pointer', fontSize: 16 }}
>
×
</button>
</div>
<div style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
{connectedNodeIds.size} connected nodes
</div>
<div style={{ fontSize: 11, color: '#22c55e', marginBottom: 8 }}>
Click a connected node to traverse
</div>
{selectedNode.dimensions && selectedNode.dimensions.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
{selectedNode.dimensions.slice(0, 5).map(dim => (
<span
key={dim}
style={{
padding: '2px 8px',
borderRadius: 999,
fontSize: 11,
background: lockedDimensionNames.has(dim) ? '#132018' : '#1a1a1a',
color: lockedDimensionNames.has(dim) ? '#86efac' : '#888',
}}
>
{dim}
</span>
))}
</div>
)}
{/* Open node button */}
<button
onClick={() => onNodeClick?.(selectedNode.id)}
style={{
marginTop: 4,
padding: '8px 12px',
background: '#22c55e',
color: '#052e16',
border: 'none',
borderRadius: '6px',
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
width: '100%',
}}
>
Open Node
</button>
</div>
)}
{/* SVG Graph */}
<svg width="100%" height="100%" style={{ display: 'block' }}>
<defs />
<rect
width="100%"
height="100%"
fill="transparent"
style={{ cursor: 'grab' }}
onPointerDown={handlePanStart}
/>
<g transform={`translate(${transform.x} ${transform.y}) scale(${transform.scale})`}>
{/* Edges */}
{graphEdges.map(edge => {
const isConnected = selectedNode && (
edge.source.id === selectedNode.id || edge.target.id === selectedNode.id
);
return (
<line
key={edge.id}
x1={edge.source.x}
y1={edge.source.y}
x2={edge.target.x}
y2={edge.target.y}
stroke={isConnected ? '#22c55e' : '#374151'}
strokeWidth={isConnected ? 1.5 : 0.75}
strokeOpacity={selectedNode ? (isConnected ? 0.9 : 0.15) : 0.6}
/>
);
})}
{/* Nodes */}
{graphNodes.map((node, index) => {
const isBaseNode = !node.isExpanded;
const isTop = isBaseNode && index < LABEL_THRESHOLD;
const isSelected = selectedNode?.id === node.id;
const isConnectedToSelected = connectedNodeIds.has(node.id);
const isDimmed = selectedNode && !isSelected && !isConnectedToSelected;
return (
<g
key={node.id}
onClick={() => handleNodeClick(node)}
style={{ cursor: 'pointer' }}
opacity={isDimmed ? 0.25 : 1}
>
{/* Highlight ring for connected nodes */}
{isConnectedToSelected && !isSelected && (
<circle
cx={node.x}
cy={node.y}
r={node.radius + 4}
fill="none"
stroke="#22c55e"
strokeWidth={2}
strokeOpacity={0.6}
/>
)}
{/* Node circle */}
<circle
cx={node.x}
cy={node.y}
r={node.radius}
fill={node.isExpanded ? '#f59e0b' : (isTop ? '#22c55e' : '#334155')}
fillOpacity={node.isExpanded ? 0.7 : (isTop ? 0.6 : 0.4)}
stroke={isSelected ? '#fff' : (node.isExpanded ? '#b45309' : (isTop ? '#166534' : '#1e293b'))}
strokeWidth={isSelected ? 2 : (isTop ? 1.5 : 0.5)}
/>
{/* Label for top base nodes OR expanded nodes */}
{(isTop || node.isExpanded) && (
<>
<text
x={node.x}
y={node.y + node.radius + 14}
textAnchor="middle"
fill={node.isExpanded ? '#fbbf24' : '#e5e7eb'}
fontSize={node.isExpanded ? 10 : 11}
fontWeight={500}
>
{(node.title || 'Untitled').slice(0, 20)}
{(node.title?.length ?? 0) > 20 ? '…' : ''}
</text>
{!node.isExpanded && node.dimensions && node.dimensions.length > 0 && (() => {
const dims = node.dimensions.slice(0, 3).map(d => d.length > 10 ? d.slice(0, 9) + '…' : d).join(' · ');
const labelWidth = dims.length * 5 + 16;
return (
<g>
<rect
x={node.x - labelWidth / 2}
y={node.y + node.radius + 18}
width={labelWidth}
height={16}
rx={8}
fill="#141414"
stroke="#262626"
strokeWidth={0.5}
/>
<text
x={node.x}
y={node.y + node.radius + 29}
textAnchor="middle"
fill="#a1a1aa"
fontSize={9}
>
{dims}
</text>
</g>
);
})()}
{/* Show dimension for expanded nodes */}
{node.isExpanded && node.dimensions && node.dimensions.length > 0 && (
<text
x={node.x}
y={node.y + node.radius + 24}
textAnchor="middle"
fill="#a1a1aa"
fontSize={9}
>
{node.dimensions[0]}
</text>
)}
</>
)}
</g>
);
})}
</g>
</svg>
</>
)}
</div>
</div>
);
}
const controlBtn: CSSProperties = {
width: 32,
height: 32,
borderRadius: 6,
border: '1px solid #262626',
background: '#141414',
color: '#888',
fontSize: 16,
cursor: 'pointer',
};
const infoPanel: CSSProperties = {
position: 'absolute',
bottom: 16,
left: 16,
width: 260,
background: '#0a0a0a',
border: '1px solid #1f1f1f',
borderRadius: 8,
padding: 14,
zIndex: 10,
};
+267
View File
@@ -0,0 +1,267 @@
"use client";
import { useState, useEffect, useRef } from 'react';
import FocusPanel from '@/components/focus/FocusPanel';
import PaneHeader from './PaneHeader';
import { NodePaneProps, PaneType } from './types';
// Simple truncate for tab titles
function truncateTitle(title: string, maxLength = 20): string {
if (title.length <= maxLength) return title;
return title.slice(0, maxLength - 1) + '…';
}
export default function NodePane({
slot,
isActive,
onPaneAction,
onCollapse,
onSwapPanes,
openTabs,
activeTab,
onTabSelect,
onTabClose,
onNodeClick,
onReorderTabs,
refreshTrigger,
onOpenInOtherSlot,
onTextSelect,
highlightedPassage,
}: NodePaneProps) {
const [nodeTitles, setNodeTitles] = useState<Record<number, string>>({});
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: number } | null>(null);
const fetchedRef = useRef<Set<number>>(new Set());
const handleTypeChange = (type: PaneType) => {
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
};
// Fetch node titles for tabs
useEffect(() => {
const fetchTitle = async (tabId: number) => {
if (fetchedRef.current.has(tabId)) return;
fetchedRef.current.add(tabId);
try {
const response = await fetch(`/api/nodes/${tabId}`);
if (response.ok) {
const data = await response.json();
if (data.success && data.node) {
setNodeTitles(prev => ({ ...prev, [tabId]: data.node.title || 'Untitled' }));
}
}
} catch (error) {
console.error('Failed to fetch node title:', error);
fetchedRef.current.delete(tabId); // Allow retry on error
}
};
openTabs.forEach(fetchTitle);
}, [openTabs]);
// Clear fetched ref when tabs are closed
useEffect(() => {
const currentTabs = new Set(openTabs);
fetchedRef.current.forEach(id => {
if (!currentTabs.has(id)) {
fetchedRef.current.delete(id);
}
});
}, [openTabs]);
// Close context menu on outside click or escape
useEffect(() => {
if (!contextMenu) return;
const handleClick = () => setContextMenu(null);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setContextMenu(null);
};
document.addEventListener('click', handleClick);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('keydown', handleKeyDown);
};
}, [contextMenu]);
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
overflow: 'hidden',
}}>
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes}>
{/* Tabs rendered inline */}
{openTabs.length === 0 ? (
<span style={{ fontSize: '12px', color: '#666' }}>No tabs open</span>
) : (
openTabs.map((tabId) => {
const title = nodeTitles[tabId] || 'Loading...';
const isActiveTab = activeTab === tabId;
return (
<div
key={tabId}
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'copyMove';
e.dataTransfer.setData('application/x-rah-tab', JSON.stringify({ id: tabId, title, sourceSlot: slot }));
e.dataTransfer.setData('text/plain', `[NODE:${tabId}:"${title}"]`);
}}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: isActiveTab ? '#1f1f1f' : 'transparent',
borderRadius: '4px',
cursor: 'grab',
flexShrink: 0,
}}
>
<button
onClick={() => onTabSelect(tabId)}
style={{
fontSize: '11px',
color: isActiveTab ? '#fff' : '#888',
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: 0,
whiteSpace: 'nowrap',
}}
>
{truncateTitle(title)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tabId);
}}
style={{
fontSize: '12px',
color: '#666',
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '0 2px',
lineHeight: 1,
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
>
×
</button>
</div>
);
})
)}
</PaneHeader>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<FocusPanel
openTabs={openTabs}
activeTab={activeTab}
onTabSelect={onTabSelect}
onNodeClick={onNodeClick}
onTabClose={onTabClose}
refreshTrigger={refreshTrigger}
onReorderTabs={onReorderTabs}
onOpenInOtherSlot={onOpenInOtherSlot}
onTextSelect={onTextSelect}
highlightedPassage={highlightedPassage}
hideTabBar
/>
</div>
{/* Context menu for tabs */}
{contextMenu && (
<div
style={{
position: 'fixed',
top: contextMenu.y,
left: contextMenu.x,
background: '#1a1a1a',
border: '1px solid #2a2a2a',
borderRadius: '6px',
padding: '4px',
zIndex: 9999,
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
minWidth: '160px',
}}
onClick={(e) => e.stopPropagation()}
>
{onOpenInOtherSlot && (
<button
onClick={() => {
onOpenInOtherSlot(contextMenu.tabId);
setContextMenu(null);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
width: '100%',
padding: '8px 12px',
background: 'transparent',
border: 'none',
borderRadius: '4px',
color: '#ccc',
fontSize: '12px',
cursor: 'pointer',
textAlign: 'left',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2a2a2a';
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = '#ccc';
}}
>
<span style={{ fontSize: '14px' }}></span>
Open in other panel
</button>
)}
<button
onClick={() => {
onTabClose(contextMenu.tabId);
setContextMenu(null);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
width: '100%',
padding: '8px 12px',
background: 'transparent',
border: 'none',
borderRadius: '4px',
color: '#ccc',
fontSize: '12px',
cursor: 'pointer',
textAlign: 'left',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2a2a2a';
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = '#ccc';
}}
>
<span style={{ fontSize: '14px' }}>×</span>
Close tab
</button>
</div>
)}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useState } from 'react';
import { X } from 'lucide-react';
import { PaneHeaderProps } from './types';
export default function PaneHeader({
slot,
onCollapse,
onSwapPanes,
children
}: PaneHeaderProps) {
const [isDragging, setIsDragging] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const handleDragStart = (e: React.DragEvent) => {
if (!slot) return;
e.dataTransfer.setData('application/x-rah-pane', slot);
e.dataTransfer.effectAllowed = 'move';
setIsDragging(true);
};
const handleDragEnd = () => {
setIsDragging(false);
};
const handleDragOver = (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes('application/x-rah-pane')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setIsDragOver(true);
};
const handleDragLeave = () => {
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const sourceSlot = e.dataTransfer.getData('application/x-rah-pane');
if (sourceSlot && sourceSlot !== slot && onSwapPanes) {
onSwapPanes();
}
};
return (
<div
draggable={!!slot && !!onSwapPanes}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
background: isDragOver ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
minHeight: '44px',
cursor: slot && onSwapPanes ? 'grab' : 'default',
opacity: isDragging ? 0.5 : 1,
borderRadius: isDragOver ? '6px' : '0',
transition: 'background 0.15s ease',
}}
>
{/* Children (tabs, etc.) - takes up available space */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0 }}>
{children}
</div>
{/* Close button (when onCollapse is provided) */}
{onCollapse && (
<button
onClick={onCollapse}
style={{
width: '28px',
height: '28px',
borderRadius: '6px',
border: '1px solid #2a2a2a',
background: '#111',
color: '#777',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.15s ease',
flexShrink: 0,
}}
title="Close pane"
onMouseEnter={(e) => {
e.currentTarget.style.background = '#1a1a1a';
e.currentTarget.style.borderColor = '#3a3a3a';
e.currentTarget.style.color = '#aaa';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#111';
e.currentTarget.style.borderColor = '#2a2a2a';
e.currentTarget.style.color = '#777';
}}
>
<X size={14} />
</button>
)}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import PaneHeader from './PaneHeader';
import ViewsOverlay from '../views/ViewsOverlay';
import type { BasePaneProps, PaneAction, PaneType } from './types';
export interface ViewsPaneProps extends BasePaneProps {
onNodeClick: (nodeId: number) => void;
onNodeOpenInOtherPane?: (nodeId: number) => void;
refreshToken?: number;
}
export default function ViewsPane({
slot,
isActive,
onPaneAction,
onCollapse,
onSwapPanes,
onNodeClick,
onNodeOpenInOtherPane,
refreshToken
}: ViewsPaneProps) {
const handleTypeChange = (type: PaneType) => {
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
};
return (
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
background: 'transparent',
overflow: 'hidden'
}}>
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} />
<div style={{ flex: 1, overflow: 'hidden' }}>
<ViewsOverlay
onNodeClick={onNodeClick}
onNodeOpenInOtherPane={onNodeOpenInOtherPane}
refreshToken={refreshToken}
/>
</div>
</div>
);
}
+579
View File
@@ -0,0 +1,579 @@
"use client";
import { useState, useCallback, useEffect, useMemo } from 'react';
import RAHChat from '@/components/agents/RAHChat';
import PaneHeader from './PaneHeader';
import { WorkflowsPaneProps, PaneType } from './types';
import type { AgentDelegation } from '@/services/agents/delegation';
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
import type { ChatMessage } from '@/components/agents/hooks/useSSEChat';
type ViewMode = 'list' | 'detail' | 'summary';
export default function WorkflowsPane({
slot,
isActive,
onPaneAction,
onCollapse,
onSwapPanes,
delegations,
onNodeClick,
openTabsData = [],
activeTabId = null,
activeDimension,
}: WorkflowsPaneProps) {
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [selectedDelegationId, setSelectedDelegationId] = useState<string | null>(null);
const [delegationMessages, setDelegationMessages] = useState<Record<string, ChatMessage[]>>({});
const selectedDelegation = delegations.find(d => d.sessionId === selectedDelegationId);
const getDelegationMessages = useCallback((sessionId: string) => {
return delegationMessages[sessionId] || [];
}, [delegationMessages]);
const setDelegationMessagesFor = useCallback((sessionId: string) => {
return (updater: (prev: ChatMessage[]) => ChatMessage[]) => {
setDelegationMessages(prev => ({
...prev,
[sessionId]: updater(prev[sessionId] || [])
}));
};
}, []);
const handleSelectDelegation = (sessionId: string) => {
const delegation = delegations.find(d => d.sessionId === sessionId);
setSelectedDelegationId(sessionId);
// Show summary view for completed/failed with no messages
if (delegation &&
(delegation.status === 'completed' || delegation.status === 'failed') &&
getDelegationMessages(sessionId).length === 0) {
setViewMode('summary');
} else {
setViewMode('detail');
}
};
const handleDeleteDelegation = async (sessionId: string) => {
try {
await fetch(`/api/rah/delegations/${sessionId}`, { method: 'DELETE' });
} catch (error) {
console.error(`Failed to delete delegation ${sessionId}:`, error);
}
// The parent will handle removing from delegations list via SSE
};
const handleBack = () => {
setSelectedDelegationId(null);
setViewMode('list');
};
const handleTypeChange = (type: PaneType) => {
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
};
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
overflow: 'hidden',
}}>
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} />
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
{viewMode === 'list' && (
<WorkflowsListView
delegations={delegations}
onSelectDelegation={handleSelectDelegation}
onDeleteDelegation={handleDeleteDelegation}
onNodeClick={onNodeClick}
/>
)}
{viewMode === 'summary' && selectedDelegation && (
<DelegationSummaryView
delegation={selectedDelegation}
onBack={handleBack}
onNodeClick={onNodeClick}
/>
)}
{viewMode === 'detail' && selectedDelegation && (
<DelegationDetailView
delegation={selectedDelegation}
openTabsData={openTabsData}
activeTabId={activeTabId}
activeDimension={activeDimension}
onNodeClick={onNodeClick}
delegations={delegations}
messages={getDelegationMessages(selectedDelegation.sessionId)}
setMessages={setDelegationMessagesFor(selectedDelegation.sessionId)}
onBack={handleBack}
/>
)}
</div>
<style jsx>{`
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
`}</style>
</div>
);
}
// Workflows list view
function WorkflowsListView({
delegations,
onSelectDelegation,
onDeleteDelegation,
onNodeClick
}: {
delegations: AgentDelegation[];
onSelectDelegation: (sessionId: string) => void;
onDeleteDelegation: (sessionId: string) => void;
onNodeClick?: (nodeId: number) => void;
}) {
const activeDelegations = delegations.filter(d => d.status === 'queued' || d.status === 'in_progress');
const completedDelegations = delegations.filter(d => d.status === 'completed' || d.status === 'failed');
const getStatusInfo = (delegation: AgentDelegation) => {
const isWiseRAH = delegation.agentType === 'wise-rah';
let color = '#6b6b6b';
let label = 'Queued';
if (delegation.status === 'in_progress') {
color = isWiseRAH ? '#8b5cf6' : '#22c55e';
label = 'Running';
} else if (delegation.status === 'completed') {
color = '#22c55e';
label = 'Done';
} else if (delegation.status === 'failed') {
color = '#ff6b6b';
label = 'Failed';
}
return { color, label };
};
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
overflow: 'hidden'
}}>
{/* Header */}
<div style={{
padding: '16px 20px',
borderBottom: '1px solid #1a1a1a',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<span style={{
color: '#e5e5e5',
fontSize: '13px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.08em'
}}>
Workflows
</span>
{activeDelegations.length > 0 && (
<span style={{
color: '#22c55e',
fontSize: '11px',
fontWeight: 500
}}>
{activeDelegations.length} running
</span>
)}
</div>
{/* List */}
<div style={{
flex: 1,
overflow: 'auto',
padding: '12px'
}}>
{delegations.length === 0 ? (
<div style={{
color: '#666',
fontSize: '12px',
textAlign: 'center',
padding: '40px 20px'
}}>
No workflows yet. Use Quick Capture or ask RA-H to run a workflow.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{activeDelegations.map((delegation) => {
const { color, label } = getStatusInfo(delegation);
return (
<WorkflowCard
key={delegation.sessionId}
delegation={delegation}
statusColor={color}
statusLabel={label}
onSelect={() => onSelectDelegation(delegation.sessionId)}
onDelete={() => onDeleteDelegation(delegation.sessionId)}
onNodeClick={onNodeClick}
/>
);
})}
{activeDelegations.length > 0 && completedDelegations.length > 0 && (
<div style={{
height: '1px',
background: '#1f1f1f',
margin: '8px 0'
}} />
)}
{completedDelegations.map((delegation) => {
const { color, label } = getStatusInfo(delegation);
return (
<WorkflowCard
key={delegation.sessionId}
delegation={delegation}
statusColor={color}
statusLabel={label}
onSelect={() => onSelectDelegation(delegation.sessionId)}
onDelete={() => onDeleteDelegation(delegation.sessionId)}
onNodeClick={onNodeClick}
/>
);
})}
</div>
)}
</div>
</div>
);
}
// Individual workflow card
function WorkflowCard({
delegation,
statusColor,
statusLabel,
onSelect,
onDelete,
onNodeClick
}: {
delegation: AgentDelegation;
statusColor: string;
statusLabel: string;
onSelect: () => void;
onDelete: () => void;
onNodeClick?: (nodeId: number) => void;
}) {
const isActive = delegation.status === 'in_progress' || delegation.status === 'queued';
return (
<div
onClick={onSelect}
style={{
padding: '14px 16px',
background: '#151515',
border: '1px solid #1f1f1f',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.15s ease',
position: 'relative'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#1a1a1a';
e.currentTarget.style.borderColor = '#2a2a2a';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#151515';
e.currentTarget.style.borderColor = '#1f1f1f';
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '8px'
}}>
<div style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: statusColor,
animation: isActive ? 'pulse 2s infinite' : 'none'
}} />
<span style={{
color: statusColor,
fontSize: '11px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
{statusLabel}
</span>
<span style={{
color: '#555',
fontSize: '11px',
marginLeft: 'auto'
}}>
{new Date(delegation.createdAt).toLocaleTimeString()}
</span>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
style={{
background: 'none',
border: 'none',
color: '#444',
cursor: 'pointer',
padding: '2px 6px',
fontSize: '14px',
lineHeight: 1
}}
onMouseEnter={(e) => e.currentTarget.style.color = '#ff6b6b'}
onMouseLeave={(e) => e.currentTarget.style.color = '#444'}
>
×
</button>
</div>
<div style={{
color: '#e5e5e5',
fontSize: '12px',
lineHeight: '1.4',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical'
}}>
{delegation.task}
</div>
{delegation.summary && delegation.status === 'completed' && (
<div style={{
color: '#666',
fontSize: '11px',
marginTop: '8px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{parseAndRenderContent(delegation.summary, onNodeClick)}
</div>
)}
</div>
);
}
// Summary view for completed delegations
function DelegationSummaryView({
delegation,
onBack,
onNodeClick
}: {
delegation: AgentDelegation;
onBack?: () => void;
onNodeClick?: (nodeId: number) => void;
}) {
const isSuccess = delegation.status === 'completed';
const statusColor = isSuccess ? '#22c55e' : '#ff6b6b';
const statusLabel = isSuccess ? 'Completed' : 'Failed';
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
padding: '24px'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '24px',
paddingBottom: '16px',
borderBottom: '1px solid #1a1a1a'
}}>
{onBack && (
<button
onClick={onBack}
style={{
background: 'none',
border: 'none',
color: '#666',
cursor: 'pointer',
padding: '4px',
display: 'flex',
alignItems: 'center',
fontSize: '14px'
}}
>
</button>
)}
<div style={{
width: '10px',
height: '10px',
borderRadius: '50%',
background: statusColor
}} />
<span style={{
color: statusColor,
fontSize: '12px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.08em'
}}>
{statusLabel}
</span>
<span style={{
color: '#666',
fontSize: '11px',
marginLeft: 'auto'
}}>
{new Date(delegation.updatedAt).toLocaleString()}
</span>
</div>
<div style={{ marginBottom: '20px' }}>
<div style={{
color: '#666',
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
marginBottom: '8px'
}}>
Task
</div>
<div style={{
color: '#e5e5e5',
fontSize: '13px',
lineHeight: '1.5'
}}>
{delegation.task}
</div>
</div>
{delegation.summary && (
<div style={{ flex: 1 }}>
<div style={{
color: '#666',
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.1em',
marginBottom: '8px'
}}>
Result
</div>
<div style={{
color: isSuccess ? '#a8a8a8' : '#fca5a5',
fontSize: '13px',
lineHeight: '1.6',
whiteSpace: 'pre-wrap'
}}>
{parseAndRenderContent(delegation.summary || '', onNodeClick)}
</div>
</div>
)}
{!delegation.summary && (
<div style={{
color: '#666',
fontSize: '12px',
fontStyle: 'italic'
}}>
No details available
</div>
)}
</div>
);
}
// Detail view with chat
function DelegationDetailView({
delegation,
openTabsData,
activeTabId,
activeDimension,
onNodeClick,
delegations,
messages,
setMessages,
onBack
}: {
delegation: AgentDelegation;
openTabsData: any[];
activeTabId: number | null;
activeDimension?: string | null;
onNodeClick?: (nodeId: number) => void;
delegations: AgentDelegation[];
messages: ChatMessage[];
setMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void;
onBack: () => void;
}) {
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{
padding: '8px 12px',
borderBottom: '1px solid #1a1a1a',
display: 'flex',
alignItems: 'center',
gap: '8px',
background: 'transparent'
}}>
<button
onClick={onBack}
style={{
background: 'none',
border: 'none',
color: '#666',
cursor: 'pointer',
padding: '4px 8px',
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '12px'
}}
onMouseEnter={(e) => e.currentTarget.style.color = '#a8a8a8'}
onMouseLeave={(e) => e.currentTarget.style.color = '#666'}
>
Workflows
</button>
<span style={{ color: '#555', fontSize: '11px' }}>|</span>
<span style={{
color: delegation.status === 'in_progress' ? '#22c55e' : '#666',
fontSize: '11px',
textTransform: 'uppercase'
}}>
{delegation.status === 'in_progress' ? 'Running' : delegation.status}
</span>
</div>
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<RAHChat
openTabsData={openTabsData}
activeTabId={activeTabId}
activeDimension={activeDimension}
onNodeClick={onNodeClick}
delegations={delegations}
messages={messages}
setMessages={setMessages}
mode="easy"
delegationMode={true}
delegationSessionId={delegation.sessionId}
/>
</div>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export { default as NodePane } from './NodePane';
export { default as ChatPane } from './ChatPane';
export { default as WorkflowsPane } from './WorkflowsPane';
export { default as DimensionsPane } from './DimensionsPane';
export { default as MapPane } from './MapPane';
export { default as ViewsPane } from './ViewsPane';
export { default as PaneHeader } from './PaneHeader';
export * from './types';
+128
View File
@@ -0,0 +1,128 @@
import { Node } from '@/types/database';
import type { AgentDelegation } from '@/services/agents/delegation';
// The six pane types
export type PaneType = 'node' | 'chat' | 'workflows' | 'dimensions' | 'map' | 'views';
// State for each slot
export interface SlotState {
type: PaneType;
// NodePane state
nodeTabs?: number[];
activeNodeTab?: number | null;
// DimensionsPane state
selectedDimension?: string | null;
viewMode?: 'grid' | 'list' | 'kanban';
}
// Actions panes can emit to the layout
export type PaneAction =
| { type: 'open-node'; nodeId: number; targetSlot?: 'A' | 'B' }
| { type: 'open-dimension'; dimension: string; targetSlot?: 'A' | 'B' }
| { type: 'switch-pane-type'; paneType: PaneType }
| { type: 'close-pane' };
// Common props for all panes
export interface BasePaneProps {
slot: 'A' | 'B';
isActive: boolean;
onPaneAction?: (action: PaneAction) => void;
onCollapse?: () => void;
onSwapPanes?: () => void;
}
// NodePane specific props
export interface NodePaneProps extends BasePaneProps {
openTabs: number[];
activeTab: number | null;
onTabSelect: (nodeId: number) => void;
onTabClose: (nodeId: number) => void;
onNodeClick?: (nodeId: number) => void;
onReorderTabs?: (fromIndex: number, toIndex: number) => void;
refreshTrigger?: number;
onOpenInOtherSlot?: (nodeId: number) => void;
onTextSelect?: (nodeId: number, nodeTitle: string, text: string) => void;
highlightedPassage?: HighlightedPassage | null;
}
// Highlighted passage for source awareness
export interface HighlightedPassage {
nodeId: number;
nodeTitle: string;
selectedText: string;
}
// ChatPane specific props
export interface ChatPaneProps extends BasePaneProps {
openTabsData: Node[];
activeTabId: number | null;
activeDimension?: string | null;
onClearDimension?: () => void;
onNodeClick?: (nodeId: number) => void;
delegations: AgentDelegation[];
// Lifted state for persistence
chatMessages?: unknown[];
setChatMessages?: React.Dispatch<React.SetStateAction<unknown[]>>;
// Source awareness
highlightedPassage?: HighlightedPassage | null;
onClearPassage?: () => void;
}
// WorkflowsPane specific props
export interface WorkflowsPaneProps extends BasePaneProps {
delegations: AgentDelegation[];
onNodeClick?: (nodeId: number) => void;
openTabsData?: Node[];
activeTabId?: number | null;
activeDimension?: string | null;
}
// DimensionsPane specific props
export interface DimensionsPaneProps extends BasePaneProps {
onNodeOpen: (nodeId: number) => void;
refreshToken: number;
onDataChanged?: () => void;
onDimensionSelect?: (dimensionName: string | null) => void;
}
// MapPane specific props
export interface MapPaneProps extends BasePaneProps {
onNodeClick?: (nodeId: number) => void;
activeTabId?: number | null;
}
// ViewsPane specific props
export interface ViewsPaneProps extends BasePaneProps {
onNodeClick: (nodeId: number) => void;
onNodeOpenInOtherPane?: (nodeId: number) => void;
refreshToken?: number;
}
// Pane header props
export interface PaneHeaderProps {
slot?: 'A' | 'B';
onCollapse?: () => void;
onSwapPanes?: () => void;
children?: React.ReactNode;
}
// Labels for pane types
export const PANE_LABELS: Record<PaneType, string> = {
node: 'Nodes',
chat: 'Chat',
workflows: 'Workflows',
dimensions: 'Dimensions',
map: 'Map',
views: 'Feed',
};
// Default slot states
export const DEFAULT_SLOT_A: SlotState = {
type: 'node',
nodeTabs: [],
activeNodeTab: null,
};
export const DEFAULT_SLOT_B: SlotState = {
type: 'chat',
};