diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84e84616..8899a28f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,6 +6,16 @@ Thank you for your interest in contributing to Maestro! This document provides g For architecture details, see [ARCHITECTURE.md](ARCHITECTURE.md). For quick reference while coding, see [CLAUDE.md](CLAUDE.md). +## Core Goals + +**Snappy interface and reduced battery consumption are fundamental goals for Maestro.** Every contribution should consider: + +- **Responsiveness**: UI interactions should feel instant. Avoid blocking the main thread. +- **Battery efficiency**: Minimize unnecessary timers, polling, and re-renders. +- **Memory efficiency**: Clean up event listeners, timers, and subscriptions properly. + +See [Performance Guidelines](#performance-guidelines) for specific practices. + ## Table of Contents - [Development Setup](#development-setup) @@ -16,6 +26,7 @@ For architecture details, see [ARCHITECTURE.md](ARCHITECTURE.md). For quick refe - [Common Development Tasks](#common-development-tasks) - [Adding a New AI Agent](#adding-a-new-ai-agent) - [Code Style](#code-style) +- [Performance Guidelines](#performance-guidelines) - [Debugging Guide](#debugging-guide) - [Commit Messages](#commit-messages) - [Pull Request Process](#pull-request-process) @@ -497,6 +508,66 @@ For detailed implementation guide, see [AGENT_SUPPORT.md](AGENT_SUPPORT.md). - Sanitize all user inputs - Use `spawn()` with `shell: false` +## Performance Guidelines + +Maestro prioritizes a snappy interface and minimal battery consumption. Follow these guidelines: + +### React Rendering + +- **Memoize expensive computations** with `useMemo` - especially sorting, filtering, and transformations +- **Use Maps for lookups** instead of `Array.find()` in loops (O(1) vs O(n)) +- **Batch state updates** - use the `useBatchedSessionUpdates` hook for high-frequency IPC updates +- **Avoid creating objects/arrays in render** - move static objects outside components or memoize them + +```typescript +// Bad: O(n) lookup in every iteration +sessions.filter(s => { + const group = groups.find(g => g.id === s.groupId); // O(n) per session + return group && !group.collapsed; +}); + +// Good: O(1) lookup with memoized Map +const groupsById = useMemo(() => new Map(groups.map(g => [g.id, g])), [groups]); +sessions.filter(s => { + const group = groupsById.get(s.groupId); // O(1) + return group && !group.collapsed; +}); +``` + +### Timers & Intervals + +- **Prefer longer intervals** - 3 seconds instead of 1 second for non-critical updates +- **Use `setTimeout` sparingly** - consider if the delay is truly necessary +- **Clean up all timers** in `useEffect` cleanup functions +- **Avoid polling** - use event-driven updates via IPC when possible + +```typescript +// RightPanel.tsx uses 3-second intervals for elapsed time updates +intervalRef.current = setInterval(updateElapsed, 3000); // Not 1000ms +``` + +### Memory & Cleanup + +- **Remove event listeners** in cleanup functions +- **Clear Maps and Sets** when no longer needed +- **Use WeakMap/WeakSet** for caches that should allow garbage collection +- **Limit log buffer sizes** - truncate old entries when buffers grow large + +### IPC & Data Transfer + +- **Batch IPC calls** - combine multiple small calls into fewer larger ones +- **Debounce persistence** - use `useDebouncedPersistence` for settings that change frequently +- **Stream large data** - don't load entire files into memory when streaming is possible + +### Profiling + +When investigating performance issues: + +1. Use Chrome DevTools Performance tab (Cmd+Option+I → Performance) +2. Record during the slow operation +3. Look for long tasks (>50ms) blocking the main thread +4. Check for excessive re-renders in React DevTools Profiler + ## Debugging Guide ### Focus Not Working diff --git a/src/renderer/hooks/useSortedSessions.ts b/src/renderer/hooks/useSortedSessions.ts index ac477e9d..6244cd0c 100644 --- a/src/renderer/hooks/useSortedSessions.ts +++ b/src/renderer/hooks/useSortedSessions.ts @@ -49,10 +49,26 @@ export interface UseSortedSessionsReturn { export function useSortedSessions(deps: UseSortedSessionsDeps): UseSortedSessionsReturn { const { sessions, groups, bookmarksCollapsed } = deps; - // Helper to get worktree children for a session - const getWorktreeChildren = (parentId: string) => - sessions.filter(s => s.parentSessionId === parentId) - .sort((a, b) => compareNamesIgnoringEmojis(a.worktreeBranch || a.name, b.worktreeBranch || b.name)); + // Memoize worktree children lookup for O(1) access instead of O(n) per parent + // This reduces complexity from O(n²) to O(n) when building sorted sessions + const worktreeChildrenByParent = useMemo(() => { + const map = new Map(); + for (const s of sessions) { + if (s.parentSessionId) { + const existing = map.get(s.parentSessionId); + if (existing) { + existing.push(s); + } else { + map.set(s.parentSessionId, [s]); + } + } + } + // Sort each group once + for (const [, children] of map) { + children.sort((a, b) => compareNamesIgnoringEmojis(a.worktreeBranch || a.name, b.worktreeBranch || b.name)); + } + return map; + }, [sessions]); // Create sorted sessions array that matches visual display order (includes ALL sessions) // Note: sorting ignores leading emojis for proper alphabetization @@ -60,7 +76,7 @@ export function useSortedSessions(deps: UseSortedSessionsDeps): UseSortedSession const sortedSessions = useMemo(() => { const sorted: Session[] = []; - // Helper to add session with its worktree children + // Helper to add session with its worktree children - now O(1) lookup const addSessionWithWorktrees = (session: Session) => { // Skip worktree children - they're added with their parent if (session.parentSessionId) return; @@ -69,8 +85,10 @@ export function useSortedSessions(deps: UseSortedSessionsDeps): UseSortedSession // Add worktree children if expanded if (session.worktreesExpanded !== false) { - const children = getWorktreeChildren(session.id); - sorted.push(...children); + const children = worktreeChildrenByParent.get(session.id); + if (children) { + sorted.push(...children); + } } }; @@ -90,8 +108,16 @@ export function useSortedSessions(deps: UseSortedSessionsDeps): UseSortedSession ungroupedSessions.forEach(addSessionWithWorktrees); return sorted; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sessions, groups]); + }, [sessions, groups, worktreeChildrenByParent]); + + // Create a Map for O(1) group lookup instead of O(n) find() calls + const groupsById = useMemo(() => { + const map = new Map(); + for (const g of groups) { + map.set(g.id, g); + } + return map; + }, [groups]); // Create visible sessions array for session jump shortcuts (Opt+Cmd+NUMBER) // Order: Bookmarked sessions first (if bookmarks folder expanded), then groups/ungrouped @@ -111,17 +137,18 @@ export function useSortedSessions(deps: UseSortedSessionsDeps): UseSortedSession // Add sessions from expanded groups and ungrouped sessions // Exclude worktree children (they don't show jump numbers) + // Use Map for O(1) group lookup instead of O(n) find() const groupAndUngrouped = sortedSessions.filter(session => { // Exclude worktree children - they're nested under parent and don't show jump badges if (session.parentSessionId) return false; if (!session.groupId) return true; // Ungrouped sessions always visible - const group = groups.find(g => g.id === session.groupId); + const group = groupsById.get(session.groupId); return group && !group.collapsed; // Only show if group is expanded }); result.push(...groupAndUngrouped); return result; - }, [sortedSessions, groups, sessions, bookmarksCollapsed]); + }, [sortedSessions, groupsById, sessions, bookmarksCollapsed]); return { sortedSessions,