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
+39
View File
@@ -5,6 +5,45 @@ import { DimensionService } from '@/services/database/dimensionService';
export const runtime = 'nodejs';
export async function GET() {
try {
const sqlite = getSQLiteClient();
// Get all dimensions with their counts
const result = sqlite.query(`
WITH dimension_counts AS (
SELECT nd.dimension, COUNT(*) AS count
FROM node_dimensions nd
GROUP BY nd.dimension
)
SELECT
d.name AS dimension,
d.description,
d.is_priority AS isPriority,
COALESCE(dc.count, 0) AS count
FROM dimensions d
LEFT JOIN dimension_counts dc ON dc.dimension = d.name
ORDER BY d.is_priority DESC, d.name ASC
`);
return NextResponse.json({
success: true,
data: result.rows.map((row: any) => ({
dimension: row.dimension,
description: row.description,
isPriority: Boolean(row.isPriority),
count: Number(row.count)
}))
});
} catch (error) {
console.error('Error fetching dimensions:', error);
return NextResponse.json({
success: false,
error: 'Failed to fetch dimensions'
}, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
+3 -9
View File
@@ -27,10 +27,10 @@ export async function POST(request: NextRequest) {
const body = await request.json();
// Validate required fields
if (!body.from_node_id || !body.to_node_id || typeof body.explanation !== 'string') {
if (!body.from_node_id || !body.to_node_id) {
return NextResponse.json({
success: false,
error: 'Missing required fields: from_node_id, to_node_id, and explanation are required'
error: 'Missing required fields: from_node_id and to_node_id are required'
}, { status: 400 });
}
@@ -57,15 +57,9 @@ export async function POST(request: NextRequest) {
const fromId = parseInt(body.from_node_id);
const toId = parseInt(body.to_node_id);
// Explanation can be empty - service will auto-generate
const explanation = String(body.explanation || '').trim();
if (!explanation) {
return NextResponse.json({
success: false,
error: 'explanation is required and cannot be empty'
}, { status: 400 });
}
const skipInference = Boolean(body.skip_inference);
const createdVia = (() => {
const raw = typeof body.created_via === 'string' ? body.created_via : '';
+1454 -26
View File
File diff suppressed because it is too large Load Diff
+56 -11
View File
@@ -12,16 +12,26 @@ interface QuickAddSubmitPayload {
interface QuickAddInputProps {
activeDelegations: AgentDelegation[];
onSubmit: (payload: QuickAddSubmitPayload) => Promise<void>;
// External control (optional - if provided, component is controlled)
isOpen?: boolean;
onClose?: () => void;
}
export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddInputProps) {
export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onClose }: QuickAddInputProps) {
const [input, setInput] = useState('');
const [isPosting, setIsPosting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isExpandedInternal, setIsExpandedInternal] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
// Support both controlled (isOpen/onClose) and uncontrolled (internal state) modes
const isControlled = isOpen !== undefined;
const isExpanded = isControlled ? isOpen : isExpandedInternal;
const setIsExpanded = isControlled
? (value: boolean) => { if (!value && onClose) onClose(); }
: setIsExpandedInternal;
const maxConcurrent = 5;
const activeCount = activeDelegations.filter(
(d) => d.status === 'queued' || d.status === 'in_progress'
@@ -149,8 +159,12 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
const hasContent = input.trim() || uploadedFile;
// Collapsed state - prominent "ADD STUFF" button
// Collapsed state - only show button if NOT controlled externally
if (!isExpanded) {
// In controlled mode, don't render anything when closed
if (isControlled) return null;
// Uncontrolled mode - show the "ADD STUFF" button
return (
<button
onClick={() => setIsExpanded(true)}
@@ -201,14 +215,10 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
);
}
// Expanded state - full width overlay
return (
// Expanded state - modal overlay (centered if controlled, absolute if uncontrolled)
const modalContent = (
<div
style={{
position: 'absolute',
top: '60px',
left: '20px',
right: '20px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
@@ -216,13 +226,15 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
padding: '16px',
borderRadius: '12px',
border: dragOver ? '1px solid #22c55e' : '1px solid #2a2a2a',
zIndex: 100,
animation: 'fadeIn 150ms ease-out',
transition: 'border-color 0.15s ease'
transition: 'border-color 0.15s ease',
width: isControlled ? '500px' : 'auto',
maxWidth: isControlled ? '90vw' : 'none',
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div style={{
@@ -534,4 +546,37 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
`}</style>
</div>
);
// In controlled mode, wrap with backdrop
if (isControlled) {
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
onClick={() => setIsExpanded(false)}
>
{modalContent}
</div>
);
}
// Uncontrolled mode - render with absolute positioning
return (
<div style={{
position: 'absolute',
top: '60px',
left: '20px',
right: '20px',
zIndex: 100,
}}>
{modalContent}
</div>
);
}
+14
View File
@@ -22,10 +22,17 @@ const createVoiceRequestId = () =>
? crypto.randomUUID()
: `voice_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
interface HighlightedPassage {
selectedText: string;
nodeId: number;
nodeTitle: string;
}
interface RAHChatProps {
openTabsData: Node[];
activeTabId: number | null;
activeDimension?: string | null;
onClearDimension?: () => void;
onNodeClick?: (nodeId: number) => void;
delegations?: AgentDelegation[];
messages?: ChatMessage[];
@@ -33,12 +40,16 @@ interface RAHChatProps {
mode?: 'easy' | 'hard';
delegationMode?: boolean;
delegationSessionId?: string;
onQuickAdd?: () => void;
highlightedPassage?: HighlightedPassage | null;
onClearPassage?: () => void;
}
export default function RAHChat({
openTabsData,
activeTabId,
activeDimension,
onClearDimension: _onClearDimension,
onNodeClick,
delegations = [],
messages: externalMessages,
@@ -46,6 +57,9 @@ export default function RAHChat({
mode = 'easy',
delegationMode = false,
delegationSessionId,
onQuickAdd: _onQuickAdd,
highlightedPassage: _highlightedPassage,
onClearPassage: _onClearPassage,
}: RAHChatProps) {
// Use external state if provided (lifted state), otherwise use local state
const [internalMessages, internalSetMessages] = useState<ChatMessage[]>([]);
+426 -381
View File
@@ -10,6 +10,7 @@ import { Node, NodeConnection, Chunk } from '@/types/database';
import DimensionTags from './dimensions/DimensionTags';
import { getNodeIcon } from '@/utils/nodeIcons';
import ConfirmDialog from '../common/ConfirmDialog';
import { SourceReader } from './source';
interface PopularDimension {
dimension: string;
@@ -36,10 +37,17 @@ interface FocusPanelProps {
onTabClose: (nodeId: number) => void;
refreshTrigger?: number;
onReorderTabs?: (fromIndex: number, toIndex: number) => void;
onOpenInOtherSlot?: (nodeId: number) => void;
hideTabBar?: boolean;
onTextSelect?: (nodeId: number, nodeTitle: string, text: string) => void;
highlightedPassage?: { nodeId: number; selectedText: string } | null;
}
export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeClick, onTabClose, refreshTrigger }: FocusPanelProps) {
export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeClick, onTabClose, refreshTrigger, onOpenInOtherSlot, hideTabBar, onTextSelect, highlightedPassage }: FocusPanelProps) {
const [nodesData, setNodesData] = useState<Record<number, Node>>({});
// Context menu state
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: number } | null>(null);
const [loadingNodes, setLoadingNodes] = useState<Set<number>>(new Set());
const [editingField, setEditingField] = useState<string | null>(null);
const [editingValue, setEditingValue] = useState<string>('');
@@ -62,6 +70,21 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
}
}, [showReembedPrompt]);
// Close context menu when clicking outside
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]);
// Edges state management (following same patterns as nodes)
const [edgesData, setEdgesData] = useState<{ [key: number]: NodeConnection[] }>({});
const [loadingEdges, setLoadingEdges] = useState<Set<number>>(new Set());
@@ -112,11 +135,40 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
const [sourceEditValue, setSourceEditValue] = useState('');
const [sourceSaving, setSourceSaving] = useState(false);
// Source reader mode: 'raw' (monospace) or 'reader' (formatted typography)
const [sourceReaderMode, setSourceReaderMode] = useState<'raw' | 'reader'>('reader');
// Embedded chunks state (actual chunks from chunks table)
const [chunksData, setChunksData] = useState<Record<number, Chunk[]>>({});
const [loadingChunks, setLoadingChunks] = useState<Set<number>>(new Set());
const [chunksExpanded, setChunksExpanded] = useState<Record<number, boolean>>({});
// Helper: preview edge type based on heuristics (mirrors backend logic)
const previewEdgeType = (explanation: string): { type: string; label: string } | null => {
const norm = (explanation || '').trim().toLowerCase();
if (!norm) return null;
const startsWithAny = (prefixes: string[]) => prefixes.some((p) => norm.startsWith(p));
if (startsWithAny(['created by', 'made by', 'authored by', 'written by', 'founded by'])) {
return { type: 'created_by', label: 'created by' };
}
if (startsWithAny(['part of', 'episode of', 'belongs to', 'in the series', 'in this series'])) {
return { type: 'part_of', label: 'part of' };
}
if (startsWithAny(['features', 'mentions', 'hosted by', 'guest:', 'host:'])) {
return { type: 'part_of', label: 'part of' };
}
if (startsWithAny(['came from', 'inspired by', 'derived from', 'from'])) {
return { type: 'source_of', label: 'source of' };
}
if (startsWithAny(['related to', 'related'])) {
return { type: 'related_to', label: 'related to' };
}
// No heuristic match - will be AI-inferred
return { type: 'inferred', label: 'will be inferred' };
};
// Fetch priority dimensions on mount
useEffect(() => {
fetchPriorityDimensions();
@@ -184,6 +236,20 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
}
}, [refreshTrigger, activeTab]);
// Clear editing state when switching nodes
useEffect(() => {
// Clear all edit modes when switching to a different node
setEditingField(null);
setEditingValue('');
setEditingNodeId(null);
// Also clear notes/desc/source edit modes
setNotesEditMode(false);
setNotesEditValue('');
setDescEditMode(false);
setDescEditValue('');
setSourceEditMode(false);
setSourceEditValue('');
}, [activeTab]);
const fetchPriorityDimensions = async () => {
try {
@@ -1024,9 +1090,9 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
// Edge management functions (following same patterns as node functions)
const handleEdgeNodeSelect = (targetNodeId: number, _targetNodeTitle?: string) => {
setPendingEdgeTarget({ id: targetNodeId, title: _targetNodeTitle || `Node ${targetNodeId}` });
setEdgeExplanation('');
const handleEdgeNodeSelect = async (targetNodeId: number, _targetNodeTitle?: string) => {
// Immediately create edge - backend auto-infers explanation and type
await createEdgeAuto(targetNodeId);
};
// Handle node search keyboard navigation
@@ -1043,19 +1109,16 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
}
};
const handleSelectNodeSuggestion = (suggestion: NodeSearchResult) => {
setPendingEdgeTarget({ id: suggestion.id, title: suggestion.title });
setEdgeExplanation('');
const handleSelectNodeSuggestion = async (suggestion: NodeSearchResult) => {
// Immediately create edge - backend auto-infers explanation and type
await createEdgeAuto(suggestion.id);
setNodeSearchSuggestions([]);
setNodeSearchQuery('');
};
const createEdgeWithExplanation = async (targetNodeId: number, explanation: string) => {
// Auto-create edge - backend handles explanation generation and type inference
const createEdgeAuto = async (targetNodeId: number) => {
if (activeNodeId === null) return;
const trimmed = (explanation || '').trim();
if (!trimmed) {
alert('Please add a short explanation for why this connection exists.');
return;
}
try {
const response = await fetch('/api/edges', {
method: 'POST',
@@ -1066,7 +1129,43 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
from_node_id: activeNodeId,
to_node_id: targetNodeId,
source: 'user',
explanation: trimmed
explanation: '' // Empty - backend will auto-generate
}),
});
if (!response.ok) {
throw new Error('Failed to create edge');
}
// Refresh edges data
await fetchEdgesData(activeNodeId);
// Reset state
setAddingEdge(null);
setEdgeExplanation('');
setPendingEdgeTarget(null);
setShowConnectionsModal(false);
} catch (error) {
console.error('Error creating edge:', error);
alert('Failed to create edge. Please try again.');
}
};
// Legacy function for manual explanation (kept for compatibility)
const createEdgeWithExplanation = async (targetNodeId: number, explanation: string) => {
if (activeNodeId === null) return;
try {
const response = await fetch('/api/edges', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
from_node_id: activeNodeId,
to_node_id: targetNodeId,
source: 'user',
explanation: explanation || '' // Backend handles empty
}),
});
@@ -1264,122 +1363,6 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
</div>
)}
{/* Explanation (required) */}
{pendingEdgeTarget && (
<div style={{
marginTop: '10px',
background: '#0f0f0f',
border: '1px solid #262626',
borderRadius: '12px',
padding: '12px',
display: 'flex',
flexDirection: 'column',
gap: '10px'
}}>
<div style={{ color: '#e5e5e5', fontSize: '13px', fontWeight: 500 }}>
Connecting to: <span style={{ color: '#a3e635' }}>{pendingEdgeTarget.title}</span>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{[
{ label: 'Made by', value: 'Created by ' },
{ label: 'Part of', value: 'Part of ' },
{ label: 'Came from', value: 'Came from ' },
{ label: 'Related', value: 'Related to ' },
].map((chip) => (
<button
key={chip.label}
type="button"
onClick={() => {
setEdgeExplanation((prev) => {
const trimmed = (prev || '').trim();
return trimmed.length > 0 ? prev : chip.value;
});
}}
style={{
padding: '6px 10px',
fontSize: '12px',
borderRadius: '999px',
border: '1px solid #262626',
background: '#141414',
color: '#e5e5e5',
cursor: 'pointer',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#1a1a1a'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#141414'; }}
title={`Prefill: ${chip.value.trim()}`}
>
{chip.label}
</button>
))}
</div>
<textarea
value={edgeExplanation}
onChange={(e) => setEdgeExplanation(e.target.value)}
placeholder="Why does this connect? (e.g., 'Author of this book', 'Inspired this insight')"
rows={2}
style={{
width: '100%',
resize: 'vertical',
background: '#141414',
border: '1px solid #1f1f1f',
color: '#fafafa',
borderRadius: '10px',
padding: '10px',
fontSize: '13px',
outline: 'none',
fontFamily: 'inherit',
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation);
}
if (e.key === 'Escape') {
e.preventDefault();
setPendingEdgeTarget(null);
setEdgeExplanation('');
}
}}
/>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button
onClick={() => {
setPendingEdgeTarget(null);
setEdgeExplanation('');
}}
style={{
padding: '8px 10px',
background: 'transparent',
border: '1px solid #262626',
color: '#a3a3a3',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '12px'
}}
>
Cancel
</button>
<button
onClick={() => createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation)}
style={{
padding: '8px 10px',
background: '#22c55e',
border: '1px solid #16a34a',
color: '#0a0a0a',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: 600
}}
>
Create connection
</button>
</div>
<div style={{ color: '#737373', fontSize: '11px' }}>
Tip: press <span style={{ fontFamily: 'monospace' }}></span>+<span style={{ fontFamily: 'monospace' }}>Enter</span> to create.
</div>
</div>
)}
</div>
{/* Existing Connections */}
@@ -1401,6 +1384,16 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
<div key={connection.id} style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{/* Direction arrow: → if current node is FROM (outgoing), ← if current node is TO (incoming) */}
<span style={{
fontSize: '12px',
color: connection.edge.from_node_id === activeTab ? '#22c55e' : '#f59e0b',
fontWeight: 600,
width: '16px',
textAlign: 'center'
}}>
{connection.edge.from_node_id === activeTab ? '→' : '←'}
</span>
<span style={{
display: 'inline-block',
fontSize: '10px',
@@ -1622,86 +1615,100 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
return (
<>
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: 'transparent' }}>
{/* Tab Bar */}
<div style={{
display: 'flex',
borderBottom: '1px solid #1a1a1a',
background: '#0f0f0f',
flexShrink: 0,
overflowX: 'auto',
overflowY: 'hidden'
}}>
{openTabs.length === 0 ? (
<div style={{
padding: '10px 16px',
fontSize: '12px',
color: '#666'
}}>
No tabs open
</div>
) : (
openTabs.map((tabId) => {
const node = nodesData[tabId];
const isActive = activeTab === tabId;
const label = node ? truncateTitle(node.title || 'Untitled') : 'Loading...';
{/* Tab Bar - hidden when rendered from NodePane which has its own tab bar */}
{!hideTabBar && (
<div style={{
display: 'flex',
borderBottom: '1px solid #1a1a1a',
background: '#0f0f0f',
flexShrink: 0,
overflowX: 'auto',
overflowY: 'hidden'
}}>
{openTabs.length === 0 ? (
<div style={{
padding: '10px 16px',
fontSize: '12px',
color: '#666'
}}>
No tabs open
</div>
) : (
openTabs.map((tabId) => {
const node = nodesData[tabId];
const isActive = activeTab === tabId;
const label = node ? truncateTitle(node.title || 'Untitled') : 'Loading...';
return (
<div
key={tabId}
style={{
display: 'flex',
alignItems: 'center',
borderRight: '1px solid #1a1a1a',
background: isActive ? '#121212' : '#0f0f0f',
borderBottom: isActive ? '2px solid #666' : 'none',
paddingBottom: isActive ? '0' : '2px',
minWidth: '120px',
maxWidth: '200px'
}}
>
<button
onClick={() => onTabSelect(tabId)}
style={{
flex: 1,
padding: '8px 12px',
fontSize: '12px',
fontFamily: 'inherit',
color: isActive ? '#fff' : '#999',
background: 'transparent',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
return (
<div
key={tabId}
draggable
onDragStart={(e) => {
const title = node?.title || 'Untitled';
e.dataTransfer.effectAllowed = 'copyMove';
e.dataTransfer.setData('application/x-rah-tab', JSON.stringify({ id: tabId, title }));
e.dataTransfer.setData('text/plain', `[NODE:${tabId}:"${title}"]`);
}}
>
{label}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tabId);
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
}}
style={{
padding: '4px 8px',
fontSize: '14px',
color: '#666',
background: 'transparent',
border: 'none',
cursor: 'pointer',
transition: 'color 0.2s'
display: 'flex',
alignItems: 'center',
borderRight: '1px solid #1a1a1a',
background: isActive ? '#121212' : '#0f0f0f',
borderBottom: isActive ? '2px solid #666' : 'none',
paddingBottom: isActive ? '0' : '2px',
minWidth: '120px',
maxWidth: '200px',
cursor: 'grab'
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
>
×
</button>
</div>
);
})
)}
</div>
<button
onClick={() => onTabSelect(tabId)}
style={{
flex: 1,
padding: '8px 12px',
fontSize: '12px',
fontFamily: 'inherit',
color: isActive ? '#fff' : '#999',
background: 'transparent',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{label}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tabId);
}}
style={{
padding: '4px 8px',
fontSize: '14px',
color: '#666',
background: 'transparent',
border: 'none',
cursor: 'pointer',
transition: 'color 0.2s'
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
>
×
</button>
</div>
);
})
)}
</div>
)}
{/* Content Area */}
<div style={{
@@ -2237,30 +2244,6 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
/>
</div>
)}
{/* Action buttons for Source tab */}
{activeContentTab === 'source' && !sourceEditMode && (
<div style={{ display: 'flex', gap: '8px', marginBottom: '4px' }}>
<button
onClick={startSourceEdit}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
fontSize: '10px',
color: '#888',
background: 'transparent',
border: '1px solid #2a2a2a',
borderRadius: '4px',
cursor: 'pointer'
}}
title="Edit source"
>
<Pencil size={12} />
Edit
</button>
</div>
)}
</div>
{/* Desc Tab Content */}
@@ -2820,46 +2803,133 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
</div>
</div>
) : (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'auto' }}>
{nodesData[activeTab]?.chunk ? (
<div
style={{
color: '#ccc',
fontSize: '12px',
lineHeight: '1.5',
padding: '12px',
background: '#0a0a0a',
border: '1px solid #1a1a1a',
borderRadius: '4px',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
flex: 1,
overflow: 'auto'
}}
>
{nodesData[activeTab].chunk}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Mode toggle and edit button */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px',
flexShrink: 0,
}}>
{/* Raw / Reader toggle */}
<div style={{
display: 'flex',
background: '#111',
borderRadius: '4px',
border: '1px solid #222',
overflow: 'hidden',
}}>
<button
onClick={() => setSourceReaderMode('raw')}
style={{
padding: '4px 10px',
fontSize: '10px',
fontWeight: 500,
color: sourceReaderMode === 'raw' ? '#fff' : '#666',
background: sourceReaderMode === 'raw' ? '#2a2a2a' : 'transparent',
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
Raw
</button>
<button
onClick={() => setSourceReaderMode('reader')}
style={{
padding: '4px 10px',
fontSize: '10px',
fontWeight: 500,
color: sourceReaderMode === 'reader' ? '#fff' : '#666',
background: sourceReaderMode === 'reader' ? '#2a2a2a' : 'transparent',
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
Reader
</button>
</div>
) : (
<div
{/* Edit button */}
<button
onClick={startSourceEdit}
style={{
color: '#555',
fontSize: '12px',
fontStyle: 'italic',
cursor: 'pointer',
padding: '12px',
border: '1px dashed #1a1a1a',
borderRadius: '4px',
textAlign: 'center',
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
gap: '4px',
padding: '4px 8px',
fontSize: '10px',
color: '#666',
background: 'transparent',
border: '1px solid #222',
borderRadius: '4px',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#888';
e.currentTarget.style.borderColor = '#333';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#666';
e.currentTarget.style.borderColor = '#222';
}}
>
No source content. Click to add.
</div>
)}
<Pencil size={10} />
Edit
</button>
</div>
{/* Content display */}
<div style={{ flex: 1, overflow: 'auto' }}>
{nodesData[activeTab]?.chunk ? (
sourceReaderMode === 'reader' ? (
<SourceReader
content={nodesData[activeTab].chunk}
onTextSelect={onTextSelect ? (text) => onTextSelect(activeTab, nodesData[activeTab]?.title || 'Untitled', text) : undefined}
highlightedText={highlightedPassage?.nodeId === activeTab ? highlightedPassage.selectedText : null}
/>
) : (
<div
style={{
color: '#ccc',
fontSize: '12px',
lineHeight: '1.5',
padding: '12px',
background: '#0a0a0a',
border: '1px solid #1a1a1a',
borderRadius: '4px',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
minHeight: '100%',
}}
>
{nodesData[activeTab].chunk}
</div>
)
) : (
<div
onClick={startSourceEdit}
style={{
color: '#555',
fontSize: '12px',
fontStyle: 'italic',
cursor: 'pointer',
padding: '12px',
border: '1px dashed #1a1a1a',
borderRadius: '4px',
textAlign: 'center',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
No source content. Click to add.
</div>
)}
</div>
</div>
)}
</div>
@@ -3062,126 +3132,6 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
</div>
)}
{/* Explanation prompt (required) */}
{pendingEdgeTarget && (
<div style={{
marginTop: '10px',
background: '#141414',
border: '1px solid #262626',
borderRadius: '16px',
padding: '16px 18px',
boxShadow: '0 0 0 1px rgba(255, 255, 255, 0.04), 0 24px 48px -12px rgba(0, 0, 0, 0.6)',
display: 'flex',
flexDirection: 'column',
gap: '12px'
}}>
<div style={{ color: '#e5e5e5', fontSize: '13px', fontWeight: 600 }}>
Create connection to: <span style={{ color: '#a3e635' }}>{pendingEdgeTarget.title}</span>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{[
{ label: 'Made by', value: 'Created by ' },
{ label: 'Part of', value: 'Part of ' },
{ label: 'Came from', value: 'Came from ' },
{ label: 'Related', value: 'Related to ' },
].map((chip) => (
<button
key={chip.label}
type="button"
onClick={() => {
setEdgeExplanation((prev) => {
const trimmed = (prev || '').trim();
return trimmed.length > 0 ? prev : chip.value;
});
}}
style={{
padding: '6px 10px',
fontSize: '12px',
borderRadius: '999px',
border: '1px solid #262626',
background: '#0f0f0f',
color: '#e5e5e5',
cursor: 'pointer',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#1a1a1a'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#0f0f0f'; }}
title={`Prefill: ${chip.value.trim()}`}
>
{chip.label}
</button>
))}
</div>
<textarea
value={edgeExplanation}
onChange={(e) => setEdgeExplanation(e.target.value)}
placeholder="Why does this connect? (e.g., 'Author of this book', 'Inspired this insight')"
rows={2}
style={{
width: '100%',
resize: 'vertical',
background: '#0f0f0f',
border: '1px solid #333',
color: '#fafafa',
borderRadius: '12px',
padding: '10px 12px',
fontSize: '13px',
outline: 'none',
fontFamily: 'inherit'
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation);
}
if (e.key === 'Escape') {
e.preventDefault();
setPendingEdgeTarget(null);
setEdgeExplanation('');
}
}}
autoFocus
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
onClick={() => {
setPendingEdgeTarget(null);
setEdgeExplanation('');
}}
style={{
padding: '8px 12px',
background: '#262626',
border: 'none',
borderRadius: '10px',
color: '#a3a3a3',
fontSize: '12px',
fontWeight: 600,
cursor: 'pointer'
}}
>
Cancel
</button>
<button
onClick={() => createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation)}
style={{
padding: '8px 12px',
background: '#22c55e',
border: '1px solid #16a34a',
borderRadius: '10px',
color: '#0a0a0a',
fontSize: '12px',
fontWeight: 700,
cursor: 'pointer'
}}
>
Create
</button>
</div>
<div style={{ color: '#737373', fontSize: '11px' }}>
Tip: press <span style={{ fontFamily: 'monospace' }}></span>+<span style={{ fontFamily: 'monospace' }}>Enter</span> to create.
</div>
</div>
)}
{/* Existing Connections */}
{!nodeSearchQuery && (
<div style={{
@@ -3231,6 +3181,17 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
>
{/* Connection header row */}
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{/* Direction arrow: → if current node is FROM (outgoing), ← if current node is TO (incoming) */}
<span style={{
fontSize: '14px',
color: connection.edge.from_node_id === activeTab ? '#22c55e' : '#f59e0b',
fontWeight: 600,
width: '18px',
textAlign: 'center',
flexShrink: 0
}}>
{connection.edge.from_node_id === activeTab ? '→' : '←'}
</span>
<span style={{
display: 'inline-flex',
alignItems: 'center',
@@ -3426,6 +3387,90 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
</div>
)}
{/* Tab Context Menu */}
{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>
)}
</>
);
}
@@ -90,11 +90,28 @@ export default function DimensionTags({
}
};
const addDimension = async (dimension: string) => {
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);
@@ -387,8 +404,8 @@ export default function DimensionTags({
<DimensionSearchModal
isOpen={isAdding}
onClose={() => setIsAdding(false)}
onDimensionSelect={(dim) => {
addDimension(dim);
onDimensionSelect={(dim, description) => {
addDimension(dim, description);
}}
existingDimensions={dimensions}
/>
@@ -0,0 +1,92 @@
/**
* Content type detection for Source Reader
* Analyzes raw content to determine the best formatting approach
*/
export type ContentType = 'transcript' | 'book' | 'markdown' | 'article' | 'raw';
// Timestamp patterns for transcript detection
const TIMESTAMP_PATTERNS = [
/^\[?\d{1,2}:\d{2}(:\d{2})?\]?\s*/, // [00:00] or [00:00:00]
/^\d{1,2}:\d{2}(:\d{2})?\s*[-–—]\s*/, // 00:00 - or 00:00:00 -
/^\(\d{1,2}:\d{2}(:\d{2})?\)\s*/, // (00:00) or (00:00:00)
/^\[\d+(\.\d+)?s\]\s*/, // [0.1s] or [12.5s] - decimal seconds
];
// Simple book detection - look for explicit chapter markers
// We no longer try to parse numbered sections (too unreliable)
const BOOK_INDICATORS = [
/\b(CHAPTER|Chapter)\s+\d+/i,
/\b(Part|PART)\s+(I|II|III|IV|V|VI|VII|VIII|IX|X|\d+)\b/i,
/\bTable\s+of\s+Contents\b/i,
/\bPREFACE\b/,
/\bINTRODUCTION\b/,
/\bAPPENDIX\b/,
];
// Markdown syntax markers
const MARKDOWN_MARKERS = [
/^#{1,6}\s/m, // Headers
/\*\*[^*]+\*\*/, // Bold
/\*[^*]+\*/, // Italic (but not bold)
/^[-*+]\s/m, // Unordered lists
/^\d+\.\s/m, // Ordered lists
/```[\s\S]*?```/, // Code blocks
/`[^`]+`/, // Inline code
/\[.+\]\(.+\)/, // Links
];
/**
* Detect the content type of raw source content
*/
export function detectContentType(content: string): ContentType {
if (!content || content.length < 50) return 'raw';
const lines = content.split('\n').slice(0, 50); // Check first 50 lines
// 1. Check for transcript patterns (timestamps)
const timestampLines = lines.filter(line =>
TIMESTAMP_PATTERNS.some(pattern => pattern.test(line.trim()))
);
if (timestampLines.length >= 3) return 'transcript';
// 2. Check for book indicators (explicit markers like "Chapter", "Table of Contents")
const bookIndicatorCount = BOOK_INDICATORS.filter(pattern => pattern.test(content)).length;
if (bookIndicatorCount >= 2) return 'book';
// Also consider very long content as book-like (>20K chars)
if (content.length > 20000 && bookIndicatorCount >= 1) return 'book';
// 3. Check for markdown patterns
const markdownScore = calculateMarkdownScore(content);
if (markdownScore > 0.3) return 'markdown';
// 4. Check for article structure (paragraphs with clear breaks)
const paragraphs = content.split(/\n\n+/).filter(p => p.trim().length > 100);
if (paragraphs.length >= 3) return 'article';
// 5. Default to raw
return 'raw';
}
/**
* Calculate a "markdown-ness" score for content
* Returns a value between 0 and 1
*/
function calculateMarkdownScore(content: string): number {
const matchCount = MARKDOWN_MARKERS.filter(m => m.test(content)).length;
return matchCount / MARKDOWN_MARKERS.length;
}
/**
* Get a human-readable label for content type
*/
export function getContentTypeLabel(type: ContentType): string {
switch (type) {
case 'transcript': return 'Transcript';
case 'book': return 'Book/Chapter';
case 'markdown': return 'Markdown';
case 'article': return 'Article';
case 'raw': return 'Plain Text';
}
}
@@ -0,0 +1,170 @@
"use client";
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import { detectContentType, getContentTypeLabel } from './ContentDetector';
import RawFormatter from './formatters/RawFormatter';
import TranscriptFormatter from './formatters/TranscriptFormatter';
import BookFormatter from './formatters/BookFormatter';
import MarkdownFormatter from './formatters/MarkdownFormatter';
import SourceSearchBar from './SourceSearchBar';
interface SourceReaderProps {
content: string;
onTextSelect?: (text: string) => void;
highlightedText?: string | null;
}
/**
* Source Reader - Read-only formatted view of source content
*
* This is a pure presentation component that NEVER modifies the source data.
* It detects content type and applies appropriate formatting for comfortable reading.
*/
export default function SourceReader({
content,
onTextSelect,
highlightedText,
}: SourceReaderProps) {
const [showSearch, setShowSearch] = useState(false);
const [searchHighlight, setSearchHighlight] = useState<string | null>(null);
const [searchMatchIndex, setSearchMatchIndex] = useState(0);
const [scrollTrigger, setScrollTrigger] = useState(0);
const contentRef = useRef<HTMLDivElement>(null);
// Detect content type (memoized for performance)
const contentType = useMemo(() => detectContentType(content), [content]);
const contentTypeLabel = getContentTypeLabel(contentType);
// Combined highlight: search takes precedence over external highlight
const activeHighlight = searchHighlight || highlightedText;
// Keyboard shortcut for Cmd+F
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
e.preventDefault();
setShowSearch(true);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
// Scroll to current match when search highlight changes
useEffect(() => {
if (!searchHighlight || !contentRef.current) return;
// Small delay to let React render the mark element
const timer = setTimeout(() => {
const currentMatch = contentRef.current?.querySelector('mark[data-search-match="current"]');
if (currentMatch) {
currentMatch.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 50);
return () => clearTimeout(timer);
}, [searchHighlight, scrollTrigger]);
const handleSearchClose = useCallback(() => {
setShowSearch(false);
setSearchHighlight(null);
setSearchMatchIndex(0);
}, []);
const handleHighlightChange = useCallback((text: string | null, matchIndex: number) => {
setSearchHighlight(text);
setSearchMatchIndex(matchIndex);
// Trigger scroll even if same text (for navigating between matches)
setScrollTrigger(prev => prev + 1);
}, []);
// Render appropriate formatter based on content type
const renderContent = () => {
const highlightIndex = showSearch ? searchMatchIndex : undefined;
switch (contentType) {
case 'transcript':
return <TranscriptFormatter content={content} onTextSelect={onTextSelect} highlightedText={activeHighlight} highlightMatchIndex={highlightIndex} />;
case 'book':
case 'article':
return <BookFormatter content={content} onTextSelect={onTextSelect} highlightedText={activeHighlight} highlightMatchIndex={highlightIndex} />;
case 'markdown':
return <MarkdownFormatter content={content} onTextSelect={onTextSelect} highlightedText={activeHighlight} highlightMatchIndex={highlightIndex} />;
default:
return <RawFormatter content={content} onTextSelect={onTextSelect} highlightedText={activeHighlight} highlightMatchIndex={highlightIndex} />;
}
};
return (
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
background: '#0f0f0f',
borderRadius: '4px',
overflow: 'hidden',
}}>
{/* Header with content type and search */}
{content && content.length >= 50 && (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 16px',
borderBottom: '1px solid #1a1a1a',
}}>
<span style={{
fontSize: '10px',
color: '#555',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
Detected: {contentTypeLabel}
</span>
<button
onClick={() => setShowSearch(!showSearch)}
title="Search content (⌘F)"
style={{
background: showSearch ? '#262626' : 'transparent',
border: 'none',
borderRadius: '4px',
padding: '4px 8px',
cursor: 'pointer',
color: showSearch ? '#fafafa' : '#555',
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '10px',
transition: 'all 150ms ease',
}}
>
<svg width="12" height="12" 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>
<span>Search</span>
</button>
</div>
)}
{/* Search bar */}
{showSearch && content && (
<SourceSearchBar
content={content}
onClose={handleSearchClose}
onHighlightChange={handleHighlightChange}
/>
)}
{/* Formatted content */}
<div
ref={contentRef}
style={{
flex: 1,
overflow: 'auto',
}}
>
{renderContent()}
</div>
</div>
);
}
@@ -0,0 +1,233 @@
"use client";
import { useState, useEffect, useRef } from 'react';
interface SourceSearchBarProps {
content: string;
onClose: () => void;
onHighlightChange: (text: string | null, matchIndex: number) => void;
}
interface SearchMatch {
index: number;
text: string;
}
export default function SourceSearchBar({
content,
onClose,
onHighlightChange
}: SourceSearchBarProps) {
const [query, setQuery] = useState('');
const [matches, setMatches] = useState<SearchMatch[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
// Search when query changes
useEffect(() => {
if (!query.trim()) {
setMatches([]);
setCurrentIndex(0);
onHighlightChange(null, 0);
return;
}
const searchQuery = query.toLowerCase();
const contentLower = content.toLowerCase();
const foundMatches: SearchMatch[] = [];
let pos = 0;
while (pos < contentLower.length) {
const index = contentLower.indexOf(searchQuery, pos);
if (index === -1) break;
foundMatches.push({
index,
text: content.slice(index, index + query.length)
});
pos = index + 1;
}
setMatches(foundMatches);
setCurrentIndex(0);
if (foundMatches.length > 0) {
onHighlightChange(foundMatches[0].text, 0);
} else {
onHighlightChange(null, 0);
}
}, [query, content, onHighlightChange]);
// Update highlight when navigating
useEffect(() => {
if (matches.length > 0 && matches[currentIndex]) {
onHighlightChange(matches[currentIndex].text, currentIndex);
}
}, [currentIndex, matches, onHighlightChange]);
const goToNext = () => {
if (matches.length === 0) return;
setCurrentIndex((prev) => (prev + 1) % matches.length);
};
const goToPrev = () => {
if (matches.length === 0) return;
setCurrentIndex((prev) => (prev - 1 + matches.length) % matches.length);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
} else if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) {
goToPrev();
} else {
goToNext();
}
}
};
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
background: '#141414',
border: '1px solid #262626',
borderRadius: '12px',
padding: '10px 16px',
margin: '8px 12px',
}}>
{/* Search icon */}
<svg
width="16"
height="16"
viewBox="0 0 20 20"
fill="#525252"
style={{ flexShrink: 0 }}
>
<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 */}
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search in source..."
style={{
flex: 1,
background: 'none',
border: 'none',
outline: 'none',
color: '#fafafa',
fontSize: '14px',
fontFamily: 'inherit',
}}
/>
{/* Match count */}
{query && (
<span style={{
fontSize: '12px',
color: matches.length > 0 ? '#737373' : '#ef4444',
whiteSpace: 'nowrap',
}}>
{matches.length > 0
? `${currentIndex + 1} of ${matches.length}`
: 'No matches'
}
</span>
)}
{/* Navigation arrows */}
{matches.length > 1 && (
<div style={{ display: 'flex', gap: '4px' }}>
<button
onClick={goToPrev}
title="Previous (Shift+Enter)"
style={{
background: '#262626',
border: 'none',
borderRadius: '4px',
padding: '4px 6px',
cursor: 'pointer',
color: '#a3a3a3',
display: 'flex',
alignItems: 'center',
}}
>
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M14.77 12.79a.75.75 0 01-1.06-.02L10 8.832 6.29 12.77a.75.75 0 11-1.08-1.04l4.25-4.5a.75.75 0 011.08 0l4.25 4.5a.75.75 0 01-.02 1.06z" clipRule="evenodd" />
</svg>
</button>
<button
onClick={goToNext}
title="Next (Enter)"
style={{
background: '#262626',
border: 'none',
borderRadius: '4px',
padding: '4px 6px',
cursor: 'pointer',
color: '#a3a3a3',
display: 'flex',
alignItems: 'center',
}}
>
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clipRule="evenodd" />
</svg>
</button>
</div>
)}
{/* Close button */}
<button
onClick={onClose}
title="Close (Esc)"
style={{
background: 'none',
border: 'none',
padding: '4px',
cursor: 'pointer',
color: '#525252',
display: 'flex',
alignItems: 'center',
}}
>
<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
{/* Keyboard hint */}
<kbd style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2px 6px',
background: '#262626',
borderRadius: '4px',
fontSize: '10px',
fontFamily: 'inherit',
color: '#525252',
border: '1px solid #333',
}}>
esc
</kbd>
</div>
);
}
@@ -0,0 +1,156 @@
"use client";
import { useCallback, useRef, useMemo } from 'react';
interface BookFormatterProps {
content: string;
onTextSelect?: (text: string) => void;
highlightedText?: string | null;
highlightMatchIndex?: number;
}
/**
* Book Formatter - Clean typography for long-form text
*
* Focuses on readability without attempting to parse chapter structure.
* Structure parsing can be done on-demand via AI.
*/
export default function BookFormatter({ content, onTextSelect, highlightedText, highlightMatchIndex = 0 }: BookFormatterProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Handle text selection
const handleMouseUp = useCallback(() => {
if (!onTextSelect) return;
const selection = window.getSelection();
const text = selection?.toString().trim();
if (text && text.length > 10) {
const truncatedText = text.length > 2000
? text.slice(0, 2000) + '...'
: text;
onTextSelect(truncatedText);
selection?.removeAllRanges();
}
}, [onTextSelect]);
// Compute all match positions in the full content
const matchPositions = useMemo(() => {
if (!highlightedText) return [];
const positions: number[] = [];
const searchText = highlightedText.toLowerCase();
const contentLower = content.toLowerCase();
let pos = 0;
while (pos < contentLower.length) {
const index = contentLower.indexOf(searchText, pos);
if (index === -1) break;
positions.push(index);
pos = index + 1;
}
return positions;
}, [content, highlightedText]);
// Track which global match index we're at as we render paragraphs
const globalMatchCounter = useRef(0);
// Reset counter before each render
globalMatchCounter.current = 0;
// Render text with all highlights, marking current one specially
const renderWithHighlight = useCallback((text: string): React.ReactNode => {
if (!highlightedText) return text;
const textLower = text.toLowerCase();
const searchLower = highlightedText.toLowerCase();
if (!textLower.includes(searchLower)) {
return text;
}
// Find all occurrences in this paragraph
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let pos = 0;
while (pos < textLower.length) {
const index = textLower.indexOf(searchLower, pos);
if (index === -1) break;
// Add text before match
if (index > lastIndex) {
parts.push(text.slice(lastIndex, index));
}
// Determine if this is the current match
const isCurrent = globalMatchCounter.current === highlightMatchIndex;
globalMatchCounter.current++;
// Add highlighted match
parts.push(
<mark
key={`match-${index}`}
data-search-match={isCurrent ? 'current' : 'other'}
style={{
background: isCurrent ? 'rgba(250, 204, 21, 0.4)' : 'rgba(168, 85, 247, 0.2)',
color: isCurrent ? '#fef08a' : '#e9d5ff',
padding: '2px 0',
borderRadius: '2px',
}}
>
{text.slice(index, index + highlightedText.length)}
</mark>
);
lastIndex = index + highlightedText.length;
pos = index + 1;
}
// Add remaining text
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? <>{parts}</> : text;
}, [highlightedText, highlightMatchIndex]);
// Split content into paragraphs (double newlines or single newlines with blank lines)
const paragraphs = content
.split(/\n\n+/)
.map(p => p.trim())
.filter(p => p.length > 0);
return (
<div
ref={containerRef}
onMouseUp={handleMouseUp}
style={{
maxWidth: '680px',
margin: '0 auto',
padding: '24px 16px',
}}
>
{/* Clean text display */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5em' }}>
{paragraphs.map((para, idx) => (
<p
key={idx}
style={{
fontFamily: "Georgia, 'Times New Roman', serif",
fontSize: '16px',
lineHeight: '1.75',
color: '#d4d4d4',
margin: 0,
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
textAlign: 'justify',
textJustify: 'inter-word',
hyphens: 'auto',
}}
>
{renderWithHighlight(para)}
</p>
))}
</div>
</div>
);
}
@@ -0,0 +1,52 @@
"use client";
import { useCallback, useRef } from 'react';
import MarkdownWithNodeTokens from '@/components/helpers/MarkdownWithNodeTokens';
interface MarkdownFormatterProps {
content: string;
onTextSelect?: (text: string) => void;
highlightedText?: string | null;
highlightMatchIndex?: number;
}
/**
* Markdown Formatter - Renders markdown content with good typography
* Note: Search highlighting not yet implemented for markdown content
*/
export default function MarkdownFormatter({ content, onTextSelect }: MarkdownFormatterProps) {
const containerRef = useRef<HTMLDivElement>(null);
const handleMouseUp = useCallback(() => {
if (!onTextSelect) return;
const selection = window.getSelection();
const text = selection?.toString().trim();
if (text && text.length > 10) {
const truncatedText = text.length > 2000
? text.slice(0, 2000) + '...'
: text;
onTextSelect(truncatedText);
selection?.removeAllRanges();
}
}, [onTextSelect]);
return (
<div
ref={containerRef}
onMouseUp={handleMouseUp}
style={{
maxWidth: '680px',
margin: '0 auto',
padding: '24px 16px',
fontFamily: "Georgia, 'Times New Roman', serif",
fontSize: '16px',
lineHeight: '1.75',
color: '#d4d4d4',
}}
>
<MarkdownWithNodeTokens content={content} />
</div>
);
}
@@ -0,0 +1,149 @@
"use client";
import { useMemo, useCallback, useRef } from 'react';
interface RawFormatterProps {
content: string;
onTextSelect?: (text: string) => void;
highlightedText?: string | null;
highlightMatchIndex?: number;
}
/**
* Raw/Typography formatter for the Source Reader
* Applies comfortable reading typography without any content transformation
* Works as the default/fallback for all content types
*/
export default function RawFormatter({ content, onTextSelect, highlightedText, highlightMatchIndex = 0 }: RawFormatterProps) {
const containerRef = useRef<HTMLDivElement>(null);
const globalMatchCounter = useRef(0);
// Reset counter before each render
globalMatchCounter.current = 0;
// Handle text selection - fires on mouseup
const handleMouseUp = useCallback(() => {
if (!onTextSelect) return;
const selection = window.getSelection();
const text = selection?.toString().trim();
// Only trigger for meaningful selections (>10 chars)
if (text && text.length > 10) {
// Truncate very long selections to 2000 chars
const truncatedText = text.length > 2000
? text.slice(0, 2000) + '...'
: text;
onTextSelect(truncatedText);
// Clear browser selection so our custom highlight shows
selection?.removeAllRanges();
}
}, [onTextSelect]);
// Render text with all highlights, marking current one specially
const renderWithHighlight = useCallback((text: string): React.ReactNode => {
if (!highlightedText) return text;
const textLower = text.toLowerCase();
const searchLower = highlightedText.toLowerCase();
if (!textLower.includes(searchLower)) {
return text;
}
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let pos = 0;
while (pos < textLower.length) {
const index = textLower.indexOf(searchLower, pos);
if (index === -1) break;
if (index > lastIndex) {
parts.push(text.slice(lastIndex, index));
}
const isCurrent = globalMatchCounter.current === highlightMatchIndex;
globalMatchCounter.current++;
parts.push(
<mark
key={`match-${index}`}
data-search-match={isCurrent ? 'current' : 'other'}
style={{
background: isCurrent ? 'rgba(250, 204, 21, 0.4)' : 'rgba(168, 85, 247, 0.2)',
color: isCurrent ? '#fef08a' : '#e9d5ff',
padding: '2px 0',
borderRadius: '2px',
}}
>
{text.slice(index, index + highlightedText.length)}
</mark>
);
lastIndex = index + highlightedText.length;
pos = index + 1;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? <>{parts}</> : text;
}, [highlightedText, highlightMatchIndex]);
// Split content into paragraphs for better rendering
const paragraphs = useMemo(() => {
if (!content) return [];
// Split on double newlines to detect paragraphs
// But preserve single newlines within paragraphs
return content
.split(/\n\n+/)
.map(p => p.trim())
.filter(p => p.length > 0);
}, [content]);
if (!content) {
return (
<div style={{
color: '#555',
fontSize: '15px',
fontStyle: 'italic',
textAlign: 'center',
padding: '40px 20px',
}}>
No source content
</div>
);
}
return (
<div
ref={containerRef}
onMouseUp={handleMouseUp}
style={{
maxWidth: '680px',
margin: '0 auto',
padding: '24px 16px',
}}
>
{paragraphs.map((paragraph, index) => (
<p
key={index}
style={{
fontFamily: "Georgia, 'Times New Roman', serif",
fontSize: '15px',
lineHeight: '1.7',
color: '#d4d4d4',
margin: 0,
marginBottom: index < paragraphs.length - 1 ? '1.5em' : 0,
whiteSpace: 'pre-wrap', // Preserve single newlines within paragraphs
wordWrap: 'break-word',
}}
>
{renderWithHighlight(paragraph)}
</p>
))}
</div>
);
}
@@ -0,0 +1,229 @@
"use client";
import { useMemo, useCallback, useRef } from 'react';
interface TranscriptFormatterProps {
content: string;
onTextSelect?: (text: string) => void;
highlightedText?: string | null;
highlightMatchIndex?: number;
}
interface TranscriptSegment {
timestamp: string;
speaker: string | null;
text: string;
}
// Timestamp patterns for parsing
const TIMESTAMP_REGEX = /^(\[?\d{1,2}:\d{2}(?::\d{2})?\]?|\(\d{1,2}:\d{2}(?::\d{2})?\)|\d{1,2}:\d{2}(?::\d{2})?\s*[-–—]|\[\d+(?:\.\d+)?s\])\s*/;
// Speaker pattern - looks for "Name:" at start after timestamp
const SPEAKER_REGEX = /^([A-Z][a-zA-Z\s]{1,30}):?\s+/;
/**
* Parse a line into timestamp, speaker, and text components
*/
function parseLine(line: string): TranscriptSegment | null {
const trimmed = line.trim();
if (!trimmed) return null;
const timestampMatch = trimmed.match(TIMESTAMP_REGEX);
if (!timestampMatch) return null;
const timestamp = timestampMatch[1].replace(/[-–—]\s*$/, '').replace(/[\[\]\(\)]/g, '').trim();
let remaining = trimmed.slice(timestampMatch[0].length);
// Check for speaker name
let speaker: string | null = null;
const speakerMatch = remaining.match(SPEAKER_REGEX);
if (speakerMatch) {
speaker = speakerMatch[1].trim();
remaining = remaining.slice(speakerMatch[0].length);
}
return {
timestamp,
speaker,
text: remaining.trim(),
};
}
/**
* Group consecutive non-timestamp lines with the previous segment
*/
function parseTranscript(content: string): TranscriptSegment[] {
const lines = content.split('\n');
const segments: TranscriptSegment[] = [];
let currentSegment: TranscriptSegment | null = null;
for (const line of lines) {
const parsed = parseLine(line);
if (parsed) {
if (currentSegment) {
segments.push(currentSegment);
}
currentSegment = parsed;
} else if (currentSegment && line.trim()) {
currentSegment.text += '\n' + line.trim();
}
}
if (currentSegment) {
segments.push(currentSegment);
}
return segments;
}
/**
* Transcript Formatter - Clean view for timestamped content
*/
export default function TranscriptFormatter({ content, onTextSelect, highlightedText, highlightMatchIndex = 0 }: TranscriptFormatterProps) {
const containerRef = useRef<HTMLDivElement>(null);
const globalMatchCounter = useRef(0);
// Reset counter before each render
globalMatchCounter.current = 0;
const segments = useMemo(() => parseTranscript(content), [content]);
const handleMouseUp = useCallback(() => {
if (!onTextSelect) return;
const selection = window.getSelection();
const text = selection?.toString().trim();
if (text && text.length > 10) {
const truncatedText = text.length > 2000
? text.slice(0, 2000) + '...'
: text;
onTextSelect(truncatedText);
selection?.removeAllRanges();
}
}, [onTextSelect]);
const renderWithHighlight = useCallback((text: string): React.ReactNode => {
if (!highlightedText) return text;
const textLower = text.toLowerCase();
const searchLower = highlightedText.toLowerCase();
if (!textLower.includes(searchLower)) {
return text;
}
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let pos = 0;
while (pos < textLower.length) {
const index = textLower.indexOf(searchLower, pos);
if (index === -1) break;
if (index > lastIndex) {
parts.push(text.slice(lastIndex, index));
}
const isCurrent = globalMatchCounter.current === highlightMatchIndex;
globalMatchCounter.current++;
parts.push(
<mark
key={`match-${index}`}
data-search-match={isCurrent ? 'current' : 'other'}
style={{
background: isCurrent ? 'rgba(250, 204, 21, 0.4)' : 'rgba(168, 85, 247, 0.2)',
color: isCurrent ? '#fef08a' : '#e9d5ff',
padding: '2px 0',
borderRadius: '2px',
}}
>
{text.slice(index, index + highlightedText.length)}
</mark>
);
lastIndex = index + highlightedText.length;
pos = index + 1;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? <>{parts}</> : text;
}, [highlightedText, highlightMatchIndex]);
if (segments.length === 0) {
return (
<div style={{
color: '#555',
fontSize: '15px',
fontStyle: 'italic',
textAlign: 'center',
padding: '40px 20px',
}}>
No transcript content detected
</div>
);
}
return (
<div
ref={containerRef}
onMouseUp={handleMouseUp}
style={{
maxWidth: '680px',
margin: '0 auto',
padding: '24px 16px',
}}
>
{segments.map((segment, index) => (
<div
key={index}
style={{
marginBottom: '24px',
}}
>
{/* Timestamp and speaker on same line */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '8px',
}}>
<span style={{
fontSize: '11px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
color: '#555',
}}>
{segment.timestamp}
</span>
{segment.speaker && (
<span style={{
fontSize: '12px',
fontWeight: 600,
color: '#888',
}}>
{segment.speaker}
</span>
)}
</div>
{/* Text content */}
<div style={{
fontFamily: "Georgia, 'Times New Roman', serif",
fontSize: '16px',
lineHeight: '1.75',
color: '#d4d4d4',
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
}}>
{renderWithHighlight(segment.text)}
</div>
</div>
))}
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { default as SourceReader } from './SourceReader';
export { detectContentType, getContentTypeLabel } from './ContentDetector';
export type { ContentType } from './ContentDetector';
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { ChevronLeft, ChevronRight, FileText, MessageSquare, Workflow, FolderOpen, Map, LayoutList } from 'lucide-react';
type RailPosition = 'left' | 'right';
type PaneType = 'nodes' | 'node' | 'chat' | 'workflows' | 'dimensions' | 'map' | 'views';
interface CollapsedRailProps {
position: RailPosition;
paneType: PaneType;
onExpand: () => void;
shortcut?: string;
}
const PANE_ICONS: Record<PaneType, React.ReactNode> = {
nodes: <FileText size={18} />,
node: <FileText size={18} />,
chat: <MessageSquare size={18} />,
workflows: <Workflow size={18} />,
dimensions: <FolderOpen size={18} />,
map: <Map size={18} />,
views: <LayoutList size={18} />,
};
const PANE_LABELS: Record<PaneType, string> = {
nodes: 'Nodes',
node: 'Focus',
chat: 'Chat',
workflows: 'Workflows',
dimensions: 'Dimensions',
map: 'Map',
views: 'Feed',
};
export default function CollapsedRail({ position, paneType, onExpand, shortcut }: CollapsedRailProps) {
const isLeft = position === 'left';
const ChevronIcon = isLeft ? ChevronRight : ChevronLeft;
return (
<div
onClick={onExpand}
style={{
width: '40px',
height: '100%',
flexShrink: 0,
borderLeft: isLeft ? 'none' : '1px solid #1f1f1f',
borderRight: isLeft ? '1px solid #1f1f1f' : 'none',
background: '#0c0c0c',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
paddingTop: '12px',
gap: '8px',
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#141414';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#0c0c0c';
}}
title={`Expand ${PANE_LABELS[paneType]}${shortcut ? ` (${shortcut})` : ''}`}
>
{/* Icon representing the collapsed panel */}
<div
style={{
width: '32px',
height: '32px',
borderRadius: '6px',
background: '#1a1a1a',
border: '1px solid #333',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#888',
transition: 'all 0.15s ease',
}}
>
{PANE_ICONS[paneType]}
</div>
{/* Chevron indicator */}
<div
style={{
color: '#666',
marginTop: '4px',
}}
>
<ChevronIcon size={14} />
</div>
{/* Vertical label */}
<div
style={{
writingMode: 'vertical-rl',
textOrientation: 'mixed',
transform: isLeft ? 'rotate(180deg)' : 'none',
fontSize: '10px',
fontWeight: 500,
color: '#777',
letterSpacing: '0.05em',
textTransform: 'uppercase',
marginTop: '8px',
}}
>
{PANE_LABELS[paneType]}
</div>
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
"use client";
import { useState, useCallback } from 'react';
import {
Search,
Plus,
LayoutList,
MessageSquare,
Map,
Folder,
Workflow,
Settings,
} from 'lucide-react';
import type { PaneType } from '../panes/types';
interface LeftToolbarProps {
onSearchClick: () => void;
onAddStuffClick: () => void;
onSettingsClick: () => void;
onPaneTypeClick: (paneType: PaneType) => void;
activePane: 'A' | 'B';
slotAType: PaneType | null;
slotBType: PaneType | null;
}
// Map pane types to their icons
const PANE_TYPE_ICONS: Record<string, typeof LayoutList> = {
views: LayoutList,
chat: MessageSquare,
map: Map,
dimensions: Folder,
workflows: Workflow,
};
const PANE_TYPE_LABELS: Record<string, string> = {
views: 'Feed',
chat: 'Chat',
map: 'Map',
dimensions: 'Dimensions',
workflows: 'Workflows',
};
// Pane types shown in the toolbar (excludes 'node' which is opened via Feed)
const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'chat', 'map', 'dimensions', 'workflows'];
interface ToolbarButtonProps {
icon: typeof Search;
label: string;
shortcut?: string;
onClick: () => void;
disabled?: boolean;
isActive?: boolean;
}
function ToolbarButton({ icon: Icon, label, shortcut, onClick, disabled, isActive }: ToolbarButtonProps) {
const [isHovered, setIsHovered] = useState(false);
return (
<button
onClick={onClick}
disabled={disabled}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
title={shortcut ? `${label} (${shortcut})` : label}
style={{
width: '36px',
height: '36px',
borderRadius: '8px',
border: 'none',
background: isHovered ? '#1a1a1a' : 'transparent',
color: isActive ? '#22c55e' : (isHovered ? '#aaa' : '#666'),
cursor: disabled ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.15s ease',
opacity: disabled ? 0.5 : 1,
}}
>
<Icon size={18} />
</button>
);
}
interface PaneTypeButtonProps {
icon: typeof LayoutList;
label: string;
paneType: PaneType;
isOpen: boolean;
isActivePane: boolean;
onClick: () => void;
}
function PaneTypeButton({ icon: Icon, label, paneType, isOpen, isActivePane, onClick }: PaneTypeButtonProps) {
const [isHovered, setIsHovered] = useState(false);
// Determine color: green if open, brighter if it's the active pane
const getColor = () => {
if (isOpen) {
return isActivePane ? '#4ade80' : '#22c55e'; // Brighter green for active
}
return isHovered ? '#aaa' : '#666';
};
return (
<button
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
title={label}
style={{
width: '36px',
height: '36px',
borderRadius: '8px',
border: 'none',
background: isOpen ? '#1a1a1a' : (isHovered ? '#151515' : 'transparent'),
color: getColor(),
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.15s ease',
position: 'relative',
}}
>
<Icon size={18} />
{/* Active pane indicator dot */}
{isActivePane && isOpen && (
<div
style={{
position: 'absolute',
bottom: '4px',
left: '50%',
transform: 'translateX(-50%)',
width: '4px',
height: '4px',
borderRadius: '50%',
background: '#4ade80',
}}
/>
)}
</button>
);
}
export default function LeftToolbar({
onSearchClick,
onAddStuffClick,
onSettingsClick,
onPaneTypeClick,
activePane,
slotAType,
slotBType,
}: LeftToolbarProps) {
// Determine which pane types are currently open
const openPaneTypes = new Set<PaneType>(
[slotAType, slotBType].filter((t): t is PaneType => t !== null)
);
// Determine which pane type is in the active pane (null if pane is closed)
const activePaneType = activePane === 'A' ? slotAType : slotBType;
return (
<div
style={{
width: '50px',
height: '100%',
background: '#0a0a0a',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '12px 0',
flexShrink: 0,
}}
>
{/* Top section - Actions */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<ToolbarButton
icon={Search}
label="Search"
shortcut="⌘K"
onClick={onSearchClick}
/>
<ToolbarButton
icon={Plus}
label="Add Stuff"
onClick={onAddStuffClick}
/>
</div>
{/* Middle section - Pane Types */}
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
padding: '8px 0',
}}
>
{TOOLBAR_PANE_TYPES.map((paneType) => {
const Icon = PANE_TYPE_ICONS[paneType];
const label = PANE_TYPE_LABELS[paneType];
const isOpen = openPaneTypes.has(paneType);
const isActivePane = activePaneType === paneType;
return (
<PaneTypeButton
key={paneType}
icon={Icon}
label={label}
paneType={paneType}
isOpen={isOpen}
isActivePane={isActivePane}
onClick={() => onPaneTypeClick(paneType)}
/>
);
})}
</div>
{/* Bottom section - Settings */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<ToolbarButton
icon={Settings}
label="Settings"
onClick={onSettingsClick}
/>
</div>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
"use client";
import { useState, useCallback, useEffect, useRef } from 'react';
import { GripVertical } from 'lucide-react';
interface SplitHandleProps {
isSecondPaneOpen: boolean;
onOpenSecondPane: () => void;
onResize: (newWidthPercent: number) => void;
onCloseSecondPane: () => void;
containerRef: React.RefObject<HTMLDivElement>;
toolbarWidth?: number;
}
export default function SplitHandle({
isSecondPaneOpen,
onOpenSecondPane,
onResize,
onCloseSecondPane,
containerRef,
toolbarWidth = 50,
}: SplitHandleProps) {
const [isDragging, setIsDragging] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const startXRef = useRef<number>(0);
const startWidthRef = useRef<number>(50);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
if (!isSecondPaneOpen) {
// First drag opens the second pane at 50%
onOpenSecondPane();
}
setIsDragging(true);
startXRef.current = e.clientX;
// Calculate current width from container
if (containerRef.current) {
const containerWidth = containerRef.current.offsetWidth - toolbarWidth;
// Assume current position is at the split point
startWidthRef.current = 50; // Default to 50% for new split
}
}, [isSecondPaneOpen, onOpenSecondPane, containerRef, toolbarWidth]);
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const containerWidth = containerRect.width - toolbarWidth;
const mouseX = e.clientX - containerRect.left - toolbarWidth;
// Calculate what percentage of the available space should be Slot B
// mouseX is distance from left edge, so slotA width = mouseX
// slotB width = containerWidth - mouseX
const slotBWidthPercent = ((containerWidth - mouseX) / containerWidth) * 100;
// Clamp between 20% and 70%
const clampedWidth = Math.max(20, Math.min(70, slotBWidthPercent));
// If dragged to less than 15%, close the pane
if (slotBWidthPercent < 15) {
onCloseSecondPane();
setIsDragging(false);
return;
}
onResize(clampedWidth);
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [isDragging, containerRef, toolbarWidth, onResize, onCloseSecondPane]);
// When second pane is closed, show a wider handle with grip icon
if (!isSecondPaneOpen) {
return (
<div
onMouseDown={handleMouseDown}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
width: '12px',
cursor: 'col-resize',
background: isHovered ? '#1a1a1a' : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.15s ease',
flexShrink: 0,
}}
title="Drag to split view (⌘\)"
>
<GripVertical
size={12}
color={isHovered ? '#888' : '#444'}
style={{ transition: 'color 0.15s ease' }}
/>
</div>
);
}
// When second pane is open, show resize handle
return (
<div
onMouseDown={handleMouseDown}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
width: '8px',
cursor: 'col-resize',
background: isDragging ? '#22c55e' : (isHovered ? '#1a1a1a' : 'transparent'),
transition: isDragging ? 'none' : 'background 0.15s ease',
flexShrink: 0,
}}
/>
);
}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -41,11 +41,12 @@ interface FolderViewOverlayProps {
onNodeOpen: (nodeId: number) => void;
refreshToken: number;
onDataChanged?: () => void;
onDimensionSelect?: (dimensionName: string | null) => void;
}
const PAGE_SIZE = 100;
export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, onDataChanged }: FolderViewOverlayProps) {
export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, onDataChanged, onDimensionSelect: _onDimensionSelect }: FolderViewOverlayProps) {
const [view, setView] = useState<'dimensions' | 'nodes'>('dimensions');
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
const [dimensionsLoading, setDimensionsLoading] = useState(true);
+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',
};
File diff suppressed because it is too large Load Diff
+9 -50
View File
@@ -143,7 +143,7 @@ export class DimensionService {
}
/**
* Build AI prompt for dimension assignment (locked + keyword dimensions)
* Build AI prompt for dimension assignment (locked dimensions only)
*/
private static buildAssignmentPrompt(
nodeData: { title: string; content?: string; link?: string; description?: string },
@@ -171,56 +171,39 @@ CONTENT PREVIEW: ${contentPreview}${nodeData.content && nodeData.content.length
})
.join('\n---\n');
return `You are categorizing a knowledge node. You will:
1. Assign LOCKED dimensions (from a provided list)
2. Suggest KEYWORD dimensions (to aid searchability)
return `You are categorizing a knowledge node into locked dimensions.
=== NODE TO CATEGORIZE ===
Title: ${nodeData.title}
${nodeContextSection}
URL: ${nodeData.link || 'none'}
=== PART 1: LOCKED DIMENSIONS ===
=== LOCKED DIMENSIONS ===
CRITICAL: Read each dimension's DESCRIPTION carefully.
The description defines what belongs in that dimension.
Only assign if the content CLEARLY matches the description.
If unsure, skip it better to miss than assign incorrectly.
AVAILABLE LOCKED DIMENSIONS:
AVAILABLE DIMENSIONS:
${dimensionsList}
=== PART 2: KEYWORD DIMENSIONS ===
Suggest 1-3 simple, lowercase keywords that:
- Capture the ESSENCE of this content
- Make it easily SEARCHABLE
- Are thoughtful and aid organization
Good keywords: ai, podcast, paper, productivity, machine-learning, book, video, interview, tutorial, framework
If unsure what keywords fit, respond with "none" no noise is better than bad tags.
=== RESPONSE FORMAT ===
LOCKED:
[dimension names from the list above, one per line, or "none"]
KEYWORDS:
[lowercase keyword suggestions, one per line, or "none"]`;
[dimension names from the list above, one per line, or "none"]`;
}
/**
* Parse AI response and extract locked + keyword dimensions
* Parse AI response and extract locked dimensions
*/
private static parseAssignmentResponse(
response: string,
availableDimensions: LockedDimension[]
): { locked: string[]; keywords: string[] } {
const lockedDimensions: string[] = [];
const keywordDimensions: string[] = [];
// Split response into LOCKED and KEYWORDS sections
const lockedMatch = response.match(/LOCKED:\s*([\s\S]*?)(?=KEYWORDS:|$)/i);
const keywordsMatch = response.match(/KEYWORDS:\s*([\s\S]*?)$/i);
// Extract LOCKED section
const lockedMatch = response.match(/LOCKED:\s*([\s\S]*?)$/i);
// Parse LOCKED section
if (lockedMatch) {
const lockedLines = lockedMatch[1].trim().split('\n');
for (const line of lockedLines) {
@@ -241,31 +224,7 @@ KEYWORDS:
}
}
// Parse KEYWORDS section
if (keywordsMatch) {
const keywordLines = keywordsMatch[1].trim().split('\n');
for (const line of keywordLines) {
// Clean and normalize keyword
const keyword = line.trim().toLowerCase()
.replace(/[^a-z0-9-]/g, '') // Only allow lowercase letters, numbers, hyphens
.slice(0, 30); // Max 30 chars per keyword
if (keyword === 'none' || keyword === '' || keyword.length < 2) {
continue;
}
if (!keywordDimensions.includes(keyword)) {
keywordDimensions.push(keyword);
// Limit to 3 keywords
if (keywordDimensions.length >= 3) {
break;
}
}
}
}
return { locked: lockedDimensions, keywords: keywordDimensions };
return { locked: lockedDimensions, keywords: [] };
}
/**
+163 -59
View File
@@ -8,81 +8,77 @@ import { createOpenAI } from '@ai-sdk/openai';
import { z } from 'zod';
const inferredEdgeContextSchema = z.object({
category: z.enum(['attribution', 'intellectual']),
type: z.enum([
'created_by',
'features',
'part_of',
'source_of',
'extends',
'supports',
'contradicts',
'related_to'
]),
type: z.enum(['created_by', 'part_of', 'source_of', 'related_to']),
confidence: z.number().min(0).max(1),
swap_direction: z.boolean(),
});
async function inferEdgeContext(params: {
explanation: string;
fromNode: Node;
toNode: Node;
}): Promise<Pick<EdgeContext, 'category' | 'type' | 'confidence'>> {
}): Promise<{ type: EdgeContext['type']; confidence: number; swap_direction: boolean }> {
const { explanation, fromNode, toNode } = params;
// Heuristic fast-paths for the 4 core UI chips.
// Heuristic fast-paths for common patterns.
// This makes classification robust and reduces reliance on the model.
const norm = explanation.trim().toLowerCase();
const startsWithAny = (prefixes: string[]) => prefixes.some((p) => norm.startsWith(p));
// "Created by X" → FROM was created by TO (no swap needed)
if (startsWithAny(['created by', 'made by', 'authored by', 'written by', 'founded by'])) {
return { category: 'attribution', type: 'created_by', confidence: 1.0 };
return { type: 'created_by', confidence: 1.0, swap_direction: false };
}
// "Author of X" → FROM is the author, so we need TO→FROM for created_by (swap needed)
if (startsWithAny(['author of', 'creator of', 'wrote', 'made', 'founded', 'created'])) {
return { type: 'created_by', confidence: 1.0, swap_direction: true };
}
if (startsWithAny(['part of', 'episode of', 'belongs to', 'in the series', 'in this series'])) {
return { category: 'attribution', type: 'part_of', confidence: 1.0 };
return { type: 'part_of', confidence: 1.0, swap_direction: false };
}
if (startsWithAny(['features', 'mentions', 'hosted by', 'guest:', 'host:'])) {
return { category: 'attribution', type: 'features', confidence: 0.95 };
if (startsWithAny(['contains', 'includes', 'features', 'mentions', 'hosted by', 'guest:', 'host:'])) {
// FROM contains/features TO → TO is part of FROM (swap needed)
return { type: 'part_of', confidence: 0.95, swap_direction: true };
}
if (startsWithAny(['came from', 'inspired by', 'derived from', 'from'])) {
return { category: 'intellectual', type: 'source_of', confidence: 0.9 };
if (startsWithAny(['came from', 'inspired by', 'derived from', 'based on', 'from', 'ideas from', 'insights from', 'ideas or insights from'])) {
// "FROM came from TO" / "FROM has ideas from TO" → no swap needed
return { type: 'source_of', confidence: 0.9, swap_direction: false };
}
if (startsWithAny(['inspired', 'source for', 'source of', 'led to'])) {
// "FROM inspired TO" / "FROM is source of TO" → swap needed (TO came from FROM)
return { type: 'source_of', confidence: 0.9, swap_direction: true };
}
if (startsWithAny(['related to', 'related'])) {
return { category: 'intellectual', type: 'related_to', confidence: 0.8 };
return { type: 'related_to', confidence: 0.8, swap_direction: false };
}
// If no API key is configured, degrade gracefully.
// We still enforce explanation, but fall back to "related_to" classification.
const apiKey = apiKeyService.getOpenAiKey();
if (!apiKey) {
return { category: 'intellectual', type: 'related_to', confidence: 0.0 };
return { type: 'related_to', confidence: 0.0, swap_direction: false };
}
const provider = createOpenAI({ apiKey });
const prompt = [
`Given this edge explanation: "${explanation}"`,
`Given two nodes and an explanation, determine the relationship type and direction.`,
``,
`From node:`,
`- Title: "${fromNode.title}"`,
`- Description: ${fromNode.description || 'No description available'}`,
`- Dimensions: ${fromNode.dimensions?.join(', ') || 'none'}`,
`FROM: "${fromNode.title}" — ${fromNode.description || 'No description'}`,
`TO: "${toNode.title}"${toNode.description || 'No description'}`,
`Explanation: "${explanation}"`,
``,
`To node:`,
`- Title: "${toNode.title}"`,
`- Description: ${toNode.description || 'No description available'}`,
`- Dimensions: ${toNode.dimensions?.join(', ') || 'none'}`,
`Edge types (the arrow shows required direction):`,
`- created_by: Content → Creator (e.g., "Book" → "Author", "Article" → "Writer")`,
`- part_of: Part → Whole (e.g., "Episode" → "Podcast", "Chapter" → "Book")`,
`- source_of: Derivative → Source (e.g., "Insight" → "Article it came from")`,
`- related_to: General relationship (bidirectional, no swap needed)`,
``,
`Classify the relationship:`,
`- category: "attribution" (factual: author, creator, host, guest) or "intellectual" (idea relationship)`,
`- type: one of [created_by, features, part_of, source_of, extends, supports, contradicts, related_to]`,
`- confidence: 0-1`,
`IMPORTANT: Check if FROM and TO match the required direction for the type.`,
`- If FROM is a Person/Creator and TO is Content, and type is created_by → swap_direction: true`,
`- If FROM is a Whole and TO is a Part, and type is part_of → swap_direction: true`,
`- If FROM is a Source and TO is Derivative, and type is source_of → swap_direction: true`,
``,
`IMPORTANT: Interpret the direction as "FROM node → TO node". Pick a type that reads correctly in that direction:`,
`- created_by: FROM was created/founded/authored by TO`,
`- features: FROM features TO (host/guest/subject appearing in FROM)`,
`- part_of: FROM is part of TO (episode→podcast, chapter→book, note→project)`,
`- source_of: FROM came from TO / was inspired by TO`,
`- extends/supports/contradicts: FROM extends/supports/contradicts TO`,
``,
`Return JSON only: {"category": "...", "type": "...", "confidence": 0.X}`
`Return JSON: {"type": "...", "swap_direction": bool, "confidence": 0.X}`
].join('\n');
try {
@@ -106,13 +102,103 @@ async function inferEdgeContext(params: {
const parsed = inferredEdgeContextSchema.safeParse(parsedJson);
if (!parsed.success) {
return { category: 'intellectual', type: 'related_to', confidence: 0.2 };
return { type: 'related_to', confidence: 0.2, swap_direction: false };
}
return parsed.data;
} catch (error) {
console.warn('[edges] inferEdgeContext failed; falling back to related_to', error);
return { category: 'intellectual', type: 'related_to', confidence: 0.2 };
return { type: 'related_to', confidence: 0.2, swap_direction: false };
}
}
// Auto-generate explanation and infer type when user doesn't provide an explanation
async function autoInferEdge(params: {
fromNode: Node;
toNode: Node;
}): Promise<{ explanation: string; type: EdgeContext['type']; confidence: number; swap_direction: boolean }> {
const { fromNode, toNode } = params;
const apiKey = apiKeyService.getOpenAiKey();
if (!apiKey) {
// Fallback without AI
return {
explanation: `Related to ${toNode.title}`,
type: 'related_to',
confidence: 0.0,
swap_direction: false,
};
}
const provider = createOpenAI({ apiKey });
const prompt = [
`Given two knowledge base nodes, determine how they are related.`,
``,
`FROM: "${fromNode.title}"`,
`Description: ${fromNode.description || 'No description'}`,
``,
`TO: "${toNode.title}"`,
`Description: ${toNode.description || 'No description'}`,
``,
`Edge types (Content→Creator means the arrow goes FROM content TO creator):`,
`- created_by: Content → Person/Creator. The content node points to its creator.`,
`- part_of: Part → Whole (episode→podcast, chapter→book)`,
`- source_of: Derivative → Source (summary→original, insight→article)`,
`- related_to: DEFAULT. Similar topics, related concepts, or when unsure.`,
``,
`CRITICAL RULES:`,
`1. If BOTH are documents/articles/content → use "related_to" or "source_of", NEVER "created_by"`,
`2. If FROM is a Person and TO is Content they created → use "created_by" with swap_direction: TRUE`,
`3. If FROM is Content and TO is the Person who created it → use "created_by" with swap_direction: FALSE`,
`4. When unsure → use "related_to"`,
``,
`Return JSON: {"explanation": "...", "type": "...", "swap_direction": bool, "confidence": 0.X}`,
].join('\n');
try {
const { text } = await generateText({
model: provider('gpt-4o-mini'),
prompt,
temperature: 0.0,
maxOutputTokens: 150,
});
const parsedJson = (() => {
try {
return JSON.parse(text);
} catch {
const match = text.match(/\{[\s\S]*\}/);
if (match) return JSON.parse(match[0]);
throw new Error('AI did not return valid JSON');
}
})();
const schema = z.object({
explanation: z.string(),
type: z.enum(['created_by', 'part_of', 'source_of', 'related_to']),
confidence: z.number().min(0).max(1),
swap_direction: z.boolean(),
});
const parsed = schema.safeParse(parsedJson);
if (!parsed.success) {
return {
explanation: `Related to ${toNode.title}`,
type: 'related_to',
confidence: 0.2,
swap_direction: false,
};
}
return parsed.data;
} catch (error) {
console.warn('[edges] autoInferEdge failed; falling back', error);
return {
explanation: `Related to ${toNode.title}`,
type: 'related_to',
confidence: 0.2,
swap_direction: false,
};
}
}
@@ -139,11 +225,6 @@ export class EdgeService {
const now = new Date().toISOString();
const sqlite = getSQLiteClient();
const explanation = (edgeData.explanation || '').trim();
if (!explanation) {
throw new Error('Edge explanation is required');
}
const createdVia: EdgeCreatedVia = edgeData.created_via;
// Fetch nodes for inference context
@@ -155,12 +236,32 @@ export class EdgeService {
if (!fromNode) throw new Error(`Source node ${edgeData.from_node_id} not found`);
if (!toNode) throw new Error(`Target node ${edgeData.to_node_id} not found`);
const inferred = edgeData.skip_inference
? { category: 'intellectual' as const, type: 'related_to' as const, confidence: 0.0 }
: await inferEdgeContext({ explanation, fromNode, toNode });
let explanation = (edgeData.explanation || '').trim();
let inferred: { type: EdgeContext['type']; confidence: number; swap_direction: boolean };
if (!explanation && !edgeData.skip_inference) {
// Auto-generate explanation and infer type
const autoResult = await autoInferEdge({ fromNode, toNode });
explanation = autoResult.explanation;
inferred = {
type: autoResult.type,
confidence: autoResult.confidence,
swap_direction: autoResult.swap_direction,
};
} else if (edgeData.skip_inference) {
inferred = { type: 'related_to' as const, confidence: 0.0, swap_direction: false };
if (!explanation) explanation = `Related to ${toNode.title}`;
} else {
inferred = await inferEdgeContext({ explanation, fromNode, toNode });
}
// Apply swap_direction: flip from/to if inference determined direction should be reversed
const finalFromId = inferred.swap_direction ? edgeData.to_node_id : edgeData.from_node_id;
const finalToId = inferred.swap_direction ? edgeData.from_node_id : edgeData.to_node_id;
const context: EdgeContext = {
...inferred,
type: inferred.type,
confidence: inferred.confidence,
inferred_at: now,
explanation,
created_via: createdVia,
@@ -170,8 +271,8 @@ export class EdgeService {
INSERT INTO edges (from_node_id, to_node_id, context, source, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(
edgeData.from_node_id,
edgeData.to_node_id,
finalFromId,
finalToId,
JSON.stringify(context),
edgeData.source,
now
@@ -184,12 +285,12 @@ export class EdgeService {
throw new Error('Failed to create edge');
}
// Broadcast edge creation event
// Broadcast edge creation event (use final IDs from the saved edge)
eventBroadcaster.broadcast({
type: 'EDGE_CREATED',
data: {
fromNodeId: newEdge.from_node_id,
toNodeId: newEdge.to_node_id,
fromNodeId: finalFromId,
toNodeId: finalToId,
edge: newEdge
}
});
@@ -242,10 +343,13 @@ export class EdgeService {
(existingContext?.created_via as EdgeCreatedVia) ||
'ui';
// Note: On update, we don't swap direction - the edge already exists with its direction.
// We only update the type and confidence based on the new explanation.
updates.context = {
...existingContext,
...incomingContext,
...inferred,
type: inferred.type,
confidence: inferred.confidence,
inferred_at: now,
explanation,
created_via,
+5 -3
View File
@@ -78,10 +78,11 @@ export class NodeService {
const result = sqlite.query<Node & { dimensions_json: string }>(query, params);
// Parse dimensions_json back to array for compatibility
// Parse dimensions_json and metadata back for compatibility
return result.rows.map(row => ({
...row,
dimensions: JSON.parse(row.dimensions_json || '[]')
dimensions: JSON.parse(row.dimensions_json || '[]'),
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
}));
}
@@ -109,7 +110,8 @@ export class NodeService {
const row = result.rows[0];
return {
...row,
dimensions: JSON.parse(row.dimensions_json || '[]')
dimensions: JSON.parse(row.dimensions_json || '[]'),
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
};
}
+4 -11
View File
@@ -64,23 +64,16 @@ export interface Edge {
export type EdgeSource = 'user' | 'ai_similarity' | 'helper_name';
export type EdgeContextCategory = 'attribution' | 'intellectual';
export type EdgeContextType =
| 'created_by'
| 'features'
| 'part_of'
| 'source_of'
| 'extends'
| 'supports'
| 'contradicts'
| 'related_to';
| 'created_by' // Content → Creator (book by author, podcast by host)
| 'part_of' // Part → Whole (episode of podcast, person discussed in book)
| 'source_of' // Derivative → Source (insight from article)
| 'related_to'; // Default — anything else or when unsure
export type EdgeCreatedVia = 'ui' | 'agent' | 'mcp' | 'workflow' | 'quicklink' | 'quick_capture_auto';
export interface EdgeContext {
// SYSTEM-INFERRED (AI classifies from explanation + nodes)
category: EdgeContextCategory;
type: EdgeContextType;
confidence: number; // 0-1
inferred_at: string; // ISO timestamp