"use client"; import { useState, useEffect, useRef } from 'react'; import { Eye, Trash2, Link, Loader, Database, Check } from 'lucide-react'; import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer'; import { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter'; import { Node, NodeConnection } from '@/types/database'; import DimensionTags from './dimensions/DimensionTags'; import { getNodeIcon } from '@/utils/nodeIcons'; import ConfirmDialog from '../common/ConfirmDialog'; 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; } export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeClick, onTabClose, refreshTrigger }: FocusPanelProps) { const [nodesData, setNodesData] = useState>({}); 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]); // 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 [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 const [edgesExpanded, setEdgesExpanded] = useState<{ [key: number]: boolean }>({}); // Connections section collapsed state (default closed) const [showConnectionsModal, setShowConnectionsModal] = useState(false); // Title expanded state for click-to-expand full title const [titleExpanded, setTitleExpanded] = useState<{ [key: number]: boolean }>({}); // 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]); 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; }); } }; 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 === 'content') { 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 === 'content' && 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', context: { explanation: 'Referenced via @ mention', created_via: 'at_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({ content: 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', context: { explanation: 'Referenced via @ mention', created_via: 'at_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); } }; 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); }; // --- @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', context: { explanation: 'Referenced via @ mention', created_via: 'at_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); } }; const embedContent = async (nodeId: number) => { const node = nodesData[nodeId]; const hasContent = node?.content?.trim(); const hasChunk = node?.chunk?.trim(); // If chunk is empty but content exists, auto-populate chunk from content if (!hasChunk && hasContent) { try { const response = await fetch(`/api/nodes/${nodeId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chunk: hasContent }) }); 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 (!hasContent && !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 = (targetNodeId: number, _targetNodeTitle?: string) => { createEdgeWithExplanation(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 = (suggestion: NodeSearchResult) => { createEdgeWithExplanation(suggestion.id, ''); setNodeSearchQuery(''); setNodeSearchSuggestions([]); }; 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', context: { explanation: explanation, created_via: 'focus_panel' } }), }); if (!response.ok) { throw new Error('Failed to create edge'); } // Refresh edges data await fetchEdgesData(activeNodeId); // Reset state setAddingEdge(null); setEdgeExplanation(''); } 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 */}
setNodeSearchQuery(e.target.value)} onKeyDown={handleNodeSearchKeyDown} placeholder="Connect to node..." style={{ flex: 1, background: 'none', border: 'none', outline: 'none', color: '#fafafa', fontSize: '16px', fontFamily: 'inherit', fontWeight: 400 }} />
{/* Search Suggestions */} {nodeSearchSuggestions.length > 0 && (
{nodeSearchSuggestions.map((suggestion, index) => (
handleSelectNodeSuggestion(suggestion)} style={{ padding: '14px 16px', 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: '12px' }} 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 && ( )}
))}
)}
{/* Existing Connections */}
Existing Connections
{loadingEdges.has(activeTab) ? (
Loading connections…
) : (() => { const list = edgesData[activeTab] || []; if (list.length === 0) { return
No connections yet. Search above to add one.
; } const visible = edgesExpanded[activeTab] ? list : list.slice(0, 5); return (
{visible.map((connection) => (
{connection.connected_node.id} {connection.connected_node.title}
{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: 'transparent', border: '1px solid #1a1a1a', borderRadius: '0', padding: '4px', outline: 'none', fontFamily: 'inherit' }} placeholder="Add explanation…" autoFocus />
) : (
{connection.edge.context?.explanation ? ( — {connection.edge.context.explanation} ) : ( No explanation )}
)}
))} {list.length > 5 && (
)}
); })()}
); }; 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 */}
{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 (
); }) )}
{/* Content Area */}
{!activeTab ? (
Select a node from the left panel to view details
) : loadingNodes.has(activeTab) ? (
Loading...
) : !currentNode ? (
Node not found.
) : nodesData[activeTab] ? (
{/* Header with status, link, and delete */}
{(() => { const node = nodesData[activeTab]; const chunkStatus = node?.chunk_status ?? null; const hasChunk = Boolean(node?.chunk && node.chunk.trim().length > 0); const StatusBadge = ({ color, label }: { color: string; label: string }) => ( {'●'} {label} ); if (embeddingNode === activeTab || chunkStatus === 'chunking') { return ( ); } if (chunkStatus === 'chunked') { if (showReembedPrompt === activeTab) { return ( <> ); } return ( ); } if (chunkStatus === 'error') { return ( ); } if (hasChunk) { return ( ); } return ( ); })()}
{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: '6px', 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 || ''); } }} 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 )}
{/* Connections Button - Green CTA */} {/* Delete Button */}
{/* Title Row with ID and Trash */}
{/* Node ID */} #{activeTab} {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, ...(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...}

)}
{ 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} />
{/* Content - Full Height Scratchpad */}
content
{editingField === 'content' ? (