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({