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 + +![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 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 } /**