mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## 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:
143
CLAUDE.md
143
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 <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.
|
||||
|
||||
BIN
docs/screenshots/symphony-active.png
Normal file
BIN
docs/screenshots/symphony-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
// 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);
|
||||
|
||||
5
src/renderer/global.d.ts
vendored
5
src/renderer/global.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user