## 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:
Pedram Amini
2026-01-11 03:15:03 -06:00
parent 0f22fb548d
commit bbb01d8abf
9 changed files with 425 additions and 144 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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