mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## CHANGES
- Added performance best-practices guide for React and main process tuning 📘 - Introduced `buildFileTreeIndices` for fast, reusable markdown file lookups ⚡ - Updated `remarkFileLinks` to accept prebuilt indices with backward compatibility 🧩 - Memoized file-tree indices in AutoRun, FilePreview, and MarkdownRenderer for speed 🚀 - Expanded test suite to cover indices building and indexed link resolution 🧪 - Made temp-file cleanup asynchronous to keep the main thread snappy 🧹 - Cached shell path resolution to avoid repeated synchronous filesystem checks 🗄️ - Precompiled leading-emoji regex to speed up session name sorting 🔤 - Consolidated SessionList filtering/grouping/sorting into one efficient memo pass 🧠 - Memoized TabBar tabs, computed labels, and style objects to cut rerenders 🎛️
This commit is contained in:
88
CLAUDE.md
88
CLAUDE.md
@@ -595,6 +595,94 @@ Each agent declares capabilities that control UI feature availability. See `src/
|
||||
|
||||
For detailed agent integration guide, see [AGENT_SUPPORT.md](AGENT_SUPPORT.md).
|
||||
|
||||
## Performance Best Practices
|
||||
|
||||
### React Component Optimization
|
||||
|
||||
**Use `React.memo` for list item components:**
|
||||
```typescript
|
||||
// Components rendered in arrays (tabs, sessions, list items) should be memoized
|
||||
const Tab = memo(function Tab({ tab, isActive, ... }: TabProps) {
|
||||
// Memoize computed values that depend on props
|
||||
const displayName = useMemo(() => getTabDisplayName(tab), [tab.name, tab.agentSessionId]);
|
||||
|
||||
// Memoize style objects to prevent new references on every render
|
||||
const tabStyle = useMemo(() => ({
|
||||
borderRadius: '6px',
|
||||
backgroundColor: isActive ? theme.colors.accent : 'transparent',
|
||||
} as React.CSSProperties), [isActive, theme.colors.accent]);
|
||||
|
||||
return <div style={tabStyle}>{displayName}</div>;
|
||||
});
|
||||
```
|
||||
|
||||
**Consolidate chained `useMemo` calls:**
|
||||
```typescript
|
||||
// BAD: Multiple dependent useMemo calls create cascade re-computations
|
||||
const filtered = useMemo(() => sessions.filter(...), [sessions]);
|
||||
const sorted = useMemo(() => filtered.sort(...), [filtered]);
|
||||
const grouped = useMemo(() => groupBy(sorted, ...), [sorted]);
|
||||
|
||||
// GOOD: Single useMemo with all transformations
|
||||
const { filtered, sorted, grouped } = useMemo(() => {
|
||||
const filtered = sessions.filter(...);
|
||||
const sorted = filtered.sort(...);
|
||||
const grouped = groupBy(sorted, ...);
|
||||
return { filtered, sorted, grouped };
|
||||
}, [sessions]);
|
||||
```
|
||||
|
||||
**Pre-compile regex patterns at module level:**
|
||||
```typescript
|
||||
// BAD: Regex compiled on every render
|
||||
const Component = () => {
|
||||
const cleaned = text.replace(/^(\p{Emoji})+\s*/u, '');
|
||||
};
|
||||
|
||||
// GOOD: Compile once at module load
|
||||
const LEADING_EMOJI_REGEX = /^(\p{Emoji})+\s*/u;
|
||||
const Component = () => {
|
||||
const cleaned = text.replace(LEADING_EMOJI_REGEX, '');
|
||||
};
|
||||
```
|
||||
|
||||
### Data Structure Pre-computation
|
||||
|
||||
**Build indices once, reuse in renders:**
|
||||
```typescript
|
||||
// BAD: O(n) tree traversal on every markdown render
|
||||
const result = remarkFileLinks({ fileTree, cwd });
|
||||
|
||||
// GOOD: Build index once when fileTree changes, pass to renders
|
||||
const indices = useMemo(() => buildFileTreeIndices(fileTree), [fileTree]);
|
||||
const result = remarkFileLinks({ indices, cwd });
|
||||
```
|
||||
|
||||
### Main Process (Node.js)
|
||||
|
||||
**Cache expensive lookups:**
|
||||
```typescript
|
||||
// BAD: Synchronous file check on every shell spawn
|
||||
fs.accessSync(shellPath, fs.constants.X_OK);
|
||||
|
||||
// GOOD: Cache resolved paths
|
||||
const shellPathCache = new Map<string, string>();
|
||||
const cached = shellPathCache.get(shell);
|
||||
if (cached) return cached;
|
||||
// ... resolve and cache
|
||||
shellPathCache.set(shell, resolved);
|
||||
```
|
||||
|
||||
**Use async file operations:**
|
||||
```typescript
|
||||
// BAD: Blocking the main process
|
||||
fs.unlinkSync(tempFile);
|
||||
|
||||
// GOOD: Non-blocking cleanup
|
||||
import * as fsPromises from 'fs/promises';
|
||||
fsPromises.unlink(tempFile).catch(() => {});
|
||||
```
|
||||
|
||||
## Onboarding Wizard
|
||||
|
||||
The wizard (`src/renderer/components/Wizard/`) guides new users through first-run setup, creating AI sessions with Auto Run documents.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import { unified } from 'unified';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkStringify from 'remark-stringify';
|
||||
import { remarkFileLinks } from '../../../renderer/utils/remarkFileLinks';
|
||||
import { remarkFileLinks, buildFileTreeIndices } from '../../../renderer/utils/remarkFileLinks';
|
||||
import type { FileNode } from '../../../renderer/types/fileTree';
|
||||
|
||||
// Helper to process markdown and return the result
|
||||
@@ -568,4 +568,94 @@ describe('remarkFileLinks', () => {
|
||||
expect(result).toContain('![Pasted image 20250519123910.png]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFileTreeIndices', () => {
|
||||
it('builds allPaths Set containing all file paths', () => {
|
||||
const indices = buildFileTreeIndices(sampleFileTree);
|
||||
|
||||
expect(indices.allPaths).toBeInstanceOf(Set);
|
||||
expect(indices.allPaths.has('README.md')).toBe(true);
|
||||
expect(indices.allPaths.has('config.json')).toBe(true);
|
||||
expect(indices.allPaths.has('OPSWAT/README.md')).toBe(true);
|
||||
expect(indices.allPaths.has('OPSWAT/Meetings/OP-0088.md')).toBe(true);
|
||||
expect(indices.allPaths.has('Notes/TODO.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('builds filenameIndex Map for quick filename lookup', () => {
|
||||
const indices = buildFileTreeIndices(sampleFileTree);
|
||||
|
||||
expect(indices.filenameIndex).toBeInstanceOf(Map);
|
||||
// README.md exists in multiple locations
|
||||
const readmePaths = indices.filenameIndex.get('README.md');
|
||||
expect(readmePaths).toBeDefined();
|
||||
expect(readmePaths).toContain('README.md');
|
||||
expect(readmePaths).toContain('OPSWAT/README.md');
|
||||
});
|
||||
|
||||
it('handles duplicate filenames correctly', () => {
|
||||
const indices = buildFileTreeIndices(sampleFileTree);
|
||||
|
||||
// Meeting Notes.md exists in both Notes and Archive
|
||||
const meetingNotesPaths = indices.filenameIndex.get('Meeting Notes.md');
|
||||
expect(meetingNotesPaths).toBeDefined();
|
||||
expect(meetingNotesPaths?.length).toBe(2);
|
||||
expect(meetingNotesPaths).toContain('Notes/Meeting Notes.md');
|
||||
expect(meetingNotesPaths).toContain('Archive/Meeting Notes.md');
|
||||
});
|
||||
|
||||
it('returns empty indices for empty file tree', () => {
|
||||
const indices = buildFileTreeIndices([]);
|
||||
|
||||
expect(indices.allPaths.size).toBe(0);
|
||||
expect(indices.filenameIndex.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not include folder paths, only files', () => {
|
||||
const indices = buildFileTreeIndices(sampleFileTree);
|
||||
|
||||
// Folders should not be in the paths
|
||||
expect(indices.allPaths.has('OPSWAT')).toBe(false);
|
||||
expect(indices.allPaths.has('Notes')).toBe(false);
|
||||
expect(indices.allPaths.has('attachments')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remarkFileLinks with pre-built indices', () => {
|
||||
it('uses pre-built indices when provided', async () => {
|
||||
const indices = buildFileTreeIndices(sampleFileTree);
|
||||
|
||||
const result = await unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkFileLinks, { indices, cwd: '' })
|
||||
.use(remarkStringify)
|
||||
.process('See [[TODO]] for tasks.');
|
||||
|
||||
expect(String(result)).toContain('[TODO](maestro-file://Notes/TODO.md)');
|
||||
});
|
||||
|
||||
it('works with pre-built indices for path-style references', async () => {
|
||||
const indices = buildFileTreeIndices(sampleFileTree);
|
||||
|
||||
const result = await unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkFileLinks, { indices, cwd: '' })
|
||||
.use(remarkStringify)
|
||||
.process('Check OPSWAT/Meetings/OP-0088.md for details.');
|
||||
|
||||
expect(String(result)).toContain('[OPSWAT/Meetings/OP-0088.md](maestro-file://OPSWAT/Meetings/OP-0088.md)');
|
||||
});
|
||||
|
||||
it('handles cwd-based proximity with pre-built indices', async () => {
|
||||
const indices = buildFileTreeIndices(sampleFileTree);
|
||||
|
||||
const result = await unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkFileLinks, { indices, cwd: 'Archive' })
|
||||
.use(remarkStringify)
|
||||
.process('See [[Meeting Notes]] for details.');
|
||||
|
||||
// Should pick Archive/Meeting Notes.md based on cwd proximity
|
||||
expect(String(result)).toContain('[Meeting Notes](<maestro-file://Archive/Meeting Notes.md>)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawn, ChildProcess } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as pty from 'node-pty';
|
||||
import * as fs from 'fs';
|
||||
import * as fsPromises from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { stripControlSequences, stripAllAnsiCodes } from './utils/terminalFilter';
|
||||
@@ -15,6 +16,9 @@ import { getAgentCapabilities } from './agent-capabilities';
|
||||
import { shellEscapeForDoubleQuotes } from './utils/shell-escape';
|
||||
import { getExpandedEnv, resolveSshPath } from './utils/cliDetection';
|
||||
|
||||
// Cache for shell path resolution to avoid repeated synchronous file system checks
|
||||
const shellPathCache = new Map<string, string>();
|
||||
|
||||
// Re-export parser types for consumers
|
||||
export type { ParsedEvent, AgentOutputParser } from './parsers';
|
||||
export { getOutputParser } from './parsers';
|
||||
@@ -290,18 +294,22 @@ function saveImageToTempFile(dataUrl: string, index: number): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up temp image files.
|
||||
* Clean up temp image files asynchronously.
|
||||
* Fire-and-forget to avoid blocking the main thread.
|
||||
*/
|
||||
function cleanupTempFiles(files: string[]): void {
|
||||
// Use async operations to avoid blocking the main thread
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (fs.existsSync(file)) {
|
||||
fs.unlinkSync(file);
|
||||
fsPromises.unlink(file)
|
||||
.then(() => {
|
||||
logger.debug('[ProcessManager] Cleaned up temp file', 'ProcessManager', { file });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[ProcessManager] Failed to clean up temp file', 'ProcessManager', { file, error: String(error) });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
// ENOENT is fine - file already deleted
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.warn('[ProcessManager] Failed to clean up temp file', 'ProcessManager', { file, error: String(error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1653,14 +1661,21 @@ export class ProcessManager extends EventEmitter {
|
||||
}
|
||||
} else if (!shell.includes('/')) {
|
||||
// Unix: resolve shell to full path - Electron's internal PATH may not include /bin
|
||||
const commonPaths = ['/bin/', '/usr/bin/', '/usr/local/bin/', '/opt/homebrew/bin/'];
|
||||
for (const prefix of commonPaths) {
|
||||
try {
|
||||
fs.accessSync(prefix + shell, fs.constants.X_OK);
|
||||
shellPath = prefix + shell;
|
||||
break;
|
||||
} catch {
|
||||
// Try next path
|
||||
// Use cache to avoid repeated synchronous file system checks
|
||||
const cachedPath = shellPathCache.get(shell);
|
||||
if (cachedPath) {
|
||||
shellPath = cachedPath;
|
||||
} else {
|
||||
const commonPaths = ['/bin/', '/usr/bin/', '/usr/local/bin/', '/opt/homebrew/bin/'];
|
||||
for (const prefix of commonPaths) {
|
||||
try {
|
||||
fs.accessSync(prefix + shell, fs.constants.X_OK);
|
||||
shellPath = prefix + shell;
|
||||
shellPathCache.set(shell, shellPath); // Cache for future calls
|
||||
break;
|
||||
} catch {
|
||||
// Try next path
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useTemplateAutocomplete, useAutoRunUndo, useAutoRunImageHandling, image
|
||||
import { TemplateAutocompleteDropdown } from './TemplateAutocompleteDropdown';
|
||||
import { generateAutoRunProseStyles, createMarkdownComponents } from '../utils/markdownConfig';
|
||||
import { formatShortcutKeys } from '../utils/shortcutFormatter';
|
||||
import { remarkFileLinks } from '../utils/remarkFileLinks';
|
||||
import { remarkFileLinks, buildFileTreeIndices } from '../utils/remarkFileLinks';
|
||||
|
||||
interface AutoRunProps {
|
||||
theme: Theme;
|
||||
@@ -1298,15 +1298,23 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
|
||||
onSelectDocument(pathWithoutExt);
|
||||
}, [onSelectDocument]);
|
||||
|
||||
// Memoize file tree indices to avoid O(n) traversal on every render
|
||||
const fileTreeIndices = useMemo(() => {
|
||||
if (fileTree.length > 0) {
|
||||
return buildFileTreeIndices(fileTree);
|
||||
}
|
||||
return null;
|
||||
}, [fileTree]);
|
||||
|
||||
// Memoize remarkPlugins - include remarkFileLinks when we have file tree
|
||||
const remarkPlugins = useMemo(() => {
|
||||
const plugins: any[] = [remarkGfm];
|
||||
if (fileTree.length > 0) {
|
||||
// cwd is empty since we're at the root of the Auto Run folder
|
||||
plugins.push([remarkFileLinks, { fileTree, cwd: '' }]);
|
||||
plugins.push([remarkFileLinks, { indices: fileTreeIndices || undefined, cwd: '' }]);
|
||||
}
|
||||
return plugins;
|
||||
}, [fileTree]);
|
||||
}, [fileTree, fileTreeIndices]);
|
||||
|
||||
// Base markdown components - stable unless theme, folderPath, or callbacks change
|
||||
// Separated from search highlighting to prevent rebuilds on every search state change
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Modal, ModalFooter } from './ui/Modal';
|
||||
import { MermaidRenderer } from './MermaidRenderer';
|
||||
import { getEncoder, formatTokenCount } from '../utils/tokenCounter';
|
||||
import { formatShortcutKeys } from '../utils/shortcutFormatter';
|
||||
import { remarkFileLinks } from '../utils/remarkFileLinks';
|
||||
import { remarkFileLinks, buildFileTreeIndices } from '../utils/remarkFileLinks';
|
||||
import remarkFrontmatter from 'remark-frontmatter';
|
||||
import { remarkFrontmatterTable } from '../utils/remarkFrontmatterTable';
|
||||
import type { FileNode } from '../types/fileTree';
|
||||
@@ -474,6 +474,14 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
|
||||
return counts;
|
||||
}, [isMarkdown, file?.content]);
|
||||
|
||||
// Memoize file tree indices to avoid O(n) traversal on every render
|
||||
const fileTreeIndices = useMemo(() => {
|
||||
if (fileTree && fileTree.length > 0) {
|
||||
return buildFileTreeIndices(fileTree);
|
||||
}
|
||||
return null;
|
||||
}, [fileTree]);
|
||||
|
||||
// Memoize remarkPlugins to prevent infinite render loops
|
||||
// Creating new arrays/objects on each render causes ReactMarkdown to re-render children
|
||||
const remarkPlugins = useMemo(() => [
|
||||
@@ -482,9 +490,9 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
|
||||
remarkFrontmatterTable,
|
||||
remarkHighlight,
|
||||
...(fileTree && fileTree.length > 0 && cwd !== undefined
|
||||
? [[remarkFileLinks, { fileTree, cwd }] as any]
|
||||
? [[remarkFileLinks, { indices: fileTreeIndices || undefined, cwd }] as any]
|
||||
: [])
|
||||
], [fileTree, cwd]);
|
||||
], [fileTree, fileTreeIndices, cwd]);
|
||||
|
||||
// Memoize rehypePlugins array to prevent unnecessary re-renders
|
||||
const rehypePlugins = useMemo(() => [rehypeRaw, rehypeSlug], []);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { Clipboard, Loader2, ImageOff } from 'lucide-react';
|
||||
import type { Theme } from '../types';
|
||||
import type { FileNode } from '../types/fileTree';
|
||||
import { remarkFileLinks } from '../utils/remarkFileLinks';
|
||||
import { remarkFileLinks, buildFileTreeIndices } from '../utils/remarkFileLinks';
|
||||
import remarkFrontmatter from 'remark-frontmatter';
|
||||
import { remarkFrontmatterTable } from '../utils/remarkFrontmatterTable';
|
||||
|
||||
@@ -244,6 +244,15 @@ interface MarkdownRendererProps {
|
||||
* This component assumes those styles are already present in a parent container.
|
||||
*/
|
||||
export const MarkdownRenderer = memo(({ content, theme, onCopy, className = '', fileTree, cwd, projectRoot, onFileClick, allowRawHtml = false, sshRemoteId }: MarkdownRendererProps) => {
|
||||
// Memoize file tree indices to avoid O(n) traversal on every render
|
||||
// Only rebuild when fileTree reference changes
|
||||
const fileTreeIndices = useMemo(() => {
|
||||
if (fileTree && fileTree.length > 0) {
|
||||
return buildFileTreeIndices(fileTree);
|
||||
}
|
||||
return null;
|
||||
}, [fileTree]);
|
||||
|
||||
// Memoize remark plugins to avoid recreating on every render
|
||||
const remarkPlugins = useMemo(() => {
|
||||
const plugins: any[] = [
|
||||
@@ -254,10 +263,10 @@ export const MarkdownRenderer = memo(({ content, theme, onCopy, className = '',
|
||||
// Add remarkFileLinks if we have file tree for relative paths,
|
||||
// OR if we have projectRoot for absolute paths (even with empty file tree)
|
||||
if ((fileTree && fileTree.length > 0 && cwd !== undefined) || projectRoot) {
|
||||
plugins.push([remarkFileLinks, { fileTree: fileTree || [], cwd: cwd || '', projectRoot }]);
|
||||
plugins.push([remarkFileLinks, { indices: fileTreeIndices || undefined, cwd: cwd || '', projectRoot }]);
|
||||
}
|
||||
return plugins;
|
||||
}, [fileTree, cwd, projectRoot]);
|
||||
}, [fileTree, fileTreeIndices, cwd, projectRoot]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -730,17 +730,17 @@ function SessionTooltipContent({
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-compiled emoji regex for better performance (compiled once at module load)
|
||||
// Matches common emoji patterns at the start of the string including:
|
||||
// - Basic emojis (😀, 🎉, etc.)
|
||||
// - Emojis with skin tone modifiers
|
||||
// - Flag emojis
|
||||
// - ZWJ sequences (👨👩👧, etc.)
|
||||
const LEADING_EMOJI_REGEX = /^(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F?|\p{Emoji_Modifier_Base}\p{Emoji_Modifier}?)+\s*/u;
|
||||
|
||||
// Strip leading emojis from a string for alphabetical sorting
|
||||
// Matches common emoji patterns at the start of the string
|
||||
const stripLeadingEmojis = (str: string): string => {
|
||||
// Match emojis at the start: emoji characters, variation selectors, ZWJ sequences, etc.
|
||||
// This regex matches most common emoji patterns including:
|
||||
// - Basic emojis (😀, 🎉, etc.)
|
||||
// - Emojis with skin tone modifiers
|
||||
// - Flag emojis
|
||||
// - ZWJ sequences (👨👩👧, etc.)
|
||||
const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F?|\p{Emoji_Modifier_Base}\p{Emoji_Modifier}?)+\s*/gu;
|
||||
return str.replace(emojiRegex, '').trim();
|
||||
return str.replace(LEADING_EMOJI_REGEX, '').trim();
|
||||
};
|
||||
|
||||
// Compare two session names, ignoring leading emojis for alphabetization
|
||||
@@ -1335,93 +1335,109 @@ function SessionListInner(props: SessionListProps) {
|
||||
);
|
||||
};
|
||||
|
||||
// Filter sessions based on search query (searches session name AND AI tab names)
|
||||
// Also filters out worktree children (they're rendered under their parents)
|
||||
const filteredSessions = useMemo(() => {
|
||||
if (!sessionFilter) {
|
||||
return sessions.filter(s => !s.parentSessionId);
|
||||
// Consolidated session categorization and sorting - computed in a single pass
|
||||
// This replaces 12+ chained useMemo calls with one comprehensive computation
|
||||
const sessionCategories = useMemo(() => {
|
||||
// Step 1: Filter sessions based on search query
|
||||
const query = sessionFilter?.toLowerCase() ?? '';
|
||||
const filtered: Session[] = [];
|
||||
|
||||
for (const s of sessions) {
|
||||
// Exclude worktree children from main list (they appear under parent)
|
||||
if (s.parentSessionId) continue;
|
||||
|
||||
if (!query) {
|
||||
filtered.push(s);
|
||||
} else {
|
||||
// Match session name
|
||||
if (s.name.toLowerCase().includes(query)) {
|
||||
filtered.push(s);
|
||||
continue;
|
||||
}
|
||||
// Match any AI tab name
|
||||
if (s.aiTabs?.some(tab => tab.name?.toLowerCase().includes(query))) {
|
||||
filtered.push(s);
|
||||
continue;
|
||||
}
|
||||
// Match worktree children branch names
|
||||
const worktreeChildren = worktreeChildrenByParentId.get(s.id);
|
||||
if (worktreeChildren?.some(child =>
|
||||
child.worktreeBranch?.toLowerCase().includes(query) ||
|
||||
child.name.toLowerCase().includes(query)
|
||||
)) {
|
||||
filtered.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const query = sessionFilter.toLowerCase();
|
||||
return sessions.filter(s => {
|
||||
// Exclude worktree children from main list (they appear under parent)
|
||||
if (s.parentSessionId) return false;
|
||||
// Match session name
|
||||
if (s.name.toLowerCase().includes(query)) return true;
|
||||
// Match any AI tab name
|
||||
if (s.aiTabs?.some(tab => tab.name?.toLowerCase().includes(query))) return true;
|
||||
// Match worktree children branch names
|
||||
const worktreeChildren = worktreeChildrenByParentId.get(s.id);
|
||||
if (worktreeChildren?.some(child =>
|
||||
child.worktreeBranch?.toLowerCase().includes(query) ||
|
||||
child.name.toLowerCase().includes(query)
|
||||
)) return true;
|
||||
return false;
|
||||
// Step 2: Categorize sessions in a single pass
|
||||
const bookmarked: Session[] = [];
|
||||
const ungrouped: Session[] = [];
|
||||
const groupedMap = new Map<string, Session[]>();
|
||||
|
||||
for (const s of filtered) {
|
||||
if (s.bookmarked) {
|
||||
bookmarked.push(s);
|
||||
}
|
||||
if (s.groupId) {
|
||||
const list = groupedMap.get(s.groupId);
|
||||
if (list) {
|
||||
list.push(s);
|
||||
} else {
|
||||
groupedMap.set(s.groupId, [s]);
|
||||
}
|
||||
} else {
|
||||
ungrouped.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Sort each category once
|
||||
const sortFn = (a: Session, b: Session) => compareSessionNames(a.name, b.name);
|
||||
|
||||
const sortedFiltered = [...filtered].sort(sortFn);
|
||||
const sortedBookmarked = [...bookmarked].sort(sortFn);
|
||||
const sortedBookmarkedParent = bookmarked.filter(s => !s.parentSessionId).sort(sortFn);
|
||||
const sortedUngrouped = [...ungrouped].sort(sortFn);
|
||||
const sortedUngroupedParent = ungrouped.filter(s => !s.parentSessionId).sort(sortFn);
|
||||
|
||||
// Sort sessions within each group
|
||||
const sortedGrouped = new Map<string, Session[]>();
|
||||
groupedMap.forEach((groupSessions, groupId) => {
|
||||
sortedGrouped.set(groupId, [...groupSessions].sort(sortFn));
|
||||
});
|
||||
|
||||
return {
|
||||
filtered,
|
||||
bookmarked,
|
||||
ungrouped,
|
||||
groupedMap,
|
||||
sortedFiltered,
|
||||
sortedBookmarked,
|
||||
sortedBookmarkedParent,
|
||||
sortedUngrouped,
|
||||
sortedUngroupedParent,
|
||||
sortedGrouped,
|
||||
};
|
||||
}, [sessionFilter, sessions, worktreeChildrenByParentId]);
|
||||
|
||||
const bookmarkedSessions = useMemo(
|
||||
() => filteredSessions.filter(s => s.bookmarked),
|
||||
[filteredSessions]
|
||||
);
|
||||
const bookmarkedParentSessions = useMemo(
|
||||
() => bookmarkedSessions.filter(s => !s.parentSessionId),
|
||||
[bookmarkedSessions]
|
||||
);
|
||||
const sortedBookmarkedSessions = useMemo(
|
||||
() => [...bookmarkedSessions].sort((a, b) => compareSessionNames(a.name, b.name)),
|
||||
[bookmarkedSessions]
|
||||
);
|
||||
const sortedBookmarkedParentSessions = useMemo(
|
||||
() => [...bookmarkedParentSessions].sort((a, b) => compareSessionNames(a.name, b.name)),
|
||||
[bookmarkedParentSessions]
|
||||
);
|
||||
|
||||
const groupedSessionsById = useMemo(() => {
|
||||
const map = new Map<string, Session[]>();
|
||||
filteredSessions.forEach(session => {
|
||||
if (!session.groupId) return;
|
||||
const list = map.get(session.groupId);
|
||||
if (list) {
|
||||
list.push(session);
|
||||
} else {
|
||||
map.set(session.groupId, [session]);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [filteredSessions]);
|
||||
const sortedGroupSessionsById = useMemo(() => {
|
||||
const map = new Map<string, Session[]>();
|
||||
groupedSessionsById.forEach((groupSessions, groupId) => {
|
||||
map.set(groupId, [...groupSessions].sort((a, b) => compareSessionNames(a.name, b.name)));
|
||||
});
|
||||
return map;
|
||||
}, [groupedSessionsById]);
|
||||
// Destructure for backwards compatibility with existing code
|
||||
const filteredSessions = sessionCategories.filtered;
|
||||
const bookmarkedSessions = sessionCategories.bookmarked;
|
||||
const bookmarkedParentSessions = sessionCategories.sortedBookmarkedParent;
|
||||
const sortedBookmarkedSessions = sessionCategories.sortedBookmarked;
|
||||
const sortedBookmarkedParentSessions = sessionCategories.sortedBookmarkedParent;
|
||||
const groupedSessionsById = sessionCategories.groupedMap;
|
||||
const sortedGroupSessionsById = sessionCategories.sortedGrouped;
|
||||
const ungroupedSessions = sessionCategories.ungrouped;
|
||||
const ungroupedParentSessions = sessionCategories.sortedUngroupedParent;
|
||||
const sortedUngroupedSessions = sessionCategories.sortedUngrouped;
|
||||
const sortedUngroupedParentSessions = sessionCategories.sortedUngroupedParent;
|
||||
const sortedFilteredSessions = sessionCategories.sortedFiltered;
|
||||
|
||||
const sortedGroups = useMemo(
|
||||
() => [...groups].sort((a, b) => compareSessionNames(a.name, b.name)),
|
||||
[groups]
|
||||
);
|
||||
const ungroupedSessions = useMemo(
|
||||
() => filteredSessions.filter(s => !s.groupId),
|
||||
[filteredSessions]
|
||||
);
|
||||
const ungroupedParentSessions = useMemo(
|
||||
() => ungroupedSessions.filter(s => !s.parentSessionId),
|
||||
[ungroupedSessions]
|
||||
);
|
||||
const sortedUngroupedSessions = useMemo(
|
||||
() => [...ungroupedSessions].sort((a, b) => compareSessionNames(a.name, b.name)),
|
||||
[ungroupedSessions]
|
||||
);
|
||||
const sortedUngroupedParentSessions = useMemo(
|
||||
() => [...ungroupedParentSessions].sort((a, b) => compareSessionNames(a.name, b.name)),
|
||||
[ungroupedParentSessions]
|
||||
);
|
||||
const sortedFilteredSessions = useMemo(
|
||||
() => [...filteredSessions].sort((a, b) => compareSessionNames(a.name, b.name)),
|
||||
[filteredSessions]
|
||||
);
|
||||
|
||||
// When filter opens, apply filter mode preferences (or defaults on first open)
|
||||
// When filter closes, save current states as filter mode preferences and restore original states
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useCallback, useEffect, memo } from 'react';
|
||||
import React, { useState, useRef, useCallback, useEffect, memo, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Plus, Star, Copy, Edit2, Mail, Pencil, Search, GitMerge, ArrowRightCircle, Minimize2, Download, Clipboard, Share2, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||
import type { AITab, Theme } from '../types';
|
||||
@@ -104,6 +104,8 @@ interface TabProps {
|
||||
* - Claude UUID: "abc123-def456-ghi789" → "ABC123" (first octet)
|
||||
* - OpenCode: "SES_4BCDFE8C5FFE4KC1UV9NSMYEDB" → "SES_4BCD" (prefix + 4 chars)
|
||||
* - Codex: "thread_abc123..." → "THR_ABC1" (prefix + 4 chars)
|
||||
*
|
||||
* Memoized per-tab via useMemo in the Tab component to avoid recalculation on every render.
|
||||
*/
|
||||
function getTabDisplayName(tab: AITab): string {
|
||||
if (tab.name) {
|
||||
@@ -139,8 +141,10 @@ function getTabDisplayName(tab: AITab): string {
|
||||
* Individual tab component styled like browser tabs (Safari/Chrome).
|
||||
* All tabs have visible borders; active tab connects to content area.
|
||||
* Includes hover overlay with session info and actions.
|
||||
*
|
||||
* Wrapped with React.memo to prevent unnecessary re-renders when sibling tabs change.
|
||||
*/
|
||||
function Tab({
|
||||
const Tab = memo(function Tab({
|
||||
tab,
|
||||
isActive,
|
||||
theme,
|
||||
@@ -340,7 +344,31 @@ function Tab({
|
||||
setOverlayOpen(false);
|
||||
};
|
||||
|
||||
const displayName = getTabDisplayName(tab);
|
||||
// Memoize display name to avoid recalculation on every render
|
||||
const displayName = useMemo(() => getTabDisplayName(tab), [tab.name, tab.agentSessionId]);
|
||||
|
||||
// Memoize tab styles to avoid creating new object references on every render
|
||||
const tabStyle = useMemo(() => ({
|
||||
// All tabs have rounded top corners
|
||||
borderTopLeftRadius: '6px',
|
||||
borderTopRightRadius: '6px',
|
||||
// Active tab: bright background matching content area
|
||||
// Inactive tabs: transparent with subtle hover
|
||||
backgroundColor: isActive
|
||||
? theme.colors.bgMain
|
||||
: (isHovered ? 'rgba(255, 255, 255, 0.08)' : 'transparent'),
|
||||
// Active tab has visible borders, inactive tabs have no borders (cleaner look)
|
||||
borderTop: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent',
|
||||
borderLeft: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent',
|
||||
borderRight: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent',
|
||||
// Active tab has no bottom border (connects to content)
|
||||
borderBottom: isActive ? `1px solid ${theme.colors.bgMain}` : '1px solid transparent',
|
||||
// Active tab sits on top of the tab bar's bottom border
|
||||
marginBottom: isActive ? '-1px' : '0',
|
||||
// Slight z-index for active tab to cover border properly
|
||||
zIndex: isActive ? 1 : 0,
|
||||
'--tw-ring-color': isDragOver ? theme.colors.accent : 'transparent'
|
||||
} as React.CSSProperties), [isActive, isHovered, isDragOver, theme.colors.bgMain, theme.colors.border, theme.colors.accent]);
|
||||
|
||||
// Browser-style tab: all tabs have borders, active tab "connects" to content
|
||||
// Active tab is bright and obvious, inactive tabs are more muted
|
||||
@@ -354,27 +382,7 @@ function Tab({
|
||||
${isDragging ? 'opacity-50' : ''}
|
||||
${isDragOver ? 'ring-2 ring-inset' : ''}
|
||||
`}
|
||||
style={{
|
||||
// All tabs have rounded top corners
|
||||
borderTopLeftRadius: '6px',
|
||||
borderTopRightRadius: '6px',
|
||||
// Active tab: bright background matching content area
|
||||
// Inactive tabs: transparent with subtle hover
|
||||
backgroundColor: isActive
|
||||
? theme.colors.bgMain
|
||||
: (isHovered ? 'rgba(255, 255, 255, 0.08)' : 'transparent'),
|
||||
// Active tab has visible borders, inactive tabs have no borders (cleaner look)
|
||||
borderTop: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent',
|
||||
borderLeft: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent',
|
||||
borderRight: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent',
|
||||
// Active tab has no bottom border (connects to content)
|
||||
borderBottom: isActive ? `1px solid ${theme.colors.bgMain}` : '1px solid transparent',
|
||||
// Active tab sits on top of the tab bar's bottom border
|
||||
marginBottom: isActive ? '-1px' : '0',
|
||||
// Slight z-index for active tab to cover border properly
|
||||
zIndex: isActive ? 1 : 0,
|
||||
'--tw-ring-color': isDragOver ? theme.colors.accent : 'transparent'
|
||||
} as React.CSSProperties}
|
||||
style={tabStyle}
|
||||
onClick={onSelect}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
@@ -744,7 +752,7 @@ function Tab({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* TabBar component for displaying AI session tabs.
|
||||
|
||||
@@ -18,15 +18,40 @@ import type { Root, Text, Link, Image } from 'mdast';
|
||||
import type { FileNode } from '../types/fileTree';
|
||||
import { buildFileIndex as buildFileIndexShared, type FilePathEntry } from '../../shared/treeUtils';
|
||||
|
||||
/**
|
||||
* Pre-built indices for file tree lookups.
|
||||
* Build these once with buildFileTreeIndices() and reuse across renders.
|
||||
*/
|
||||
export interface FileTreeIndices {
|
||||
/** Set of all relative paths in the tree */
|
||||
allPaths: Set<string>;
|
||||
/** Map from filename to array of paths containing that filename */
|
||||
filenameIndex: Map<string, string[]>;
|
||||
}
|
||||
|
||||
export interface RemarkFileLinksOptions {
|
||||
/** The file tree to validate paths against */
|
||||
fileTree: FileNode[];
|
||||
/** The file tree to validate paths against (used if indices not provided) */
|
||||
fileTree?: FileNode[];
|
||||
/** Pre-built indices for O(1) lookups - pass this to avoid rebuilding on every render */
|
||||
indices?: FileTreeIndices;
|
||||
/** Current working directory for proximity-based matching (relative path) */
|
||||
cwd: string;
|
||||
/** Project root absolute path - used to convert absolute paths to relative */
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build file tree indices for use with remarkFileLinks.
|
||||
* Call this once when fileTree changes and pass the result to remarkFileLinks.
|
||||
* This avoids O(n) tree traversal on every markdown render.
|
||||
*/
|
||||
export function buildFileTreeIndices(fileTree: FileNode[]): FileTreeIndices {
|
||||
const fileEntries = buildFileIndex(fileTree);
|
||||
const allPaths = new Set(fileEntries.map(e => e.relativePath));
|
||||
const filenameIndex = buildFilenameIndex(fileEntries);
|
||||
return { allPaths, filenameIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a flat index of all files in the tree for quick lookup
|
||||
* @see {@link buildFileIndexShared} from shared/treeUtils for the underlying implementation
|
||||
@@ -197,12 +222,26 @@ const ABSOLUTE_PATH_PATTERN = /\/(?:[^/\n]+\/)+[^/\n]+\.(?:md|txt|json|yaml|yml|
|
||||
* The remark plugin
|
||||
*/
|
||||
export function remarkFileLinks(options: RemarkFileLinksOptions) {
|
||||
const { fileTree, cwd, projectRoot } = options;
|
||||
const { fileTree, indices, cwd, projectRoot } = options;
|
||||
|
||||
// Build indices
|
||||
const fileEntries = buildFileIndex(fileTree);
|
||||
const allPaths = new Set(fileEntries.map(e => e.relativePath));
|
||||
const filenameIndex = buildFilenameIndex(fileEntries);
|
||||
// Use pre-built indices if provided, otherwise build them (fallback for backwards compatibility)
|
||||
let allPaths: Set<string>;
|
||||
let filenameIndex: Map<string, string[]>;
|
||||
|
||||
if (indices) {
|
||||
// Use pre-built indices - O(1) access
|
||||
allPaths = indices.allPaths;
|
||||
filenameIndex = indices.filenameIndex;
|
||||
} else if (fileTree) {
|
||||
// Fallback: build indices from fileTree - O(n) traversal
|
||||
const fileEntries = buildFileIndex(fileTree);
|
||||
allPaths = new Set(fileEntries.map(e => e.relativePath));
|
||||
filenameIndex = buildFilenameIndex(fileEntries);
|
||||
} else {
|
||||
// No file tree data provided - use empty indices
|
||||
allPaths = new Set();
|
||||
filenameIndex = new Map();
|
||||
}
|
||||
|
||||
// Helper to convert absolute path to relative path
|
||||
const toRelativePath = (absPath: string): string | null => {
|
||||
|
||||
Reference in New Issue
Block a user