Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions docs/FEATURES_GRAPH.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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: <file name>` query.
- **Filtering**: Use the legend to toggle visibility of clusters, file types, or confidence levels.

## Technical Implementation
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion scripts/verify-checkjs-ratchet.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
23 changes: 14 additions & 9 deletions src/renderer/components/search/AtlasTabContent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions src/renderer/components/search/ChatPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
11 changes: 10 additions & 1 deletion src/renderer/components/search/SearchTabContent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
105 changes: 59 additions & 46 deletions src/renderer/components/search/UnifiedSearchModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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 || [];
Expand All @@ -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}".`);
}
}
};
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1924,9 +1940,8 @@ export default function UnifiedSearchModal({
<button
className="dropdown-menu-item dropdown-menu-item--default flex w-full items-center gap-compact text-left"
onClick={() => {
if (contextMenu.data?.path) {
window.electronAPI?.files?.open?.(contextMenu.data.path);
}
// The shared actions report a missing path or a failed open.
openFile(contextMenu.data?.path);
closeContextMenu();
}}
>
Expand All @@ -1935,9 +1950,7 @@ export default function UnifiedSearchModal({
<button
className="dropdown-menu-item dropdown-menu-item--default flex w-full items-center gap-compact text-left"
onClick={() => {
if (contextMenu.data?.path) {
window.electronAPI?.files?.reveal?.(contextMenu.data.path);
}
revealFile(contextMenu.data?.path);
closeContextMenu();
}}
>
Expand Down
Loading