mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
OAuth enabled but no valid token found. Starting authentication...
Found expired OAuth token, attempting refresh... Token refresh successful ## CHANGES - 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 🎨
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<string, number>();
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
@@ -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<string, CachedParsedFile>();
|
||||
|
||||
/**
|
||||
* Cache for the reverse link index (which files link to which).
|
||||
* Invalidated when any file changes.
|
||||
*/
|
||||
interface CachedReverseLinkIndex {
|
||||
/** The reverse index map */
|
||||
reverseIndex: Map<string, Set<string>>;
|
||||
/** Set of existing files */
|
||||
existingFiles: Set<string>;
|
||||
/** Map of file path to mtime when index was built */
|
||||
fileMtimes: Map<string, number>;
|
||||
/** 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<Parsed
|
||||
const fullPath = `${rootPath}/${relativePath}`;
|
||||
|
||||
try {
|
||||
// Get file stats first to check size
|
||||
// Get file stats first to check size and mtime
|
||||
const stat = await window.maestro.fs.stat(fullPath);
|
||||
const fileSize = stat?.size ?? 0;
|
||||
if (!stat) {
|
||||
return null;
|
||||
}
|
||||
const fileSize = stat.size ?? 0;
|
||||
// Parse modifiedAt ISO string to timestamp for cache comparison
|
||||
const fileMtime = stat.modifiedAt ? new Date(stat.modifiedAt).getTime() : 0;
|
||||
const isLargeFile = fileSize > 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<Parsed
|
||||
// Note: We intentionally do NOT store 'content' in the returned object.
|
||||
// The content has been parsed for links and stats, and is no longer needed.
|
||||
// This "lazy load" approach minimizes memory usage by discarding content immediately.
|
||||
return {
|
||||
const parsed: ParsedFile = {
|
||||
relativePath,
|
||||
fullPath,
|
||||
fileSize,
|
||||
@@ -336,6 +420,11 @@ async function parseFile(rootPath: string, relativePath: string): Promise<Parsed
|
||||
stats,
|
||||
allInternalLinkPaths: internalLinks, // Store all links to identify broken ones later
|
||||
};
|
||||
|
||||
// Cache the result with mtime
|
||||
parsedFileCache.set(fullPath, { data: parsed, mtime: fileMtime });
|
||||
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse file ${fullPath}:`, error);
|
||||
return null;
|
||||
@@ -346,6 +435,9 @@ async function parseFile(rootPath: string, relativePath: string): Promise<Parsed
|
||||
* Quickly extract just the internal links from a file (no stats computation).
|
||||
* Used for building the reverse link index efficiently.
|
||||
*
|
||||
* Uses caching with mtime-based invalidation - if we have a cached full parse,
|
||||
* we can extract links from it without re-reading the file.
|
||||
*
|
||||
* @param rootPath - Root directory path
|
||||
* @param relativePath - Path relative to root
|
||||
* @returns LinkIndexEntry or null if reading fails
|
||||
@@ -354,11 +446,25 @@ async function parseFileLinksOnly(rootPath: string, relativePath: string): Promi
|
||||
const fullPath = `${rootPath}/${relativePath}`;
|
||||
|
||||
try {
|
||||
// Get file stats first to check size
|
||||
// Get file stats first to check size and mtime
|
||||
const stat = await window.maestro.fs.stat(fullPath);
|
||||
const fileSize = stat?.size ?? 0;
|
||||
if (!stat) {
|
||||
return null;
|
||||
}
|
||||
const fileSize = stat.size ?? 0;
|
||||
// Parse modifiedAt ISO string to timestamp for cache comparison
|
||||
const fileMtime = stat.modifiedAt ? new Date(stat.modifiedAt).getTime() : 0;
|
||||
const isLargeFile = fileSize > 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) {
|
||||
|
||||
@@ -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<LayoutOptions> = {
|
||||
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<string, { width: number; height: number }>();
|
||||
|
||||
/**
|
||||
* 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<DocumentNodeData>): { 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<DocumentNodeData>);
|
||||
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<ForceNodeDatum>()
|
||||
.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<string, { width: number; height: number }>();
|
||||
|
||||
// Add document nodes with estimated dimensions plus padding for spacing
|
||||
for (const node of documentNodes) {
|
||||
const dims = estimateNodeDimensions(node as Node<DocumentNodeData>);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1655,36 +1655,35 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
|
||||
</div>
|
||||
|
||||
{/* Settings Storage Location */}
|
||||
<div
|
||||
className="flex items-start gap-3 p-4 rounded-xl border relative"
|
||||
style={{ backgroundColor: theme.colors.bgMain, borderColor: theme.colors.border }}
|
||||
>
|
||||
{/* BETA Badge */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold opacity-70 uppercase mb-2 flex items-center gap-2">
|
||||
<FolderSync className="w-3 h-3" />
|
||||
Storage Location
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase"
|
||||
style={{ backgroundColor: theme.colors.warning + '30', color: theme.colors.warning }}
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
className="absolute top-2 right-2 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase"
|
||||
style={{ backgroundColor: theme.colors.warning + '30', color: theme.colors.warning }}
|
||||
className="p-3 rounded border space-y-3"
|
||||
style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}
|
||||
>
|
||||
Beta
|
||||
</div>
|
||||
<div
|
||||
className="p-2 rounded-lg flex-shrink-0"
|
||||
style={{ backgroundColor: theme.colors.accent + '20' }}
|
||||
>
|
||||
<FolderSync className="w-5 h-5" style={{ color: theme.colors.accent }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] uppercase font-bold opacity-50 mb-1">Storage Location</p>
|
||||
<p className="font-semibold mb-1">Settings folder</p>
|
||||
<p className="text-xs opacity-60 mb-2">
|
||||
Choose where Maestro stores settings, sessions, and groups. Use a synced folder (iCloud Drive, Dropbox, OneDrive) to share across devices.
|
||||
</p>
|
||||
<p className="text-xs opacity-50 mb-4 italic">
|
||||
Note: Only run Maestro on one device at a time to avoid sync conflicts.
|
||||
</p>
|
||||
{/* Settings folder header */}
|
||||
<div>
|
||||
<p className="text-sm font-semibold" style={{ color: theme.colors.textMain }}>Settings folder</p>
|
||||
<p className="text-xs opacity-60 mt-0.5">
|
||||
Choose where Maestro stores settings, sessions, and groups. Use a synced folder (iCloud Drive, Dropbox, OneDrive) to share across devices.
|
||||
</p>
|
||||
<p className="text-xs opacity-50 mt-1 italic">
|
||||
Note: Only run Maestro on one device at a time to avoid sync conflicts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Default Location */}
|
||||
<div className="mb-3">
|
||||
<p className="text-[10px] uppercase font-bold opacity-40 mb-1">Default Location</p>
|
||||
<div>
|
||||
<label className="block text-xs opacity-60 mb-1">Default Location</label>
|
||||
<div
|
||||
className="text-xs p-2 rounded font-mono truncate"
|
||||
style={{ backgroundColor: theme.colors.bgActivity }}
|
||||
@@ -1696,8 +1695,8 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
|
||||
|
||||
{/* Current Location (if different) */}
|
||||
{customSyncPath && (
|
||||
<div className="mb-3">
|
||||
<p className="text-[10px] uppercase font-bold opacity-40 mb-1">Current Location (Custom)</p>
|
||||
<div>
|
||||
<label className="block text-xs opacity-60 mb-1">Current Location (Custom)</label>
|
||||
<div
|
||||
className="text-xs p-2 rounded font-mono truncate flex items-center gap-2"
|
||||
style={{ backgroundColor: theme.colors.accent + '15', border: `1px solid ${theme.colors.accent}40` }}
|
||||
@@ -1788,7 +1787,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
|
||||
{/* Success Message */}
|
||||
{syncMigratedCount !== null && syncMigratedCount > 0 && !syncError && (
|
||||
<div
|
||||
className="mt-3 p-2 rounded text-xs flex items-center gap-2"
|
||||
className="p-2 rounded text-xs flex items-center gap-2"
|
||||
style={{
|
||||
backgroundColor: theme.colors.success + '20',
|
||||
color: theme.colors.success,
|
||||
@@ -1802,7 +1801,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
|
||||
{/* Error Message */}
|
||||
{syncError && (
|
||||
<div
|
||||
className="mt-3 p-2 rounded text-xs flex items-start gap-2"
|
||||
className="p-2 rounded text-xs flex items-start gap-2"
|
||||
style={{
|
||||
backgroundColor: theme.colors.error + '20',
|
||||
color: theme.colors.error,
|
||||
@@ -1816,7 +1815,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
|
||||
{/* Restart Required Warning */}
|
||||
{syncRestartRequired && !syncError && (
|
||||
<div
|
||||
className="mt-3 p-2 rounded text-xs flex items-center gap-2"
|
||||
className="p-2 rounded text-xs flex items-center gap-2"
|
||||
style={{
|
||||
backgroundColor: theme.colors.warning + '20',
|
||||
color: theme.colors.warning,
|
||||
|
||||
@@ -262,7 +262,7 @@ export function AgentComparisonChart({ data, theme, colorBlindMode = false }: Ag
|
||||
className="flex items-center gap-3 flex-shrink-0"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
>
|
||||
<div className="w-16 text-xs text-right" title="Query count">
|
||||
<div className="text-xs text-right whitespace-nowrap" title="Query count">
|
||||
{formatNumber(agent.count)} {agent.count === 1 ? 'query' : 'queries'}
|
||||
</div>
|
||||
<div className="w-14 text-xs text-right font-medium" title="Total duration" style={{ color: theme.colors.textMain }}>
|
||||
|
||||
Reference in New Issue
Block a user