diff --git a/CLAUDE.md b/CLAUDE.md
index 0750722c..d2558bf6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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 {children};
+
+// GOOD: Memoized value only changes when dependencies change
+const contextValue = useMemo(() => ({
+ sessions,
+ updateSession,
+}), [sessions, updateSession]);
+return {children};
+```
+
+### 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.
diff --git a/docs/screenshots/symphony-active.png b/docs/screenshots/symphony-active.png
new file mode 100644
index 00000000..54dea5ea
Binary files /dev/null and b/docs/screenshots/symphony-active.png differ
diff --git a/docs/symphony.md b/docs/symphony.md
index 93cb1980..d7d26d9f 100644
--- a/docs/symphony.md
+++ b/docs/symphony.md
@@ -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
+
+
+
+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
diff --git a/package.json b/package.json
index 3e21a9a0..b9524433 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src/main/index.ts b/src/main/index.ts
index d618da8b..21c31b57 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -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
diff --git a/src/main/preload.ts b/src/main/preload.ts
index d475d492..128e6d20 100644
--- a/src/main/preload.ts
+++ b/src/main/preload.ts
@@ -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)
diff --git a/src/main/web-server/services/broadcastService.ts b/src/main/web-server/services/broadcastService.ts
index 689a9853..5af0610f 100644
--- a/src/main/web-server/services/broadcastService.ts
+++ b/src/main/web-server/services/broadcastService.ts
@@ -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
}
/**
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 1e4d6e9a..97987315 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -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);
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index 9ef7eee8..df5e7836 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -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;
broadcastTabsChange: (sessionId: string, aiTabs: Array<{
id: string;
diff --git a/src/renderer/hooks/batch/useBatchProcessor.ts b/src/renderer/hooks/batch/useBatchProcessor.ts
index 19784919..c38777e8 100644
--- a/src/renderer/hooks/batch/useBatchProcessor.ts
+++ b/src/renderer/hooks/batch/useBatchProcessor.ts
@@ -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
diff --git a/src/web/hooks/useWebSocket.ts b/src/web/hooks/useWebSocket.ts
index 83321389..da196e81 100644
--- a/src/web/hooks/useWebSocket.ts
+++ b/src/web/hooks/useWebSocket.ts
@@ -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
}
/**