## CHANGES

- Added multi-document Auto Run progress fields across IPC and WebSocket state 📚
- Web clients now receive aggregated task totals and completions across documents 📈
- Active tab UI docs expanded with screenshot and richer contribution details 🖼️
- Canonical `activeTab` lookup is now memoized to kill repeated O(n) finds 
- Staged images, logs, and prompt tab toggles now reuse memoized `activeTab` 🧠
- Tab-completion suggestions now debounce input only while menu is open ⌨️
- @mention suggestions now debounce filter only while menu is open 🔎
- Disabling worktrees now removes all sub-agents and reports counts 🧹
- Added performance guidance: debounce, throttle, batching, virtualization, parallel IPC 🚀
- Bumped version to 0.14.5 for this release tag 🏷️
This commit is contained in:
Pedram Amini
2026-01-11 12:20:36 -06:00
parent bbb01d8abf
commit 1d34eb6f2b
11 changed files with 234 additions and 32 deletions

143
CLAUDE.md
View File

@@ -646,6 +646,20 @@ const Component = () => {
};
```
**Memoize helper function results used in render body:**
```typescript
// BAD: O(n) lookup on every keystroke (runs on every render)
const activeTab = activeSession ? getActiveTab(activeSession) : undefined;
// Then used multiple times in JSX...
// GOOD: Memoize once, use everywhere
const activeTab = useMemo(
() => activeSession ? getActiveTab(activeSession) : undefined,
[activeSession?.aiTabs, activeSession?.activeTabId]
);
// Use activeTab directly in JSX - no repeated lookups
```
### Data Structure Pre-computation
**Build indices once, reuse in renders:**
@@ -683,6 +697,135 @@ import * as fsPromises from 'fs/promises';
fsPromises.unlink(tempFile).catch(() => {});
```
### Debouncing and Throttling
**Use debouncing for user input and persistence:**
```typescript
// Session persistence uses 2-second debounce to prevent excessive disk I/O
// See: src/renderer/hooks/utils/useDebouncedPersistence.ts
const { persist, isPending } = useDebouncedPersistence(session, 2000);
// Always flush on visibility change and beforeunload to prevent data loss
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) flushPending();
};
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('beforeunload', flushPending);
return () => { /* cleanup */ };
}, []);
```
**Debounce expensive search operations:**
```typescript
// BAD: Fuzzy matching all files on every keystroke
const suggestions = useMemo(() => {
return getAtMentionSuggestions(atMentionFilter); // Runs 2000+ fuzzy matches per keystroke
}, [atMentionFilter]);
// GOOD: Debounce the filter value first (100ms is imperceptible)
const debouncedFilter = useDebouncedValue(atMentionFilter, 100);
const suggestions = useMemo(() => {
return getAtMentionSuggestions(debouncedFilter); // Only runs after user stops typing
}, [debouncedFilter]);
```
**Use throttling for high-frequency events:**
```typescript
// Scroll handlers should be throttled to ~4ms (240fps max)
const handleScroll = useThrottledCallback(() => {
// expensive scroll logic
}, 4);
```
### Update Batching
**Batch rapid state updates during streaming:**
```typescript
// During AI streaming, IPC triggers 100+ updates/second
// Without batching: 100+ React re-renders/second
// With batching at 150ms: ~6 renders/second
// See: src/renderer/hooks/session/useBatchedSessionUpdates.ts
// Update types that get batched:
// - appendLog (accumulated via string chunks)
// - setStatus (last wins)
// - updateUsage (accumulated)
// - updateContextUsage (high water mark - never decreases)
```
### Virtual Scrolling
**Use virtual scrolling for large lists (100+ items):**
```typescript
// See: src/renderer/components/HistoryPanel.tsx
import { useVirtualizer } from '@tanstack/react-virtual';
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 40, // estimated row height
});
```
### IPC Parallelization
**Parallelize independent async operations:**
```typescript
// BAD: Sequential awaits (4 × 50ms = 200ms)
const branches = await git.branch(cwd);
const remotes = await git.remote(cwd);
const status = await git.status(cwd);
// GOOD: Parallel execution (max 50ms = 4x faster)
const [branches, remotes, status] = await Promise.all([
git.branch(cwd),
git.remote(cwd),
git.status(cwd),
]);
```
### Visibility-Aware Operations
**Pause background operations when app is hidden:**
```typescript
// See: src/renderer/hooks/git/useGitStatusPolling.ts
const handleVisibilityChange = () => {
if (document.hidden) {
stopPolling(); // Save battery/CPU when backgrounded
} else {
startPolling();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
```
### Context Provider Memoization
**Always memoize context values:**
```typescript
// BAD: New object on every render triggers all consumers to re-render
return <Context.Provider value={{ sessions, updateSession }}>{children}</Context.Provider>;
// GOOD: Memoized value only changes when dependencies change
const contextValue = useMemo(() => ({
sessions,
updateSession,
}), [sessions, updateSession]);
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
```
### Event Listener Cleanup
**Always clean up event listeners:**
```typescript
useEffect(() => {
const handler = (e: Event) => { /* ... */ };
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, []);
```
## Onboarding Wizard
The wizard (`src/renderer/components/Wizard/`) guides new users through first-run setup, creating AI sessions with Auto Run documents.

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -71,10 +71,18 @@ Click **Create Agent** and Maestro will:
### Active Tab
View your in-progress Symphony sessions:
- Links to jump to the agent session
- Progress indicators for current document
- Links to draft PRs
- Cancel/abort controls
![Active Contributions](./screenshots/symphony-active.png)
Each active contribution shows:
- **Issue title and repository** — The GitHub issue being worked on
- **Status badge** — Running, Paused, or Waiting
- **Document progress** — Current document and total count
- **Time elapsed** — How long the contribution has been running
- **Token usage** — Input/output tokens and estimated cost
- **Pause/Cancel controls** — Pause to review or cancel to abort
Click **Check PR Status** to verify your draft PR on GitHub.
### History Tab

View File

@@ -1,6 +1,6 @@
{
"name": "maestro",
"version": "0.14.4",
"version": "0.14.5",
"description": "Maestro hones fractured attention into focused intent.",
"main": "dist/main/index.js",
"author": {

View File

@@ -1019,6 +1019,11 @@ function setupIpcHandlers() {
completedTasks: number;
currentTaskIndex: number;
isStopping?: boolean;
// Multi-document progress fields
totalDocuments?: number;
currentDocumentIndex?: number;
totalTasksAcrossAllDocs?: number;
completedTasksAcrossAllDocs?: number;
} | null) => {
if (webServer) {
// Always call broadcastAutoRunState - it stores the state for new clients

View File

@@ -343,6 +343,11 @@ contextBridge.exposeInMainWorld('maestro', {
completedTasks: number;
currentTaskIndex: number;
isStopping?: boolean;
// Multi-document progress fields
totalDocuments?: number;
currentDocumentIndex?: number;
totalTasksAcrossAllDocs?: number;
completedTasksAcrossAllDocs?: number;
} | null) =>
ipcRenderer.invoke('web:broadcastAutoRunState', sessionId, state),
// Broadcast tab changes to web clients (for tab sync)

View File

@@ -95,6 +95,11 @@ export interface AutoRunState {
completedTasks: number;
currentTaskIndex: number;
isStopping?: boolean;
// Multi-document progress fields
totalDocuments?: number; // Total number of documents in the run
currentDocumentIndex?: number; // Current document being processed (0-based)
totalTasksAcrossAllDocs?: number; // Total tasks across all documents
completedTasksAcrossAllDocs?: number; // Completed tasks across all documents
}
/**

View File

@@ -38,6 +38,7 @@ import {
// Settings
useSettings,
useDebouncedPersistence,
useDebouncedValue,
// Session management
useActivityTracker,
useHandsOnTimeTracker,
@@ -2865,9 +2866,7 @@ function MaestroConsoleInner() {
// Keyboard navigation state
const [selectedSidebarIndex, setSelectedSidebarIndex] = useState(0);
// Note: activeSession is now provided by SessionContext
const activeTabForError = useMemo(() => (
activeSession ? getActiveTab(activeSession) : null
), [activeSession]);
// Note: activeTab is memoized later at line ~3795 - use that for all tab operations
// Discover slash commands when a session becomes active and doesn't have them yet
// Fetches custom Claude commands from .claude/commands/ directories (fast, file system read)
@@ -3790,7 +3789,12 @@ You are taking over this conversation. Based on the context above, provide a bri
// For AI mode: use active tab's inputValue (stored per-tab)
// For terminal mode: use local state (shared across tabs)
const isAiMode = activeSession?.inputMode === 'ai';
const activeTab = activeSession ? getActiveTab(activeSession) : undefined;
// PERF: Memoize activeTab lookup to avoid O(n) .find() on every keystroke
// This is THE canonical activeTab for the component - use this instead of calling getActiveTab()
const activeTab = useMemo(
() => activeSession ? getActiveTab(activeSession) : undefined,
[activeSession?.aiTabs, activeSession?.activeTabId]
);
const isResumingSession = !!activeTab?.agentSessionId;
const canAttachImages = useMemo(() => {
if (!activeSession || activeSession.inputMode !== 'ai') return false;
@@ -3895,12 +3899,11 @@ You are taking over this conversation. Based on the context above, provide a bri
// Images are stored per-tab and only used in AI mode
// Get staged images from the active tab
// PERF: Use memoized activeTab instead of calling getActiveTab again
const stagedImages = useMemo(() => {
if (!activeSession || activeSession.inputMode !== 'ai') return [];
const activeTab = getActiveTab(activeSession);
return activeTab?.stagedImages || [];
}, [activeSession?.aiTabs, activeSession?.activeTabId, activeSession?.inputMode]);
}, [activeTab?.stagedImages, activeSession?.inputMode]);
// Set staged images on the active tab
const setStagedImages = useCallback((imagesOrUpdater: string[] | ((prev: string[]) => string[])) => {
@@ -3975,22 +3978,25 @@ You are taking over this conversation. Based on the context above, provide a bri
const activeSessionInputMode = activeSession?.inputMode;
// Tab completion suggestions (must be after inputValue is defined)
// PERF: Depend on specific session properties, not the entire activeSession object
// PERF: Only debounce when menu is open to avoid unnecessary state updates during normal typing
const debouncedInputForTabCompletion = useDebouncedValue(tabCompletionOpen ? inputValue : '', 50);
const tabCompletionSuggestions = useMemo(() => {
if (!tabCompletionOpen || !activeSessionId || activeSessionInputMode !== 'terminal') {
return [];
}
return getTabCompletionSuggestions(inputValue, tabCompletionFilter);
}, [tabCompletionOpen, activeSessionId, activeSessionInputMode, inputValue, tabCompletionFilter, getTabCompletionSuggestions]);
return getTabCompletionSuggestions(debouncedInputForTabCompletion, tabCompletionFilter);
}, [tabCompletionOpen, activeSessionId, activeSessionInputMode, debouncedInputForTabCompletion, tabCompletionFilter, getTabCompletionSuggestions]);
// @ mention suggestions for AI mode
// PERF: Depend on specific session properties, not the entire activeSession object
// PERF: Only debounce when menu is open to avoid unnecessary state updates during normal typing
// When menu is closed, pass empty string to skip debounce hook overhead entirely
const debouncedAtMentionFilter = useDebouncedValue(atMentionOpen ? atMentionFilter : '', 100);
const atMentionSuggestions = useMemo(() => {
if (!atMentionOpen || !activeSessionId || activeSessionInputMode !== 'ai') {
return [];
}
return getAtMentionSuggestions(atMentionFilter);
}, [atMentionOpen, activeSessionId, activeSessionInputMode, atMentionFilter, getAtMentionSuggestions]);
return getAtMentionSuggestions(debouncedAtMentionFilter);
}, [atMentionOpen, activeSessionId, activeSessionInputMode, debouncedAtMentionFilter, getAtMentionSuggestions]);
// Sync file tree selection to match tab completion suggestion
// This highlights the corresponding file/folder in the right panel when navigating tab completion
@@ -6207,7 +6213,8 @@ You are taking over this conversation. Based on the context above, provide a bri
}, [activeSession?.id, activeSession?.autoRunFolderPath, activeSession?.autoRunSelectedFile, activeSession?.sshRemoteId, activeSession?.sessionSshRemoteConfig?.remoteId, loadTaskCounts]);
// Auto-scroll logs
const activeTabLogs = activeSession ? getActiveTab(activeSession)?.logs : undefined;
// PERF: Use memoized activeTab instead of calling getActiveTab() again
const activeTabLogs = activeTab?.logs;
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'instant' });
}, [activeTabLogs, activeSession?.shellLogs, activeSession?.inputMode]);
@@ -8569,17 +8576,31 @@ You are taking over this conversation. Based on the context above, provide a bri
const handleDisableWorktreeConfig = useCallback(() => {
if (!activeSession) return;
setSessions(prev => prev.map(s =>
s.id === activeSession.id
? { ...s, worktreeConfig: undefined, worktreeParentPath: undefined }
: s
));
// Count worktree children that will be removed
const worktreeChildCount = sessions.filter(s => s.parentSessionId === activeSession.id).length;
setSessions(prev => prev
// Remove all worktree children of this parent
.filter(s => s.parentSessionId !== activeSession.id)
// Clear worktree config on the parent
.map(s =>
s.id === activeSession.id
? { ...s, worktreeConfig: undefined, worktreeParentPath: undefined }
: s
)
);
const childMessage = worktreeChildCount > 0
? ` Removed ${worktreeChildCount} worktree sub-agent${worktreeChildCount > 1 ? 's' : ''}.`
: '';
addToast({
type: 'success',
title: 'Worktrees Disabled',
message: 'Worktree configuration cleared for this agent.',
message: `Worktree configuration cleared for this agent.${childMessage}`,
});
}, [activeSession, addToast]);
}, [activeSession, sessions, addToast]);
const handleCreateWorktreeFromConfig = useCallback(async (branchName: string, basePath: string) => {
if (!activeSession || !basePath) {
@@ -9716,11 +9737,11 @@ You are taking over this conversation. Based on the context above, provide a bri
setPromptComposerStagedImages={activeGroupChatId ? setGroupChatStagedImages : (canAttachImages ? setStagedImages : undefined)}
onPromptImageAttachBlocked={activeGroupChatId || !blockCodexResumeImages ? undefined : showImageAttachBlockedNotice}
onPromptOpenLightbox={handleSetLightboxImage}
promptTabSaveToHistory={activeGroupChatId ? false : (activeSession ? getActiveTab(activeSession)?.saveToHistory ?? false : false)}
promptTabSaveToHistory={activeGroupChatId ? false : (activeTab?.saveToHistory ?? false)}
onPromptToggleTabSaveToHistory={activeGroupChatId ? undefined : handlePromptToggleTabSaveToHistory}
promptTabReadOnlyMode={activeGroupChatId ? groupChatReadOnlyMode : (activeSession ? getActiveTab(activeSession)?.readOnlyMode ?? false : false)}
promptTabReadOnlyMode={activeGroupChatId ? groupChatReadOnlyMode : (activeTab?.readOnlyMode ?? false)}
onPromptToggleTabReadOnlyMode={handlePromptToggleTabReadOnlyMode}
promptTabShowThinking={activeGroupChatId ? false : (activeSession ? getActiveTab(activeSession)?.showThinking ?? false : false)}
promptTabShowThinking={activeGroupChatId ? false : (activeTab?.showThinking ?? false)}
onPromptToggleTabShowThinking={activeGroupChatId ? undefined : handlePromptToggleTabShowThinking}
promptSupportsThinking={!activeGroupChatId && hasActiveSessionCapability('supportsThinkingDisplay')}
promptEnterToSend={enterToSendAI}
@@ -10629,8 +10650,8 @@ You are taking over this conversation. Based on the context above, provide a bri
setPreviewFile(filePreviewHistory[index]);
}
}}
onClearAgentError={activeTabForError?.agentError && activeSession ? () => handleClearAgentError(activeSession.id, activeTabForError.id) : undefined}
onShowAgentErrorModal={activeTabForError?.agentError && activeSession ? () => setAgentErrorModalSessionId(activeSession.id) : undefined}
onClearAgentError={activeTab?.agentError && activeSession ? () => handleClearAgentError(activeSession.id, activeTab.id) : undefined}
onShowAgentErrorModal={activeTab?.agentError && activeSession ? () => setAgentErrorModalSessionId(activeSession.id) : undefined}
showFlashNotification={(message: string) => {
setSuccessFlashNotification(message);
setTimeout(() => setSuccessFlashNotification(null), 2000);

View File

@@ -252,6 +252,11 @@ interface MaestroAPI {
completedTasks: number;
currentTaskIndex: number;
isStopping?: boolean;
// Multi-document progress fields
totalDocuments?: number;
currentDocumentIndex?: number;
totalTasksAcrossAllDocs?: number;
completedTasksAcrossAllDocs?: number;
} | null) => Promise<void>;
broadcastTabsChange: (sessionId: string, aiTabs: Array<{
id: string;

View File

@@ -259,13 +259,18 @@ export function useBatchProcessor({
* receive state updates without waiting for React's render cycle.
*/
const broadcastAutoRunState = useCallback((sessionId: string, state: BatchRunState | null) => {
if (state && (state.isRunning || state.completedTasks > 0)) {
if (state && (state.isRunning || state.completedTasks > 0 || state.completedTasksAcrossAllDocs > 0)) {
window.maestro.web.broadcastAutoRunState(sessionId, {
isRunning: state.isRunning,
totalTasks: state.totalTasks,
completedTasks: state.completedTasks,
currentTaskIndex: state.currentTaskIndex,
isStopping: state.isStopping,
// Multi-document progress fields
totalDocuments: state.documents?.length ?? 0,
currentDocumentIndex: state.currentDocumentIndex,
totalTasksAcrossAllDocs: state.totalTasksAcrossAllDocs,
completedTasksAcrossAllDocs: state.completedTasksAcrossAllDocs,
});
} else {
// When not running and no completed tasks, broadcast null to clear the state

View File

@@ -91,6 +91,11 @@ export interface AutoRunState {
completedTasks: number;
currentTaskIndex: number;
isStopping?: boolean;
// Multi-document progress fields
totalDocuments?: number; // Total number of documents in the run
currentDocumentIndex?: number; // Current document being processed (0-based)
totalTasksAcrossAllDocs?: number; // Total tasks across all documents
completedTasksAcrossAllDocs?: number; // Completed tasks across all documents
}
/**