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:
Pedram Amini
2026-01-02 08:41:50 -06:00
parent 75063fc449
commit 581dec2cb9
8 changed files with 572 additions and 99 deletions

View File

@@ -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);
});
});
});

View File

@@ -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]);

View File

@@ -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();
}
});

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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 }}>