From 949d6b5f149b985c45ed7f73d9e75688c027950f Mon Sep 17 00:00:00 2001 From: Levy Tate <78818969+iLevyTate@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:35:10 -0500 Subject: [PATCH] fix(knowledge-os): folder nodes, chat scope, graph refresh and silent failures in the search modal Search and Chat: preview details no longer linger under a new selection, Add to scope chunks past 1000 files, Ask AI is limited to visible results, reopening starts a clean conversation, a refused send keeps the input, Ctrl+1..5 works from text fields, and conversation switching keeps the last click. Graph and Atlas: folder nodes resolve the smart folder path instead of sending its name, the vector DB status listener uses electronAPI, the delayed cluster refresh no longer overwrites user changes, knowledge edges survive redraws, metadata and tag calls are chunked to their schema limits, regrouped groups no longer parse to NaN ids, and failed rename, move, open and reveal calls are reported. --- docs/FEATURES_GRAPH.md | 22 +- scripts/verify-checkjs-ratchet.js | 2 +- .../components/search/AtlasTabContent.jsx | 23 +- src/renderer/components/search/ChatPanel.jsx | 6 +- .../components/search/SearchTabContent.jsx | 11 +- .../components/search/UnifiedSearchModal.jsx | 105 +++--- .../components/search/hooks/useChatTab.js | 125 +++++-- .../components/search/hooks/useGraphTab.js | 338 +++++++++++++----- .../components/search/nodes/FolderNode.jsx | 11 +- .../search/utils/clusterRegroupUtils.js | 15 + .../search/utils/graphLayoutUtils.js | 99 +++++ src/renderer/components/search/utils/index.js | 15 + .../components/search/utils/ipcBatchUtils.js | 110 ++++++ test/components/ChatPanel.test.js | 31 +- test/components/FolderNode.test.js | 29 +- test/components/UnifiedSearchModal.test.js | 104 ++++++ .../search/knowledgeGraphHelpers.test.js | 268 ++++++++++++++ .../search/useChatTab.scope.test.js | 226 +++++++++++- .../search/useGraphTab.knowledgeFixes.test.js | 293 +++++++++++++++ 19 files changed, 1630 insertions(+), 203 deletions(-) create mode 100644 src/renderer/components/search/utils/ipcBatchUtils.js create mode 100644 test/renderer/components/search/knowledgeGraphHelpers.test.js create mode 100644 test/renderer/components/search/useGraphTab.knowledgeFixes.test.js diff --git a/docs/FEATURES_GRAPH.md b/docs/FEATURES_GRAPH.md index a94a97a..a0d94a2 100644 --- a/docs/FEATURES_GRAPH.md +++ b/docs/FEATURES_GRAPH.md @@ -17,7 +17,11 @@ semantically. - **React Flow Integration**: Smooth, zoomable canvas interface. - **Auto-Layout**: Uses ELK algorithms to organize nodes intelligently. -- **Context Menus**: Right-click on any node for quick actions (Open, Reveal in Explorer, Organize). +- **Context Menus**: Right-click a file node for Open File, Reveal in Folder, Find Similar, Focus on + Node and Show Related Documents. Right-click a cluster for Expand/Collapse cluster, Tag Cluster, + Create Smart Folder, Move All to Folder and Export File List. Other nodes (the query node, + suggested-folder nodes, duplicate groups) have no context menu; a suggested-folder node has its + own toolbar for moving its connected files there or revealing the folder. ### Semantic Clustering @@ -31,10 +35,16 @@ The graph doesn't just show files; it shows _meaning_. ### Exploration Tools -- **Focus Mode**: Double-click any node to center the graph on it and reveal its specific - connections. +- **Double-click**: Double-clicking a cluster expands it into its member files. Double-clicking a + file selects it and adds its similar files to the graph (as many hops as the hop setting allows). + Double-clicking other nodes does nothing. +- **Focus Mode**: "Focus on Node" (file context menu, or the Focus button in the details panel) + centers the graph on one node and its nearby connections. - **Expansion**: Dynamically load more connections as you explore "hops" away from your starting point. +- **Find similar**: The context-menu **Find Similar** adds similar files to the graph. The details + panel's **Find similar** button is different: it switches to the Search tab with a + `similar to: ` query. - **Filtering**: Use the legend to toggle visibility of clusters, file types, or confidence levels. ## Technical Implementation @@ -63,9 +73,11 @@ The graph doesn't just show files; it shows _meaning_. visualized. 3. **Explore**: - **Click** a node to select it. - - **Double-click** to focus and expand. + - **Double-click** a cluster to expand it, or a file to add its similar files. - **Drag** nodes to rearrange your view. -4. **Action**: Right-click to open files or apply organization suggestions. +4. **Action**: Right-click a file to open or reveal it, or a cluster to tag, move or export its + files. To act on an organization suggestion, select the suggested-folder node and use its + toolbar. ## Other Knowledge OS views diff --git a/scripts/verify-checkjs-ratchet.js b/scripts/verify-checkjs-ratchet.js index d80eb91..b7db8e0 100644 --- a/scripts/verify-checkjs-ratchet.js +++ b/scripts/verify-checkjs-ratchet.js @@ -43,7 +43,7 @@ const COVERED_DIRS = [ 'src/renderer/utils', 'src/renderer/components' ]; -const GLOBAL_DIRECTIVE_FLOOR = 493; +const GLOBAL_DIRECTIVE_FLOOR = 494; // ──────────────────────────────────────────────────────────────────────── const DIRECTIVE_SCAN_LINES = 5; diff --git a/src/renderer/components/search/AtlasTabContent.jsx b/src/renderer/components/search/AtlasTabContent.jsx index e4d03c9..6ac52a1 100644 --- a/src/renderer/components/search/AtlasTabContent.jsx +++ b/src/renderer/components/search/AtlasTabContent.jsx @@ -15,6 +15,7 @@ import { useAppSelector } from '../../store/hooks'; import { joinPath } from '../../utils/platform'; import { useMoveFilesToFolder } from './hooks/useMoveFilesToFolder'; import { useAtlasRebuildPoll } from './hooks/useAtlasRebuildPoll'; +import { FILE_METADATA_BATCH_LIMIT, chunkArray, addTagsChunked } from './utils/ipcBatchUtils'; import { pickNextPointInDirection, ARROW_DIRECTIONS, @@ -38,7 +39,6 @@ import { buildEmbeddingData, nearestPointIndex, indicesInLasso } from './atlasEm */ const PAGE_LIMIT = 5000; -const META_BATCH_LIMIT = 100; const SIMILAR_TOP_K = 10; const MAX_FOLDER_LABELS = 10; const MIN_LABEL_MEMBERS = 3; @@ -157,8 +157,7 @@ function AtlasTabContent({ isActive, openFile, revealFile, setError, simpleMode } const api = /** @type {any} */ (window).electronAPI?.embeddings; if (!api?.getFileMetadata || missing.length === 0) return out; - for (let i = 0; i < missing.length; i += META_BATCH_LIMIT) { - const chunk = missing.slice(i, i + META_BATCH_LIMIT); + for (const chunk of chunkArray(missing, FILE_METADATA_BATCH_LIMIT)) { try { const res = await api.getFileMetadata(chunk); for (const id of chunk) { @@ -858,14 +857,20 @@ function AtlasTabContent({ isActive, openFile, revealFile, setError, simpleMode setIsBulkWorking(true); setBulkStatus(`Tagging ${selectedFileIds.length} file(s)…`); try { - const api = /** @type {any} */ (window).electronAPI?.files; - const res = await api?.addTags?.(selectedFileIds, tags); - if (!res?.success) { - throw new Error(atlasIpcFailureMessage(res?.errors?.[0]?.error || res, 'Tags')); + // One call takes at most 1000 ids and may tag only some of them, so + // send the selection in chunks and report what was really tagged. + const res = await addTagsChunked(selectedFileIds, tags); + if (!res.success) { + throw new Error(atlasIpcFailureMessage(res.error || res, 'Tags')); } - setBulkStatus(`Tagged ${selectedFileIds.length} file(s).`); - setTagDraft(null); metaCacheRef.current.clear(); + if (res.failed > 0) { + setBulkStatus(`Tagged ${res.updated} of ${res.total} file(s).`); + setBothErrors(`Could not tag ${res.failed} of ${res.total} file(s).`); + } else { + setBulkStatus(`Tagged ${res.updated} file(s).`); + setTagDraft(null); + } } catch (err) { setBothErrors(atlasIpcFailureMessage(err, 'Tags')); } finally { diff --git a/src/renderer/components/search/ChatPanel.jsx b/src/renderer/components/search/ChatPanel.jsx index 74ae311..bb00309 100644 --- a/src/renderer/components/search/ChatPanel.jsx +++ b/src/renderer/components/search/ChatPanel.jsx @@ -874,8 +874,10 @@ function ChatPanel({ const trimmed = input.trim(); if (!trimmed || isSending) return; try { - await onSend(trimmed); - setInput(''); + // onSend resolves false when the send was refused (e.g. strict scope + // with no documents); keep the text so it is not lost. + const accepted = await onSend(trimmed); + if (accepted !== false) setInput(''); } catch { // Keep the input so the user can retry on failure } diff --git a/src/renderer/components/search/SearchTabContent.jsx b/src/renderer/components/search/SearchTabContent.jsx index 53fd57a..c301dd8 100644 --- a/src/renderer/components/search/SearchTabContent.jsx +++ b/src/renderer/components/search/SearchTabContent.jsx @@ -144,7 +144,16 @@ export default function SearchTabContent({ setActiveTab('chat'); // If there is a query, ask about it if (debouncedQuery) { - handleChatSend(`Tell me about "${debouncedQuery}" based on these results`); + // "These results" are the ones on screen: send their ids so + // the answer is drawn from them, not the whole library. + const resultIds = searchResults + .map((result) => result?.id) + .filter((id) => typeof id === 'string' && id.length > 0) + .slice(0, 200); + handleChatSend( + `Tell me about "${debouncedQuery}" based on these results`, + resultIds + ); } }} title="Ask AI assistant about these results" diff --git a/src/renderer/components/search/UnifiedSearchModal.jsx b/src/renderer/components/search/UnifiedSearchModal.jsx index 9a4b783..ef583e1 100644 --- a/src/renderer/components/search/UnifiedSearchModal.jsx +++ b/src/renderer/components/search/UnifiedSearchModal.jsx @@ -242,7 +242,7 @@ export default function UnifiedSearchModal({ // Chat tab state + logic live in hooks/useChatTab (§I Phase 4). The // graph bridge (handleUseSourcesInGraph) stays below — it writes graph state. - const chatTab = useChatTab({ isOpen, searchResults, defaultTopK, openFile, setError }); + const chatTab = useChatTab({ isOpen, searchResults, defaultTopK, openFile }); const { chatMessages, setChatMessages, @@ -1104,6 +1104,27 @@ export default function UnifiedSearchModal({ onClose?.(); } + // Tab switching: Ctrl + 1 search, 2 relate, 3 atlas, 4 chat, 5 timeline + // (F-212: Chat and Timeline had no hotkey). Handled before the + // editable-target guard so it works while a text field has focus. + // AltGr reports Ctrl+Alt on Windows and types a character, so Alt is + // excluded. + if (e.ctrlKey && !e.altKey) { + const tabForKey = { + 1: 'search', + 2: graphViewEnabled ? 'graph' : null, + 3: graphViewEnabled ? 'atlas' : null, + 4: 'chat', + 5: 'timeline' + }[e.key]; + if (tabForKey) { + e.preventDefault(); + pendingInitialGraphTabRef.current = false; + setActiveTab(tabForKey); + return; + } + } + if (isEditableTarget(e.target)) { return; } @@ -1157,34 +1178,6 @@ export default function UnifiedSearchModal({ } } - // Tab switching: Ctrl + 1 search, 2 relate, 3 atlas, 4 chat, 5 timeline - if (e.ctrlKey && e.key === '1') { - e.preventDefault(); - pendingInitialGraphTabRef.current = false; - setActiveTab('search'); - } - if (e.ctrlKey && e.key === '2' && graphViewEnabled) { - e.preventDefault(); - pendingInitialGraphTabRef.current = false; - setActiveTab('graph'); - } - if (e.ctrlKey && e.key === '3' && graphViewEnabled) { - e.preventDefault(); - pendingInitialGraphTabRef.current = false; - setActiveTab('atlas'); - } - // F-212: Chat and Timeline had no hotkey (mouse only). - if (e.ctrlKey && e.key === '4') { - e.preventDefault(); - pendingInitialGraphTabRef.current = false; - setActiveTab('chat'); - } - if (e.ctrlKey && e.key === '5') { - e.preventDefault(); - pendingInitialGraphTabRef.current = false; - setActiveTab('timeline'); - } - // Graph keyboard shortcuts — only when the graph tab is active so we // don't fight browser zoom or undo behaviors elsewhere in the modal. if (graphViewEnabled && activeTab === 'graph') { @@ -1369,8 +1362,11 @@ export default function UnifiedSearchModal({ }; const handleFolderAction = async (event) => { - const { action, folderId, folderPath } = event.detail || {}; + // folderPath is the smart folder's path on disk ('' when the suggested + // name matches no smart folder); folderName is only for messages. + const { action, folderId, folderName, folderPath } = event.detail || {}; if (!folderId) return; + const folderLabel = folderName || folderPath || 'this folder'; if (action === 'moveFilesHere') { // Find all file nodes connected to this folder node via organize edges const currentEdges = edgesRef.current || []; @@ -1389,21 +1385,34 @@ export default function UnifiedSearchModal({ return; } setGraphStatus( - `Moving ${filePaths.length} file${filePaths.length === 1 ? '' : 's'} to "${folderPath}"...` + `Moving ${filePaths.length} file${filePaths.length === 1 ? '' : 's'} to "${folderLabel}"...` ); + let outcome = null; try { - await handleMoveAllToFolderRef.current?.( - { id: folderId, label: folderPath, memberIds: fileIds }, - folderPath + // With no known path the move asks the user for a destination. + outcome = await handleMoveAllToFolderRef.current?.( + { id: folderId, label: folderLabel, memberIds: fileIds }, + folderPath || undefined ); } catch { - setGraphStatus('Failed to move files.'); + showError('Failed to move files.'); + } finally { + // A successful move sets its own status; otherwise (cancelled or + // failed, which the move reports itself) clear "Moving...". + if (!outcome?.moved) setGraphStatus(''); } } else if (action === 'reveal') { + if (!folderPath) { + showError(`"${folderLabel}" is not one of your smart folders, so it cannot be revealed.`); + return; + } try { - await window.electronAPI?.files?.reveal?.(folderPath); + const result = await window.electronAPI?.files?.reveal?.(folderPath); + if (!result || result.success === false) { + showError(`Could not reveal "${folderLabel}": ${result?.error || 'folder not found'}`); + } } catch { - setGraphStatus(`Could not reveal "${folderPath}" in Explorer.`); + showError(`Could not reveal "${folderLabel}".`); } } }; @@ -1437,7 +1446,8 @@ export default function UnifiedSearchModal({ reactFlowInstance, setFocusDepth, setFocusNodeId, - setGraphStatus + setGraphStatus, + showError ]); // handleMoveAllToFolder accessed via ref to avoid TDZ // Prevent wheel events on the graph container from scrolling the parent modal. @@ -1555,15 +1565,21 @@ export default function UnifiedSearchModal({ // Don't re-fetch if we already have details for this path if (lastFetchedDetailsPathRef.current === path) return undefined; + // A different file is selected: drop the previous file's details so the + // preview never shows them under the new selection while this loads, or + // when the new file has no analysis history. + lastFetchedDetailsPathRef.current = null; + setSelectedDocumentDetails(null); + let cancelled = false; const requestId = (detailsRequestCounterRef.current += 1); const fetchDetails = async () => { setIsLoadingDocumentDetails(true); try { const history = await window.electronAPI?.analysisHistory?.getFileHistory?.(path); - if (!cancelled && detailsRequestCounterRef.current === requestId && history) { + if (!cancelled && detailsRequestCounterRef.current === requestId) { lastFetchedDetailsPathRef.current = path; - setSelectedDocumentDetails(history); + setSelectedDocumentDetails(history || null); } } catch (err) { logger.warn('Failed to fetch document details', err); @@ -1924,9 +1940,8 @@ export default function UnifiedSearchModal({