- align os MCP/runtime/docs with ra-h contract - add safe direct node lookup and context-optional flows - gate edge and context writes behind confirmation
2191 lines
72 KiB
TypeScript
2191 lines
72 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState, type DragEvent } from 'react';
|
|
import { Trash2, Loader, Database, RefreshCw, Pencil, X, Save, Plus, Link2, Tag, Share2, AlignLeft, ChevronDown, ChevronRight, Check, Folder } from 'lucide-react';
|
|
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
|
|
import { Node, NodeConnection } from '@/types/database';
|
|
import { getNodeIcon } from '@/utils/nodeIcons';
|
|
import ConfirmDialog from '../common/ConfirmDialog';
|
|
import { SourceReader } from './source';
|
|
import SourceEditor from './source/SourceEditor';
|
|
import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl';
|
|
import { normalizeNodeLink } from '@/utils/nodeLink';
|
|
import NodeSearchModal from './edges/NodeSearchModal';
|
|
import { getNodeProcessedState, parseNodeMetadata } from '@/services/nodes/metadata';
|
|
import type { ContextSummary } from '@/types/database';
|
|
|
|
interface FocusPanelProps {
|
|
openTabs: number[];
|
|
activeTab: number | null;
|
|
onTabSelect: (nodeId: number) => void;
|
|
onNodeClick?: (nodeId: number) => void;
|
|
onTabClose: (nodeId: number) => void;
|
|
refreshTrigger?: number;
|
|
onTextSelect?: (nodeId: number, nodeTitle: string, text: string) => void;
|
|
highlightedPassage?: { nodeId: number; selectedText: string } | null;
|
|
}
|
|
|
|
type HoverSection = 'description' | 'source' | null;
|
|
|
|
export default function FocusPanel({
|
|
openTabs,
|
|
activeTab,
|
|
onTabSelect,
|
|
onNodeClick,
|
|
onTabClose,
|
|
refreshTrigger,
|
|
onTextSelect,
|
|
highlightedPassage,
|
|
}: FocusPanelProps) {
|
|
const [nodesData, setNodesData] = useState<Record<number, Node>>({});
|
|
const [edgesData, setEdgesData] = useState<Record<number, NodeConnection[]>>({});
|
|
const [loadingNodes, setLoadingNodes] = useState<Set<number>>(new Set());
|
|
const [loadingEdges, setLoadingEdges] = useState<Set<number>>(new Set());
|
|
const [embeddingNode, setEmbeddingNode] = useState<number | null>(null);
|
|
const [deletingNode, setDeletingNode] = useState<number | null>(null);
|
|
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<number | null>(null);
|
|
const [deletingEdge, setDeletingEdge] = useState<number | null>(null);
|
|
const [edgeEditingId, setEdgeEditingId] = useState<number | null>(null);
|
|
const [edgeEditingValue, setEdgeEditingValue] = useState('');
|
|
const [edgeSavingId, setEdgeSavingId] = useState<number | null>(null);
|
|
const [edgesExpanded, setEdgesExpanded] = useState<Record<number, boolean>>({});
|
|
const [edgeSearchOpen, setEdgeSearchOpen] = useState(false);
|
|
const [hoveredConnectionId, setHoveredConnectionId] = useState<number | null>(null);
|
|
const [propsCollapsed, setPropsCollapsed] = useState(false);
|
|
|
|
const [titleEditMode, setTitleEditMode] = useState(false);
|
|
const [titleEditValue, setTitleEditValue] = useState('');
|
|
const [titleSaving, setTitleSaving] = useState(false);
|
|
const [linkEditMode, setLinkEditMode] = useState(false);
|
|
const [linkEditValue, setLinkEditValue] = useState('');
|
|
const [linkSaving, setLinkSaving] = useState(false);
|
|
const [descEditMode, setDescEditMode] = useState(false);
|
|
const [descEditValue, setDescEditValue] = useState('');
|
|
const [descSaving, setDescSaving] = useState(false);
|
|
const [regeneratingDescription, setRegeneratingDescription] = useState<number | null>(null);
|
|
const [sourceEditMode, setSourceEditMode] = useState(false);
|
|
const [sourceEditValue, setSourceEditValue] = useState('');
|
|
const [sourceSaving, setSourceSaving] = useState(false);
|
|
const [availableContexts, setAvailableContexts] = useState<ContextSummary[]>([]);
|
|
const [contextSaving, setContextSaving] = useState(false);
|
|
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
|
const [hoveredSection, setHoveredSection] = useState<HoverSection>(null);
|
|
|
|
const titleInputRef = useRef<HTMLInputElement>(null);
|
|
const linkInputRef = useRef<HTMLInputElement>(null);
|
|
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const sourceTextareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const skipTitleBlurRef = useRef(false);
|
|
const skipLinkBlurRef = useRef(false);
|
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
|
|
|
const currentNode = activeTab !== null ? nodesData[activeTab] : undefined;
|
|
const normalizedCurrentLink = normalizeNodeLink(currentNode?.link || null);
|
|
const currentProcessedState = getNodeProcessedState(currentNode?.metadata);
|
|
const currentNodeMetadata = parseNodeMetadata(currentNode?.metadata);
|
|
const currentEdges = activeTab !== null ? edgesData[activeTab] || [] : [];
|
|
const currentEdgesExpanded = activeTab !== null ? Boolean(edgesExpanded[activeTab]) : false;
|
|
const visibleEdges = currentEdgesExpanded ? currentEdges : currentEdges.slice(0, 3);
|
|
|
|
useEffect(() => {
|
|
if (titleEditMode) titleInputRef.current?.focus();
|
|
}, [titleEditMode]);
|
|
|
|
useEffect(() => {
|
|
if (linkEditMode) linkInputRef.current?.focus();
|
|
}, [linkEditMode]);
|
|
|
|
useEffect(() => {
|
|
if (descEditMode) descTextareaRef.current?.focus();
|
|
}, [descEditMode]);
|
|
|
|
useEffect(() => {
|
|
if (sourceEditMode) sourceTextareaRef.current?.focus();
|
|
}, [sourceEditMode]);
|
|
|
|
useEffect(() => {
|
|
if (!contextMenuOpen) return;
|
|
|
|
const handlePointerDown = (event: MouseEvent) => {
|
|
if (!contextMenuRef.current?.contains(event.target as globalThis.Node)) {
|
|
setContextMenuOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousedown', handlePointerDown);
|
|
return () => document.removeEventListener('mousedown', handlePointerDown);
|
|
}, [contextMenuOpen]);
|
|
|
|
useEffect(() => {
|
|
void (async () => {
|
|
try {
|
|
const response = await fetch('/api/contexts');
|
|
const data = await response.json();
|
|
if (response.ok && data.success) {
|
|
setAvailableContexts(data.data || []);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch contexts:', error);
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
|
|
useEffect(() => {
|
|
openTabs.forEach((tabId) => {
|
|
if (!nodesData[tabId] && !loadingNodes.has(tabId)) {
|
|
void fetchNodeData(tabId);
|
|
}
|
|
});
|
|
}, [openTabs, nodesData, loadingNodes]);
|
|
|
|
useEffect(() => {
|
|
openTabs.forEach((tabId) => {
|
|
if (nodesData[tabId] && !edgesData[tabId] && !loadingEdges.has(tabId)) {
|
|
void fetchEdgesData(tabId);
|
|
}
|
|
});
|
|
}, [openTabs, nodesData, edgesData, loadingEdges]);
|
|
|
|
useEffect(() => {
|
|
if (refreshTrigger !== undefined && refreshTrigger > 0 && activeTab) {
|
|
void fetchNodeData(activeTab);
|
|
void fetchEdgesData(activeTab);
|
|
}
|
|
}, [refreshTrigger, activeTab]);
|
|
|
|
useEffect(() => {
|
|
setTitleEditMode(false);
|
|
setTitleEditValue('');
|
|
setLinkEditMode(false);
|
|
setLinkEditValue('');
|
|
setDescEditMode(false);
|
|
setDescEditValue('');
|
|
setSourceEditMode(false);
|
|
setSourceEditValue('');
|
|
setContextMenuOpen(false);
|
|
setEdgeSearchOpen(false);
|
|
setEdgeEditingId(null);
|
|
setEdgeEditingValue('');
|
|
setHoveredSection(null);
|
|
}, [activeTab]);
|
|
|
|
const fetchNodeData = async (id: number) => {
|
|
setLoadingNodes(prev => new Set(prev).add(id));
|
|
try {
|
|
const response = await fetch(`/api/nodes/${id}`);
|
|
if (response.status === 404) {
|
|
setNodesData(prev => {
|
|
const next = { ...prev };
|
|
delete next[id];
|
|
return next;
|
|
});
|
|
setEdgesData(prev => {
|
|
const next = { ...prev };
|
|
delete next[id];
|
|
return next;
|
|
});
|
|
onTabClose(id);
|
|
return;
|
|
}
|
|
const data = await response.json();
|
|
if (response.ok && data.node) {
|
|
setNodesData(prev => ({ ...prev, [id]: data.node }));
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Failed to fetch node ${id}:`, error);
|
|
} finally {
|
|
setLoadingNodes(prev => {
|
|
const next = new Set(prev);
|
|
next.delete(id);
|
|
return next;
|
|
});
|
|
}
|
|
};
|
|
|
|
const fetchEdgesData = async (nodeId: number) => {
|
|
setLoadingEdges(prev => new Set(prev).add(nodeId));
|
|
try {
|
|
const response = await fetch(`/api/nodes/${nodeId}/edges`);
|
|
const data = await response.json();
|
|
if (response.ok && data.success && data.data) {
|
|
setEdgesData(prev => ({ ...prev, [nodeId]: data.data }));
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error fetching edges for node ${nodeId}:`, error);
|
|
} finally {
|
|
setLoadingEdges(prev => {
|
|
const next = new Set(prev);
|
|
next.delete(nodeId);
|
|
return next;
|
|
});
|
|
}
|
|
};
|
|
|
|
const updateNode = async (nodeId: number, updates: Record<string, unknown>) => {
|
|
const response = await fetch(`/api/nodes/${nodeId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(updates),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to update node');
|
|
}
|
|
|
|
const result = await response.json();
|
|
if (result.node) {
|
|
setNodesData(prev => ({ ...prev, [nodeId]: result.node }));
|
|
return result.node as Node;
|
|
}
|
|
|
|
throw new Error('Missing updated node in response');
|
|
};
|
|
|
|
const toggleProcessedState = async () => {
|
|
if (activeTab === null || !currentNode) return;
|
|
|
|
const nextState = currentProcessedState === 'processed' ? 'not_processed' : 'processed';
|
|
|
|
try {
|
|
await updateNode(activeTab, {
|
|
metadata: {
|
|
state: nextState,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error('Error updating processed state:', error);
|
|
window.alert('Failed to update processed state. Please try again.');
|
|
}
|
|
};
|
|
|
|
const renderMetadataSection = () => {
|
|
const metadataEntries = Object.entries(currentNodeMetadata).filter(([, value]) => value !== undefined);
|
|
const rawJson = metadataEntries.length > 0 ? JSON.stringify(currentNodeMetadata, null, 2) : '';
|
|
|
|
return (
|
|
<section style={{ ...S.section, minWidth: 0, width: '100%', overflow: 'hidden' }}>
|
|
<div style={S.sectionHeader}>
|
|
<span style={S.sectionLabel}>Metadata</span>
|
|
<div style={S.sectionRule} />
|
|
</div>
|
|
|
|
{metadataEntries.length === 0 ? (
|
|
<div style={{ color: 'var(--rah-text-muted)', fontSize: '12px', fontStyle: 'italic' }}>
|
|
No metadata stored for this node.
|
|
</div>
|
|
) : (
|
|
<div className="metadata-group">
|
|
<div className="metadata-group-label">raw</div>
|
|
<pre className="metadata-raw">{rawJson}</pre>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
};
|
|
|
|
const startTitleEdit = () => {
|
|
if (!currentNode) return;
|
|
setTitleEditValue(currentNode.title || '');
|
|
setTitleEditMode(true);
|
|
};
|
|
|
|
const saveTitle = async () => {
|
|
if (!activeTab) return;
|
|
const nextTitle = titleEditValue.trim();
|
|
if (!nextTitle) {
|
|
window.alert('Title cannot be empty');
|
|
return;
|
|
}
|
|
|
|
setTitleSaving(true);
|
|
try {
|
|
await updateNode(activeTab, { title: nextTitle });
|
|
setTitleEditMode(false);
|
|
setTitleEditValue('');
|
|
} catch (error) {
|
|
console.error('Error saving title:', error);
|
|
window.alert('Failed to save title. Please try again.');
|
|
} finally {
|
|
setTitleSaving(false);
|
|
}
|
|
};
|
|
|
|
const startLinkEdit = () => {
|
|
setLinkEditValue(currentNode?.link || '');
|
|
setLinkEditMode(true);
|
|
};
|
|
|
|
const saveLink = async () => {
|
|
if (!activeTab) return;
|
|
setLinkSaving(true);
|
|
try {
|
|
await updateNode(activeTab, { link: linkEditValue.trim() || null });
|
|
setLinkEditMode(false);
|
|
setLinkEditValue('');
|
|
} catch (error) {
|
|
console.error('Error saving link:', error);
|
|
window.alert('Failed to save URL. Please try again.');
|
|
} finally {
|
|
setLinkSaving(false);
|
|
}
|
|
};
|
|
|
|
const startDescEdit = () => {
|
|
setDescEditValue(currentNode?.description || '');
|
|
setDescEditMode(true);
|
|
};
|
|
|
|
const saveDesc = async () => {
|
|
if (!activeTab) return;
|
|
setDescSaving(true);
|
|
try {
|
|
await updateNode(activeTab, { description: descEditValue });
|
|
setDescEditMode(false);
|
|
setDescEditValue('');
|
|
} catch (error) {
|
|
console.error('Error saving description:', error);
|
|
window.alert('Failed to save description. Please try again.');
|
|
} finally {
|
|
setDescSaving(false);
|
|
}
|
|
};
|
|
|
|
const regenerateDescription = async (nodeId: number) => {
|
|
setRegeneratingDescription(nodeId);
|
|
try {
|
|
const response = await fetch(`/api/nodes/${nodeId}/regenerate-description`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to regenerate description');
|
|
}
|
|
|
|
const result = await response.json();
|
|
if (result.node) {
|
|
setNodesData(prev => ({ ...prev, [nodeId]: result.node }));
|
|
}
|
|
} catch (error) {
|
|
console.error('Error regenerating description:', error);
|
|
window.alert('Failed to regenerate description. Please try again.');
|
|
} finally {
|
|
setRegeneratingDescription(null);
|
|
}
|
|
};
|
|
|
|
const startSourceEdit = () => {
|
|
setSourceEditValue(currentNode?.source || '');
|
|
setSourceEditMode(true);
|
|
};
|
|
|
|
const saveSource = async () => {
|
|
if (!activeTab) return;
|
|
setSourceSaving(true);
|
|
try {
|
|
await updateNode(activeTab, { source: sourceEditValue });
|
|
setSourceEditMode(false);
|
|
setSourceEditValue('');
|
|
} catch (error) {
|
|
console.error('Error saving source:', error);
|
|
window.alert('Failed to save source. Please try again.');
|
|
} finally {
|
|
setSourceSaving(false);
|
|
}
|
|
};
|
|
|
|
const saveContext = async (value: string) => {
|
|
if (!activeTab) return;
|
|
setContextSaving(true);
|
|
try {
|
|
await updateNode(activeTab, { context_id: value ? Number(value) : null });
|
|
setContextMenuOpen(false);
|
|
} catch (error) {
|
|
console.error('Error saving context:', error);
|
|
window.alert('Failed to save context. Please try again.');
|
|
} finally {
|
|
setContextSaving(false);
|
|
}
|
|
};
|
|
|
|
const embedContent = async (nodeId: number) => {
|
|
setEmbeddingNode(nodeId);
|
|
try {
|
|
const response = await fetch('/api/ingestion', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ nodeId }),
|
|
});
|
|
|
|
const result = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(result.error || 'Failed to embed content');
|
|
}
|
|
|
|
await fetchNodeData(nodeId);
|
|
} catch (error) {
|
|
console.error('Error embedding content:', error);
|
|
window.alert(`Failed to embed content: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
} finally {
|
|
setEmbeddingNode(null);
|
|
}
|
|
};
|
|
|
|
const createEdgeWithExplanation = async (targetNodeId: number, explanation: string) => {
|
|
if (activeTab === null) return;
|
|
try {
|
|
const response = await fetch('/api/edges', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
from_node_id: activeTab,
|
|
to_node_id: targetNodeId,
|
|
source: 'user',
|
|
explanation: explanation || '',
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to create edge');
|
|
}
|
|
|
|
await fetchEdgesData(activeTab);
|
|
} catch (error) {
|
|
console.error('Error creating edge:', error);
|
|
window.alert('Failed to create connection. Please try again.');
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const startEditEdgeExplanation = (edgeId: number, currentExplanation?: string) => {
|
|
setEdgeEditingId(edgeId);
|
|
setEdgeEditingValue(currentExplanation || '');
|
|
};
|
|
|
|
const cancelEditEdgeExplanation = () => {
|
|
setEdgeEditingId(null);
|
|
setEdgeEditingValue('');
|
|
};
|
|
|
|
const saveEdgeExplanation = async (
|
|
edgeId: number,
|
|
currentContext: Record<string, unknown> | null | undefined
|
|
) => {
|
|
if (activeTab === null) return;
|
|
setEdgeSavingId(edgeId);
|
|
try {
|
|
const response = await fetch(`/api/edges/${edgeId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
context: {
|
|
...(currentContext ?? {}),
|
|
explanation: edgeEditingValue,
|
|
},
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to update edge');
|
|
}
|
|
|
|
await fetchEdgesData(activeTab);
|
|
cancelEditEdgeExplanation();
|
|
} catch (error) {
|
|
console.error('Failed updating edge explanation:', error);
|
|
window.alert('Failed to update connection explanation.');
|
|
} finally {
|
|
setEdgeSavingId(null);
|
|
}
|
|
};
|
|
|
|
const deleteEdge = async (edgeId: number) => {
|
|
if (activeTab === null) return;
|
|
setDeletingEdge(edgeId);
|
|
try {
|
|
const response = await fetch(`/api/edges/${edgeId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to delete edge');
|
|
}
|
|
|
|
await fetchEdgesData(activeTab);
|
|
} catch (error) {
|
|
console.error('Error deleting edge:', error);
|
|
window.alert('Failed to delete connection. Please try again.');
|
|
} finally {
|
|
setDeletingEdge(null);
|
|
}
|
|
};
|
|
|
|
const executeDeleteNode = async (nodeId: number) => {
|
|
setDeletingNode(nodeId);
|
|
try {
|
|
const response = await fetch(`/api/nodes/${nodeId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to delete node');
|
|
}
|
|
|
|
onTabClose(nodeId);
|
|
setNodesData(prev => {
|
|
const next = { ...prev };
|
|
delete next[nodeId];
|
|
return next;
|
|
});
|
|
} catch (error) {
|
|
console.error('Error deleting node:', error);
|
|
window.alert('Failed to delete node. Please try again.');
|
|
} finally {
|
|
setDeletingNode(null);
|
|
}
|
|
};
|
|
|
|
const renderStatusIndicator = () => {
|
|
if (!currentNode || activeTab === null) return null;
|
|
|
|
const chunkStatus = currentNode.chunk_status ?? null;
|
|
if (embeddingNode === activeTab || chunkStatus === 'chunking') {
|
|
return <Loader size={12} className="animate-spin" style={{ color: '#facc15', flexShrink: 0 }} />;
|
|
}
|
|
|
|
if (chunkStatus === 'error') {
|
|
return (
|
|
<button
|
|
onClick={() => void embedContent(activeTab)}
|
|
className="status-button"
|
|
title="Embedding failed - click to retry"
|
|
>
|
|
<Database size={10} />
|
|
Retry
|
|
</button>
|
|
);
|
|
}
|
|
|
|
if (chunkStatus === 'not_chunked') {
|
|
return (
|
|
<span className="status-chip" title="Source changed and is queued for embedding">
|
|
Pending embed
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// ── Shared inline style constants (styled-jsx doesn't apply inside helper fns) ──
|
|
const S = {
|
|
section: {
|
|
display: 'flex' as const,
|
|
flexDirection: 'column' as const,
|
|
gap: '10px',
|
|
paddingTop: '18px',
|
|
marginTop: '4px',
|
|
},
|
|
sectionHeader: {
|
|
display: 'flex' as const,
|
|
alignItems: 'center' as const,
|
|
gap: '10px',
|
|
marginBottom: '2px',
|
|
},
|
|
sectionLabel: {
|
|
fontSize: '9.5px',
|
|
letterSpacing: '0.1em',
|
|
textTransform: 'uppercase' as const,
|
|
color: 'var(--rah-text-muted)',
|
|
fontWeight: 600,
|
|
flexShrink: 0,
|
|
},
|
|
sectionRule: {
|
|
flex: 1,
|
|
height: '1px',
|
|
background: 'var(--rah-border)',
|
|
},
|
|
iconBtn: {
|
|
display: 'inline-flex' as const,
|
|
alignItems: 'center' as const,
|
|
justifyContent: 'center' as const,
|
|
width: '22px',
|
|
height: '22px',
|
|
background: 'transparent',
|
|
border: '1px solid transparent',
|
|
borderRadius: '5px',
|
|
color: 'var(--rah-text-muted)',
|
|
cursor: 'pointer',
|
|
padding: 0,
|
|
},
|
|
editorBlock: {
|
|
display: 'flex' as const,
|
|
flexDirection: 'column' as const,
|
|
gap: '10px',
|
|
},
|
|
textarea: {
|
|
display: 'block' as const,
|
|
width: '100%',
|
|
boxSizing: 'border-box' as const,
|
|
background: 'var(--rah-bg-surface)',
|
|
border: '1px solid var(--rah-border-strong)',
|
|
borderRadius: '8px',
|
|
color: 'var(--rah-text-base)',
|
|
fontFamily: 'inherit',
|
|
outline: 'none',
|
|
padding: '12px',
|
|
},
|
|
editorActions: {
|
|
display: 'flex' as const,
|
|
justifyContent: 'flex-end' as const,
|
|
gap: '8px',
|
|
},
|
|
primaryBtn: {
|
|
display: 'inline-flex' as const,
|
|
alignItems: 'center' as const,
|
|
gap: '5px',
|
|
padding: '6px 12px',
|
|
border: 'none',
|
|
background: 'var(--rah-accent-green)',
|
|
color: 'var(--rah-text-inverse)',
|
|
fontSize: '11px',
|
|
fontWeight: 600,
|
|
borderRadius: '6px',
|
|
cursor: 'pointer',
|
|
fontFamily: 'inherit',
|
|
},
|
|
secondaryBtn: {
|
|
display: 'inline-flex' as const,
|
|
alignItems: 'center' as const,
|
|
gap: '5px',
|
|
padding: '6px 12px',
|
|
border: '1px solid var(--rah-border-strong)',
|
|
background: 'transparent',
|
|
color: 'var(--rah-text-soft)',
|
|
fontSize: '11px',
|
|
borderRadius: '6px',
|
|
cursor: 'pointer',
|
|
fontFamily: 'inherit',
|
|
},
|
|
};
|
|
|
|
const renderDescriptionSection = () => (
|
|
<section
|
|
style={S.section}
|
|
onMouseEnter={() => setHoveredSection('description')}
|
|
onMouseLeave={() => setHoveredSection(prev => prev === 'description' ? null : prev)}
|
|
>
|
|
{/* Section header with extending rule */}
|
|
<div style={S.sectionHeader}>
|
|
<span style={S.sectionLabel}>Description</span>
|
|
<div style={S.sectionRule} />
|
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', opacity: hoveredSection === 'description' || descEditMode ? 1 : 0, transition: 'opacity 150ms ease' }}>
|
|
{!descEditMode && (
|
|
<>
|
|
<button type="button" style={S.iconBtn} onClick={startDescEdit} title="Edit">
|
|
<Pencil size={11} />
|
|
</button>
|
|
{activeTab !== null && (
|
|
<button
|
|
type="button"
|
|
style={S.iconBtn}
|
|
onClick={() => void regenerateDescription(activeTab)}
|
|
disabled={regeneratingDescription === activeTab}
|
|
title="Regenerate"
|
|
>
|
|
{regeneratingDescription === activeTab
|
|
? <Loader size={11} className="animate-spin" />
|
|
: <RefreshCw size={11} />}
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{descEditMode ? (
|
|
<div style={S.editorBlock}>
|
|
<textarea
|
|
ref={descTextareaRef}
|
|
value={descEditValue}
|
|
onChange={(e) => setDescEditValue(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); void saveDesc(); }
|
|
if (e.key === 'Escape') { e.preventDefault(); setDescEditMode(false); setDescEditValue(''); }
|
|
}}
|
|
disabled={descSaving}
|
|
style={{ ...S.textarea, minHeight: '90px', lineHeight: '1.6', fontSize: '13.5px' }}
|
|
rows={4}
|
|
placeholder="Add a concise description..."
|
|
/>
|
|
<div style={S.editorActions}>
|
|
<button type="button" style={S.secondaryBtn} onClick={() => { setDescEditMode(false); setDescEditValue(''); }} disabled={descSaving}>
|
|
<X size={13} /> Cancel
|
|
</button>
|
|
<button type="button" style={S.primaryBtn} onClick={() => void saveDesc()} disabled={descSaving}>
|
|
{descSaving ? <Loader size={13} className="animate-spin" /> : <Save size={13} />} Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
onClick={startDescEdit}
|
|
style={{ color: 'var(--rah-text-base)', fontSize: '13.5px', lineHeight: '1.65', cursor: 'text' }}
|
|
>
|
|
{currentNode?.description
|
|
? parseAndRenderContent(currentNode.description, onNodeClick || onTabSelect)
|
|
: <span style={{ color: 'var(--rah-text-muted)', fontStyle: 'italic' }}>Add a description...</span>}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
|
|
const renderConnectionsSection = () => (
|
|
<section style={S.section}>
|
|
{/* Section header */}
|
|
<div style={S.sectionHeader}>
|
|
<span style={S.sectionLabel}>Connections</span>
|
|
<div style={S.sectionRule} />
|
|
</div>
|
|
|
|
{loadingEdges.has(activeTab ?? -1) ? (
|
|
<span style={{ color: 'var(--rah-text-muted)', fontSize: '12px' }}>Loading…</span>
|
|
) : currentEdges.length === 0 ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setEdgeSearchOpen(true)}
|
|
style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
|
alignSelf: 'flex-start',
|
|
background: 'transparent',
|
|
border: '1px dashed var(--rah-border-strong)',
|
|
borderRadius: '8px',
|
|
color: 'var(--rah-text-muted)',
|
|
fontSize: '12px',
|
|
fontFamily: 'inherit',
|
|
cursor: 'pointer',
|
|
padding: '7px 12px',
|
|
}}
|
|
>
|
|
<Plus size={13} />
|
|
Add connection
|
|
</button>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
{visibleEdges.map((connection) => {
|
|
const isOutgoing = connection.edge.from_node_id === activeTab;
|
|
const explanation = typeof connection.edge.context?.explanation === 'string'
|
|
? connection.edge.context.explanation
|
|
: '';
|
|
const isHovered = hoveredConnectionId === connection.id;
|
|
|
|
return (
|
|
<div
|
|
key={connection.id}
|
|
onMouseEnter={() => setHoveredConnectionId(connection.id)}
|
|
onMouseLeave={() => setHoveredConnectionId(null)}
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '2px',
|
|
padding: '6px 8px',
|
|
borderRadius: '6px',
|
|
background: isHovered ? 'var(--rah-bg-hover)' : 'transparent',
|
|
transition: 'background 120ms ease',
|
|
margin: '0 -8px',
|
|
}}
|
|
>
|
|
{/* Title row */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}>
|
|
<span style={{
|
|
fontSize: '11px',
|
|
color: isOutgoing ? 'var(--rah-accent-green)' : '#f59e0b',
|
|
flexShrink: 0,
|
|
width: '12px',
|
|
textAlign: 'center',
|
|
}}>
|
|
{isOutgoing ? '↗' : '↙'}
|
|
</span>
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>
|
|
{getNodeIcon(connection.connected_node, 12)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => (onNodeClick || onTabSelect)(connection.connected_node.id)}
|
|
title={connection.connected_node.title}
|
|
style={{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
background: 'transparent',
|
|
border: 'none',
|
|
padding: 0,
|
|
color: 'var(--rah-text-base)',
|
|
fontSize: '13px',
|
|
fontWeight: 500,
|
|
textAlign: 'left',
|
|
cursor: 'pointer',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
fontFamily: 'inherit',
|
|
}}
|
|
>
|
|
{connection.connected_node.title}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void deleteEdge(connection.edge.id)}
|
|
disabled={deletingEdge === connection.edge.id}
|
|
title="Remove"
|
|
style={{
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
background: 'transparent',
|
|
border: 'none',
|
|
color: 'var(--rah-text-muted)',
|
|
cursor: 'pointer',
|
|
padding: '2px',
|
|
borderRadius: '4px',
|
|
flexShrink: 0,
|
|
opacity: isHovered ? 1 : 0,
|
|
transition: 'opacity 120ms ease, color 120ms ease',
|
|
}}
|
|
>
|
|
{deletingEdge === connection.edge.id ? '…' : <Trash2 size={11} />}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Explanation row */}
|
|
{edgeEditingId === connection.edge.id ? (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginLeft: '18px' }}>
|
|
<input
|
|
value={edgeEditingValue}
|
|
onChange={(e) => setEdgeEditingValue(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') { e.preventDefault(); void saveEdgeExplanation(connection.edge.id, connection.edge.context); }
|
|
if (e.key === 'Escape') { e.preventDefault(); cancelEditEdgeExplanation(); }
|
|
}}
|
|
placeholder="Describe this connection..."
|
|
autoFocus
|
|
style={{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
background: 'var(--rah-bg-surface)',
|
|
border: '1px solid var(--rah-border-strong)',
|
|
borderRadius: '6px',
|
|
padding: '5px 8px',
|
|
color: 'var(--rah-text-base)',
|
|
fontSize: '12px',
|
|
outline: 'none',
|
|
fontFamily: 'inherit',
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => void saveEdgeExplanation(connection.edge.id, connection.edge.context)}
|
|
disabled={edgeSavingId === connection.edge.id}
|
|
style={{ padding: '5px 8px', fontSize: '10px', border: 'none', background: 'var(--rah-accent-green)', color: 'var(--rah-text-inverse)', borderRadius: '5px', cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0 }}
|
|
>
|
|
Save
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={cancelEditEdgeExplanation}
|
|
style={{ padding: '5px 8px', fontSize: '10px', border: '1px solid var(--rah-border-strong)', background: 'transparent', color: 'var(--rah-text-soft)', borderRadius: '5px', cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0 }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
) : (explanation || isHovered) ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => startEditEdgeExplanation(connection.edge.id, explanation)}
|
|
title={explanation || 'Add a note about this connection'}
|
|
style={{
|
|
display: 'block',
|
|
marginLeft: '18px',
|
|
padding: 0,
|
|
border: 'none',
|
|
background: 'transparent',
|
|
color: explanation ? 'var(--rah-text-muted)' : 'var(--rah-border-stronger)',
|
|
fontSize: '11.5px',
|
|
lineHeight: 1.4,
|
|
textAlign: 'left',
|
|
cursor: 'pointer',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
maxWidth: '100%',
|
|
fontFamily: 'inherit',
|
|
fontStyle: explanation ? 'normal' : 'italic',
|
|
opacity: isHovered ? 1 : explanation ? 0.7 : 0,
|
|
transition: 'opacity 120ms ease',
|
|
}}
|
|
>
|
|
{explanation || 'Add note…'}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Expand / collapse */}
|
|
{currentEdges.length > 3 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (activeTab === null) return;
|
|
setEdgesExpanded(prev => ({ ...prev, [activeTab]: !currentEdgesExpanded }));
|
|
}}
|
|
style={{
|
|
display: 'block',
|
|
background: 'transparent',
|
|
border: 'none',
|
|
color: 'var(--rah-text-muted)',
|
|
fontSize: '11px',
|
|
fontFamily: 'inherit',
|
|
cursor: 'pointer',
|
|
padding: '2px 0',
|
|
textAlign: 'left',
|
|
}}
|
|
>
|
|
{currentEdgesExpanded ? '↑ Show less' : `+ ${currentEdges.length - 3} more`}
|
|
</button>
|
|
)}
|
|
|
|
{/* Add connection */}
|
|
{currentEdges.length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setEdgeSearchOpen(true)}
|
|
style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: '5px',
|
|
alignSelf: 'flex-start',
|
|
background: 'transparent',
|
|
border: 'none',
|
|
color: 'var(--rah-text-muted)',
|
|
fontSize: '11px',
|
|
fontFamily: 'inherit',
|
|
cursor: 'pointer',
|
|
padding: '2px 0',
|
|
}}
|
|
>
|
|
<Plus size={11} />
|
|
Add connection
|
|
</button>
|
|
)}
|
|
</section>
|
|
);
|
|
|
|
const renderSourceSection = () => {
|
|
const sourceContent = currentNode?.source || '';
|
|
|
|
return (
|
|
<section
|
|
style={S.section}
|
|
onMouseEnter={() => setHoveredSection('source')}
|
|
onMouseLeave={() => setHoveredSection(prev => prev === 'source' ? null : prev)}
|
|
>
|
|
{/* Section header with extending rule */}
|
|
<div style={S.sectionHeader}>
|
|
<span style={S.sectionLabel}>Source</span>
|
|
<div style={S.sectionRule} />
|
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', opacity: hoveredSection === 'source' || sourceEditMode ? 1 : 0, transition: 'opacity 150ms ease' }}>
|
|
{!sourceEditMode && (
|
|
<button type="button" style={S.iconBtn} onClick={startSourceEdit} title="Edit source">
|
|
<Pencil size={11} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{sourceEditMode ? (
|
|
<div style={S.editorBlock}>
|
|
<SourceEditor
|
|
value={sourceEditValue}
|
|
onChange={setSourceEditValue}
|
|
disabled={sourceSaving}
|
|
/>
|
|
<div style={S.editorActions}>
|
|
<button type="button" style={S.secondaryBtn} onClick={() => { setSourceEditMode(false); setSourceEditValue(''); }} disabled={sourceSaving}>
|
|
<X size={13} /> Cancel
|
|
</button>
|
|
<button type="button" style={S.primaryBtn} onClick={() => void saveSource()} disabled={sourceSaving}>
|
|
{sourceSaving ? <Loader size={13} className="animate-spin" /> : <Save size={13} />} Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : sourceContent ? (
|
|
<div style={{ minHeight: '240px' }}>
|
|
<SourceReader
|
|
content={sourceContent}
|
|
onTextSelect={onTextSelect ? (text) => onTextSelect(activeTab!, currentNode?.title || 'Untitled', text) : undefined}
|
|
highlightedText={highlightedPassage?.nodeId === activeTab ? highlightedPassage.selectedText : null}
|
|
onContentClick={startSourceEdit}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={startSourceEdit}
|
|
style={{
|
|
display: 'block',
|
|
width: '100%',
|
|
background: 'transparent',
|
|
border: '1px dashed var(--rah-border-strong)',
|
|
borderRadius: '8px',
|
|
fontFamily: 'inherit',
|
|
fontSize: '13px',
|
|
fontStyle: 'italic',
|
|
color: 'var(--rah-text-muted)',
|
|
cursor: 'text',
|
|
padding: '16px',
|
|
textAlign: 'left',
|
|
}}
|
|
>
|
|
Add source content...
|
|
</button>
|
|
)}
|
|
</section>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="focus-panel">
|
|
{!activeTab ? (
|
|
<div className="empty-state">Select a node from the left panel to view details</div>
|
|
) : loadingNodes.has(activeTab) ? (
|
|
<div className="empty-state">Loading...</div>
|
|
) : !currentNode ? (
|
|
<div className="empty-state">Node not found.</div>
|
|
) : (
|
|
<div className={`document-view ${currentProcessedState === 'processed' ? 'document-view-processed' : ''}`}>
|
|
|
|
{/* ── Title ── */}
|
|
<div className="title-row">
|
|
<div className="title-stack">
|
|
{titleEditMode ? (
|
|
<input
|
|
ref={titleInputRef}
|
|
type="text"
|
|
value={titleEditValue}
|
|
onChange={(e) => setTitleEditValue(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') { e.preventDefault(); void saveTitle(); }
|
|
if (e.key === 'Escape') { e.preventDefault(); skipTitleBlurRef.current = true; setTitleEditMode(false); setTitleEditValue(''); }
|
|
}}
|
|
onBlur={() => {
|
|
if (skipTitleBlurRef.current) { skipTitleBlurRef.current = false; return; }
|
|
void saveTitle();
|
|
}}
|
|
disabled={titleSaving}
|
|
className="title-input"
|
|
placeholder="Enter title..."
|
|
/>
|
|
) : (
|
|
<button type="button" className="title-button" onClick={startTitleEdit}>
|
|
{currentNode.title || 'Untitled'}
|
|
</button>
|
|
)}
|
|
|
|
<div className="node-status-row">
|
|
<button
|
|
type="button"
|
|
onClick={() => void toggleProcessedState()}
|
|
className={`processed-control ${currentProcessedState === 'processed' ? 'is-processed' : ''}`}
|
|
title="Toggle processed state"
|
|
>
|
|
<span className={`processed-control-box ${currentProcessedState === 'processed' ? 'is-processed' : ''}`}>
|
|
<Check size={12} strokeWidth={2.8} />
|
|
</span>
|
|
<span className="processed-control-copy">
|
|
<span className="processed-control-label">processed</span>
|
|
</span>
|
|
</button>
|
|
|
|
{currentProcessedState === 'processed' && (
|
|
<span className="processed-indicator-badge">processed</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Properties block ── */}
|
|
<div className="props-block">
|
|
|
|
{/* Node identity row — with collapse toggle + delete */}
|
|
<div className="prop-row node-row">
|
|
<div className="prop-label">node</div>
|
|
<div className="prop-value">
|
|
<span className="node-meta-value">
|
|
<span className="node-meta-icon">{getNodeIcon(currentNode, 12)}</span>
|
|
<span className="node-id-pill">#{currentNode.id}</span>
|
|
</span>
|
|
</div>
|
|
<div className="node-row-actions">
|
|
<button
|
|
type="button"
|
|
className="props-toggle"
|
|
onClick={() => setPropsCollapsed(c => !c)}
|
|
title={propsCollapsed ? 'Expand properties' : 'Collapse properties'}
|
|
>
|
|
{propsCollapsed ? <ChevronRight size={18} strokeWidth={2.5} /> : <ChevronDown size={18} strokeWidth={2.5} />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="delete-node-button"
|
|
onClick={() => setPendingDeleteNodeId(activeTab)}
|
|
disabled={deletingNode === activeTab}
|
|
title="Delete node"
|
|
>
|
|
{deletingNode === activeTab ? '…' : <Trash2 size={11} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{!propsCollapsed && (<>
|
|
|
|
{/* Status indicator row (only when relevant) */}
|
|
{renderStatusIndicator() !== null && (
|
|
<div className="prop-row">
|
|
<div className="prop-label">embed</div>
|
|
<div className="prop-value">{renderStatusIndicator()}</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Link */}
|
|
<div className="prop-row">
|
|
<div className="prop-label">link</div>
|
|
<div className="prop-value">
|
|
{linkEditMode ? (
|
|
<input
|
|
ref={linkInputRef}
|
|
type="url"
|
|
value={linkEditValue}
|
|
onChange={(e) => setLinkEditValue(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') { e.preventDefault(); void saveLink(); }
|
|
if (e.key === 'Escape') { e.preventDefault(); skipLinkBlurRef.current = true; setLinkEditMode(false); setLinkEditValue(''); }
|
|
}}
|
|
onBlur={() => {
|
|
if (skipLinkBlurRef.current) { skipLinkBlurRef.current = false; return; }
|
|
void saveLink();
|
|
}}
|
|
disabled={linkSaving}
|
|
className="prop-input"
|
|
placeholder="https://..."
|
|
/>
|
|
) : currentNode.link ? (
|
|
normalizedCurrentLink ? (
|
|
<a
|
|
href={normalizedCurrentLink}
|
|
className="prop-link"
|
|
onClick={(e) => {
|
|
if (e.metaKey || e.ctrlKey) { e.preventDefault(); startLinkEdit(); return; }
|
|
if (!shouldOpenExternally(normalizedCurrentLink)) return;
|
|
e.preventDefault();
|
|
void openExternalUrl(normalizedCurrentLink).catch(() => window.alert(`Unable to open ${currentNode.link}`));
|
|
}}
|
|
title={`${normalizedCurrentLink} (Cmd+Click to edit)`}
|
|
>
|
|
{currentNode.link}
|
|
</a>
|
|
) : (
|
|
<button type="button" className="prop-empty-btn" onClick={startLinkEdit} title="Invalid link. Click to edit.">
|
|
{currentNode.link}
|
|
</button>
|
|
)
|
|
) : (
|
|
<button type="button" className="prop-empty-btn" onClick={startLinkEdit}>
|
|
Empty
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="prop-row">
|
|
<div className="prop-label">context</div>
|
|
<div className="prop-value context-prop-value">
|
|
<div className="context-select-shell" ref={contextMenuRef}>
|
|
<button
|
|
type="button"
|
|
className="context-select-trigger"
|
|
disabled={contextSaving}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setContextMenuOpen((prev) => !prev);
|
|
}}
|
|
>
|
|
<span className="context-select-current">
|
|
<span className="context-select-badge">
|
|
<Folder size={13} />
|
|
</span>
|
|
<span className={currentNode.context ? 'context-select-name' : 'context-select-empty'}>
|
|
{currentNode.context?.name || 'No context'}
|
|
</span>
|
|
</span>
|
|
<ChevronDown size={13} className={`context-select-chevron ${contextMenuOpen ? 'open' : ''}`} />
|
|
</button>
|
|
|
|
{contextMenuOpen ? (
|
|
<div className="context-select-menu">
|
|
<button
|
|
type="button"
|
|
className={`context-option ${currentNode.context_id == null ? 'active' : ''}`}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
void saveContext('');
|
|
}}
|
|
>
|
|
No context
|
|
</button>
|
|
{availableContexts.map((context) => (
|
|
<button
|
|
key={context.id}
|
|
type="button"
|
|
className={`context-option ${currentNode.context_id === context.id ? 'active' : ''}`}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
void saveContext(String(context.id));
|
|
}}
|
|
>
|
|
<span>{context.name}</span>
|
|
<span className="context-option-count">{context.count}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Connections */}
|
|
<div className="prop-row prop-row-top">
|
|
<div className="prop-label">edge</div>
|
|
<div className="prop-value">
|
|
{loadingEdges.has(activeTab) ? (
|
|
<span className="prop-muted">Loading…</span>
|
|
) : currentEdges.length === 0 ? (
|
|
<button type="button" className="prop-empty-btn" onClick={() => setEdgeSearchOpen(true)}>
|
|
Empty
|
|
</button>
|
|
) : (
|
|
<div className="conn-list">
|
|
{visibleEdges.map((connection) => {
|
|
const isOut = connection.edge.from_node_id === activeTab;
|
|
return (
|
|
<div
|
|
key={connection.id}
|
|
className="conn-row"
|
|
onMouseEnter={() => setHoveredConnectionId(connection.id)}
|
|
onMouseLeave={() => setHoveredConnectionId(null)}
|
|
>
|
|
<span className={`conn-dir ${isOut ? 'out' : 'in'}`}>{isOut ? '↗' : '↙'}</span>
|
|
<span className="conn-icon">{getNodeIcon(connection.connected_node, 12)}</span>
|
|
<button
|
|
type="button"
|
|
className="conn-title-btn"
|
|
onClick={() => (onNodeClick || onTabSelect)(connection.connected_node.id)}
|
|
title={connection.connected_node.title}
|
|
>
|
|
{connection.connected_node.title}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="conn-del-btn"
|
|
style={{ opacity: hoveredConnectionId === connection.id ? 1 : 0 }}
|
|
onClick={() => void deleteEdge(connection.edge.id)}
|
|
disabled={deletingEdge === connection.edge.id}
|
|
title="Remove"
|
|
>
|
|
<Trash2 size={10} />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
{currentEdges.length > 3 && (
|
|
<button
|
|
type="button"
|
|
className="conn-more-btn"
|
|
onClick={() => setEdgesExpanded(prev => ({ ...prev, [activeTab]: !currentEdgesExpanded }))}
|
|
>
|
|
{currentEdgesExpanded ? '↑ Show less' : `+ ${currentEdges.length - 3} more`}
|
|
</button>
|
|
)}
|
|
<button type="button" className="conn-add-btn" onClick={() => setEdgeSearchOpen(true)}>
|
|
<Plus size={11} /> Add connection
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="prop-row prop-row-top">
|
|
<div className="prop-label">desc</div>
|
|
<div className="prop-value">
|
|
{descEditMode ? (
|
|
<div className="desc-edit-block">
|
|
<textarea
|
|
ref={descTextareaRef}
|
|
value={descEditValue}
|
|
onChange={(e) => setDescEditValue(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); void saveDesc(); }
|
|
if (e.key === 'Escape') { e.preventDefault(); setDescEditMode(false); setDescEditValue(''); }
|
|
}}
|
|
disabled={descSaving}
|
|
className="desc-textarea"
|
|
placeholder="Add a description..."
|
|
rows={4}
|
|
/>
|
|
<div className="desc-actions">
|
|
<button type="button" className="btn-secondary" onClick={() => { setDescEditMode(false); setDescEditValue(''); }} disabled={descSaving}>
|
|
<X size={12} /> Cancel
|
|
</button>
|
|
<button type="button" className="btn-primary" onClick={() => void saveDesc()} disabled={descSaving}>
|
|
{descSaving ? <Loader size={12} className="animate-spin" /> : <Save size={12} />} Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="desc-read-wrap">
|
|
<div className="desc-text" onClick={startDescEdit}>
|
|
{currentNode.description
|
|
? parseAndRenderContent(currentNode.description, onNodeClick || onTabSelect)
|
|
: <span className="prop-empty-btn" style={{ display: 'block' }}>Empty</span>}
|
|
</div>
|
|
<div className="desc-hover-actions">
|
|
<button type="button" className="icon-btn" onClick={startDescEdit} title="Edit">
|
|
<Pencil size={11} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="icon-btn"
|
|
onClick={() => void regenerateDescription(activeTab)}
|
|
disabled={regeneratingDescription === activeTab}
|
|
title="Regenerate"
|
|
>
|
|
{regeneratingDescription === activeTab ? <Loader size={11} className="animate-spin" /> : <RefreshCw size={11} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
</>)}
|
|
|
|
{/* Drag handle / ID (hidden, for drag-to-chat) */}
|
|
<span
|
|
draggable
|
|
onDragStart={(e: DragEvent<HTMLSpanElement>) => {
|
|
const title = currentNode.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 }));
|
|
e.dataTransfer.setData('text/plain', `[NODE:${activeTab}:"${title}"]`);
|
|
}}
|
|
className="node-drag-handle"
|
|
title={`Node ${activeTab} — drag to chat`}
|
|
>
|
|
{activeTab}
|
|
</span>
|
|
</div>
|
|
|
|
{renderSourceSection()}
|
|
{renderMetadataSection()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<NodeSearchModal
|
|
isOpen={edgeSearchOpen}
|
|
onClose={() => setEdgeSearchOpen(false)}
|
|
excludeNodeId={activeTab}
|
|
onEdgeCreate={async (nodeId, explanation) => {
|
|
if (!explanation || !explanation.trim()) {
|
|
window.alert('Add a short explanation for the relationship before creating the edge.');
|
|
return;
|
|
}
|
|
await createEdgeWithExplanation(nodeId, explanation.trim());
|
|
}}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={pendingDeleteNodeId !== null}
|
|
title="Delete this node?"
|
|
message="This will permanently remove the node and its data."
|
|
confirmLabel="Delete"
|
|
onConfirm={() => {
|
|
if (pendingDeleteNodeId === null) return;
|
|
void executeDeleteNode(pendingDeleteNodeId);
|
|
setPendingDeleteNodeId(null);
|
|
}}
|
|
onCancel={() => setPendingDeleteNodeId(null)}
|
|
/>
|
|
|
|
<style jsx>{`
|
|
/* ── Layout ── */
|
|
.focus-panel {
|
|
height: 100%;
|
|
overflow: auto;
|
|
padding: 24px 20px 32px;
|
|
}
|
|
|
|
.document-view {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 100%;
|
|
width: 100%;
|
|
max-width: 980px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.document-view-processed {
|
|
position: relative;
|
|
}
|
|
|
|
.document-view-processed::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 4px;
|
|
left: -14px;
|
|
bottom: 4px;
|
|
width: 2px;
|
|
border-radius: 999px;
|
|
background: linear-gradient(180deg, rgba(74, 222, 128, 0.85), rgba(74, 222, 128, 0.15));
|
|
}
|
|
|
|
.empty-state {
|
|
color: var(--rah-text-muted);
|
|
font-size: 13px;
|
|
text-align: center;
|
|
margin-top: 40px;
|
|
}
|
|
|
|
/* ── Title row ── */
|
|
.title-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
margin-bottom: 18px;
|
|
}
|
|
|
|
.title-stack {
|
|
display: flex;
|
|
flex: 1;
|
|
min-width: 0;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.node-status-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.node-meta-value {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
}
|
|
|
|
.node-meta-icon {
|
|
display: flex;
|
|
align-items: center;
|
|
color: var(--rah-text-muted);
|
|
}
|
|
|
|
.node-id-pill {
|
|
font-size: 10px;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
|
|
color: var(--rah-text-muted);
|
|
background: var(--rah-bg-surface);
|
|
border: 1px solid var(--rah-border);
|
|
border-radius: 4px;
|
|
padding: 1px 5px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.title-button,
|
|
.title-input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--rah-text-active);
|
|
font-family: inherit;
|
|
text-align: left;
|
|
}
|
|
|
|
.title-button {
|
|
padding: 0;
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
line-height: 1.25;
|
|
cursor: text;
|
|
white-space: normal;
|
|
word-break: break-word;
|
|
letter-spacing: -0.01em;
|
|
}
|
|
|
|
.title-input {
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
line-height: 1.25;
|
|
letter-spacing: -0.01em;
|
|
padding: 0;
|
|
outline: none;
|
|
}
|
|
|
|
.processed-control {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
background: rgba(255, 255, 255, 0.02);
|
|
border: 1px solid var(--rah-border);
|
|
border-radius: 999px;
|
|
padding: 7px 10px;
|
|
cursor: pointer;
|
|
color: var(--rah-text-base);
|
|
transition: border-color 140ms ease, background 140ms ease, transform 140ms ease, box-shadow 140ms ease;
|
|
min-width: 0;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.processed-control:hover {
|
|
border-color: var(--rah-border-strong);
|
|
background: rgba(255, 255, 255, 0.04);
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
|
|
}
|
|
|
|
.processed-control.is-processed {
|
|
border-color: rgba(74, 222, 128, 0.26);
|
|
background: rgba(74, 222, 128, 0.07);
|
|
}
|
|
|
|
.processed-control-box {
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--rah-border-strong);
|
|
background: var(--rah-bg-surface);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: transparent;
|
|
flex-shrink: 0;
|
|
transition: all 140ms ease;
|
|
}
|
|
|
|
.processed-control-box.is-processed {
|
|
border-color: rgba(74, 222, 128, 0.65);
|
|
background: rgba(74, 222, 128, 0.18);
|
|
color: #86efac;
|
|
box-shadow: inset 0 0 0 1px rgba(74, 222, 128, 0.15);
|
|
}
|
|
|
|
.processed-control-copy {
|
|
display: inline-flex;
|
|
min-width: 0;
|
|
text-align: left;
|
|
}
|
|
|
|
.processed-control-label {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--rah-text-active);
|
|
line-height: 1.2;
|
|
text-transform: lowercase;
|
|
}
|
|
|
|
.processed-indicator-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 4px 9px;
|
|
border-radius: 999px;
|
|
background: rgba(74, 222, 128, 0.10);
|
|
border: 1px solid rgba(74, 222, 128, 0.22);
|
|
color: #86efac;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.node-row-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
flex-shrink: 0;
|
|
padding-right: 4px;
|
|
}
|
|
|
|
.delete-node-button {
|
|
color: #ef4444;
|
|
background: transparent;
|
|
border: none;
|
|
width: 26px;
|
|
height: 26px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
border-radius: 5px;
|
|
transition: color 120ms ease, background 120ms ease, transform 120ms ease;
|
|
}
|
|
|
|
.delete-node-button:hover:enabled {
|
|
color: #ef4444;
|
|
background: rgba(239, 68, 68, 0.12);
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
/* ── Properties block ── */
|
|
.props-block {
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.prop-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
min-height: 32px;
|
|
padding: 0;
|
|
}
|
|
|
|
.prop-row-top {
|
|
}
|
|
|
|
.prop-label {
|
|
width: 58px;
|
|
flex-shrink: 0;
|
|
padding: 7px 8px 7px 0;
|
|
color: var(--rah-text-muted);
|
|
font-size: 11px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.prop-value {
|
|
flex: 1;
|
|
min-width: 0;
|
|
padding: 7px 12px 7px 0;
|
|
overflow: hidden;
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.context-prop-value {
|
|
overflow: visible;
|
|
}
|
|
|
|
.props-toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 30px;
|
|
height: 30px;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 5px;
|
|
color: var(--rah-text-soft);
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
transition: color 120ms ease, background 120ms ease, transform 120ms ease;
|
|
}
|
|
|
|
.props-toggle:hover {
|
|
background: var(--rah-bg-hover);
|
|
color: var(--rah-text-active);
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
/* Link */
|
|
.prop-link {
|
|
color: var(--rah-text-soft);
|
|
text-decoration: none;
|
|
font-size: 12px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
display: block;
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
transition: color 120ms ease;
|
|
}
|
|
|
|
.prop-link:hover { color: #3b82f6; }
|
|
|
|
.prop-input {
|
|
width: 100%;
|
|
background: transparent;
|
|
border: none;
|
|
border-bottom: 1px solid var(--rah-border-strong);
|
|
color: var(--rah-text-base);
|
|
font-size: 12px;
|
|
outline: none;
|
|
padding: 0 0 3px 0;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.context-select-shell {
|
|
position: relative;
|
|
z-index: 4;
|
|
}
|
|
|
|
.context-select-trigger {
|
|
width: 100%;
|
|
min-height: 38px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
padding: 0 10px 0 8px;
|
|
border: 1px solid var(--rah-border-strong);
|
|
border-radius: 10px;
|
|
background: linear-gradient(180deg, var(--rah-bg-surface), var(--rah-bg-panel));
|
|
color: var(--rah-text-primary);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.context-select-current {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.context-select-badge {
|
|
width: 22px;
|
|
height: 22px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 7px;
|
|
border: 1px solid var(--rah-border);
|
|
background: var(--rah-bg-base);
|
|
color: var(--rah-text-muted);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.context-select-name,
|
|
.context-select-empty {
|
|
font-size: 12px;
|
|
line-height: 1.2;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.context-select-name {
|
|
color: var(--rah-text-primary);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.context-select-empty {
|
|
color: var(--rah-text-muted);
|
|
font-style: italic;
|
|
}
|
|
|
|
.context-select-chevron {
|
|
color: var(--rah-text-muted);
|
|
flex-shrink: 0;
|
|
transition: transform 120ms ease;
|
|
}
|
|
|
|
.context-select-chevron.open {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
.context-select-menu {
|
|
position: relative;
|
|
margin-top: 6px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
padding: 6px;
|
|
border: 1px solid var(--rah-border);
|
|
border-radius: 12px;
|
|
background: var(--rah-bg-surface);
|
|
box-shadow: var(--rah-shadow-floating);
|
|
}
|
|
|
|
.context-option {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
width: 100%;
|
|
min-height: 32px;
|
|
padding: 0 10px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
background: transparent;
|
|
color: var(--rah-text-secondary);
|
|
font-size: 12px;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.context-option.active {
|
|
background: color-mix(in srgb, var(--rah-accent-green) 10%, var(--rah-bg-panel));
|
|
color: var(--rah-text-primary);
|
|
}
|
|
|
|
.context-option-count {
|
|
font-size: 10px;
|
|
color: var(--rah-text-muted);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.prop-empty-btn {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--rah-text-muted);
|
|
font-size: 12px;
|
|
font-style: italic;
|
|
padding: 0;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.prop-muted {
|
|
color: var(--rah-text-muted);
|
|
font-size: 12px;
|
|
}
|
|
|
|
/* Status chips */
|
|
.status-button,
|
|
.status-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 2px 6px;
|
|
font-size: 10px;
|
|
border-radius: 999px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.status-button {
|
|
color: #ef4444;
|
|
background: transparent;
|
|
border: 1px solid #7f1d1d;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.status-chip {
|
|
color: #f59e0b;
|
|
border: 1px solid #3d3500;
|
|
background: rgba(245, 158, 11, 0.06);
|
|
}
|
|
|
|
/* ── Connections in property ── */
|
|
.conn-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1px;
|
|
width: 100%;
|
|
}
|
|
|
|
.conn-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
padding: 2px 4px;
|
|
border-radius: 4px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.conn-row:hover { background: var(--rah-bg-hover); }
|
|
|
|
.conn-dir {
|
|
font-size: 10px;
|
|
flex-shrink: 0;
|
|
width: 12px;
|
|
text-align: center;
|
|
}
|
|
|
|
.conn-dir.out { color: var(--rah-accent-green); }
|
|
.conn-dir.in { color: #f59e0b; }
|
|
|
|
.conn-icon {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.conn-title-btn {
|
|
flex: 1;
|
|
min-width: 0;
|
|
background: transparent;
|
|
border: none;
|
|
padding: 0;
|
|
color: var(--rah-text-base);
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-family: inherit;
|
|
transition: color 120ms ease;
|
|
}
|
|
|
|
.conn-title-btn:hover { color: var(--rah-accent-green); }
|
|
|
|
.conn-del-btn {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--rah-text-muted);
|
|
cursor: pointer;
|
|
padding: 2px;
|
|
border-radius: 3px;
|
|
flex-shrink: 0;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
transition: opacity 120ms ease, color 120ms ease;
|
|
}
|
|
|
|
.conn-del-btn:hover:enabled { color: #dc2626; }
|
|
|
|
.conn-more-btn {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--rah-text-muted);
|
|
font-size: 11px;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
padding: 3px 4px;
|
|
text-align: left;
|
|
transition: color 120ms ease;
|
|
}
|
|
|
|
.conn-more-btn:hover { color: var(--rah-text-soft); }
|
|
|
|
.conn-add-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--rah-text-muted);
|
|
font-size: 11px;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
padding: 3px 4px;
|
|
transition: color 120ms ease;
|
|
}
|
|
|
|
.conn-add-btn:hover { color: var(--rah-accent-green); }
|
|
|
|
.metadata-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
width: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.metadata-group-label {
|
|
font-size: 10px;
|
|
color: var(--rah-text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.metadata-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
gap: 8px;
|
|
width: 100%;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.metadata-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
padding: 10px 12px;
|
|
border-radius: 10px;
|
|
background: rgba(255, 255, 255, 0.025);
|
|
border: 1px solid var(--rah-border);
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.metadata-key {
|
|
font-size: 10px;
|
|
color: var(--rah-text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.metadata-value {
|
|
font-size: 12px;
|
|
color: var(--rah-text-base);
|
|
line-height: 1.45;
|
|
word-break: break-word;
|
|
overflow-wrap: anywhere;
|
|
min-width: 0;
|
|
}
|
|
|
|
.metadata-raw {
|
|
margin: 0;
|
|
padding: 12px 14px;
|
|
border-radius: 12px;
|
|
background: rgba(0, 0, 0, 0.24);
|
|
border: 1px solid var(--rah-border);
|
|
color: var(--rah-text-soft);
|
|
font-size: 11px;
|
|
line-height: 1.55;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
overflow-wrap: anywhere;
|
|
overflow: hidden;
|
|
width: 100%;
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
box-sizing: border-box;
|
|
display: block;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
|
|
}
|
|
|
|
/* ── Description in property ── */
|
|
.desc-read-wrap {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.desc-text {
|
|
color: var(--rah-text-base);
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
cursor: text;
|
|
padding-right: 44px;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.desc-hover-actions {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
opacity: 0;
|
|
transition: opacity 120ms ease;
|
|
}
|
|
|
|
.desc-read-wrap:hover .desc-hover-actions { opacity: 1; }
|
|
|
|
.icon-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 20px;
|
|
height: 20px;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--rah-text-muted);
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
transition: color 120ms ease, background 120ms ease;
|
|
}
|
|
|
|
.icon-btn:hover:enabled {
|
|
color: var(--rah-text-base);
|
|
background: var(--rah-bg-active);
|
|
}
|
|
|
|
.desc-edit-block {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
width: 100%;
|
|
}
|
|
|
|
.desc-textarea {
|
|
width: 100%;
|
|
background: var(--rah-bg-base);
|
|
border: 1px solid var(--rah-border-strong);
|
|
border-radius: 6px;
|
|
color: var(--rah-text-base);
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
outline: none;
|
|
padding: 8px 10px;
|
|
resize: vertical;
|
|
min-height: 88px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.desc-textarea:focus { border-color: var(--rah-border-stronger); }
|
|
|
|
.desc-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 6px;
|
|
}
|
|
|
|
.btn-primary {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 5px 10px;
|
|
border: none;
|
|
background: var(--rah-accent-green);
|
|
color: var(--rah-text-inverse);
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.btn-primary:hover:enabled { background: var(--rah-accent-green-hover); }
|
|
|
|
.btn-secondary {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 5px 10px;
|
|
border: 1px solid var(--rah-border-strong);
|
|
background: transparent;
|
|
color: var(--rah-text-soft);
|
|
font-size: 11px;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.btn-secondary:hover:enabled {
|
|
border-color: var(--rah-border-stronger);
|
|
color: var(--rah-text-base);
|
|
}
|
|
|
|
/* ── Drag handle (invisible but functional) ── */
|
|
.node-drag-handle {
|
|
display: none;
|
|
position: absolute;
|
|
}
|
|
`}</style>
|
|
</>
|
|
);
|
|
}
|