Files
ra-h-os/src/components/focus/FocusPanel.tsx
T
“BeeRad” 7444599817 fix: harden external link navigation in the web app
- add a shared external-navigation guard at the document level
- route onboarding, markdown, settings, and node source links through one helper
- stop browser fallback code from replacing the current app page on failures

Generated with Codex
2026-03-17 18:20:37 +11:00

3139 lines
120 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<Record<number, Node>>({});
// Context menu state
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: number } | null>(null);
const [loadingNodes, setLoadingNodes] = useState<Set<number>>(new Set());
const [editingField, setEditingField] = useState<string | null>(null);
const [editingValue, setEditingValue] = useState<string>('');
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
const [savingField, setSavingField] = useState<string | null>(null);
const [embeddingNode, setEmbeddingNode] = useState<number | null>(null);
const [showReembedPrompt, setShowReembedPrompt] = useState<number | null>(null);
const [priorityDimensions, setPriorityDimensions] = useState<string[]>([]);
const activeNodeId = activeTab;
const currentNode = activeNodeId !== null ? nodesData[activeNodeId] : undefined;
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(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<Set<number>>(new Set());
const [addingEdge, setAddingEdge] = useState<number | null>(null);
const [nodeSearchQuery, setNodeSearchQuery] = useState('');
const [nodeSearchSuggestions, setNodeSearchSuggestions] = useState<NodeSearchResult[]>([]);
const [selectedSearchIndex, setSelectedSearchIndex] = useState(0);
const [edgeExplanation, setEdgeExplanation] = useState('');
const [pendingEdgeTarget, setPendingEdgeTarget] = useState<{ id: number; title: string } | null>(null);
const [deletingEdge, setDeletingEdge] = useState<number | null>(null);
const [edgeEditingId, setEdgeEditingId] = useState<number | null>(null);
const [edgeEditingValue, setEdgeEditingValue] = useState<string>('');
const [edgeSavingId, setEdgeSavingId] = useState<number | null>(null);
const [deletingNode, setDeletingNode] = useState<number | null>(null);
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<number | null>(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<number | null>(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<HTMLTextAreaElement>(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<Record<number, Chunk[]>>({});
const [loadingChunks, setLoadingChunks] = useState<Set<number>>(new Set());
const [chunksExpanded, setChunksExpanded] = useState<Record<number, boolean>>({});
// Helper: preview edge type based on heuristics (mirrors backend logic)
const previewEdgeType = (explanation: string): { type: string; label: string } | null => {
const norm = (explanation || '').trim().toLowerCase();
if (!norm) return null;
const startsWithAny = (prefixes: string[]) => prefixes.some((p) => norm.startsWith(p));
if (startsWithAny(['created by', 'made by', 'authored by', 'written by', 'founded by'])) {
return { type: 'created_by', label: 'created by' };
}
if (startsWithAny(['part of', 'episode of', 'belongs to', 'in the series', 'in this series'])) {
return { type: 'part_of', label: 'part of' };
}
if (startsWithAny(['features', 'mentions', 'hosted by', 'guest:', 'host:'])) {
return { type: 'part_of', label: 'part of' };
}
if (startsWithAny(['came from', 'inspired by', 'derived from', 'from'])) {
return { type: 'source_of', label: 'source of' };
}
if (startsWithAny(['related to', 'related'])) {
return { type: 'related_to', label: 'related to' };
}
// No heuristic match - will be AI-inferred
return { type: 'inferred', label: 'will be inferred' };
};
// Fetch priority dimensions on mount
useEffect(() => {
fetchPriorityDimensions();
}, []);
// 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<string, string> = {};
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<NodeSearchResult[]>([]);
const [mentionIndex, setMentionIndex] = useState(0);
const mentionTimeout = useRef<ReturnType<typeof setTimeout> | 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<HTMLTextAreaElement>) => {
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<string, unknown> | null | undefined
) => {
if (activeNodeId === null) return;
setEdgeSavingId(edgeId);
try {
const newContext: Record<string, unknown> = {
...(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 <div style={{ color: '#777', fontSize: '12px' }}>Open a node to manage connections.</div>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Search Section — only visible when toggled */}
{edgeSearchOpen && (
<div style={{ marginBottom: '12px', position: 'relative' }}>
<input
type="text"
autoFocus
value={nodeSearchQuery}
onChange={(e) => 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 && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
marginTop: '4px',
background: '#141414',
border: '1px solid #262626',
borderRadius: '8px',
maxHeight: '200px',
overflowY: 'auto',
zIndex: 10,
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)'
}}>
{nodeSearchSuggestions.map((suggestion, index) => (
<div
key={suggestion.id}
onClick={() => 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';
}
}}
>
<span style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '9px',
fontWeight: 600,
color: '#0a0a0a',
background: '#22c55e',
padding: '3px 6px',
borderRadius: '6px',
minWidth: '24px',
textAlign: 'center',
flexShrink: 0,
fontFamily: "'SF Mono', 'Fira Code', monospace"
}}>
{suggestion.id}
</span>
<span style={{
fontSize: '13px',
color: '#e5e5e5',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1
}}>
{suggestion.title}
</span>
{index === selectedSearchIndex && (
<span style={{ color: '#525252', fontSize: '13px' }}></span>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Connections List */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{loadingEdges.has(activeTab) ? (
<div style={{ color: '#555', fontSize: '12px', fontStyle: 'italic', padding: '16px 0', textAlign: 'center' }}>Loading connections</div>
) : (() => {
const list = edgesData[activeTab] || [];
if (list.length === 0) {
return <div style={{ color: '#555', fontSize: '12px', fontStyle: 'italic', padding: '16px 0', textAlign: 'center' }}>No connections yet.</div>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{list.map((connection) => (
<div
key={connection.id}
style={{
padding: '6px 0',
borderBottom: '1px solid #141414',
display: 'flex',
flexDirection: 'column',
gap: '2px',
minWidth: 0,
}}
>
{/* Row 1: arrow + ID + icon + title + delete */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}>
<span style={{
fontSize: '11px',
color: connection.edge.from_node_id === activeTab ? '#22c55e' : '#f59e0b',
fontWeight: 600,
width: '14px',
textAlign: 'center',
flexShrink: 0,
}}>
{connection.edge.from_node_id === activeTab ? '→' : '←'}
</span>
<span style={{
fontSize: '9px',
fontWeight: 700,
color: '#000',
background: '#22c55e',
padding: '1px 5px',
borderRadius: '4px',
flexShrink: 0,
fontFamily: "'SF Mono', 'Fira Code', monospace",
}}>
{connection.connected_node.id}
</span>
<span style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{getNodeIcon(connection.connected_node, dimensionIcons, 12)}
</span>
<span
onClick={() => 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}
</span>
<button
onClick={() => deleteEdge(connection.edge.id)}
disabled={deletingEdge === connection.edge.id}
style={{
color: '#444',
fontSize: '14px',
background: 'transparent',
border: 'none',
cursor: deletingEdge === connection.edge.id ? 'not-allowed' : 'pointer',
padding: '0 2px',
flexShrink: 0,
lineHeight: 1,
}}
onMouseEnter={(e) => { if (deletingEdge !== connection.edge.id) e.currentTarget.style.color = '#dc2626'; }}
onMouseLeave={(e) => { if (deletingEdge !== connection.edge.id) e.currentTarget.style.color = '#444'; }}
>
{deletingEdge === connection.edge.id ? '...' : '×'}
</button>
</div>
{/* Row 2: type chip + explanation (compact, single line) */}
{edgeEditingId === connection.edge.id ? (
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', paddingLeft: '20px' }}>
<input
value={edgeEditingValue}
onChange={(e) => 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
/>
<button
onClick={() => saveEdgeExplanation(connection.edge.id, connection.edge.context)}
disabled={edgeSavingId === connection.edge.id}
style={{
fontSize: '10px',
color: '#000',
background: '#22c55e',
border: 'none',
borderRadius: '4px',
padding: '3px 8px',
cursor: edgeSavingId === connection.edge.id ? 'not-allowed' : 'pointer',
fontWeight: 600,
flexShrink: 0,
}}
>
{edgeSavingId === connection.edge.id ? '...' : 'Save'}
</button>
<button
onClick={cancelEditEdgeExplanation}
style={{
fontSize: '10px',
color: '#666',
background: 'transparent',
border: 'none',
padding: '3px 4px',
cursor: 'pointer',
flexShrink: 0,
}}
>
Cancel
</button>
</div>
) : (
<div
onClick={() => 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' && (
<span style={{
fontSize: '9px',
color: '#888',
background: '#1a1a1a',
border: '1px solid #222',
padding: '1px 5px',
borderRadius: '999px',
flexShrink: 0,
whiteSpace: 'nowrap',
}}>
{String(connection.edge.context.type).replace(/_/g, ' ')}
</span>
)}
<span
style={{
fontSize: '11px',
color: connection.edge.context?.explanation ? '#777' : '#444',
fontStyle: connection.edge.context?.explanation ? 'normal' : 'italic',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
title={String(connection.edge.context?.explanation || '')}
>
{(connection.edge.context?.explanation as string) || 'Add explanation...'}
</span>
</div>
)}
</div>
))}
</div>
);
})()}
</div>
</div>
);
};
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 (
<>
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: 'transparent' }}>
{/* Tab Bar - hidden when rendered from NodePane which has its own tab bar */}
{!hideTabBar && (
<div style={{
display: 'flex',
borderBottom: '1px solid #1a1a1a',
background: '#0f0f0f',
flexShrink: 0,
overflowX: 'auto',
overflowY: 'hidden'
}}>
{openTabs.length === 0 ? (
<div style={{
padding: '10px 16px',
fontSize: '12px',
color: '#666'
}}>
No tabs open
</div>
) : (
openTabs.map((tabId) => {
const node = nodesData[tabId];
const isActive = activeTab === tabId;
const label = node ? truncateTitle(node.title || 'Untitled') : 'Loading...';
return (
<div
key={tabId}
draggable
onDragStart={(e) => {
const title = node?.title || 'Untitled';
e.dataTransfer.effectAllowed = 'copyMove';
e.dataTransfer.setData('application/x-rah-tab', JSON.stringify({ id: tabId, title }));
e.dataTransfer.setData('text/plain', `[NODE:${tabId}:"${title}"]`);
}}
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'
}}
>
<button
onClick={() => onTabSelect(tabId)}
style={{
flex: 1,
padding: '8px 12px',
fontSize: '12px',
fontFamily: 'inherit',
color: isActive ? '#fff' : '#999',
background: 'transparent',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{label}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tabId);
}}
style={{
padding: '4px 8px',
fontSize: '14px',
color: '#666',
background: 'transparent',
border: 'none',
cursor: 'pointer',
transition: 'color 0.2s'
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
>
×
</button>
</div>
);
})
)}
</div>
)}
{/* Content Area */}
<div style={{
flex: 1,
overflow: 'auto',
padding: '20px',
position: 'relative'
}}>
{!activeTab ? (
<div style={{
color: '#666',
fontSize: '13px',
textAlign: 'center',
marginTop: '40px'
}}>
Select a node from the left panel to view details
</div>
) : loadingNodes.has(activeTab) ? (
<div style={{
color: '#666',
fontSize: '13px'
}}>
Loading...
</div>
) : !currentNode ? (
<div style={{ color: '#666', fontSize: '13px' }}>Node not found.</div>
) : nodesData[activeTab] ? (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* URL Row - Above Title */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '8px',
paddingLeft: '4px'
}}>
{/* Embedding status - only show when embedding or error */}
{(() => {
const node = nodesData[activeTab];
const chunkStatus = node?.chunk_status ?? null;
if (embeddingNode === activeTab || chunkStatus === 'chunking') {
return (
<Loader size={12} className="animate-spin" style={{ color: '#facc15', flexShrink: 0 }} />
);
}
if (chunkStatus === 'error') {
return (
<button
onClick={() => embedContent(activeTab)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '2px 6px',
fontSize: '10px',
color: '#ef4444',
background: 'transparent',
border: '1px solid #7f1d1d',
borderRadius: '4px',
cursor: 'pointer',
flexShrink: 0
}}
title="Embedding failed - click to retry"
>
<Database size={10} />
Retry
</button>
);
}
return null;
})()}
<div style={{ flex: 1, minWidth: 0 }}>
{editingField === 'link' ? (
<input
ref={inputRef as React.RefObject<HTMLInputElement>}
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 ? (
<a
href={nodesData[activeTab].link}
onClick={(e) => {
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}
</a>
) : (
<span
onClick={() => startEdit('link', '')}
style={{
color: '#555',
fontSize: '11px',
cursor: 'pointer',
fontStyle: 'italic'
}}
>
Click to add URL
</span>
)}
</div>
</div>
{/* Title Row - Node ID, Title, Connections, Trash */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '8px'
}}>
{/* Node ID - Draggable */}
<span
draggable
onDragStart={(e: DragEvent<HTMLSpanElement>) => {
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}
</span>
{/* Node type icon */}
{nodesData[activeTab] && (
<span style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{getNodeIcon(nodesData[activeTab], dimensionIcons, 18)}
</span>
)}
{editingField === 'title' ? (
<input
ref={inputRef as React.RefObject<HTMLInputElement>}
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..."
/>
) : (
<h1
onClick={() => {
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' && <span style={{ color: '#555', fontSize: '10px', marginLeft: '6px' }}>saving...</span>}
</h1>
)}
{/* Connections Button — opens Edges tab */}
<button
onClick={() => setActiveContentTab('edges')}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
fontSize: '10px',
fontWeight: 500,
color: activeContentTab === 'edges' ? '#22c55e' : '#666',
background: activeContentTab === 'edges' ? '#0f2417' : 'transparent',
border: '1px solid',
borderColor: activeContentTab === 'edges' ? '#22c55e' : '#262626',
borderRadius: '4px',
cursor: 'pointer',
transition: 'all 0.2s',
flexShrink: 0
}}
onMouseEnter={(e) => {
if (activeContentTab !== 'edges') {
e.currentTarget.style.color = '#22c55e';
e.currentTarget.style.borderColor = '#166534';
}
}}
onMouseLeave={(e) => {
if (activeContentTab !== 'edges') {
e.currentTarget.style.color = '#666';
e.currentTarget.style.borderColor = '#262626';
}
}}
title="Connections"
>
<Link size={12} />
<span>Edges</span>
{activeTab && (
<span style={{ fontWeight: 600 }}>
<span
style={{
minWidth: '18px',
height: '18px',
borderRadius: '999px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 6px',
background: activeContentTab === 'edges' ? 'rgba(34, 197, 94, 0.18)' : '#1a1a1a',
color: activeContentTab === 'edges' ? '#a7f3b8' : '#888',
fontSize: '10px',
lineHeight: 1,
}}
>
{(edgesData[activeTab] || []).length}
</span>
</span>
)}
</button>
{/* Delete Button */}
<button
onClick={() => confirmDeleteNode(activeTab)}
disabled={deletingNode === activeTab}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '4px',
color: deletingNode === activeTab ? '#994444' : '#525252',
background: 'transparent',
border: '1px solid #262626',
borderRadius: '4px',
cursor: deletingNode === activeTab ? 'wait' : 'pointer',
transition: 'all 0.2s',
flexShrink: 0
}}
onMouseEnter={(e) => {
if (deletingNode !== activeTab) {
e.currentTarget.style.color = '#dc2626';
e.currentTarget.style.borderColor = '#dc2626';
e.currentTarget.style.background = 'rgba(220, 38, 38, 0.1)';
}
}}
onMouseLeave={(e) => {
if (deletingNode !== activeTab) {
e.currentTarget.style.color = '#525252';
e.currentTarget.style.borderColor = '#262626';
e.currentTarget.style.background = 'transparent';
}
}}
title="Delete node"
>
{deletingNode === activeTab ? '...' : <Trash2 size={12} />}
</button>
</div>
{/* Dimensions Section */}
<div style={{ marginBottom: '16px' }}>
<div style={{
overflowX: 'auto',
overflowY: 'visible',
position: 'relative'
}}>
<DimensionTags
dimensions={nodesData[activeTab].dimensions || []}
priorityDimensions={priorityDimensions}
onUpdate={async (newDimensions) => {
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}
/>
</div>
</div>
{/* Notes | Desc | Source Tabs */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Tab Bar */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0',
marginBottom: '12px',
borderBottom: '1px solid #1a1a1a'
}}>
<button
onClick={() => { setActiveContentTab('desc'); setNotesEditMode(false); setSourceEditMode(false); }}
style={{
padding: '8px 16px',
fontSize: '11px',
fontWeight: activeContentTab === 'desc' ? 600 : 400,
color: activeContentTab === 'desc' ? '#e5e5e5' : '#666',
background: 'transparent',
border: 'none',
borderBottom: activeContentTab === 'desc' ? '2px solid #22c55e' : '2px solid transparent',
cursor: 'pointer',
transition: 'all 0.15s',
marginBottom: '-1px'
}}
>
Desc
</button>
<button
onClick={() => { setActiveContentTab('notes'); setDescEditMode(false); setSourceEditMode(false); }}
style={{
padding: '8px 16px',
fontSize: '11px',
fontWeight: activeContentTab === 'notes' ? 600 : 400,
color: activeContentTab === 'notes' ? '#e5e5e5' : '#666',
background: 'transparent',
border: 'none',
borderBottom: activeContentTab === 'notes' ? '2px solid #22c55e' : '2px solid transparent',
cursor: 'pointer',
transition: 'all 0.15s',
marginBottom: '-1px'
}}
>
Notes
</button>
<button
onClick={() => { setActiveContentTab('edges'); setDescEditMode(false); setNotesEditMode(false); setSourceEditMode(false); }}
style={{
padding: '8px 16px',
fontSize: '11px',
fontWeight: activeContentTab === 'edges' ? 600 : 400,
color: activeContentTab === 'edges' ? '#e5e5e5' : '#666',
background: 'transparent',
border: 'none',
borderBottom: activeContentTab === 'edges' ? '2px solid #22c55e' : '2px solid transparent',
cursor: 'pointer',
transition: 'all 0.15s',
marginBottom: '-1px'
}}
>
Edges{activeTab && edgesData[activeTab]?.length ? ` (${edgesData[activeTab].length})` : ''}
</button>
<button
onClick={() => { setActiveContentTab('source'); setDescEditMode(false); setNotesEditMode(false); setEdgeSearchOpen(false); }}
style={{
padding: '8px 16px',
fontSize: '11px',
fontWeight: activeContentTab === 'source' ? 600 : 400,
color: activeContentTab === 'source' ? '#e5e5e5' : '#666',
background: 'transparent',
border: 'none',
borderBottom: activeContentTab === 'source' ? '2px solid #22c55e' : '2px solid transparent',
cursor: 'pointer',
transition: 'all 0.15s',
marginBottom: '-1px'
}}
>
Source
</button>
<div style={{ flex: 1 }} />
{/* Action buttons for Desc tab */}
{activeContentTab === 'desc' && !descEditMode && (
<div style={{ display: 'flex', gap: '8px', marginBottom: '4px' }}>
<button
onClick={() => activeTab && regenerateDescription(activeTab)}
disabled={regeneratingDescription === activeTab}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
fontSize: '10px',
color: '#888',
background: 'transparent',
border: '1px solid #2a2a2a',
borderRadius: '4px',
cursor: 'pointer'
}}
title="Regenerate description with AI"
>
<RefreshCw size={12} style={{ animation: regeneratingDescription === activeTab ? 'spin 1s linear infinite' : 'none' }} />
Regenerate
</button>
<button
onClick={startDescEdit}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
fontSize: '10px',
color: '#888',
background: 'transparent',
border: '1px solid #2a2a2a',
borderRadius: '4px',
cursor: 'pointer'
}}
title="Edit description"
>
<Pencil size={12} />
Edit
</button>
</div>
)}
{/* Action buttons for Notes tab */}
{activeContentTab === 'notes' && !notesEditMode && (
<div style={{ display: 'flex', gap: '8px', marginBottom: '4px' }}>
<button
onClick={createLinkedNote}
disabled={creatingNote}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
fontSize: '10px',
color: '#22c55e',
background: 'transparent',
border: '1px solid #166534',
borderRadius: '4px',
cursor: 'pointer'
}}
title="Create a new linked node"
>
{creatingNote ? <Loader size={12} className="animate-spin" /> : <Plus size={12} />}
Node
</button>
<button
onClick={startNotesEdit}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
fontSize: '10px',
color: '#888',
background: 'transparent',
border: '1px solid #2a2a2a',
borderRadius: '4px',
cursor: 'pointer'
}}
title="Edit notes"
>
<Pencil size={12} />
Edit
</button>
</div>
)}
{/* Formatting toolbar for Notes edit mode - inline */}
{activeContentTab === 'notes' && notesEditMode && (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<FormattingToolbar
textareaRef={notesTextareaRef}
value={notesEditValue}
onChange={setNotesEditValue}
inline
/>
</div>
)}
{/* Action button for Edges tab */}
{activeContentTab === 'edges' && (
<div style={{ display: 'flex', gap: '8px', marginBottom: '4px' }}>
<button
onClick={() => {
setEdgeSearchOpen(!edgeSearchOpen);
if (edgeSearchOpen) {
setNodeSearchQuery('');
setNodeSearchSuggestions([]);
}
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
fontSize: '10px',
color: edgeSearchOpen ? '#000' : '#22c55e',
background: edgeSearchOpen ? '#22c55e' : 'transparent',
border: '1px solid #166534',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 600,
}}
>
<Plus size={12} />
{edgeSearchOpen ? 'Cancel' : 'Create'}
</button>
</div>
)}
</div>
{/* Desc Tab Content */}
{activeContentTab === 'desc' && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{descEditMode ? (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div style={{
fontSize: '11px',
color: '#f59e0b',
marginBottom: '8px',
padding: '8px',
background: '#1a1500',
borderRadius: '4px',
border: '1px solid #3d3500'
}}>
Used as context for AI. Clearly describe what this node is in 280 chars or less.
</div>
{/* Editor */}
<div style={{ position: 'relative', flex: 1, display: 'flex', flexDirection: 'column' }}>
<textarea
value={descEditValue}
onChange={(e) => {
const newValue = e.target.value.slice(0, 280);
setDescEditValue(newValue);
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
cancelDescEdit();
}
}}
disabled={descSaving}
style={{
color: '#e5e5e5',
fontSize: '15px',
lineHeight: '1.7',
background: 'transparent',
border: '1px solid #2a2a2a',
borderRadius: '4px',
padding: '12px',
fontFamily: 'inherit',
width: '100%',
flex: 1,
minHeight: '120px',
resize: 'none',
outline: 'none',
overflow: 'auto'
}}
placeholder="Write a brief description of this node (max 280 chars)..."
maxLength={280}
/>
<span style={{
position: 'absolute',
bottom: '12px',
right: '12px',
fontSize: '10px',
color: descEditValue.length >= 260 ? '#f59e0b' : '#555'
}}>
{descEditValue.length}/280
</span>
</div>
{/* Save/Cancel/Sync buttons */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '12px'
}}>
{/* Sync to Source button - left side */}
<div>
<button
onClick={syncDescToSource}
disabled={descSaving}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '6px 10px',
fontSize: '10px',
color: '#f59e0b',
background: 'transparent',
border: '1px solid #3d3500',
borderRadius: '4px',
cursor: 'pointer'
}}
title="Copy description to source and re-embed"
>
{descSaving ? <Loader size={12} className="animate-spin" /> : <RefreshCw size={12} />}
Sync to Source
</button>
</div>
{/* Save/Cancel - right side */}
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={cancelDescEdit}
disabled={descSaving}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '6px 12px',
fontSize: '11px',
color: '#888',
background: 'transparent',
border: '1px solid #2a2a2a',
borderRadius: '4px',
cursor: 'pointer'
}}
>
<X size={14} />
Cancel
</button>
<button
onClick={saveDesc}
disabled={descSaving}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '6px 12px',
fontSize: '11px',
color: '#000',
background: '#22c55e',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 600
}}
>
{descSaving ? <Loader size={14} className="animate-spin" /> : <Save size={14} />}
Save
</button>
</div>
</div>
</div>
) : nodesData[activeTab]?.description ? (
<div
style={{
color: '#e5e5e5',
fontSize: '15px',
lineHeight: '1.7',
padding: '4px',
flex: 1,
overflow: 'auto'
}}
>
{nodesData[activeTab].description}
</div>
) : (
<div
onClick={startDescEdit}
style={{
color: '#555',
fontSize: '12px',
fontStyle: 'italic',
cursor: 'pointer',
padding: '8px',
minHeight: '100px',
border: '1px dashed #1a1a1a',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'transparent',
flex: 1,
transition: 'border-color 0.15s'
}}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#2a2a2a'; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = '#1a1a1a'; }}
>
Click to add a description...
</div>
)}
</div>
)}
{/* Notes Tab Content */}
{activeContentTab === 'notes' && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{notesEditMode ? (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Editor */}
<div style={{ position: 'relative', flex: 1, display: 'flex', flexDirection: 'column' }}>
<textarea
ref={notesTextareaRef}
value={notesEditValue}
onChange={(e) => {
const val = e.target.value;
setNotesEditValue(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) {
resetMention();
}
}}
onKeyDown={(e) => {
// @mention navigation
if (mentionActive && mentionResults.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setMentionIndex(i => Math.min(i + 1, mentionResults.length - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setMentionIndex(i => Math.max(i - 1, 0));
return;
}
if (e.key === 'Enter' && mentionResults[mentionIndex]) {
e.preventDefault();
handleNotesMentionSelect(mentionResults[mentionIndex].id, mentionResults[mentionIndex].title);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
resetMention();
return;
}
}
if (e.key === 'Escape') {
cancelNotesEdit();
}
}}
disabled={notesSaving}
style={{
color: '#e5e5e5',
fontSize: '15px',
lineHeight: '1.7',
background: 'transparent',
border: '1px solid #2a2a2a',
borderRadius: '4px',
padding: '12px',
fontFamily: 'inherit',
width: '100%',
flex: 1,
minHeight: '200px',
resize: 'none',
outline: 'none',
overflow: 'auto'
}}
placeholder="Start writing... Use @ to mention nodes, and Markdown for formatting."
/>
{/* @mention dropdown */}
{mentionActive && (
<div style={{
position: 'absolute',
top: '50px',
left: '12px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
zIndex: 1000,
maxHeight: '200px',
overflowY: 'auto',
minWidth: '300px',
boxShadow: '0 4px 12px rgba(0,0,0,0.5)'
}}>
{mentionQuery.trim().length < 2 ? (
<div style={{ padding: '8px 12px', fontSize: '12px', color: '#666' }}>
Type at least 2 characters to search...
</div>
) : mentionResults.length === 0 ? (
<div style={{ padding: '8px 12px', fontSize: '12px', color: '#666' }}>
No nodes found
</div>
) : (
mentionResults.map((n, idx) => (
<div
key={n.id}
onMouseDown={(e) => {
e.preventDefault();
handleNotesMentionSelect(n.id, n.title);
}}
onMouseEnter={() => setMentionIndex(idx)}
style={{
padding: '8px 12px',
fontSize: '13px',
color: '#ddd',
cursor: 'pointer',
background: idx === mentionIndex ? '#252525' : 'transparent',
borderBottom: idx < mentionResults.length - 1 ? '1px solid #2a2a2a' : 'none'
}}
>
<span style={{ color: '#22c55e', marginRight: '8px', fontWeight: 600 }}>{n.id}</span>
<span>{n.title.length > 50 ? n.title.slice(0, 50) + '…' : n.title}</span>
</div>
))
)}
</div>
)}
</div>
{/* Save/Cancel/Sync buttons */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '12px'
}}>
{/* Sync to Source button - left side */}
<div>
{nodesData[activeTab]?.notes && (
<button
onClick={() => setShowSyncConfirm(true)}
disabled={notesSaving || syncing}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '6px 10px',
fontSize: '10px',
color: '#f59e0b',
background: 'transparent',
border: '1px solid #3d3500',
borderRadius: '4px',
cursor: 'pointer'
}}
title="Copy notes to source and re-embed"
>
{syncing ? <Loader size={12} className="animate-spin" /> : <RefreshCw size={12} />}
Sync to Source
</button>
)}
</div>
{/* Save/Cancel - right side */}
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={cancelNotesEdit}
disabled={notesSaving || syncing}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '6px 12px',
fontSize: '11px',
color: '#888',
background: 'transparent',
border: '1px solid #2a2a2a',
borderRadius: '4px',
cursor: 'pointer'
}}
>
<X size={14} />
Cancel
</button>
<button
onClick={saveNotes}
disabled={notesSaving || syncing}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '6px 12px',
fontSize: '11px',
color: '#000',
background: '#22c55e',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 600
}}
>
{notesSaving ? <Loader size={14} className="animate-spin" /> : <Save size={14} />}
Save
</button>
</div>
</div>
{/* Sync confirmation dialog */}
{showSyncConfirm && (
<div style={{
marginTop: '12px',
padding: '12px',
background: '#1a1500',
border: '1px solid #3d3500',
borderRadius: '4px'
}}>
<div style={{ fontSize: '12px', color: '#f59e0b', marginBottom: '8px' }}>
This will overwrite your source content and update what search uses.
</div>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button
onClick={() => setShowSyncConfirm(false)}
style={{
padding: '4px 10px',
fontSize: '11px',
color: '#888',
background: 'transparent',
border: '1px solid #2a2a2a',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Cancel
</button>
<button
onClick={syncToSource}
style={{
padding: '4px 10px',
fontSize: '11px',
color: '#000',
background: '#f59e0b',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 600
}}
>
Sync Now
</button>
</div>
</div>
)}
</div>
) : nodesData[activeTab]?.notes ? (
<div
style={{
color: '#e5e5e5',
fontSize: '15px',
lineHeight: '1.7',
padding: '4px',
flex: 1,
overflow: 'auto'
}}
>
<MarkdownWithNodeTokens
content={nodesData[activeTab].notes || ''}
onNodeClick={onNodeClick || onTabSelect}
/>
</div>
) : (
<div
onClick={startNotesEdit}
style={{
color: '#555',
fontSize: '12px',
fontStyle: 'italic',
cursor: 'pointer',
padding: '8px',
minHeight: '200px',
border: '1px dashed #1a1a1a',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'transparent',
flex: 1,
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#2a2a2a';
e.currentTarget.style.color = '#666';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#1a1a1a';
e.currentTarget.style.color = '#555';
}}
>
Click to add notes...
</div>
)}
</div>
)}
{/* Edges Tab Content */}
{activeContentTab === 'edges' && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'auto' }}>
{renderConnectionsBody()}
</div>
)}
{/* Source Tab Content */}
{activeContentTab === 'source' && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{sourceEditMode ? (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div style={{
fontSize: '11px',
color: '#f59e0b',
marginBottom: '8px',
padding: '8px',
background: '#1a1500',
borderRadius: '4px',
border: '1px solid #3d3500'
}}>
Editing source changes what search uses. This is the raw content that gets embedded.
</div>
<textarea
value={sourceEditValue}
onChange={(e) => setSourceEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
cancelSourceEdit();
}
}}
disabled={sourceSaving}
style={{
color: '#ccc',
fontSize: '12px',
lineHeight: '1.5',
background: 'transparent',
border: '1px solid #2a2a2a',
borderRadius: '4px',
padding: '12px',
fontFamily: 'monospace',
width: '100%',
flex: 1,
minHeight: '200px',
resize: 'none',
outline: 'none',
overflow: 'auto'
}}
placeholder="Add source content for embedding..."
/>
<div style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '8px',
marginTop: '12px'
}}>
<button
onClick={cancelSourceEdit}
disabled={sourceSaving}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '6px 12px',
fontSize: '11px',
color: '#888',
background: 'transparent',
border: '1px solid #2a2a2a',
borderRadius: '4px',
cursor: 'pointer'
}}
>
<X size={14} />
Cancel
</button>
<button
onClick={saveSource}
disabled={sourceSaving}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '6px 12px',
fontSize: '11px',
color: '#000',
background: '#22c55e',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 600
}}
>
{sourceSaving ? <Loader size={14} className="animate-spin" /> : <Save size={14} />}
Save
</button>
</div>
</div>
) : (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Mode toggle and edit button */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px',
flexShrink: 0,
}}>
{/* Raw / Reader toggle */}
<div style={{
display: 'flex',
background: '#111',
borderRadius: '4px',
border: '1px solid #222',
overflow: 'hidden',
}}>
<button
onClick={() => setSourceReaderMode('raw')}
style={{
padding: '4px 10px',
fontSize: '10px',
fontWeight: 500,
color: sourceReaderMode === 'raw' ? '#fff' : '#666',
background: sourceReaderMode === 'raw' ? '#2a2a2a' : 'transparent',
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
Raw
</button>
<button
onClick={() => setSourceReaderMode('reader')}
style={{
padding: '4px 10px',
fontSize: '10px',
fontWeight: 500,
color: sourceReaderMode === 'reader' ? '#fff' : '#666',
background: sourceReaderMode === 'reader' ? '#2a2a2a' : 'transparent',
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
Reader
</button>
</div>
{/* Edit button */}
<button
onClick={startSourceEdit}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
fontSize: '10px',
color: '#666',
background: 'transparent',
border: '1px solid #222',
borderRadius: '4px',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#888';
e.currentTarget.style.borderColor = '#333';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#666';
e.currentTarget.style.borderColor = '#222';
}}
>
<Pencil size={10} />
Edit
</button>
</div>
{/* Content display */}
<div style={{ flex: 1, overflow: 'auto' }}>
{nodesData[activeTab]?.chunk ? (
sourceReaderMode === 'reader' ? (
<SourceReader
content={nodesData[activeTab].chunk}
onTextSelect={onTextSelect ? (text) => onTextSelect(activeTab, nodesData[activeTab]?.title || 'Untitled', text) : undefined}
highlightedText={highlightedPassage?.nodeId === activeTab ? highlightedPassage.selectedText : null}
/>
) : (
<div
style={{
color: '#ccc',
fontSize: '12px',
lineHeight: '1.5',
padding: '12px',
background: '#0a0a0a',
border: '1px solid #1a1a1a',
borderRadius: '4px',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
minHeight: '100%',
}}
>
{nodesData[activeTab].chunk}
</div>
)
) : (
<div
onClick={startSourceEdit}
style={{
color: '#555',
fontSize: '12px',
fontStyle: 'italic',
cursor: 'pointer',
padding: '12px',
border: '1px dashed #1a1a1a',
borderRadius: '4px',
textAlign: 'center',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
No source content. Click to add.
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
) : (
<div style={{
color: '#666',
fontSize: '13px'
}}>
Node not found
</div>
)}
</div>
</div>
<ConfirmDialog
open={pendingDeleteNodeId !== null}
title="Delete this node?"
message="This will permanently remove the node and its data."
confirmLabel="Delete"
onConfirm={handleConfirmNodeDelete}
onCancel={handleCancelNodeDelete}
/>
{/* Tab Context Menu */}
{contextMenu && (
<div
style={{
position: 'fixed',
top: contextMenu.y,
left: contextMenu.x,
background: '#1a1a1a',
border: '1px solid #2a2a2a',
borderRadius: '6px',
padding: '4px',
zIndex: 9999,
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
minWidth: '160px',
}}
onClick={(e) => e.stopPropagation()}
>
{onOpenInOtherSlot && (
<button
onClick={() => {
onOpenInOtherSlot(contextMenu.tabId);
setContextMenu(null);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
width: '100%',
padding: '8px 12px',
background: 'transparent',
border: 'none',
borderRadius: '4px',
color: '#ccc',
fontSize: '12px',
cursor: 'pointer',
textAlign: 'left',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2a2a2a';
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = '#ccc';
}}
>
<span style={{ fontSize: '14px' }}></span>
Open in other panel
</button>
)}
<button
onClick={() => {
onTabClose(contextMenu.tabId);
setContextMenu(null);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
width: '100%',
padding: '8px 12px',
background: 'transparent',
border: 'none',
borderRadius: '4px',
color: '#ccc',
fontSize: '12px',
cursor: 'pointer',
textAlign: 'left',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2a2a2a';
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = '#ccc';
}}
>
<span style={{ fontSize: '14px' }}>×</span>
Close tab
</button>
</div>
)}
</>
);
}