From 581dec2cb90600a4eca3d1ec368e49e2ed4f0b2a Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 2 Jan 2026 08:41:50 -0600 Subject: [PATCH] OAuth enabled but no valid token found. Starting authentication... Found expired OAuth token, attempting refresh... Token refresh successful ## CHANGES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Supercharged DocumentGraph builds with mtime-based parsed-file caching ⚡ - Added cache controls: clear all, invalidate files, and fetch stats 🧰 - Auto-invalidated graph cache on filesystem change events before rebuilding 🔄 - Greatly reduced redundant file reads on repeated graph generations 🚀 - Improved reverse-link extraction by reusing cached parses when unchanged 🧠 - MindMap layout now spaces nodes using real, content-based heights 📐 - Cached node height calculations to keep large maps snappy 🗜️ - Force/hierarchical layouts now estimate node dimensions from actual content 🧩 - Context transfer now immediately spawns target agent with formatted history 🧾 - Settings “Storage Location” section redesigned for clearer, cleaner UI 🎨 --- .../DocumentGraph/graphDataBuilder.test.ts | 127 +++++++++++++++- src/renderer/App.tsx | 138 ++++++++++++++++-- .../DocumentGraph/DocumentGraphView.tsx | 5 +- .../components/DocumentGraph/MindMap.tsx | 107 ++++++++++---- .../DocumentGraph/graphDataBuilder.ts | 116 ++++++++++++++- .../DocumentGraph/layoutAlgorithms.ts | 115 ++++++++++++--- src/renderer/components/SettingsModal.tsx | 61 ++++---- .../UsageDashboard/AgentComparisonChart.tsx | 2 +- 8 files changed, 572 insertions(+), 99 deletions(-) diff --git a/src/__tests__/renderer/components/DocumentGraph/graphDataBuilder.test.ts b/src/__tests__/renderer/components/DocumentGraph/graphDataBuilder.test.ts index c01285f2..73d5219e 100644 --- a/src/__tests__/renderer/components/DocumentGraph/graphDataBuilder.test.ts +++ b/src/__tests__/renderer/components/DocumentGraph/graphDataBuilder.test.ts @@ -10,6 +10,9 @@ import { buildGraphData, isDocumentNode, isExternalLinkNode, + clearGraphDataCache, + invalidateCacheForFiles, + getGraphCacheStats, type DocumentNodeData, type ProgressData, BATCH_SIZE_BEFORE_YIELD, @@ -107,18 +110,25 @@ describe('graphDataBuilder', () => { return Promise.resolve(null); } - function mockStatImpl(filePath: string): Promise<{ size: number } | null> { + function mockStatImpl(filePath: string): Promise<{ size: number; modifiedAt: string } | null> { const relativePath = filePath.replace('/test/', ''); const entry = getEntry(relativePath); if (entry && 'size' in entry) { - return Promise.resolve({ size: entry.size }); + // Return a consistent modifiedAt timestamp for cache testing + return Promise.resolve({ + size: entry.size, + modifiedAt: '2024-01-01T00:00:00.000Z', + }); } return Promise.resolve(null); } beforeEach(() => { + // Clear the cache before each test to ensure isolation + clearGraphDataCache(); + mockReadDir = vi.fn().mockImplementation(mockReadDirImpl); mockReadFile = vi.fn().mockImplementation(mockReadFileImpl); mockStat = vi.fn().mockImplementation(mockStatImpl); @@ -448,4 +458,117 @@ describe('graphDataBuilder', () => { expect(typeof result.hasMore).toBe('boolean'); }); }); + + describe('caching', () => { + it('should cache parsed files and reuse on subsequent builds', async () => { + // First build - should read all files + await buildGraphData({ + rootPath: '/test', + focusFile: 'readme.md', + maxDepth: 1, + }); + + const firstReadFileCallCount = mockReadFile.mock.calls.length; + + // Second build - should use cache for unchanged files + await buildGraphData({ + rootPath: '/test', + focusFile: 'readme.md', + maxDepth: 1, + }); + + const secondReadFileCallCount = mockReadFile.mock.calls.length; + + // Cache should reduce file reads (stat is still called to check mtime) + // The second build should call readFile fewer times because of cache hits + expect(secondReadFileCallCount).toBeLessThan(firstReadFileCallCount * 2); + }); + + it('should report cache stats', async () => { + // Initially empty + clearGraphDataCache(); + let stats = getGraphCacheStats(); + expect(stats.parsedFileCount).toBe(0); + + // Build graph to populate cache + await buildGraphData({ + rootPath: '/test', + focusFile: 'readme.md', + maxDepth: 1, + }); + + stats = getGraphCacheStats(); + expect(stats.parsedFileCount).toBeGreaterThan(0); + }); + + it('should invalidate cache for specific files', async () => { + // Build to populate cache + await buildGraphData({ + rootPath: '/test', + focusFile: 'readme.md', + }); + + const statsBefore = getGraphCacheStats(); + expect(statsBefore.parsedFileCount).toBeGreaterThan(0); + + // Invalidate specific file + invalidateCacheForFiles(['/test/readme.md']); + + // Cache should still have other files but not the invalidated one + const statsAfter = getGraphCacheStats(); + expect(statsAfter.parsedFileCount).toBeLessThan(statsBefore.parsedFileCount); + }); + + it('should clear entire cache', async () => { + // Build to populate cache + await buildGraphData({ + rootPath: '/test', + focusFile: 'readme.md', + maxDepth: 2, + }); + + expect(getGraphCacheStats().parsedFileCount).toBeGreaterThan(0); + + // Clear cache + clearGraphDataCache(); + + expect(getGraphCacheStats().parsedFileCount).toBe(0); + }); + + it('should re-parse file when mtime changes', async () => { + // First build + await buildGraphData({ + rootPath: '/test', + focusFile: 'readme.md', + }); + + const initialCallCount = mockReadFile.mock.calls.length; + + // Change the mtime for readme.md + mockStat.mockImplementation((filePath: string) => { + const relativePath = filePath.replace('/test/', ''); + const entry = getEntry(relativePath); + + if (entry && 'size' in entry) { + return Promise.resolve({ + size: entry.size, + // Different mtime for readme.md + modifiedAt: filePath.includes('readme') + ? '2024-06-01T00:00:00.000Z' + : '2024-01-01T00:00:00.000Z', + }); + } + return Promise.resolve(null); + }); + + // Second build - should re-read readme.md due to mtime change + await buildGraphData({ + rootPath: '/test', + focusFile: 'readme.md', + }); + + // Should have additional readFile calls for the changed file + expect(mockReadFile.mock.calls.length).toBeGreaterThan(initialCallCount); + }); + }); }); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index ac6d3310..6adfc9a9 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -3298,33 +3298,76 @@ function MaestroConsoleInner() { return { success: false, error: 'Source tab not found' }; } + // Format the context as text to be sent to the agent + // Only include user messages and AI responses, not system messages + const formattedContext = sourceTab.logs + .filter(log => log.text && log.text.trim() && (log.source === 'user' || log.source === 'ai' || log.source === 'stdout')) + .map(log => { + const role = log.source === 'user' ? 'User' : 'Assistant'; + return `${role}: ${log.text}`; + }) + .join('\n\n'); + + const sourceName = activeSession!.name || activeSession!.projectRoot.split('/').pop() || 'Unknown'; + const sourceAgentName = activeSession!.toolType; + + // Create the context message to be sent directly to the agent + const contextMessage = formattedContext + ? `# Context from Previous Session + +The following is a conversation from another session ("${sourceName}" using ${sourceAgentName}). Review this context to understand the prior work and decisions made. + +--- + +${formattedContext} + +--- + +# Your Task + +You are taking over this conversation. Based on the context above, provide a brief summary of where things left off and ask what the user would like to focus on next.` + : 'No context available from the previous session.'; + // Transfer context to the target session's active tab - // Create a new tab in the target session with the transferred context + // Create a new tab in the target session and immediately send context to agent const newTabId = `tab-${Date.now()}`; const transferNotice: LogEntry = { id: `transfer-notice-${Date.now()}`, timestamp: Date.now(), source: 'system', - text: `Context transferred from "${activeSession!.name}" (${activeSession!.toolType})${options.groomContext ? ' - cleaned to reduce size' : ''}`, + text: `Context transferred from "${sourceName}" (${sourceAgentName})${options.groomContext ? ' - cleaned to reduce size' : ''}`, + }; + + // Create user message entry for the context being sent + const userContextMessage: LogEntry = { + id: `user-context-${Date.now()}`, + timestamp: Date.now(), + source: 'user', + text: contextMessage, }; const newTab: AITab = { id: newTabId, - name: `From: ${activeSession!.name}`, - logs: [transferNotice, ...sourceTab.logs], + name: `From: ${sourceName}`, + logs: [transferNotice, userContextMessage], agentSessionId: null, starred: false, inputValue: '', stagedImages: [], createdAt: Date.now(), - state: 'idle', + state: 'busy', // Start in busy state since we're spawning immediately + thinkingStartTime: Date.now(), + awaitingSessionId: true, // Mark as awaiting session ID }; - // Add the new tab to the target session + // Add the new tab to the target session and set it as active setSessions(prev => prev.map(s => { if (s.id === targetSessionId) { return { ...s, + state: 'busy', + busySource: 'ai', + thinkingStartTime: Date.now(), aiTabs: [...s.aiTabs, newTab], activeTabId: newTabId, }; @@ -3335,7 +3378,7 @@ function MaestroConsoleInner() { // Navigate to the target session setActiveSessionId(targetSessionId); - // Calculate estimated tokens for the message + // Calculate estimated tokens for the toast const estimatedTokens = sourceTab.logs .filter(log => log.text && log.source !== 'system') .reduce((sum, log) => sum + Math.round((log.text?.length || 0) / 4), 0); @@ -3343,11 +3386,11 @@ function MaestroConsoleInner() { ? ` (~${estimatedTokens.toLocaleString()} tokens)` : ''; - // Show success toast with detailed info + // Show success toast addToast({ type: 'success', - title: 'Context Transferred', - message: `"${activeSession!.name}" → "${targetSession.name}"${tokenInfo}. Ready in new tab.`, + title: 'Context Sent', + message: `"${sourceName}" → "${targetSession.name}"${tokenInfo}`, sessionId: targetSessionId, tabId: newTabId, }); @@ -3357,6 +3400,81 @@ function MaestroConsoleInner() { setTransferSourceAgent(null); setTransferTargetAgent(null); + // Spawn the agent with the context - do this after state updates + (async () => { + try { + // Get agent configuration + const agent = await window.maestro.agents.get(targetSession.toolType); + if (!agent) throw new Error(`${targetSession.toolType} agent not found`); + + const baseArgs = agent.args ?? []; + const commandToUse = agent.path || agent.command; + + // Build the full prompt with Maestro system prompt for new sessions + let effectivePrompt = contextMessage; + + // Get git branch for template substitution + let gitBranch: string | undefined; + if (targetSession.isGitRepo) { + try { + const status = await gitService.getStatus(targetSession.cwd); + gitBranch = status.branch; + } catch { + // Ignore git errors + } + } + + // Prepend Maestro system prompt since this is a new session + if (maestroSystemPrompt) { + const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, { + session: targetSession, + gitBranch, + }); + effectivePrompt = `${substitutedSystemPrompt}\n\n---\n\n# User Request\n\n${effectivePrompt}`; + } + + // Spawn agent + const spawnSessionId = `${targetSessionId}-ai-${newTabId}`; + await window.maestro.process.spawn({ + sessionId: spawnSessionId, + toolType: targetSession.toolType, + cwd: targetSession.cwd, + command: commandToUse, + args: [...baseArgs], + prompt: effectivePrompt, + // Per-session config overrides (if set) + sessionCustomPath: targetSession.customPath, + sessionCustomArgs: targetSession.customArgs, + sessionCustomEnvVars: targetSession.customEnvVars, + sessionCustomModel: targetSession.customModel, + sessionCustomContextWindow: targetSession.customContextWindow, + sessionSshRemoteConfig: targetSession.sessionSshRemoteConfig, + }); + } catch (error) { + console.error('Failed to spawn agent for context transfer:', error); + const errorLog: LogEntry = { + id: `error-${Date.now()}`, + timestamp: Date.now(), + source: 'system', + text: `Error: Failed to spawn agent - ${(error as Error).message}`, + }; + setSessions(prev => prev.map(s => { + if (s.id !== targetSessionId) return s; + return { + ...s, + state: 'idle', + busySource: undefined, + thinkingStartTime: undefined, + aiTabs: s.aiTabs.map(tab => + tab.id === newTabId + ? { ...tab, state: 'idle' as const, thinkingStartTime: undefined, logs: [...tab.logs, errorLog] } + : tab + ), + }; + })); + } + })(); + return { success: true, newSessionId: targetSessionId, newTabId }; }, [activeSession, sessions, setSessions, setActiveSessionId, addToast, resetTransfer]); diff --git a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx index f63c0a19..5ad4c434 100644 --- a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx +++ b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx @@ -36,7 +36,7 @@ import { useLayerStack } from '../../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../../constants/modalPriorities'; import { Modal, ModalFooter } from '../ui/Modal'; import { useDebouncedCallback } from '../../hooks/utils'; -import { buildGraphData, ProgressData, GraphNodeData, CachedExternalData } from './graphDataBuilder'; +import { buildGraphData, ProgressData, GraphNodeData, CachedExternalData, invalidateCacheForFiles } from './graphDataBuilder'; import { MindMap, MindMapNode, MindMapLink, convertToMindMapData, NodePositionOverride } from './MindMap'; import { NodeContextMenu } from './NodeContextMenu'; import { GraphLegend } from './GraphLegend'; @@ -501,6 +501,9 @@ export function DocumentGraphView({ const unsubscribe = window.maestro.documentGraph.onFilesChanged((data) => { if (data.rootPath === rootPath) { + // Invalidate cache for changed files before rebuilding graph + const changedPaths = data.changes.map((c: { filePath: string }) => c.filePath); + invalidateCacheForFiles(changedPaths); debouncedLoadGraphData(); } }); diff --git a/src/renderer/components/DocumentGraph/MindMap.tsx b/src/renderer/components/DocumentGraph/MindMap.tsx index f79f0468..5147a404 100644 --- a/src/renderer/components/DocumentGraph/MindMap.tsx +++ b/src/renderer/components/DocumentGraph/MindMap.tsx @@ -116,8 +116,8 @@ export interface MindMapProps { /** Horizontal spacing between depth levels */ const HORIZONTAL_SPACING = 340; -/** Minimum vertical spacing between nodes */ -const VERTICAL_SPACING = 120; +/** Minimum vertical gap between nodes (added to node heights) */ +const VERTICAL_GAP = 30; /** Document node width */ const NODE_WIDTH = 260; /** Header height for node title bar */ @@ -134,17 +134,36 @@ const CHARS_PER_LINE = 35; const DESC_PADDING = 20; // 10px top + 10px bottom /** - * Calculate node height based on preview character limit + * Cache for node height calculations. + * Key format: `${textLength}:${previewCharLimit}` - uses text length as proxy for content */ -function calculateNodeHeight(hasPreview: boolean, previewCharLimit: number): number { - if (!hasPreview) { +const nodeHeightCache = new Map(); + +/** + * Calculate node height based on actual content length (with caching) + */ +function calculateNodeHeight(previewText: string | undefined, previewCharLimit: number): number { + if (!previewText) { return NODE_HEIGHT_BASE; } - // Estimate number of lines based on character limit - const estimatedLines = Math.ceil(previewCharLimit / CHARS_PER_LINE); - // Minimum 2 lines, cap at reasonable max - const lines = Math.max(2, Math.min(estimatedLines, 15)); - return NODE_HEIGHT_BASE + (lines * DESC_LINE_HEIGHT) + DESC_PADDING; + + // Use text length as cache key (exact content not needed, just length matters for height) + const cacheKey = `${Math.min(previewText.length, previewCharLimit)}:${previewCharLimit}`; + const cached = nodeHeightCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + // Truncate to the limit, then calculate actual lines needed + const truncatedLength = Math.min(previewText.length, previewCharLimit); + const actualLines = Math.ceil(truncatedLength / CHARS_PER_LINE); + // Minimum 1 line, cap at reasonable max + const lines = Math.max(1, Math.min(actualLines, 15)); + const height = NODE_HEIGHT_BASE + (lines * DESC_LINE_HEIGHT) + DESC_PADDING; + + // Cache the result + nodeHeightCache.set(cacheKey, height); + return height; } /** Scale factor for center node */ const CENTER_NODE_SCALE = 1.15; @@ -502,8 +521,8 @@ function calculateMindMapLayout( const centerX = canvasWidth / 2; const centerY = canvasHeight / 2 - (showExternalLinks && externalNodes.length > 0 ? 50 : 0); const centerWidth = NODE_WIDTH * CENTER_NODE_SCALE; - const centerHasPreview = !!(centerNode.description || centerNode.contentPreview); - const centerHeight = calculateNodeHeight(centerHasPreview, previewCharLimit) * CENTER_NODE_SCALE; + const centerPreviewText = centerNode.description || centerNode.contentPreview; + const centerHeight = calculateNodeHeight(centerPreviewText, previewCharLimit) * CENTER_NODE_SCALE; const positionedNodes: MindMapNode[] = []; const usedLinks: MindMapLink[] = []; @@ -542,42 +561,74 @@ function calculateMindMapLayout( const leftNodes = nodesAtDepth.slice(0, midpoint); const rightNodes = nodesAtDepth.slice(midpoint); - // Calculate positions for left column + // Calculate positions for left column - use actual node heights const leftX = centerX - (HORIZONTAL_SPACING * depth); - const leftTotalHeight = leftNodes.length * VERTICAL_SPACING; - const leftStartY = centerY - leftTotalHeight / 2 + VERTICAL_SPACING / 2; + + // First pass: calculate heights for all left nodes + const leftNodeHeights = leftNodes.map(node => { + const previewText = node.description || node.contentPreview; + return calculateNodeHeight(previewText, previewCharLimit); + }); + + // Calculate total height needed (sum of heights + gaps between nodes) + const leftTotalHeight = leftNodeHeights.reduce((sum, h) => sum + h, 0) + + Math.max(0, leftNodes.length - 1) * VERTICAL_GAP; + + // Start position: center the column vertically + let leftCurrentY = centerY - leftTotalHeight / 2; leftNodes.forEach((node, index) => { - const hasPreview = !!(node.description || node.contentPreview); - const height = calculateNodeHeight(hasPreview, previewCharLimit); + const height = leftNodeHeights[index]; + // Position at center of this node's space + const nodeY = leftCurrentY + height / 2; + positionedNodes.push({ ...node, x: leftX, - y: leftStartY + index * VERTICAL_SPACING, + y: nodeY, width: NODE_WIDTH, height, depth, side: 'left', }); + + // Move to next position: current node's height + gap + leftCurrentY += height + VERTICAL_GAP; }); - // Calculate positions for right column + // Calculate positions for right column - use actual node heights const rightX = centerX + (HORIZONTAL_SPACING * depth); - const rightTotalHeight = rightNodes.length * VERTICAL_SPACING; - const rightStartY = centerY - rightTotalHeight / 2 + VERTICAL_SPACING / 2; + + // First pass: calculate heights for all right nodes + const rightNodeHeights = rightNodes.map(node => { + const previewText = node.description || node.contentPreview; + return calculateNodeHeight(previewText, previewCharLimit); + }); + + // Calculate total height needed (sum of heights + gaps between nodes) + const rightTotalHeight = rightNodeHeights.reduce((sum, h) => sum + h, 0) + + Math.max(0, rightNodes.length - 1) * VERTICAL_GAP; + + // Start position: center the column vertically + let rightCurrentY = centerY - rightTotalHeight / 2; rightNodes.forEach((node, index) => { - const hasPreview = !!(node.description || node.contentPreview); - const height = calculateNodeHeight(hasPreview, previewCharLimit); + const height = rightNodeHeights[index]; + // Position at center of this node's space + const nodeY = rightCurrentY + height / 2; + positionedNodes.push({ ...node, x: rightX, - y: rightStartY + index * VERTICAL_SPACING, + y: nodeY, width: NODE_WIDTH, height, depth, side: 'right', }); + + // Move to next position: current node's height + gap + rightCurrentY += height + VERTICAL_GAP; }); } @@ -636,8 +687,8 @@ function calculateMindMapLayout( } }); - // Calculate bounds - use max node height for padding - const maxNodeHeight = calculateNodeHeight(true, previewCharLimit); + // Calculate bounds - use max node height for padding (simulate full preview text) + const maxNodeHeight = calculateNodeHeight('x'.repeat(previewCharLimit), previewCharLimit); const xs = positionedNodes.map(n => n.x); const ys = positionedNodes.map(n => n.y); const bounds = { @@ -1524,13 +1575,13 @@ export function convertToMindMapData( if (node.data.nodeType === 'document') { const docData = node.data as DocumentNodeData; // Use description (frontmatter) or contentPreview (plaintext) for display - const hasPreviewText = !!(docData.description || docData.contentPreview); + const previewText = docData.description || docData.contentPreview; mindMapNode = { id: node.id, x: 0, y: 0, width: NODE_WIDTH, - height: calculateNodeHeight(hasPreviewText, previewCharLimit), + height: calculateNodeHeight(previewText, previewCharLimit), depth: 0, side: 'center' as const, nodeType: 'document' as const, diff --git a/src/renderer/components/DocumentGraph/graphDataBuilder.ts b/src/renderer/components/DocumentGraph/graphDataBuilder.ts index 1c2cf20e..e81b4a24 100644 --- a/src/renderer/components/DocumentGraph/graphDataBuilder.ts +++ b/src/renderer/components/DocumentGraph/graphDataBuilder.ts @@ -15,6 +15,77 @@ import { PERFORMANCE_THRESHOLDS } from '../../../shared/performance-metrics'; // Performance metrics instance for graph data building const perfMetrics = getRendererPerfMetrics('DocumentGraph'); +// ============================================================================ +// Parsed File Cache +// ============================================================================ + +/** + * Cached parsed file entry with modification time for invalidation + */ +interface CachedParsedFile { + /** The parsed file data */ + data: ParsedFile; + /** File modification time (ms since epoch) when cached */ + mtime: number; +} + +/** + * Module-level cache for parsed files. + * Key: full file path, Value: cached data with mtime + * + * This cache persists across graph rebuilds, significantly speeding up + * incremental updates when only a few files change. + */ +const parsedFileCache = new Map(); + +/** + * Cache for the reverse link index (which files link to which). + * Invalidated when any file changes. + */ +interface CachedReverseLinkIndex { + /** The reverse index map */ + reverseIndex: Map>; + /** Set of existing files */ + existingFiles: Set; + /** Map of file path to mtime when index was built */ + fileMtimes: Map; + /** Root path this index was built for */ + rootPath: string; +} + +let reverseLinkIndexCache: CachedReverseLinkIndex | null = null; + +/** + * Clear the parsed file cache (e.g., when switching projects) + */ +export function clearGraphDataCache(): void { + parsedFileCache.clear(); + reverseLinkIndexCache = null; + console.log('[DocumentGraph] Cache cleared'); +} + +/** + * Invalidate cache entries for specific files (e.g., after file changes) + */ +export function invalidateCacheForFiles(filePaths: string[]): void { + for (const filePath of filePaths) { + parsedFileCache.delete(filePath); + } + // Invalidate reverse index since links may have changed + reverseLinkIndexCache = null; + console.log(`[DocumentGraph] Invalidated cache for ${filePaths.length} file(s)`); +} + +/** + * Get cache statistics for debugging + */ +export function getGraphCacheStats(): { parsedFileCount: number; hasReverseIndex: boolean } { + return { + parsedFileCount: parsedFileCache.size, + hasReverseIndex: reverseLinkIndexCache !== null, + }; +} + /** * Size threshold for "large" files that need special handling. * Files larger than this will have their content truncated for parsing @@ -280,6 +351,8 @@ async function scanMarkdownFiles( * Parse a single markdown file and extract its data. * For large files (>1MB), content is truncated to prevent UI blocking. * + * Uses caching with mtime-based invalidation to avoid re-parsing unchanged files. + * * @param rootPath - Root directory path * @param relativePath - Path relative to root * @returns Parsed file data or null if reading fails @@ -288,11 +361,22 @@ async function parseFile(rootPath: string, relativePath: string): Promise LARGE_FILE_THRESHOLD; + // Check cache - if we have a cached version with matching mtime, use it + const cached = parsedFileCache.get(fullPath); + if (cached && cached.mtime === fileMtime) { + return cached.data; + } + // Read file content const content = await window.maestro.fs.readFile(fullPath); if (content === null || content === undefined) { @@ -327,7 +411,7 @@ async function parseFile(rootPath: string, relativePath: string): Promise LARGE_FILE_THRESHOLD; + // Check cache - if we have a cached full parse with matching mtime, extract links from it + const cached = parsedFileCache.get(fullPath); + if (cached && cached.mtime === fileMtime) { + return { + relativePath, + outgoingLinks: cached.data.internalLinks, + }; + } + // Read file content const content = await window.maestro.fs.readFile(fullPath); if (content === null || content === undefined) { diff --git a/src/renderer/components/DocumentGraph/layoutAlgorithms.ts b/src/renderer/components/DocumentGraph/layoutAlgorithms.ts index f0bd6faf..ccacd415 100644 --- a/src/renderer/components/DocumentGraph/layoutAlgorithms.ts +++ b/src/renderer/components/DocumentGraph/layoutAlgorithms.ts @@ -21,7 +21,7 @@ import { SimulationLinkDatum, } from 'd3-force'; import dagre from '@dagrejs/dagre'; -import type { GraphNodeData } from './graphDataBuilder'; +import type { GraphNodeData, DocumentNodeData } from './graphDataBuilder'; /** * Layout configuration options @@ -47,15 +47,79 @@ export interface LayoutOptions { * Default layout options */ const DEFAULT_OPTIONS: Required = { - nodeWidth: 280, - nodeHeight: 120, + nodeWidth: 220, + nodeHeight: 80, rankDirection: 'TB', - nodeSeparation: 100, - rankSeparation: 180, + nodeSeparation: 80, + rankSeparation: 150, centerX: 0, centerY: 0, }; +/** + * Cache for node dimension estimates. + * Key format: `${titleLength}:${previewLength}` - uses lengths as proxy for content + */ +const nodeDimensionCache = new Map(); + +/** + * Estimate node dimensions based on content (with caching). + * This provides more accurate sizing for layout calculations. + * Only works with document nodes (not external link nodes). + */ +function estimateNodeDimensions(node: Node): { width: number; height: number } { + const data = node.data; + const titleText = data.title || ''; + const previewText = data.description || data.contentPreview || ''; + + // Use content lengths as cache key + const cacheKey = `${Math.min(titleText.length, 40)}:${Math.min(previewText.length, 100)}`; + const cached = nodeDimensionCache.get(cacheKey); + if (cached) { + return cached; + } + + // Base dimensions (padding + minimum content) + const padding = 24; // 12px padding on each side + const minWidth = 160; + const maxWidth = 280; + + // Estimate title width (roughly 8px per character at 14px font) + const truncatedTitle = titleText.length > 40 ? titleText.slice(0, 40) + '...' : titleText; + const titleWidth = Math.min(truncatedTitle.length * 7.5 + 20, maxWidth - padding); // +20 for icon + + // Stats row is fixed width (roughly 100px for the three stats) + const statsWidth = 120; + + // Description width if present + let descriptionWidth = 0; + let descriptionHeight = 0; + + if (previewText) { + const truncatedPreview = previewText.length > 100 ? previewText.slice(0, 100) + '...' : previewText; + // Description wraps, estimate based on max width + descriptionWidth = Math.min(truncatedPreview.length * 6, maxWidth - padding); + // Estimate line count for height (roughly 17px per line at 12px font with 1.4 line height) + const charsPerLine = Math.floor((maxWidth - padding) / 6); + const lineCount = Math.ceil(truncatedPreview.length / charsPerLine); + descriptionHeight = lineCount * 17; + } + + // Calculate final dimensions + const contentWidth = Math.max(titleWidth, statsWidth, descriptionWidth); + const width = Math.max(minWidth, Math.min(contentWidth + padding, maxWidth)); + + // Height: title (20px) + margin (8px) + stats (16px) + margin (8px if desc) + description + let height = padding + 20 + 8 + 16; // Base: padding + title + margin + stats + if (descriptionHeight > 0) { + height += 8 + descriptionHeight; // margin + description + } + + const result = { width, height }; + nodeDimensionCache.set(cacheKey, result); + return result; +} + /** * Extended node datum for d3-force simulation */ @@ -109,14 +173,17 @@ export function applyForceLayout( // PHASE 1: Layout document nodes only (internal links) const internalEdges = edges.filter((e) => e.type !== 'external'); - const simNodes: ForceNodeDatum[] = documentNodes.map((node) => ({ - id: node.id, - x: node.position.x || Math.random() * 400 - 200, - y: node.position.y || Math.random() * 400 - 200, - width: opts.nodeWidth, - height: opts.nodeHeight, - isExternal: false, - })); + const simNodes: ForceNodeDatum[] = documentNodes.map((node) => { + const dims = estimateNodeDimensions(node as Node); + return { + id: node.id, + x: node.position.x || Math.random() * 400 - 200, + y: node.position.y || Math.random() * 400 - 200, + width: dims.width, + height: dims.height, + isExternal: false, + }; + }); const nodeMap = new Map(simNodes.map((n) => [n.id, n])); @@ -148,7 +215,7 @@ export function applyForceLayout( .force( 'collide', forceCollide() - .radius((d) => Math.max(d.width, d.height) / 2 + opts.nodeSeparation / 2) + .radius((d) => Math.max(d.width, d.height) / 2 + opts.nodeSeparation / 2 + 20) .strength(1.0) .iterations(3) ) @@ -276,11 +343,16 @@ export function applyHierarchicalLayout( g.setDefaultEdgeLabel(() => ({})); - // Add document nodes + // Store node dimensions for later use + const nodeDimensions = new Map(); + + // Add document nodes with estimated dimensions plus padding for spacing for (const node of documentNodes) { + const dims = estimateNodeDimensions(node as Node); + nodeDimensions.set(node.id, dims); g.setNode(node.id, { - width: opts.nodeWidth + 20, - height: opts.nodeHeight + 10, + width: dims.width + 20, + height: dims.height + 20, label: node.id, }); } @@ -300,15 +372,16 @@ export function applyHierarchicalLayout( for (const node of documentNodes) { const dagreNode = g.node(node.id); + const dims = nodeDimensions.get(node.id) || { width: opts.nodeWidth, height: opts.nodeHeight }; if (dagreNode) { - const x = dagreNode.x - opts.nodeWidth / 2; - const y = dagreNode.y - opts.nodeHeight / 2; + const x = dagreNode.x - dims.width / 2; + const y = dagreNode.y - dims.height / 2; positionMap.set(node.id, { x, y }); minX = Math.min(minX, x); - maxX = Math.max(maxX, x + opts.nodeWidth); + maxX = Math.max(maxX, x + dims.width); minY = Math.min(minY, y); - maxY = Math.max(maxY, y + opts.nodeHeight); + maxY = Math.max(maxY, y + dims.height); } } diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index 51e341a5..6cff791d 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -1655,36 +1655,35 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro {/* Settings Storage Location */} -
- {/* BETA Badge */} +
+
- Beta -
-
- -
-
-

Storage Location

-

Settings folder

-

- Choose where Maestro stores settings, sessions, and groups. Use a synced folder (iCloud Drive, Dropbox, OneDrive) to share across devices. -

-

- Note: Only run Maestro on one device at a time to avoid sync conflicts. -

+ {/* Settings folder header */} +
+

Settings folder

+

+ Choose where Maestro stores settings, sessions, and groups. Use a synced folder (iCloud Drive, Dropbox, OneDrive) to share across devices. +

+

+ Note: Only run Maestro on one device at a time to avoid sync conflicts. +

+
{/* Default Location */} -
-

Default Location

+
+
-

Current Location (Custom)

+
+
0 && !syncError && (
-
+
{formatNumber(agent.count)} {agent.count === 1 ? 'query' : 'queries'}