From cea1df7f1f34b9f86ce7d403b07190e79a162eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CBeeRad=E2=80=9D?= Date: Wed, 14 Jan 2026 22:15:09 +1100 Subject: [PATCH] sync: Focus Panel UX Refresh from private repo - Three-tab interface: Notes | Desc | Source - Markdown preview with node tokens (react-markdown + remark-gfm) - Formatting toolbar (H1/H2/H3/Bold/Italic) - @mention functionality for linking nodes - Description edit with 280 char limit Run `npm install` to get new dependencies. --- package.json | 2 + src/components/focus/FocusPanel.tsx | 1798 ++++++++++++----- src/components/focus/FormattingToolbar.tsx | 176 ++ .../helpers/MarkdownWithNodeTokens.tsx | 259 +++ 4 files changed, 1694 insertions(+), 541 deletions(-) create mode 100644 src/components/focus/FormattingToolbar.tsx create mode 100644 src/components/helpers/MarkdownWithNodeTokens.tsx diff --git a/package.json b/package.json index ad5f10d..050c2c1 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,8 @@ "pdfjs-dist": "^5.4.296", "react": "^19.0.1", "react-dom": "^19.0.1", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "tailwind-merge": "^2.5.2", "uuid": "^11.1.0", "youtube-captions-scraper": "^2.0.3", diff --git a/src/components/focus/FocusPanel.tsx b/src/components/focus/FocusPanel.tsx index 3737710..79bc090 100644 --- a/src/components/focus/FocusPanel.tsx +++ b/src/components/focus/FocusPanel.tsx @@ -1,10 +1,12 @@ "use client"; import { useState, useEffect, useRef, type DragEvent } from 'react'; -import { Eye, Trash2, Link, Loader, Database, Check, RefreshCw } from 'lucide-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 } from '@/types/database'; +import { Node, NodeConnection, Chunk } from '@/types/database'; import DimensionTags from './dimensions/DimensionTags'; import { getNodeIcon } from '@/utils/nodeIcons'; import ConfirmDialog from '../common/ConfirmDialog'; @@ -91,6 +93,30 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli // Description regeneration state const [regeneratingDescription, setRegeneratingDescription] = useState(null); + // Content tab state: 'notes', 'desc', or 'source' + const [activeContentTab, setActiveContentTab] = useState<'notes' | 'desc' | 'source'>('notes'); + + // 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(null); + + // Source (chunk) edit mode + const [sourceEditMode, setSourceEditMode] = useState(false); + const [sourceEditValue, setSourceEditValue] = useState(''); + const [sourceSaving, setSourceSaving] = useState(false); + + // Embedded chunks state (actual chunks from chunks table) + const [chunksData, setChunksData] = useState>({}); + const [loadingChunks, setLoadingChunks] = useState>(new Set()); + const [chunksExpanded, setChunksExpanded] = useState>({}); + // Fetch priority dimensions on mount useEffect(() => { fetchPriorityDimensions(); @@ -219,6 +245,33 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli } }; + // 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; @@ -365,6 +418,285 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli } }; + // 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({ content: 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].content || ''); + 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(); @@ -559,6 +891,68 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli } }; + // 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 hasContent = node?.content?.trim(); @@ -1336,160 +1730,52 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
Node not found.
) : nodesData[activeTab] ? (
- {/* Header with status, link, and delete */} + {/* URL Row - Above Title */}
-
- {(() => { - const node = nodesData[activeTab]; - const chunkStatus = node?.chunk_status ?? null; - const hasChunk = Boolean(node?.chunk && node.chunk.trim().length > 0); + {/* Embedding status - only show when embedding or error */} + {(() => { + const node = nodesData[activeTab]; + const chunkStatus = node?.chunk_status ?? null; - const StatusBadge = ({ color, label }: { color: string; label: string }) => ( - - {'●'} - {label} - + if (embeddingNode === activeTab || chunkStatus === 'chunking') { + return ( + ); + } - if (embeddingNode === activeTab || chunkStatus === 'chunking') { - return ( - - ); - } - - if (chunkStatus === 'chunked') { - if (showReembedPrompt === activeTab) { - return ( - <> - - - - ); - } - - return ( - - ); - } - - if (chunkStatus === 'error') { - return ( - - ); - } - - if (hasChunk) { - return ( - - ); - } - + if (chunkStatus === 'error') { return ( ); - })()} -
+ } + + return null; + })()}
{editingField === 'link' ? ( @@ -1506,7 +1792,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli fontSize: '11px', background: 'transparent', border: '1px solid #1a1a1a', - borderRadius: '6px', + borderRadius: '4px', padding: '4px 6px', fontFamily: 'inherit', width: '100%', @@ -1515,9 +1801,9 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli placeholder="Enter URL..." /> ) : nodesData[activeTab].link ? ( - { if (e.metaKey || e.ctrlKey) { @@ -1539,7 +1825,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli {nodesData[activeTab].link} ) : ( - startEdit('link', '')} style={{ color: '#555', @@ -1552,93 +1838,15 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli )}
- - {/* Connections Button - Green CTA */} - - - {/* Delete Button */} -
- {/* Title Row with ID and Trash */} -
+ {/* Title Row - Node ID, Title, Connections, Trash */} +
{/* Node ID - Draggable */} ) : ( -

{ if (titleExpanded[activeTab]) { startEdit('title', nodesData[activeTab].title || ''); @@ -1713,6 +1921,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli border: '1px solid transparent', transition: 'border-color 0.2s', flex: 1, + minWidth: 0, ...(titleExpanded[activeTab] ? { whiteSpace: 'normal', wordWrap: 'break-word' @@ -1722,11 +1931,11 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli textOverflow: 'ellipsis' }) }} - onMouseEnter={(e) => { - e.currentTarget.style.borderColor = '#1a1a1a'; + onMouseEnter={(e) => { + e.currentTarget.style.borderColor = '#1a1a1a'; }} - onMouseLeave={(e) => { - e.currentTarget.style.borderColor = 'transparent'; + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = 'transparent'; }} title={titleExpanded[activeTab] ? undefined : (nodesData[activeTab].title || 'Untitled')} > @@ -1735,119 +1944,80 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli

)} -
- - {/* Description Section */} -
-
- - description - - -
- {editingField === 'description' ? ( -
-