"use client"; import { useEffect, useRef, useState, type DragEvent } from 'react'; import { Trash2, Loader, Database, RefreshCw, Pencil, X, Save, Plus, Link2, Tag, Share2, AlignLeft } from 'lucide-react'; import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer'; import { Node, NodeConnection } from '@/types/database'; import DimensionTags from './dimensions/DimensionTags'; import { getNodeIcon } from '@/utils/nodeIcons'; import { useDimensionIcons } from '@/context/DimensionIconsContext'; import ConfirmDialog from '../common/ConfirmDialog'; import { SourceReader } from './source'; import SourceEditor from './source/SourceEditor'; import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl'; import NodeSearchModal from './edges/NodeSearchModal'; interface FocusPanelProps { openTabs: number[]; activeTab: number | null; onTabSelect: (nodeId: number) => void; onNodeClick?: (nodeId: number) => void; onTabClose: (nodeId: number) => void; refreshTrigger?: number; onTextSelect?: (nodeId: number, nodeTitle: string, text: string) => void; highlightedPassage?: { nodeId: number; selectedText: string } | null; onReorderTabs?: (fromIndex: number, toIndex: number) => void; onOpenInOtherSlot?: (nodeId: number) => void; hideTabBar?: boolean; } type HoverSection = 'description' | 'source' | null; export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeClick, onTabClose, refreshTrigger, onTextSelect, highlightedPassage, onReorderTabs, onOpenInOtherSlot, hideTabBar, }: FocusPanelProps) { const { dimensionIcons } = useDimensionIcons(); const [nodesData, setNodesData] = useState>({}); const [edgesData, setEdgesData] = useState>({}); const [loadingNodes, setLoadingNodes] = useState>(new Set()); const [loadingEdges, setLoadingEdges] = useState>(new Set()); const [embeddingNode, setEmbeddingNode] = useState(null); const [deletingNode, setDeletingNode] = useState(null); const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState(null); const [deletingEdge, setDeletingEdge] = useState(null); const [edgeEditingId, setEdgeEditingId] = useState(null); const [edgeEditingValue, setEdgeEditingValue] = useState(''); const [edgeSavingId, setEdgeSavingId] = useState(null); const [edgesExpanded, setEdgesExpanded] = useState>({}); const [edgeSearchOpen, setEdgeSearchOpen] = useState(false); const [hoveredConnectionId, setHoveredConnectionId] = useState(null); const [propsCollapsed, setPropsCollapsed] = useState(false); const [titleEditMode, setTitleEditMode] = useState(false); const [titleEditValue, setTitleEditValue] = useState(''); const [titleSaving, setTitleSaving] = useState(false); const [linkEditMode, setLinkEditMode] = useState(false); const [linkEditValue, setLinkEditValue] = useState(''); const [linkSaving, setLinkSaving] = useState(false); const [descEditMode, setDescEditMode] = useState(false); const [descEditValue, setDescEditValue] = useState(''); const [descSaving, setDescSaving] = useState(false); const [regeneratingDescription, setRegeneratingDescription] = useState(null); const [sourceEditMode, setSourceEditMode] = useState(false); const [sourceEditValue, setSourceEditValue] = useState(''); const [sourceSaving, setSourceSaving] = useState(false); const [hoveredSection, setHoveredSection] = useState(null); const titleInputRef = useRef(null); const linkInputRef = useRef(null); const descTextareaRef = useRef(null); const sourceTextareaRef = useRef(null); const skipTitleBlurRef = useRef(false); const skipLinkBlurRef = useRef(false); const currentNode = activeTab !== null ? nodesData[activeTab] : undefined; const currentEdges = activeTab !== null ? edgesData[activeTab] || [] : []; const currentEdgesExpanded = activeTab !== null ? Boolean(edgesExpanded[activeTab]) : false; const visibleEdges = currentEdgesExpanded ? currentEdges : currentEdges.slice(0, 3); useEffect(() => { if (titleEditMode) titleInputRef.current?.focus(); }, [titleEditMode]); useEffect(() => { if (linkEditMode) linkInputRef.current?.focus(); }, [linkEditMode]); useEffect(() => { if (descEditMode) descTextareaRef.current?.focus(); }, [descEditMode]); useEffect(() => { if (sourceEditMode) sourceTextareaRef.current?.focus(); }, [sourceEditMode]); useEffect(() => { openTabs.forEach((tabId) => { if (!nodesData[tabId] && !loadingNodes.has(tabId)) { void fetchNodeData(tabId); } }); }, [openTabs, nodesData, loadingNodes]); useEffect(() => { openTabs.forEach((tabId) => { if (nodesData[tabId] && !edgesData[tabId] && !loadingEdges.has(tabId)) { void fetchEdgesData(tabId); } }); }, [openTabs, nodesData, edgesData, loadingEdges]); useEffect(() => { if (refreshTrigger !== undefined && refreshTrigger > 0 && activeTab) { void fetchNodeData(activeTab); void fetchEdgesData(activeTab); } }, [refreshTrigger, activeTab]); useEffect(() => { setTitleEditMode(false); setTitleEditValue(''); setLinkEditMode(false); setLinkEditValue(''); setDescEditMode(false); setDescEditValue(''); setSourceEditMode(false); setSourceEditValue(''); setEdgeSearchOpen(false); setEdgeEditingId(null); setEdgeEditingValue(''); setHoveredSection(null); }, [activeTab]); const fetchNodeData = async (id: number) => { setLoadingNodes(prev => new Set(prev).add(id)); try { const response = await fetch(`/api/nodes/${id}`); const data = await response.json(); if (response.ok && data.node) { setNodesData(prev => ({ ...prev, [id]: data.node })); } } catch (error) { console.warn(`Failed to fetch node ${id}:`, error); } finally { setLoadingNodes(prev => { const next = new Set(prev); next.delete(id); return next; }); } }; 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 (response.ok && 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 next = new Set(prev); next.delete(nodeId); return next; }); } }; const updateNode = async (nodeId: number, updates: Record) => { const response = await fetch(`/api/nodes/${nodeId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), }); if (!response.ok) { throw new Error('Failed to update node'); } const result = await response.json(); if (result.node) { setNodesData(prev => ({ ...prev, [nodeId]: result.node })); return result.node as Node; } throw new Error('Missing updated node in response'); }; const startTitleEdit = () => { if (!currentNode) return; setTitleEditValue(currentNode.title || ''); setTitleEditMode(true); }; const saveTitle = async () => { if (!activeTab) return; const nextTitle = titleEditValue.trim(); if (!nextTitle) { window.alert('Title cannot be empty'); return; } setTitleSaving(true); try { await updateNode(activeTab, { title: nextTitle }); setTitleEditMode(false); setTitleEditValue(''); } catch (error) { console.error('Error saving title:', error); window.alert('Failed to save title. Please try again.'); } finally { setTitleSaving(false); } }; const startLinkEdit = () => { setLinkEditValue(currentNode?.link || ''); setLinkEditMode(true); }; const saveLink = async () => { if (!activeTab) return; setLinkSaving(true); try { await updateNode(activeTab, { link: linkEditValue.trim() || null }); setLinkEditMode(false); setLinkEditValue(''); } catch (error) { console.error('Error saving link:', error); window.alert('Failed to save URL. Please try again.'); } finally { setLinkSaving(false); } }; const startDescEdit = () => { setDescEditValue(currentNode?.description || ''); setDescEditMode(true); }; const saveDesc = async () => { if (!activeTab) return; setDescSaving(true); try { await updateNode(activeTab, { description: descEditValue }); setDescEditMode(false); setDescEditValue(''); } catch (error) { console.error('Error saving description:', error); window.alert('Failed to save description. Please try again.'); } finally { setDescSaving(false); } }; 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); window.alert('Failed to regenerate description. Please try again.'); } finally { setRegeneratingDescription(null); } }; const startSourceEdit = () => { setSourceEditValue(currentNode?.source || currentNode?.chunk || ''); setSourceEditMode(true); }; const saveSource = async () => { if (!activeTab) return; setSourceSaving(true); try { await updateNode(activeTab, { source: sourceEditValue }); setSourceEditMode(false); setSourceEditValue(''); } catch (error) { console.error('Error saving source:', error); window.alert('Failed to save source. Please try again.'); } finally { setSourceSaving(false); } }; const embedContent = async (nodeId: number) => { 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'); } await fetchNodeData(nodeId); } catch (error) { console.error('Error embedding content:', error); window.alert(`Failed to embed content: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setEmbeddingNode(null); } }; const createEdgeAuto = async (targetNodeId: number) => { if (activeTab === null) return; try { const response = await fetch('/api/edges', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from_node_id: activeTab, to_node_id: targetNodeId, source: 'user', explanation: '', }), }); if (!response.ok) { throw new Error('Failed to create edge'); } await fetchEdgesData(activeTab); } catch (error) { console.error('Error creating edge:', error); window.alert('Failed to create connection. Please try again.'); throw error; } }; const createEdgeWithExplanation = async (targetNodeId: number, explanation: string) => { if (activeTab === null) return; try { const response = await fetch('/api/edges', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from_node_id: activeTab, to_node_id: targetNodeId, source: 'user', explanation: explanation || '', }), }); if (!response.ok) { throw new Error('Failed to create edge'); } await fetchEdgesData(activeTab); } catch (error) { console.error('Error creating edge:', error); window.alert('Failed to create connection. Please try again.'); throw error; } }; const startEditEdgeExplanation = (edgeId: number, currentExplanation?: string) => { setEdgeEditingId(edgeId); setEdgeEditingValue(currentExplanation || ''); }; const cancelEditEdgeExplanation = () => { setEdgeEditingId(null); setEdgeEditingValue(''); }; const saveEdgeExplanation = async ( edgeId: number, currentContext: Record | null | undefined ) => { if (activeTab === null) return; setEdgeSavingId(edgeId); try { const response = await fetch(`/api/edges/${edgeId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ context: { ...(currentContext ?? {}), explanation: edgeEditingValue, }, }), }); if (!response.ok) { throw new Error('Failed to update edge'); } await fetchEdgesData(activeTab); cancelEditEdgeExplanation(); } catch (error) { console.error('Failed updating edge explanation:', error); window.alert('Failed to update connection explanation.'); } finally { setEdgeSavingId(null); } }; const deleteEdge = async (edgeId: number) => { if (activeTab === 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(activeTab); } catch (error) { console.error('Error deleting edge:', error); window.alert('Failed to delete connection. Please try again.'); } finally { setDeletingEdge(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 next = { ...prev }; delete next[nodeId]; return next; }); } catch (error) { console.error('Error deleting node:', error); window.alert('Failed to delete node. Please try again.'); } finally { setDeletingNode(null); } }; const renderStatusIndicator = () => { if (!currentNode || activeTab === null) return null; const chunkStatus = currentNode.chunk_status ?? null; if (embeddingNode === activeTab || chunkStatus === 'chunking') { return ; } if (chunkStatus === 'error') { return ( ); } if (chunkStatus === 'not_chunked') { return ( Pending embed ); } return null; }; // ── Shared inline style constants (styled-jsx doesn't apply inside helper fns) ── const S = { section: { display: 'flex' as const, flexDirection: 'column' as const, gap: '10px', paddingTop: '18px', marginTop: '4px', }, sectionHeader: { display: 'flex' as const, alignItems: 'center' as const, gap: '10px', marginBottom: '2px', }, sectionLabel: { fontSize: '9.5px', letterSpacing: '0.1em', textTransform: 'uppercase' as const, color: 'var(--rah-text-muted)', fontWeight: 600, flexShrink: 0, }, sectionRule: { flex: 1, height: '1px', background: 'var(--rah-border)', }, iconBtn: { display: 'inline-flex' as const, alignItems: 'center' as const, justifyContent: 'center' as const, width: '22px', height: '22px', background: 'transparent', border: '1px solid transparent', borderRadius: '5px', color: 'var(--rah-text-muted)', cursor: 'pointer', padding: 0, }, editorBlock: { display: 'flex' as const, flexDirection: 'column' as const, gap: '10px', }, textarea: { display: 'block' as const, width: '100%', boxSizing: 'border-box' as const, background: 'var(--rah-bg-surface)', border: '1px solid var(--rah-border-strong)', borderRadius: '8px', color: 'var(--rah-text-base)', fontFamily: 'inherit', outline: 'none', padding: '12px', }, editorActions: { display: 'flex' as const, justifyContent: 'flex-end' as const, gap: '8px', }, primaryBtn: { display: 'inline-flex' as const, alignItems: 'center' as const, gap: '5px', padding: '6px 12px', border: 'none', background: 'var(--rah-accent-green)', color: 'var(--rah-text-inverse)', fontSize: '11px', fontWeight: 600, borderRadius: '6px', cursor: 'pointer', fontFamily: 'inherit', }, secondaryBtn: { display: 'inline-flex' as const, alignItems: 'center' as const, gap: '5px', padding: '6px 12px', border: '1px solid var(--rah-border-strong)', background: 'transparent', color: 'var(--rah-text-soft)', fontSize: '11px', borderRadius: '6px', cursor: 'pointer', fontFamily: 'inherit', }, }; const renderSourceSection = () => { const sourceContent = currentNode?.source || currentNode?.chunk || ''; return (
setHoveredSection('source')} onMouseLeave={() => setHoveredSection(prev => prev === 'source' ? null : prev)} > {/* Section header with extending rule */}
Source
{!sourceEditMode && ( )}
{sourceEditMode ? (
) : sourceContent ? (
onTextSelect(activeTab!, currentNode?.title || 'Untitled', text) : undefined} highlightedText={highlightedPassage?.nodeId === activeTab ? highlightedPassage.selectedText : null} onContentClick={startSourceEdit} />
) : ( )}
); }; return ( <>
{!activeTab ? (
Select a node from the left panel to view details
) : loadingNodes.has(activeTab) ? (
Loading...
) : !currentNode ? (
Node not found.
) : (
{/* ── Title ── */}
{titleEditMode ? ( setTitleEditValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); void saveTitle(); } if (e.key === 'Escape') { e.preventDefault(); skipTitleBlurRef.current = true; setTitleEditMode(false); setTitleEditValue(''); } }} onBlur={() => { if (skipTitleBlurRef.current) { skipTitleBlurRef.current = false; return; } void saveTitle(); }} disabled={titleSaving} className="title-input" placeholder="Enter title..." /> ) : ( )}
{/* ── Properties block ── */}
{/* Node identity row — with collapse toggle + delete */}
node
{getNodeIcon(currentNode, dimensionIcons, 12)} #{currentNode.id}
{!propsCollapsed && (<> {/* Status indicator row (only when relevant) */} {renderStatusIndicator() !== null && (
embed
{renderStatusIndicator()}
)} {/* Link */}
link
{linkEditMode ? ( setLinkEditValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); void saveLink(); } if (e.key === 'Escape') { e.preventDefault(); skipLinkBlurRef.current = true; setLinkEditMode(false); setLinkEditValue(''); } }} onBlur={() => { if (skipLinkBlurRef.current) { skipLinkBlurRef.current = false; return; } void saveLink(); }} disabled={linkSaving} className="prop-input" placeholder="https://..." /> ) : currentNode.link ? ( { if (e.metaKey || e.ctrlKey) { e.preventDefault(); startLinkEdit(); return; } if (!shouldOpenExternally(currentNode.link || '')) return; e.preventDefault(); void openExternalUrl(currentNode.link || '').catch(() => window.alert(`Unable to open ${currentNode.link}`)); }} title={`${currentNode.link} (Cmd+Click to edit)`} > {currentNode.link} ) : ( )}
{/* Dimensions */}
dime
{ try { await updateNode(activeTab, { dimensions: newDimensions }); } catch (error) { console.error('Error saving dimensions:', error); window.alert('Failed to save dimensions. Please try again.'); } }} />
{/* Connections */}
edge
{loadingEdges.has(activeTab) ? ( Loading… ) : currentEdges.length === 0 ? ( ) : (
{visibleEdges.map((connection) => { const isOut = connection.edge.from_node_id === activeTab; return (
setHoveredConnectionId(connection.id)} onMouseLeave={() => setHoveredConnectionId(null)} > {isOut ? '↗' : '↙'} {getNodeIcon(connection.connected_node, dimensionIcons, 12)}
); })} {currentEdges.length > 3 && ( )}
)}
{/* Description */}
desc
{descEditMode ? (