- 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
3139 lines
120 KiB
TypeScript
3139 lines
120 KiB
TypeScript
"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>
|
||
)}
|
||
|
||
</>
|
||
);
|
||
}
|