"use client"; import { useState, useEffect, useRef, type DragEvent } from 'react'; import { Eye, Trash2, Link, Loader, Database, Check, RefreshCw, Pencil, X, Save, Plus } from 'lucide-react'; import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer'; import MarkdownWithNodeTokens from '@/components/helpers/MarkdownWithNodeTokens'; import FormattingToolbar from '@/components/focus/FormattingToolbar'; import { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter'; import { Node, NodeConnection, Chunk } from '@/types/database'; import DimensionTags from './dimensions/DimensionTags'; import { getNodeIcon } from '@/utils/nodeIcons'; import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl'; import { useDimensionIcons } from '@/context/DimensionIconsContext'; import ConfirmDialog from '../common/ConfirmDialog'; import { SourceReader } from './source'; interface PopularDimension { dimension: string; count: number; isPriority: boolean; } interface DimensionsResponse { success: boolean; data: PopularDimension[]; } interface NodeSearchResult { id: number; title: string; dimensions?: string[]; } interface FocusPanelProps { openTabs: number[]; activeTab: number | null; onTabSelect: (nodeId: number) => void; onNodeClick?: (nodeId: number) => void; 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, onOpenInOtherSlot, hideTabBar, onTextSelect, highlightedPassage }: FocusPanelProps) { const { dimensionIcons } = useDimensionIcons(); const [nodesData, setNodesData] = useState>({}); // Context menu state const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: number } | null>(null); const [loadingNodes, setLoadingNodes] = useState>(new Set()); const [editingField, setEditingField] = useState(null); const [editingValue, setEditingValue] = useState(''); const [editingNodeId, setEditingNodeId] = useState(null); const [savingField, setSavingField] = useState(null); const [embeddingNode, setEmbeddingNode] = useState(null); const [showReembedPrompt, setShowReembedPrompt] = useState(null); const [priorityDimensions, setPriorityDimensions] = useState([]); const activeNodeId = activeTab; const currentNode = activeNodeId !== null ? nodesData[activeNodeId] : undefined; const inputRef = useRef(null); // Auto-hide re-embed prompt after a few seconds useEffect(() => { if (showReembedPrompt !== null) { const timeout = setTimeout(() => setShowReembedPrompt(null), 4000); return () => clearTimeout(timeout); } }, [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>(new Set()); const [addingEdge, setAddingEdge] = useState(null); const [nodeSearchQuery, setNodeSearchQuery] = useState(''); const [nodeSearchSuggestions, setNodeSearchSuggestions] = useState([]); const [selectedSearchIndex, setSelectedSearchIndex] = useState(0); const [edgeExplanation, setEdgeExplanation] = useState(''); const [pendingEdgeTarget, setPendingEdgeTarget] = useState<{ id: number; title: string } | null>(null); const [deletingEdge, setDeletingEdge] = useState(null); const [edgeEditingId, setEdgeEditingId] = useState(null); const [edgeEditingValue, setEdgeEditingValue] = useState(''); const [edgeSavingId, setEdgeSavingId] = useState(null); const [deletingNode, setDeletingNode] = useState(null); const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState(null); // Chunk content toggle state - default to expanded (true) const [chunkExpanded, setChunkExpanded] = useState<{ [key: number]: boolean }>({}); // Edges expand/collapse state // edgesExpanded removed — all connections shown in Edges tab // Connections section collapsed state (default closed) const [edgeSearchOpen, setEdgeSearchOpen] = useState(false); // Title expanded state for click-to-expand full title const [titleExpanded, setTitleExpanded] = useState<{ [key: number]: boolean }>({}); // Description regeneration state const [regeneratingDescription, setRegeneratingDescription] = useState(null); // Content tab state: 'notes', 'desc', or 'source' const [activeContentTab, setActiveContentTab] = useState<'notes' | 'desc' | 'edges' | 'source'>('desc'); // Desc (description) edit mode state const [descEditMode, setDescEditMode] = useState(false); const [descEditValue, setDescEditValue] = useState(''); const [descSaving, setDescSaving] = useState(false); // Notes edit mode state (separate from inline editing) const [notesEditMode, setNotesEditMode] = useState(false); const [notesEditValue, setNotesEditValue] = useState(''); const [notesSaving, setNotesSaving] = useState(false); const notesTextareaRef = useRef(null); // Source (chunk) edit mode const [sourceEditMode, setSourceEditMode] = useState(false); 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>({}); const [loadingChunks, setLoadingChunks] = useState>(new Set()); const [chunksExpanded, setChunksExpanded] = useState>({}); // 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(); }, []); // Generate node search suggestions useEffect(() => { if (!nodeSearchQuery.trim() || !activeTab) { setNodeSearchSuggestions([]); return; } const fetchNodeSearchSuggestions = async () => { try { const response = await fetch(`/api/nodes/search?q=${encodeURIComponent(nodeSearchQuery)}&limit=10`); const result = await response.json(); if (result.success) { const nodeSuggestions: NodeSearchResult[] = result.data .filter((node: any) => node.id !== activeTab) // Exclude current node .map((node: any) => ({ id: node.id, title: node.title, dimensions: node.dimensions || [] })); setNodeSearchSuggestions(nodeSuggestions); setSelectedSearchIndex(0); } } catch (error) { console.error('Error fetching node search suggestions:', error); setNodeSearchSuggestions([]); } }; const timeoutId = setTimeout(fetchNodeSearchSuggestions, 200); return () => clearTimeout(timeoutId); }, [nodeSearchQuery, activeTab]); // Fetch node data when new tabs are opened useEffect(() => { openTabs.forEach((tabId) => { if (!nodesData[tabId] && !loadingNodes.has(tabId)) { fetchNodeData(tabId); } }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [openTabs]); useEffect(() => { openTabs.forEach((tabId) => { if (nodesData[tabId] && !edgesData[tabId] && !loadingEdges.has(tabId)) { fetchEdgesData(tabId); } }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [openTabs, nodesData, edgesData]); // Refresh data when SSE events trigger updates useEffect(() => { if (refreshTrigger !== undefined && refreshTrigger > 0 && activeTab) { fetchNodeData(activeTab); fetchEdgesData(activeTab); } }, [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 { const response = await fetch('/api/dimensions/popular'); const payload = (await response.json()) as DimensionsResponse; if (payload?.success && Array.isArray(payload.data)) { const priority = payload.data.filter((d) => d.isPriority).map((d) => d.dimension); setPriorityDimensions(priority); } } catch (error) { console.error('Error fetching priority dimensions:', error); } }; const fetchNodeData = async (id: number) => { setLoadingNodes(prev => new Set(prev).add(id)); // First try to fetch as a node try { const nodeResponse = await fetch(`/api/nodes/${id}`); if (nodeResponse.ok) { const data = await nodeResponse.json(); if (data.node) { setNodesData(prev => ({ ...prev, [id]: data.node })); setLoadingNodes(prev => { const newSet = new Set(prev); newSet.delete(id); return newSet; }); return; } } } catch (error) { console.warn(`Failed to fetch node ${id}:`, error); } setLoadingNodes(prev => { const newSet = new Set(prev); newSet.delete(id); return newSet; }); }; const fetchEdgesData = async (nodeId: number) => { setLoadingEdges(prev => new Set(prev).add(nodeId)); try { const response = await fetch(`/api/nodes/${nodeId}/edges`); const data = await response.json(); if (data.success && data.data) { setEdgesData(prev => ({ ...prev, [nodeId]: data.data })); } } catch (error) { console.error(`Error fetching edges for node ${nodeId}:`, error); } finally { setLoadingEdges(prev => { const newSet = new Set(prev); newSet.delete(nodeId); return newSet; }); } }; // Fetch embedded chunks for a node const fetchChunksData = async (nodeId: number) => { if (loadingChunks.has(nodeId)) return; setLoadingChunks(prev => new Set(prev).add(nodeId)); try { const response = await fetch(`/api/nodes/${nodeId}/chunks`); const data = await response.json(); if (data.success && data.chunks) { setChunksData(prev => ({ ...prev, [nodeId]: data.chunks })); } } catch (error) { console.error(`Error fetching chunks for node ${nodeId}:`, error); } finally { setLoadingChunks(prev => { const newSet = new Set(prev); newSet.delete(nodeId); return newSet; }); } }; // Fetch chunks when switching to Source tab useEffect(() => { if (activeContentTab === 'source' && activeTab && !chunksData[activeTab] && !loadingChunks.has(activeTab)) { fetchChunksData(activeTab); } }, [activeContentTab, activeTab]); const truncateTitle = (title: string, maxLength: number = 20) => { if (title.length <= maxLength) return title; return title.substring(0, maxLength) + '...'; }; // Focus input when editing starts useEffect(() => { if (editingField && inputRef.current) { inputRef.current.focus(); if (inputRef.current instanceof HTMLInputElement || inputRef.current instanceof HTMLTextAreaElement) { inputRef.current.select(); } } }, [editingField]); const startEdit = (field: string, currentValue: string) => { if (savingField) return; // Don't start edit if currently saving setEditingField(field); setEditingValue(currentValue || ''); if (activeNodeId !== null) setEditingNodeId(activeNodeId); }; const cancelEdit = () => { setEditingField(null); setEditingValue(''); setEditingNodeId(null); }; const saveField = async () => { const nodeId = editingNodeId ?? activeNodeId; if (!nodeId || !editingField) return; // Validate required fields if (editingField === 'title' && !editingValue.trim()) { alert('Title cannot be empty'); return; } setSavingField(editingField); try { const updateData: Record = {}; if (editingField === 'notes') { updateData.content = editingValue; } else { updateData[editingField] = editingValue; } const response = await fetch(`/api/nodes/${nodeId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(updateData), }); if (!response.ok) { throw new Error('Failed to save'); } const result = await response.json(); if (result.node) { setNodesData(prev => ({ ...prev, [nodeId]: result.node })); } // Safety net: ensure edges exist for any tokens present in saved content if (editingField === 'notes' && typeof editingValue === 'string') { try { const tokens = parseNodeMarkers(editingValue); const uniqueTargets = Array.from(new Set(tokens.map(t => t.id))).filter(id => id !== nodeId); await Promise.allSettled(uniqueTargets.map(async (toId) => { await fetch('/api/edges', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from_node_id: nodeId, to_node_id: toId, source: 'user', explanation: 'Referenced via @ mention' }) }); })); // Refresh edges after ensuring await fetchEdgesData(nodeId); } catch (e) { console.warn('Failed to ensure edges from tokens:', e); } } setEditingField(null); setEditingValue(''); setEditingNodeId(null); } catch (error) { console.error('Error saving field:', error); alert('Failed to save changes. Please try again.'); } finally { setSavingField(null); } }; // Explicit content saver to avoid stale state reads (used after @mention insert) const saveContentExplicit = async (contentValue: string, nodeId: number) => { if (!nodeId) return; setSavingField('content'); try { const response = await fetch(`/api/nodes/${nodeId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ notes: contentValue }), }); if (!response.ok) throw new Error('Failed to save'); const result = await response.json(); if (result.node) { setNodesData(prev => ({ ...prev, [nodeId]: result.node })); } // Safety net: ensure edges for tokens in this specific content try { const tokens = parseNodeMarkers(contentValue); const uniqueTargets = Array.from(new Set(tokens.map(t => t.id))).filter(id => id !== nodeId); await Promise.allSettled(uniqueTargets.map(async (toId) => { await fetch('/api/edges', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from_node_id: nodeId, to_node_id: toId, source: 'user', explanation: 'Referenced via @ mention' }) }); })); await fetchEdgesData(nodeId); } catch (e) { console.warn('Failed to ensure edges from tokens (explicit save):', e); } // Exit edit mode setEditingField(null); setEditingValue(''); } catch (e) { console.error('Error saving content (explicit):', e); alert('Failed to save changes. Please try again.'); } finally { setSavingField(null); } }; // Save desc (description) with explicit Save button const saveDesc = async () => { if (!activeTab) return; setDescSaving(true); try { const response = await fetch(`/api/nodes/${activeTab}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description: descEditValue }), }); if (!response.ok) throw new Error('Failed to save'); const result = await response.json(); if (result.node) { setNodesData(prev => ({ ...prev, [activeTab]: result.node })); } setDescEditMode(false); setDescEditValue(''); } catch (e) { console.error('Error saving description:', e); alert('Failed to save description. Please try again.'); } finally { setDescSaving(false); } }; // Cancel desc editing const cancelDescEdit = () => { setDescEditMode(false); setDescEditValue(''); }; // Start editing desc const startDescEdit = () => { if (!activeTab || !nodesData[activeTab]) return; setDescEditValue(nodesData[activeTab].description || ''); setDescEditMode(true); }; // Sync description to source (chunk) and re-embed const syncDescToSource = async () => { if (!activeTab) return; setDescSaving(true); try { // Save description to chunk field const response = await fetch(`/api/nodes/${activeTab}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chunk: descEditValue }), }); if (!response.ok) throw new Error('Failed to sync'); const result = await response.json(); if (result.node) { setNodesData(prev => ({ ...prev, [activeTab]: result.node })); } // Trigger re-embedding await fetch('/api/ingestion', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ nodeId: activeTab }), }); // Refresh chunks data fetchChunksData(activeTab); alert('Description synced to source and re-embedded successfully.'); } catch (e) { console.error('Error syncing description to source:', e); alert('Failed to sync to source. Please try again.'); } finally { setDescSaving(false); } }; // Save notes (content) with explicit Save button const saveNotes = async () => { if (!activeTab) return; setNotesSaving(true); try { const response = await fetch(`/api/nodes/${activeTab}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ notes: notesEditValue }), }); if (!response.ok) throw new Error('Failed to save'); const result = await response.json(); if (result.node) { setNodesData(prev => ({ ...prev, [activeTab]: result.node })); } // Ensure edges for any node tokens try { const tokens = parseNodeMarkers(notesEditValue); const uniqueTargets = Array.from(new Set(tokens.map(t => t.id))).filter(id => id !== activeTab); await Promise.allSettled(uniqueTargets.map(async (toId) => { await fetch('/api/edges', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from_node_id: activeTab, to_node_id: toId, source: 'user', explanation: 'Referenced via @ mention' }) }); })); await fetchEdgesData(activeTab); } catch (e) { console.warn('Failed to ensure edges from tokens:', e); } setNotesEditMode(false); setNotesEditValue(''); } catch (e) { console.error('Error saving notes:', e); alert('Failed to save notes. Please try again.'); } finally { setNotesSaving(false); } }; // Cancel notes editing const cancelNotesEdit = () => { setNotesEditMode(false); setNotesEditValue(''); }; // Start editing notes const startNotesEdit = () => { if (!activeTab || !nodesData[activeTab]) return; setNotesEditValue(nodesData[activeTab].notes || ''); setNotesEditMode(true); }; // Save source (chunk) with explicit Save button const saveSource = async () => { if (!activeTab) return; setSourceSaving(true); try { const response = await fetch(`/api/nodes/${activeTab}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chunk: sourceEditValue }), }); if (!response.ok) throw new Error('Failed to save'); const result = await response.json(); if (result.node) { setNodesData(prev => ({ ...prev, [activeTab]: result.node })); } setSourceEditMode(false); setSourceEditValue(''); } catch (e) { console.error('Error saving source:', e); alert('Failed to save source. Please try again.'); } finally { setSourceSaving(false); } }; // Cancel source editing const cancelSourceEdit = () => { setSourceEditMode(false); setSourceEditValue(''); }; // Start editing source const startSourceEdit = () => { if (!activeTab || !nodesData[activeTab]) return; setSourceEditValue(nodesData[activeTab].chunk || ''); setSourceEditMode(true); }; // Sync Notes content to Source (with confirmation) const [showSyncConfirm, setShowSyncConfirm] = useState(false); const [syncing, setSyncing] = useState(false); // Create linked note state const [creatingNote, setCreatingNote] = useState(false); const syncToSource = async () => { if (!activeTab) return; setSyncing(true); setShowSyncConfirm(false); try { // First, save notes content to chunk field const response = await fetch(`/api/nodes/${activeTab}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chunk: notesEditValue }), }); if (!response.ok) throw new Error('Failed to sync'); const result = await response.json(); if (result.node) { setNodesData(prev => ({ ...prev, [activeTab]: result.node })); } // Then trigger re-embedding await fetch('/api/ingestion', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ nodeId: activeTab }), }); // Refresh chunks data fetchChunksData(activeTab); // Stay in edit mode but show success alert('Content synced to source and re-embedded successfully.'); } catch (e) { console.error('Error syncing to source:', e); alert('Failed to sync to source. Please try again.'); } finally { setSyncing(false); } }; // Create a new linked note from current node const createLinkedNote = async () => { if (!activeTab || !nodesData[activeTab]) return; setCreatingNote(true); const sourceNodeId = activeTab; const currentNode = nodesData[sourceNodeId]; let newNodeId: number | null = null; try { const noteTitle = `New Node from ${currentNode.title}`; const noteDescription = `New node - ideas or insights from ${currentNode.title}`; // Create the new node const createResponse = await fetch('/api/nodes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: noteTitle, type: 'note', content: '', description: noteDescription, dimensions: currentNode.dimensions || [] }), }); if (!createResponse.ok) throw new Error('Failed to create note'); const createResult = await createResponse.json(); newNodeId = createResult.data?.id || createResult.node?.id || createResult.id; if (!newNodeId) throw new Error('No node ID returned'); // Create edge from new note to source node const edgeResponse = await fetch('/api/edges', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from_node_id: newNodeId, to_node_id: sourceNodeId, source: 'user', explanation: `Ideas or insights from "${currentNode.title}"` }), }); if (!edgeResponse.ok) { console.warn('Edge creation failed but note was created'); } // Open the new note in focus if (onTabSelect && newNodeId) { onTabSelect(newNodeId); } } catch (e) { console.error('Error creating linked note:', e); // If node was created but something else failed, still open it if (newNodeId && onTabSelect) { onTabSelect(newNodeId); } else { alert('Failed to create note. Please try again.'); } } finally { setCreatingNote(false); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveField(); } else if (e.key === 'Escape') { cancelEdit(); } }; const handleBlur = () => { // If mention is active, defer saving slightly to avoid race with selection const attemptSave = () => { if (editingField) { saveField(); } }; if (mentionActive) { setTimeout(() => { if (!mentionActive) attemptSave(); }, 220); return; } // Small delay to allow for clicking inline buttons setTimeout(attemptSave, 150); }; // Regenerate description for a node const regenerateDescription = async (nodeId: number) => { setRegeneratingDescription(nodeId); try { const response = await fetch(`/api/nodes/${nodeId}/regenerate-description`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error('Failed to regenerate description'); } const result = await response.json(); if (result.node) { setNodesData(prev => ({ ...prev, [nodeId]: result.node })); } } catch (error) { console.error('Error regenerating description:', error); alert('Failed to regenerate description. Please try again.'); } finally { setRegeneratingDescription(null); } }; // --- @mention state --- const [mentionActive, setMentionActive] = useState(false); const [mentionQuery, setMentionQuery] = useState(''); const [mentionResults, setMentionResults] = useState([]); const [mentionIndex, setMentionIndex] = useState(0); const mentionTimeout = useRef | null>(null); const resetMention = () => { setMentionActive(false); setMentionQuery(''); setMentionResults([]); setMentionIndex(0); }; const findAtTrigger = (text: string, caret: number): { atIndex: number; query: string } | null => { // Find last '@' before caret that is at start or preceded by whitespace for (let i = caret - 1; i >= 0; i--) { const ch = text[i]; if (ch === '@') { const prev = i === 0 ? ' ' : text[i - 1]; if (/\s/.test(prev)) { const span = text.slice(i + 1, caret); // Stop if span contains disallowed characters (newline or punctuation other than - _ .) if (/[^A-Za-z0-9 _\-.]/.test(span)) return null; return { atIndex: i, query: span }; } // If '@' is not preceded by whitespace, keep scanning left } // Stop scanning back on whitespace/newline to avoid spanning words if (ch === '\n') break; } return null; }; const runMentionSearch = async (query: string) => { if (!activeTab) return; if (query.trim().length < 2) { setMentionResults([]); return; } try { const resp = await fetch(`/api/nodes/search?q=${encodeURIComponent(query)}&limit=10`); const payload = (await resp.json()) as { success: boolean; data: NodeSearchResult[] }; if (payload?.success && Array.isArray(payload.data)) { const filtered = payload.data .filter((n) => n.id !== activeTab) .map((n) => ({ ...n, dimensions: n.dimensions ?? [] })); setMentionResults(filtered); setMentionIndex(0); } } catch (error) { console.warn('mention search failed:', error); setMentionResults([]); } }; const handleTextareaChange = (e: React.ChangeEvent) => { const val = e.target.value; setEditingValue(val); // Detect @mention const caret = e.target.selectionStart || val.length; const trig = findAtTrigger(val, caret); if (trig) { setMentionActive(true); setMentionQuery(trig.query); if (mentionTimeout.current) clearTimeout(mentionTimeout.current); mentionTimeout.current = setTimeout(() => runMentionSearch(trig.query), 280); } else if (mentionActive) { // Exit mention mode if trigger no longer valid resetMention(); } }; const replaceMentionWithToken = async (nodeId: number, title: string) => { if (!inputRef.current || !(inputRef.current instanceof HTMLTextAreaElement)) return; if (activeNodeId === null) return; const ta = inputRef.current as HTMLTextAreaElement; const sourceNodeId = activeNodeId!; const text = editingValue; const caret = ta.selectionStart || text.length; let trig = findAtTrigger(text, caret); if (!trig) { // Fallback: try to locate the last occurrence of the current mention token if (mentionQuery && mentionQuery.length > 0) { const needle = '@' + mentionQuery; const idx = text.lastIndexOf(needle); if (idx !== -1) { trig = { atIndex: idx, query: mentionQuery }; } } if (!trig) return; } // Build quote-safe token const quoteTitleForToken = (t: string): string => { if (t.includes('"')) { // Wrap with single quotes; normalize inner straight single quotes const norm = t.replace(/'/g, '’'); return `'${norm}'`; } // Wrap with double quotes; keep title as-is (no straight doubles inside) return `"${t}"`; }; const token = `[NODE:${nodeId}:${quoteTitleForToken(title)}]`; const before = text.slice(0, trig.atIndex); const after = text.slice(trig.atIndex + 1 + trig.query.length); // skip '@' and query const newVal = before + token + after; setEditingValue(newVal); // Restore caret after token const newCaret = (before + token).length; requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = newCaret; ta.focus(); }); // Create edge (idempotent server-side) if (sourceNodeId) { try { await fetch('/api/edges', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from_node_id: sourceNodeId, to_node_id: nodeId, source: 'user', explanation: 'Referenced via @ mention' }) }); } catch (e) { console.warn('edge create failed for mention:', e); } } resetMention(); // Persist content immediately to avoid losing the token on refresh/navigation try { await saveContentExplicit(newVal, sourceNodeId); } catch (e) { console.warn('auto-save after mention failed:', e); } }; // Handle @mention selection in the Notes tab const handleNotesMentionSelect = async (nodeId: number, title: string) => { if (!notesTextareaRef.current || activeNodeId === null) return; const ta = notesTextareaRef.current; const sourceNodeId = activeNodeId; const text = notesEditValue; const caret = ta.selectionStart || text.length; let trig = findAtTrigger(text, caret); if (!trig) { // Fallback: try to locate the last occurrence of the current mention token if (mentionQuery && mentionQuery.length > 0) { const needle = '@' + mentionQuery; const idx = text.lastIndexOf(needle); if (idx !== -1) { trig = { atIndex: idx, query: mentionQuery }; } } if (!trig) return; } // Build quote-safe token const quoteTitleForToken = (t: string): string => { if (t.includes('"')) { const norm = t.replace(/'/g, '\u2019'); // curly right single quote return `'${norm}'`; } return `"${t}"`; }; const token = `[NODE:${nodeId}:${quoteTitleForToken(title)}]`; const before = text.slice(0, trig.atIndex); const after = text.slice(trig.atIndex + 1 + trig.query.length); const newVal = before + token + after; setNotesEditValue(newVal); // Restore caret after token const newCaret = (before + token).length; requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = newCaret; ta.focus(); }); // Create edge if (sourceNodeId) { try { await fetch('/api/edges', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from_node_id: sourceNodeId, to_node_id: nodeId, source: 'user', explanation: 'Referenced via @ mention' }) }); // Refresh edges for the current node fetchEdgesData(sourceNodeId); } catch (e) { console.warn('edge create failed for notes mention:', e); } } resetMention(); }; const embedContent = async (nodeId: number) => { const node = nodesData[nodeId]; const hasNotes = node?.notes?.trim(); const hasChunk = node?.chunk?.trim(); // If chunk is empty but content exists, auto-populate chunk from content if (!hasChunk && hasNotes) { try { const response = await fetch(`/api/nodes/${nodeId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chunk: hasNotes }) }); if (response.ok) { const result = await response.json(); if (result.node) { setNodesData(prev => ({ ...prev, [nodeId]: result.node })); } } } catch (error) { console.error('Failed to auto-populate chunk for embedding:', error); return; } } // If neither content nor chunk exist, require content if (!hasNotes && !hasChunk) { startEdit('content', ''); return; } setEmbeddingNode(nodeId); try { const response = await fetch('/api/ingestion', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ nodeId }), }); const result = await response.json(); if (!response.ok) { throw new Error(result.error || 'Failed to embed content'); } // Show result details const nodeStatus = result.node_embedding?.status; const chunkStatus = result.chunk_embeddings?.status; const chunksCreated = result.chunk_embeddings?.chunks_created || 0; let message = 'Embedding complete:\n'; if (nodeStatus === 'completed') message += '✓ Node metadata embedded\n'; if (chunkStatus === 'completed') message += `✓ ${chunksCreated} chunks embedded\n`; if (chunkStatus === 'skipped') message += '• No chunk content to embed\n'; console.log(message.trim()); // Refresh node data to get updated status await fetchNodeData(nodeId); } catch (error) { console.error('Error embedding content:', error); alert(`Failed to embed content: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setEmbeddingNode(null); } }; // Edge management functions (following same patterns as node functions) const handleEdgeNodeSelect = async (targetNodeId: number, _targetNodeTitle?: string) => { // Immediately create edge - backend auto-infers explanation and type await createEdgeAuto(targetNodeId); }; // Handle node search keyboard navigation const handleNodeSearchKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedSearchIndex(prev => Math.min(prev + 1, nodeSearchSuggestions.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedSearchIndex(prev => Math.max(prev - 1, 0)); } else if (e.key === 'Enter' && nodeSearchSuggestions[selectedSearchIndex]) { e.preventDefault(); handleSelectNodeSuggestion(nodeSearchSuggestions[selectedSearchIndex]); } }; const handleSelectNodeSuggestion = async (suggestion: NodeSearchResult) => { // Immediately create edge - backend auto-infers explanation and type await createEdgeAuto(suggestion.id); setNodeSearchSuggestions([]); setNodeSearchQuery(''); }; // Auto-create edge - backend handles explanation generation and type inference const createEdgeAuto = async (targetNodeId: number) => { 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: '' // 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); setEdgeSearchOpen(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 }), }); if (!response.ok) { throw new Error('Failed to create edge'); } // Refresh edges data await fetchEdgesData(activeNodeId); // Reset state setAddingEdge(null); setEdgeExplanation(''); setPendingEdgeTarget(null); setNodeSearchQuery(''); setNodeSearchSuggestions([]); setEdgeSearchOpen(false); } catch (error) { console.error('Error creating edge:', error); alert('Failed to create edge. Please try again.'); } }; const startEditEdgeExplanation = (edgeId: number, currentExplanation: string | undefined) => { if (edgeSavingId) return; setEdgeEditingId(edgeId); setEdgeEditingValue(currentExplanation || ''); }; const cancelEditEdgeExplanation = () => { setEdgeEditingId(null); setEdgeEditingValue(''); }; const saveEdgeExplanation = async ( edgeId: number, currentContext: Record | null | undefined ) => { if (activeNodeId === null) return; setEdgeSavingId(edgeId); try { const newContext: Record = { ...(currentContext ?? {}), explanation: edgeEditingValue }; const response = await fetch(`/api/edges/${edgeId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ context: newContext }) }); if (!response.ok) throw new Error('Failed to update edge'); await fetchEdgesData(activeNodeId); setEdgeEditingId(null); setEdgeEditingValue(''); } catch (e) { console.error('Failed updating edge explanation:', e); alert('Failed to update edge explanation'); } finally { setEdgeSavingId(null); } }; const deleteEdge = async (edgeId: number) => { if (activeNodeId === null) return; setDeletingEdge(edgeId); try { const response = await fetch(`/api/edges/${edgeId}`, { method: 'DELETE' }); if (!response.ok) { throw new Error('Failed to delete edge'); } await fetchEdgesData(activeNodeId); } catch (error) { console.error('Error deleting edge:', error); alert('Failed to delete edge. Please try again.'); } finally { setDeletingEdge(null); } }; const renderConnectionsBody = () => { if (!activeTab) { return
Open a node to manage connections.
; } return (
{/* Search Section — only visible when toggled */} {edgeSearchOpen && (
setNodeSearchQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault(); setEdgeSearchOpen(false); setNodeSearchQuery(''); setNodeSearchSuggestions([]); } else { handleNodeSearchKeyDown(e); } }} placeholder="Search for a node to connect..." style={{ width: '100%', background: '#111', border: '1px solid #2a2a2a', borderRadius: '6px', padding: '8px 12px', color: '#e5e5e5', fontSize: '12px', fontFamily: 'inherit', outline: 'none', }} /> {/* Search Suggestions */} {nodeSearchSuggestions.length > 0 && (
{nodeSearchSuggestions.map((suggestion, index) => (
handleSelectNodeSuggestion(suggestion)} style={{ padding: '10px 14px', cursor: 'pointer', borderBottom: index < nodeSearchSuggestions.length - 1 ? '1px solid #1f1f1f' : 'none', background: index === selectedSearchIndex ? '#1a1a1a' : 'transparent', transition: 'background 100ms ease', display: 'flex', alignItems: 'center', gap: '10px' }} onMouseEnter={(e) => { if (index !== selectedSearchIndex) { e.currentTarget.style.background = '#1a1a1a'; } }} onMouseLeave={(e) => { if (index !== selectedSearchIndex) { e.currentTarget.style.background = 'transparent'; } }} > {suggestion.id} {suggestion.title} {index === selectedSearchIndex && ( )}
))}
)}
)} {/* Connections List */}
{loadingEdges.has(activeTab) ? (
Loading connections…
) : (() => { const list = edgesData[activeTab] || []; if (list.length === 0) { return
No connections yet.
; } return (
{list.map((connection) => (
{/* Row 1: arrow + ID + icon + title + delete */}
{connection.edge.from_node_id === activeTab ? '→' : '←'} {connection.connected_node.id} {getNodeIcon(connection.connected_node, dimensionIcons, 12)} onNodeClick?.(connection.connected_node.id)} style={{ fontSize: '12px', color: '#e5e5e5', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, minWidth: 0, cursor: 'pointer', }} onMouseEnter={(e) => { e.currentTarget.style.color = '#22c55e'; }} onMouseLeave={(e) => { e.currentTarget.style.color = '#e5e5e5'; }} > {connection.connected_node.title}
{/* Row 2: type chip + explanation (compact, single line) */} {edgeEditingId === connection.edge.id ? (
setEdgeEditingValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); saveEdgeExplanation(connection.edge.id, connection.edge.context); } else if (e.key === 'Escape') { e.preventDefault(); cancelEditEdgeExplanation(); } }} style={{ flex: 1, fontSize: '11px', color: '#ddd', background: '#111', border: '1px solid #2a2a2a', borderRadius: '4px', padding: '3px 6px', outline: 'none', fontFamily: 'inherit', minWidth: 0, }} placeholder="Add explanation…" autoFocus />
) : (
startEditEdgeExplanation(connection.edge.id, connection.edge.context?.explanation)} style={{ display: 'flex', alignItems: 'center', gap: '6px', paddingLeft: '20px', cursor: 'pointer', minWidth: 0, }} onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.8'; }} onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }} > {typeof connection.edge.context?.type === 'string' && ( {String(connection.edge.context.type).replace(/_/g, ' ')} )} {(connection.edge.context?.explanation as string) || 'Add explanation...'}
)}
))}
); })()}
); }; const handleConfirmNodeDelete = () => { if (pendingDeleteNodeId === null) return; executeDeleteNode(pendingDeleteNodeId); setPendingDeleteNodeId(null); }; const handleCancelNodeDelete = () => { setPendingDeleteNodeId(null); }; const executeDeleteNode = async (nodeId: number) => { setDeletingNode(nodeId); try { const response = await fetch(`/api/nodes/${nodeId}`, { method: 'DELETE' }); if (!response.ok) { throw new Error('Failed to delete node'); } onTabClose(nodeId); setNodesData(prev => { const newData = { ...prev }; delete newData[nodeId]; return newData; }); } catch (error) { console.error('Error deleting node:', error); alert('Failed to delete node. Please try again.'); } finally { setDeletingNode(null); } }; const confirmDeleteNode = (nodeId: number) => { setPendingDeleteNodeId(nodeId); }; return ( <>
{/* Tab Bar - hidden when rendered from NodePane which has its own tab bar */} {!hideTabBar && (
{openTabs.length === 0 ? (
No tabs open
) : ( openTabs.map((tabId) => { const node = nodesData[tabId]; const isActive = activeTab === tabId; const label = node ? truncateTitle(node.title || 'Untitled') : 'Loading...'; return (
{ 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}"]`); }} onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, 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', cursor: 'grab' }} >
); }) )}
)} {/* Content Area */}
{!activeTab ? (
Select a node from the left panel to view details
) : loadingNodes.has(activeTab) ? (
Loading...
) : !currentNode ? (
Node not found.
) : nodesData[activeTab] ? (
{/* URL Row - Above Title */}
{/* Embedding status - only show when embedding or error */} {(() => { const node = nodesData[activeTab]; const chunkStatus = node?.chunk_status ?? null; if (embeddingNode === activeTab || chunkStatus === 'chunking') { return ( ); } if (chunkStatus === 'error') { return ( ); } return null; })()}
{editingField === 'link' ? ( } type="url" value={editingValue} onChange={(e) => setEditingValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={handleBlur} disabled={savingField === 'link'} style={{ color: '#3b82f6', fontSize: '11px', background: 'transparent', border: '1px solid #1a1a1a', borderRadius: '4px', padding: '4px 6px', fontFamily: 'inherit', width: '100%', outline: 'none' }} placeholder="Enter URL..." /> ) : nodesData[activeTab].link ? ( { if (e.metaKey || e.ctrlKey) { e.preventDefault(); startEdit('link', nodesData[activeTab].link || ''); return; } const link = nodesData[activeTab].link; if (!link || !shouldOpenExternally(link)) { return; } e.preventDefault(); void openExternalUrl(link).catch((error) => { console.error('[FocusPanel] Failed to open node link', error); window.alert(`Unable to open ${link}`); }); }} style={{ color: '#3b82f6', fontSize: '11px', textDecoration: 'none', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }} title={`${nodesData[activeTab].link} (Cmd+Click to edit)`} > {nodesData[activeTab].link} ) : ( startEdit('link', '')} style={{ color: '#555', fontSize: '11px', cursor: 'pointer', fontStyle: 'italic' }} > Click to add URL )}
{/* Title Row - Node ID, Title, Connections, Trash */}
{/* Node ID - Draggable */} ) => { const title = nodesData[activeTab]?.title || 'Untitled'; e.dataTransfer.effectAllowed = 'copyMove'; e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: activeTab, title })); e.dataTransfer.setData('application/node-info', JSON.stringify({ id: activeTab, title, dimensions: nodesData[activeTab]?.dimensions || [] })); e.dataTransfer.setData('text/plain', `[NODE:${activeTab}:"${title}"]`); }} style={{ display: 'inline-block', background: '#22c55e', color: '#0a0a0a', fontSize: '10px', fontWeight: 600, padding: '2px 6px', borderRadius: '4px', flexShrink: 0, cursor: 'grab' }} title="Drag to chat to reference this node" > {activeTab} {/* Node type icon */} {nodesData[activeTab] && ( {getNodeIcon(nodesData[activeTab], dimensionIcons, 18)} )} {editingField === 'title' ? ( } type="text" value={editingValue} onChange={(e) => setEditingValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={handleBlur} disabled={savingField === 'title'} style={{ fontSize: '20px', fontWeight: 'bold', color: '#fff', background: 'transparent', border: '1px solid #1a1a1a', borderRadius: '0', padding: '4px 8px', fontFamily: 'inherit', flex: 1, outline: 'none' }} placeholder="Enter title..." /> ) : (

{ if (titleExpanded[activeTab]) { startEdit('title', nodesData[activeTab].title || ''); } else { setTitleExpanded(prev => ({ ...prev, [activeTab]: true })); setTimeout(() => { setTitleExpanded(prev => ({ ...prev, [activeTab]: false })); }, 3000); } }} style={{ fontSize: '20px', fontWeight: 'bold', color: '#fff', fontFamily: 'inherit', cursor: 'pointer', padding: '4px 8px', margin: '0', borderRadius: '0', background: 'transparent', border: '1px solid transparent', transition: 'border-color 0.2s', flex: 1, minWidth: 0, ...(titleExpanded[activeTab] ? { whiteSpace: 'normal', wordWrap: 'break-word' } : { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }) }} onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#1a1a1a'; }} onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }} title={titleExpanded[activeTab] ? undefined : (nodesData[activeTab].title || 'Untitled')} > {nodesData[activeTab].title || 'Untitled'} {savingField === 'title' && saving...}

)} {/* Connections Button — opens Edges tab */} {/* Delete Button */}
{/* Dimensions Section */}
{ try { const response = await fetch(`/api/nodes/${activeTab}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ dimensions: newDimensions }), }); if (!response.ok) { throw new Error('Failed to save'); } const result = await response.json(); if (result.node) { setNodesData(prev => ({ ...prev, [activeTab]: result.node })); } } catch (error) { console.error('Error saving dimensions:', error); alert('Failed to save dimensions. Please try again.'); } }} onPriorityToggle={async (dimension) => { try { const response = await fetch('/api/dimensions/popular', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ dimension }), }); if (!response.ok) { throw new Error('Failed to toggle priority'); } // Refresh priority dimensions list await fetchPriorityDimensions(); } catch (error) { console.error('Error toggling priority:', error); alert('Failed to toggle priority. Please try again.'); } }} disabled={false} />
{/* Notes | Desc | Source Tabs */}
{/* Tab Bar */}
{/* Action buttons for Desc tab */} {activeContentTab === 'desc' && !descEditMode && (
)} {/* Action buttons for Notes tab */} {activeContentTab === 'notes' && !notesEditMode && (
)} {/* Formatting toolbar for Notes edit mode - inline */} {activeContentTab === 'notes' && notesEditMode && (
)} {/* Action button for Edges tab */} {activeContentTab === 'edges' && (
)}
{/* Desc Tab Content */} {activeContentTab === 'desc' && (
{descEditMode ? (
Used as context for AI. Clearly describe what this node is in 280 chars or less.
{/* Editor */}