diff --git a/CLAUDE-AGENTS.md b/CLAUDE-AGENTS.md new file mode 100644 index 00000000..d70092e8 --- /dev/null +++ b/CLAUDE-AGENTS.md @@ -0,0 +1,73 @@ +# CLAUDE-AGENTS.md + +Agent support documentation for the Maestro codebase. For the main guide, see [[CLAUDE.md]]. For detailed integration instructions, see [AGENT_SUPPORT.md](AGENT_SUPPORT.md). + +## Supported Agents + +| ID | Name | Status | Notes | +|----|------|--------|-------| +| `claude-code` | Claude Code | **Active** | Primary agent, `--print --verbose --output-format stream-json` | +| `codex` | OpenAI Codex | **Active** | Full support, `--json`, YOLO mode default | +| `opencode` | OpenCode | **Active** | Multi-provider support (75+ LLMs), stub session storage | +| `terminal` | Terminal | Internal | Hidden from UI, used for shell sessions | + +Additional `ToolType` values (`aider`, `claude`) are defined in types but not yet implemented in `agent-detector.ts`. + +## Agent Capabilities + +Each agent declares capabilities that control UI feature availability. See `src/main/agent-capabilities.ts` for the full interface. + +| Capability | Description | UI Feature Controlled | +|------------|-------------|----------------------| +| `supportsResume` | Can resume previous sessions | Resume button | +| `supportsReadOnlyMode` | Has plan/read-only mode | Read-only toggle | +| `supportsJsonOutput` | Emits structured JSON | Output parsing | +| `supportsSessionId` | Emits session ID | Session ID pill | +| `supportsImageInput` | Accepts image attachments | Attach image button | +| `supportsSlashCommands` | Has discoverable commands | Slash autocomplete | +| `supportsSessionStorage` | Persists browsable sessions | Sessions browser | +| `supportsCostTracking` | Reports token costs | Cost widget | +| `supportsUsageStats` | Reports token counts | Context window widget | +| `supportsBatchMode` | Runs per-message | Batch processing | +| `supportsStreaming` | Streams output | Real-time display | +| `supportsResultMessages` | Distinguishes final result | Message classification | + +## Agent-Specific Details + +### Claude Code +- **Binary:** `claude` +- **JSON Output:** `--output-format stream-json` +- **Resume:** `--resume ` +- **Read-only:** `--permission-mode plan` +- **Session Storage:** `~/.claude/projects//` + +### OpenAI Codex +- **Binary:** `codex` +- **JSON Output:** `--json` +- **Batch Mode:** `exec` subcommand +- **Resume:** `resume ` (v0.30.0+) +- **Read-only:** `--sandbox read-only` +- **YOLO Mode:** `--dangerously-bypass-approvals-and-sandbox` (enabled by default) +- **Session Storage:** `~/.codex/sessions/YYYY/MM/DD/*.jsonl` + +### OpenCode +- **Binary:** `opencode` +- **JSON Output:** `--format json` +- **Batch Mode:** `run` subcommand +- **Resume:** `--session ` +- **Read-only:** `--agent plan` +- **YOLO Mode:** Auto-enabled in batch mode (no flag needed) +- **Multi-Provider:** Supports 75+ LLMs including Ollama, LM Studio, llama.cpp + +## Adding New Agents + +To add support for a new agent: + +1. Add agent definition to `src/main/agent-detector.ts` +2. Define capabilities in `src/main/agent-capabilities.ts` +3. Create output parser in `src/main/parsers/{agent}-output-parser.ts` +4. Register parser in `src/main/parsers/index.ts` +5. (Optional) Create session storage in `src/main/storage/{agent}-session-storage.ts` +6. (Optional) Add error patterns to `src/main/parsers/error-patterns.ts` + +See [AGENT_SUPPORT.md](AGENT_SUPPORT.md) for comprehensive integration documentation. diff --git a/CLAUDE-FEATURES.md b/CLAUDE-FEATURES.md new file mode 100644 index 00000000..02be44ce --- /dev/null +++ b/CLAUDE-FEATURES.md @@ -0,0 +1,175 @@ +# CLAUDE-FEATURES.md + +Feature documentation for Usage Dashboard and Document Graph. For the main guide, see [[CLAUDE.md]]. + +## Usage Dashboard + +The Usage Dashboard (`src/renderer/components/UsageDashboard/`) provides analytics and visualizations for AI agent usage. + +### Architecture + +``` +src/renderer/components/UsageDashboard/ +├── UsageDashboardModal.tsx # Main modal with view tabs (Overview, Agents, Activity, AutoRun) +├── SummaryCards.tsx # Metric cards (queries, duration, cost, Auto Runs) +├── AgentComparisonChart.tsx # Bar chart comparing agent usage +├── SourceDistributionChart.tsx # Pie chart for user vs auto queries +├── ActivityHeatmap.tsx # Weekly activity heatmap (GitHub-style) +├── DurationTrendsChart.tsx # Line chart for duration over time +├── AutoRunStats.tsx # Auto Run-specific statistics +├── ChartSkeletons.tsx # Loading skeleton components +├── ChartErrorBoundary.tsx # Error boundary with retry +└── EmptyState.tsx # Empty state when no data +``` + +### Backend Components + +``` +src/main/ +├── stats-db.ts # SQLite database (better-sqlite3) with WAL mode +│ ├── query_events table # AI queries with duration, tokens, cost +│ ├── auto_run_sessions table # Auto Run session tracking +│ ├── auto_run_tasks table # Individual task tracking +│ └── _migrations table # Schema migration tracking +├── ipc/handlers/stats.ts # IPC handlers for stats operations +└── utils/statsCache.ts # Query result caching +``` + +### Key Patterns + +**Real-time Updates:** +```typescript +// Backend broadcasts after each database write +mainWindow?.webContents.send('stats:updated'); + +// Frontend subscribes with debouncing +useEffect(() => { + const unsubscribe = window.maestro.stats.onStatsUpdated(() => { + debouncedRefresh(); + }); + return () => unsubscribe?.(); +}, []); +``` + +**Colorblind-Friendly Palettes:** +```typescript +import { COLORBLIND_AGENT_PALETTE, getColorBlindAgentColor } from '../constants/colorblindPalettes'; +// Wong-based palette with high contrast for accessibility +``` + +**Chart Error Boundaries:** +```typescript + + + +``` + +### Related Settings + +```typescript +// In useSettings.ts +statsCollectionEnabled: boolean // Enable/disable stats collection (default: true) +defaultStatsTimeRange: 'day' | 'week' | 'month' | 'year' | 'all' // Default time filter +colorBlindMode: boolean // Use accessible color palettes +preventSleepEnabled: boolean // Prevent system sleep while agents are busy (default: false) +``` + +--- + +## Document Graph + +The Document Graph (`src/renderer/components/DocumentGraph/`) visualizes markdown file relationships and wiki-link connections using React Flow. + +### Architecture + +``` +src/renderer/components/DocumentGraph/ +├── DocumentGraphView.tsx # Main modal with React Flow canvas +├── DocumentNode.tsx # Document file node component +├── ExternalLinkNode.tsx # External URL domain node +├── NodeContextMenu.tsx # Right-click context menu +├── NodeBreadcrumb.tsx # Path breadcrumb for selected node +├── GraphLegend.tsx # Collapsible legend explaining node/edge types +├── graphDataBuilder.ts # Scans directory, extracts links, builds graph data +└── layoutAlgorithms.ts # Force-directed and hierarchical layout algorithms + +src/renderer/utils/ +├── markdownLinkParser.ts # Parses [[wiki-links]] and [markdown](links) +└── documentStats.ts # Computes document statistics (word count, etc.) + +src/main/ipc/handlers/ +└── documentGraph.ts # Chokidar file watcher for real-time updates +``` + +### Key Patterns + +**Building Graph Data:** +```typescript +import { buildGraphData } from './graphDataBuilder'; +const { nodes, edges, stats } = await buildGraphData( + rootPath, + showExternalLinks, + maxNodes, + offset, + progressCallback +); +``` + +**Layout Algorithms:** +```typescript +import { applyForceLayout, applyHierarchicalLayout, animateLayoutTransition } from './layoutAlgorithms'; +const positionedNodes = layoutMode === 'force' + ? applyForceLayout(nodes, edges) + : applyHierarchicalLayout(nodes, edges); +animateLayoutTransition(currentNodes, positionedNodes, setNodes, savePositions); +``` + +**Node Animation (additions/removals):** +```typescript +import { diffNodes, animateNodesEntering, animateNodesExiting } from './layoutAlgorithms'; +const { added, removed, stable } = diffNodes(previousNodes, newNodes); +animateNodesExiting(removed, () => animateNodesEntering(added)); +``` + +**Real-time File Watching:** +```typescript +// Backend watches for .md file changes +window.maestro.documentGraph.watchFolder(rootPath); +window.maestro.documentGraph.onFilesChanged((changes) => { + debouncedRebuildGraph(); +}); +// Cleanup on modal close +window.maestro.documentGraph.unwatchFolder(rootPath); +``` + +**Keyboard Navigation:** +```typescript +// Arrow keys navigate to connected nodes (spatial detection) +// Enter opens selected node +// Tab cycles through connected nodes +// Escape closes modal +``` + +### Large File Handling + +Files over 1MB are truncated to first 100KB for link extraction to prevent UI blocking: +```typescript +const LARGE_FILE_THRESHOLD = 1 * 1024 * 1024; // 1MB +const LARGE_FILE_PARSE_LIMIT = 100 * 1024; // 100KB +``` + +### Pagination + +Default limit of 50 nodes with "Load more" for large directories: +```typescript +const DEFAULT_MAX_NODES = 50; +const LOAD_MORE_INCREMENT = 25; +``` + +### Related Settings + +```typescript +// In useSettings.ts +documentGraphShowExternalLinks: boolean // Show external link nodes (default: false) +documentGraphMaxNodes: number // Initial pagination limit (50-1000, default: 50) +``` diff --git a/CLAUDE-IPC.md b/CLAUDE-IPC.md new file mode 100644 index 00000000..24bf4fa4 --- /dev/null +++ b/CLAUDE-IPC.md @@ -0,0 +1,86 @@ +# CLAUDE-IPC.md + +IPC API surface documentation for the Maestro codebase. For the main guide, see [[CLAUDE.md]]. + +## Overview + +The `window.maestro` API exposes the following namespaces: + +## Core APIs + +- `settings` - Get/set app settings +- `sessions` / `groups` - Persistence +- `process` - Spawn, write, kill, resize +- `fs` - readDir, readFile +- `dialog` - Folder selection +- `shells` - Detect available shells +- `logger` - System logging + +## Agent & Agent Sessions + +- `agents` - Detect, get, config, refresh, custom paths, getCapabilities +- `agentSessions` - Generic agent session storage API (list, read, search, delete) +- `agentError` - Agent error handling (clearError, retryAfterError) +- `claude` - (Deprecated) Claude Code sessions - use `agentSessions` instead + +## Git Integration + +- `git` - Status, diff, isRepo, numstat, branches, tags, info +- `git` - Worktree support: worktreeInfo, getRepoRoot, worktreeSetup, worktreeCheckout +- `git` - PR creation: createPR, checkGhCli, getDefaultBranch + +## Web & Live Sessions + +- `web` - Broadcast user input, Auto Run state, tab changes to web clients +- `live` - Toggle live sessions, get status, dashboard URL, connected clients +- `webserver` - Get URL, connected client count +- `tunnel` - Cloudflare tunnel: isCloudflaredInstalled, start, stop, getStatus + +## Automation + +- `autorun` - Document and image management for Auto Run +- `playbooks` - Batch run configuration management +- `history` - Per-session execution history (see History API below) +- `cli` - CLI activity detection for playbook runs +- `tempfile` - Temporary file management for batch processing + +## Analytics & Visualization + +- `stats` - Usage statistics: recordQuery, getAggregatedStats, exportCsv, clearOldData, getDatabaseSize +- `stats` - Auto Run tracking: startAutoRun, endAutoRun, recordTask, getAutoRunSessions +- `stats` - Real-time updates via `stats:updated` event broadcast +- `documentGraph` - File watching: watchFolder, unwatchFolder +- `documentGraph` - Real-time updates via `documentGraph:filesChanged` event + +## History API + +Per-session history storage with 5,000 entries per session (up from 1,000 global). Each session's history is stored as a JSON file in `~/Library/Application Support/Maestro/history/{sessionId}.json`. + +```typescript +window.maestro.history = { + getAll: (projectPath?, sessionId?) => Promise, + add: (entry) => Promise, + clear: (projectPath?, sessionId?) => Promise, + delete: (entryId, sessionId?) => Promise, + update: (entryId, updates, sessionId?) => Promise, + // For AI context integration: + getFilePath: (sessionId) => Promise, + listSessions: () => Promise, + // External change detection: + onExternalChange: (handler) => () => void, + reload: () => Promise, +}; +``` + +**AI Context Integration**: Use `getFilePath(sessionId)` to get the path to a session's history file. This file can be passed directly to AI agents as context, giving them visibility into past completed tasks, decisions, and work patterns. + +## Power Management + +- `power` - Sleep prevention: setEnabled, isEnabled, getStatus, addReason, removeReason + +## Utilities + +- `fonts` - Font detection +- `notification` - Desktop notifications, text-to-speech +- `devtools` - Developer tools: open, close, toggle +- `attachments` - Image attachment management diff --git a/CLAUDE-PATTERNS.md b/CLAUDE-PATTERNS.md new file mode 100644 index 00000000..986963ae --- /dev/null +++ b/CLAUDE-PATTERNS.md @@ -0,0 +1,254 @@ +# CLAUDE-PATTERNS.md + +Core implementation patterns for the Maestro codebase. For the main guide, see [[CLAUDE.md]]. + +## 1. Process Management + +Each session runs **two processes** simultaneously: +- AI agent process (Claude Code, etc.) - spawned with `-ai` suffix +- Terminal process (PTY shell) - spawned with `-terminal` suffix + +```typescript +// Session stores both PIDs +session.aiPid // AI agent process +session.terminalPid // Terminal process +``` + +## 2. Security Requirements + +**Always use `execFileNoThrow`** for external commands: +```typescript +import { execFileNoThrow } from './utils/execFile'; +const result = await execFileNoThrow('git', ['status'], cwd); +// Returns: { stdout, stderr, exitCode } - never throws +``` + +**Never use shell-based command execution** - it creates injection vulnerabilities. The `execFileNoThrow` utility is the safe alternative. + +## 3. Settings Persistence + +Add new settings in `useSettings.ts`: +```typescript +// 1. Add state with default value +const [mySetting, setMySettingState] = useState(defaultValue); + +// 2. Add wrapper that persists +const setMySetting = (value) => { + setMySettingState(value); + window.maestro.settings.set('mySetting', value); +}; + +// 3. Load from batch response in useEffect (settings use batch loading) +// In the loadSettings useEffect, extract from allSettings object: +const allSettings = await window.maestro.settings.getAll(); +const savedMySetting = allSettings['mySetting']; +if (savedMySetting !== undefined) setMySettingState(savedMySetting); +``` + +## 4. Adding Modals + +1. Create component in `src/renderer/components/` +2. Add priority in `src/renderer/constants/modalPriorities.ts` +3. Register with layer stack: + +```typescript +import { useLayerStack } from '../contexts/LayerStackContext'; +import { MODAL_PRIORITIES } from '../constants/modalPriorities'; + +const { registerLayer, unregisterLayer } = useLayerStack(); +const onCloseRef = useRef(onClose); +onCloseRef.current = onClose; + +useEffect(() => { + if (isOpen) { + const id = registerLayer({ + type: 'modal', + priority: MODAL_PRIORITIES.YOUR_MODAL, + onEscape: () => onCloseRef.current(), + }); + return () => unregisterLayer(id); + } +}, [isOpen, registerLayer, unregisterLayer]); +``` + +## 5. Theme Colors + +Themes have 13 required colors. Use inline styles for theme colors: +```typescript +style={{ color: theme.colors.textMain }} // Correct +className="text-gray-500" // Wrong for themed text +``` + +## 6. Multi-Tab Sessions + +Sessions support multiple AI conversation tabs: +```typescript +// Each session has an array of tabs +session.aiTabs: AITab[] +session.activeTabId: string + +// Each tab maintains its own conversation +interface AITab { + id: string; + name: string; + logs: LogEntry[]; // Tab-specific history + agentSessionId?: string; // Agent session continuity +} + +// Tab operations +const activeTab = session.aiTabs.find(t => t.id === session.activeTabId); +``` + +## 7. Execution Queue + +Messages are queued when the AI is busy: +```typescript +// Queue items for sequential execution +interface QueuedItem { + type: 'message' | 'slashCommand'; + content: string; + timestamp: number; +} + +// Add to queue instead of sending directly when busy +session.executionQueue.push({ type: 'message', content, timestamp: Date.now() }); +``` + +## 8. Auto Run + +File-based document automation system: +```typescript +// Auto Run state on session +session.autoRunFolderPath?: string; // Document folder path +session.autoRunSelectedFile?: string; // Currently selected document +session.autoRunMode?: 'edit' | 'preview'; + +// API for Auto Run operations +window.maestro.autorun.listDocuments(folderPath); +window.maestro.autorun.readDocument(folderPath, filename); +window.maestro.autorun.saveDocument(folderPath, filename, content); +``` + +**Worktree Support:** Auto Run can operate in a git worktree, allowing users to continue interactive editing in the main repo while Auto Run processes tasks in the background. When `batchRunState.worktreeActive` is true, read-only mode is disabled and a git branch icon appears in the UI. See `useBatchProcessor.ts` for worktree setup logic. + +**Playbook Assets:** Playbooks can include non-markdown assets (config files, YAML, Dockerfiles, scripts) in an `assets/` subfolder. When installing playbooks from the marketplace or importing from ZIP files, Maestro copies the entire folder structure including assets. See the [Maestro-Playbooks repository](https://github.com/pedramamini/Maestro-Playbooks) for the convention documentation. + +``` +playbook-folder/ +├── 01_TASK.md +├── 02_TASK.md +├── README.md +└── assets/ + ├── config.yaml + ├── Dockerfile + └── setup.sh +``` + +Documents can reference assets using `{{AUTORUN_FOLDER}}/assets/filename`. The manifest lists assets explicitly: +```json +{ + "id": "example-playbook", + "documents": [...], + "assets": ["config.yaml", "Dockerfile", "setup.sh"] +} +``` + +## 9. Tab Hover Overlay Menu + +AI conversation tabs display a hover overlay menu after a 400ms delay when hovering over tabs with an established session. The overlay includes tab management and context operations: + +**Menu Structure:** +```typescript +// Tab operations (always shown) +- Copy Session ID (if session exists) +- Star/Unstar Session (if session exists) +- Rename Tab +- Mark as Unread + +// Context management (shown when applicable) +- Context: Compact (if tab has 5+ messages) +- Context: Merge Into (if session exists) +- Context: Send to Agent (if session exists) + +// Tab close actions (always shown) +- Close (disabled if only one tab) +- Close Others (disabled if only one tab) +- Close Tabs to the Left (disabled if first tab) +- Close Tabs to the Right (disabled if last tab) +``` + +**Implementation Pattern:** +```typescript +const [overlayOpen, setOverlayOpen] = useState(false); +const [overlayPosition, setOverlayPosition] = useState<{ top: number; left: number } | null>(null); + +const handleMouseEnter = () => { + if (!tab.agentSessionId) return; // Only for established sessions + + hoverTimeoutRef.current = setTimeout(() => { + if (tabRef.current) { + const rect = tabRef.current.getBoundingClientRect(); + setOverlayPosition({ top: rect.bottom + 4, left: rect.left }); + } + setOverlayOpen(true); + }, 400); +}; + +// Render overlay via portal to escape stacking context +{overlayOpen && overlayPosition && createPortal( +
+ {/* Overlay menu items */} +
, + document.body +)} +``` + +**Key Features:** +- Appears after 400ms hover delay (only for tabs with `agentSessionId`) +- Fixed positioning at tab bottom +- Mouse can move from tab to overlay without closing +- Disabled states with visual feedback (opacity-40, cursor-default) +- Theme-aware styling +- Dividers separate action groups + +See `src/renderer/components/TabBar.tsx` (Tab component) for implementation details. + +## 10. SSH Remote Sessions + +Sessions can execute commands on remote hosts via SSH. **Critical:** There are two different SSH identifiers with different lifecycles: + +```typescript +// Set AFTER AI agent spawns (via onSshRemote callback) +session.sshRemoteId: string | undefined + +// Set BEFORE spawn (user configuration) +session.sessionSshRemoteConfig: { + enabled: boolean; + remoteId: string | null; // The SSH config ID + workingDirOverride?: string; +} +``` + +**Common pitfall:** `sshRemoteId` is only populated after the AI agent spawns. For terminal-only SSH sessions (no AI agent), it remains `undefined`. Always use both as fallback: + +```typescript +// WRONG - fails for terminal-only SSH sessions +const sshId = session.sshRemoteId; + +// CORRECT - works for all SSH sessions +const sshId = session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId; +``` + +This applies to any operation that needs to run on the remote: +- `window.maestro.fs.readDir(path, sshId)` +- `gitService.isRepo(path, sshId)` +- Directory existence checks for `cd` command tracking + +Similarly for checking if a session is remote: +```typescript +// WRONG +const isRemote = !!session.sshRemoteId; + +// CORRECT +const isRemote = !!session.sshRemoteId || !!session.sessionSshRemoteConfig?.enabled; +``` diff --git a/CLAUDE-PERFORMANCE.md b/CLAUDE-PERFORMANCE.md new file mode 100644 index 00000000..00525dd7 --- /dev/null +++ b/CLAUDE-PERFORMANCE.md @@ -0,0 +1,232 @@ +# CLAUDE-PERFORMANCE.md + +Performance best practices for the Maestro codebase. For the main guide, see [[CLAUDE.md]]. + +## React Component Optimization + +**Use `React.memo` for list item components:** +```typescript +// Components rendered in arrays (tabs, sessions, list items) should be memoized +const Tab = memo(function Tab({ tab, isActive, ... }: TabProps) { + // Memoize computed values that depend on props + const displayName = useMemo(() => getTabDisplayName(tab), [tab.name, tab.agentSessionId]); + + // Memoize style objects to prevent new references on every render + const tabStyle = useMemo(() => ({ + borderRadius: '6px', + backgroundColor: isActive ? theme.colors.accent : 'transparent', + } as React.CSSProperties), [isActive, theme.colors.accent]); + + return
{displayName}
; +}); +``` + +**Consolidate chained `useMemo` calls:** +```typescript +// BAD: Multiple dependent useMemo calls create cascade re-computations +const filtered = useMemo(() => sessions.filter(...), [sessions]); +const sorted = useMemo(() => filtered.sort(...), [filtered]); +const grouped = useMemo(() => groupBy(sorted, ...), [sorted]); + +// GOOD: Single useMemo with all transformations +const { filtered, sorted, grouped } = useMemo(() => { + const filtered = sessions.filter(...); + const sorted = filtered.sort(...); + const grouped = groupBy(sorted, ...); + return { filtered, sorted, grouped }; +}, [sessions]); +``` + +**Pre-compile regex patterns at module level:** +```typescript +// BAD: Regex compiled on every render +const Component = () => { + const cleaned = text.replace(/^(\p{Emoji})+\s*/u, ''); +}; + +// GOOD: Compile once at module load +const LEADING_EMOJI_REGEX = /^(\p{Emoji})+\s*/u; +const Component = () => { + const cleaned = text.replace(LEADING_EMOJI_REGEX, ''); +}; +``` + +**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:** +```typescript +// BAD: O(n) tree traversal on every markdown render +const result = remarkFileLinks({ fileTree, cwd }); + +// GOOD: Build index once when fileTree changes, pass to renders +const indices = useMemo(() => buildFileTreeIndices(fileTree), [fileTree]); +const result = remarkFileLinks({ indices, cwd }); +``` + +## Main Process (Node.js) + +**Cache expensive lookups:** +```typescript +// BAD: Synchronous file check on every shell spawn +fs.accessSync(shellPath, fs.constants.X_OK); + +// GOOD: Cache resolved paths +const shellPathCache = new Map(); +const cached = shellPathCache.get(shell); +if (cached) return cached; +// ... resolve and cache +shellPathCache.set(shell, resolved); +``` + +**Use async file operations:** +```typescript +// BAD: Blocking the main process +fs.unlinkSync(tempFile); + +// GOOD: Non-blocking cleanup +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); +}, []); +``` diff --git a/CLAUDE-SESSION.md b/CLAUDE-SESSION.md new file mode 100644 index 00000000..2f30a497 --- /dev/null +++ b/CLAUDE-SESSION.md @@ -0,0 +1,106 @@ +# CLAUDE-SESSION.md + +Session interface and code conventions for the Maestro codebase. For the main guide, see [[CLAUDE.md]]. + +## Session Interface + +Key fields on the Session object (abbreviated - see `src/renderer/types/index.ts` for full definition): + +```typescript +interface Session { + // Identity + id: string; + name: string; + groupId?: string; // Session grouping + toolType: ToolType; // 'claude-code' | 'codex' | 'opencode' | 'terminal' + state: SessionState; // 'idle' | 'busy' | 'error' | 'connecting' + inputMode: 'ai' | 'terminal'; // Which process receives input + bookmarked?: boolean; // Pinned to top + + // Paths + cwd: string; // Current working directory (can change via cd) + projectRoot: string; // Initial directory (never changes, used for session storage) + fullPath: string; // Full resolved path + + // Processes + aiPid: number; // AI process ID + port: number; // Web server communication port + + // Multi-Tab Support + aiTabs: AITab[]; // Multiple conversation tabs + activeTabId: string; // Currently active tab + closedTabHistory: ClosedTab[]; // Undo stack for closed tabs + + // Logs (per-tab) + shellLogs: LogEntry[]; // Terminal output history + + // Execution Queue + executionQueue: QueuedItem[]; // Sequential execution queue + + // Usage & Stats + usageStats?: UsageStats; // Token usage and cost + contextUsage: number; // Context window usage percentage + workLog: WorkLogItem[]; // Work tracking + + // Git Integration + isGitRepo: boolean; // Git features enabled + changedFiles: FileArtifact[]; // Git change tracking + gitBranches?: string[]; // Branch cache for completion + gitTags?: string[]; // Tag cache for completion + + // File Explorer + fileTree: any[]; // File tree structure + fileExplorerExpanded: string[]; // Expanded folder paths + fileExplorerScrollPos: number; // Scroll position + + // Web/Live Sessions + isLive: boolean; // Accessible via web interface + liveUrl?: string; // Live session URL + + // Auto Run + autoRunFolderPath?: string; // Auto Run document folder + autoRunSelectedFile?: string; // Selected document + autoRunMode?: 'edit' | 'preview'; // Current mode + + // Command History + aiCommandHistory?: string[]; // AI input history + shellCommandHistory?: string[]; // Terminal input history + + // Error Handling + agentError?: AgentError; // Current agent error (auth, tokens, rate limit, etc.) + agentErrorPaused?: boolean; // Input blocked while error modal shown +} + +interface AITab { + id: string; + name: string; + logs: LogEntry[]; // Tab-specific conversation history + agentSessionId?: string; // Agent session for this tab + scrollTop?: number; + draftInput?: string; +} +``` + +--- + +## Code Conventions + +### TypeScript +- Strict mode enabled +- Interface definitions for all data structures +- Types exported via `preload.ts` for renderer + +### React Components +- Functional components with hooks +- Tailwind for layout, inline styles for theme colors +- `tabIndex={-1}` + `outline-none` for programmatic focus + +### Commit Messages +``` +feat: new feature +fix: bug fix +docs: documentation +refactor: code refactoring +``` + +**IMPORTANT**: Do NOT create a `CHANGELOG.md` file. This project does not use changelogs - all change documentation goes in commit messages and PR descriptions only. diff --git a/CLAUDE-WIZARD.md b/CLAUDE-WIZARD.md new file mode 100644 index 00000000..af49e083 --- /dev/null +++ b/CLAUDE-WIZARD.md @@ -0,0 +1,202 @@ +# CLAUDE-WIZARD.md + +Wizard documentation for the Maestro codebase. For the main guide, see [[CLAUDE.md]]. + +## Onboarding Wizard + +The wizard (`src/renderer/components/Wizard/`) guides new users through first-run setup, creating AI sessions with Auto Run documents. + +### Wizard Architecture + +``` +src/renderer/components/Wizard/ +├── MaestroWizard.tsx # Main orchestrator, screen transitions +├── WizardContext.tsx # State management (useReducer pattern) +├── WizardResumeModal.tsx # Resume incomplete wizard dialog +├── WizardExitConfirmModal.tsx # Exit confirmation dialog +├── ScreenReaderAnnouncement.tsx # Accessibility announcements +├── screens/ # Individual wizard steps +│ ├── AgentSelectionScreen.tsx # Step 1: Choose AI agent +│ ├── DirectorySelectionScreen.tsx # Step 2: Select project folder +│ ├── ConversationScreen.tsx # Step 3: AI project discovery +│ └── PhaseReviewScreen.tsx # Step 4: Review generated plan +├── services/ # Business logic +│ ├── wizardPrompts.ts # System prompts, response parser +│ ├── conversationManager.ts # AI conversation handling +│ └── phaseGenerator.ts # Document generation +└── tour/ # Post-setup walkthrough + ├── TourOverlay.tsx # Spotlight overlay + ├── TourStep.tsx # Step tooltip + ├── tourSteps.ts # Step definitions + └── useTour.tsx # Tour state management +``` + +### Wizard Flow + +1. **Agent Selection** → Select available AI (Claude Code, etc.) and project name +2. **Directory Selection** → Choose project folder, validates Git repo status +3. **Conversation** → AI asks clarifying questions, builds confidence score (0-100) +4. **Phase Review** → View/edit generated Phase 1 document, choose to start tour + +When confidence reaches 80+ and agent signals "ready", user proceeds to Phase Review where Auto Run documents are generated and saved to `Auto Run Docs/Initiation/`. The `Initiation/` subfolder keeps wizard-generated documents separate from user-created playbooks. + +### Triggering the Wizard + +```typescript +// From anywhere with useWizard hook +const { openWizard } = useWizard(); +openWizard(); + +// Keyboard shortcut (default) +Cmd+Shift+N // Opens wizard + +// Also available in: +// - Command K menu: "New Agent Wizard" +// - Hamburger menu: "New Agent Wizard" +``` + +### State Persistence (Resume) + +Wizard state persists to `wizardResumeState` in settings when user advances past step 1. On next app launch, if incomplete state exists, `WizardResumeModal` offers "Resume" or "Start Fresh". + +```typescript +// Check for saved state +const hasState = await hasResumeState(); + +// Load saved state +const savedState = await loadResumeState(); + +// Clear saved state +clearResumeState(); +``` + +#### State Lifecycle + +The Wizard maintains two types of state: + +1. **In-Memory State** (React `useReducer`) + - Managed in `WizardContext.tsx` + - Includes: `currentStep`, `isOpen`, `isComplete`, conversation history, etc. + - Lives only during the app session + - Must be reset when opening wizard after completion + +2. **Persisted State** (Settings) + - Stored in `wizardResumeState` via `window.maestro.settings` + - Enables resume functionality across app restarts + - Automatically saved when advancing past step 1 + - Cleared on completion or when user chooses "Just Quit" + +**State Save Triggers:** +- Auto-save: When `currentStep` changes (step > 1) - `WizardContext.tsx:791` +- Manual save: User clicks "Save & Exit" - `MaestroWizard.tsx:147` + +**State Clear Triggers:** +- Wizard completion: `App.tsx:4681` + `WizardContext.tsx:711` +- User quits: "Just Quit" button - `MaestroWizard.tsx:168` +- User starts fresh: "Start Fresh" in resume modal - `App.tsx` resume handlers + +**Opening Wizard Logic:** +The `openWizard()` function in `WizardContext.tsx:528-535` handles state initialization: +```typescript +// If previous wizard was completed, reset in-memory state first +if (state.isComplete === true) { + dispatch({ type: 'RESET_WIZARD' }); // Clear stale state +} +dispatch({ type: 'OPEN_WIZARD' }); // Show wizard UI +``` + +This ensures: +- **Fresh starts**: Completed wizards don't contaminate new runs +- **Resume works**: Abandoned wizards (isComplete: false) preserve state +- **No race conditions**: Persisted state is checked after wizard opens + +**Important:** The persisted state and in-memory state are independent. Clearing one doesn't automatically clear the other. Both must be managed correctly to prevent state contamination (see Issue #89). + +### Tour System + +The tour highlights UI elements with spotlight cutouts: + +```typescript +// Add data-tour attribute to spotlight elements +
...
+ +// Tour steps defined in tourSteps.ts +{ + id: 'autorun-panel', + title: 'Auto Run in Action', + description: '...', + selector: '[data-tour="autorun-panel"]', + position: 'left', // tooltip position + uiActions: [ // UI state changes before spotlight + { type: 'setRightTab', value: 'autorun' }, + ], +} +``` + +### Customization Points + +| What | Where | +|------|-------| +| Add wizard step | `WizardContext.tsx` (WIZARD_TOTAL_STEPS, WizardStep type, STEP_INDEX) | +| Modify wizard prompts | `src/prompts/wizard-*.md` (content), `services/wizardPrompts.ts` (logic) | +| Change confidence threshold | `READY_CONFIDENCE_THRESHOLD` in wizardPrompts.ts (default: 80) | +| Add tour step | `tour/tourSteps.ts` array | +| Modify Auto Run document format | `src/prompts/wizard-document-generation.md` | +| Change wizard keyboard shortcut | `shortcuts.ts` → `openWizard` | + +### Related Settings + +```typescript +// In useSettings.ts +wizardCompleted: boolean // First wizard completion +tourCompleted: boolean // First tour completion +firstAutoRunCompleted: boolean // Triggers celebration modal +``` + +--- + +## Inline Wizard (`/wizard`) + +The Inline Wizard creates Auto Run Playbook documents from within an existing agent session. Unlike the full-screen Onboarding Wizard above, it runs inside a single tab. + +### Prerequisites + +- Auto Run document folder must be configured for the session +- If not set, `/wizard` errors with instructions to configure it + +### User Flow + +1. **Start**: Type `/wizard` in any AI tab → tab enters wizard mode +2. **Conversation**: Back-and-forth with agent, confidence gauge builds (0-100%) +3. **Generation**: At 80%+ confidence, generates docs (Austin Facts shown, cancellable) +4. **Completion**: Tab returns to normal with preserved context, docs in unique subfolder + +### Key Behaviors + +- Multiple wizards can run in different tabs simultaneously +- Wizard state is **per-tab** (`AITab.wizardState`), not per-session +- Documents written to unique subfolder under Auto Run folder (e.g., `Auto Run Docs/Project-Name/`) +- On completion, tab renamed to "Project: {SubfolderName}" +- Final AI message summarizes generated docs and next steps +- Same `agentSessionId` preserved for context continuity + +### Architecture + +``` +src/renderer/components/InlineWizard/ +├── WizardConversationView.tsx # Conversation phase UI +├── WizardInputPanel.tsx # Input with confidence gauge +├── DocumentGenerationView.tsx # Generation phase with Austin Facts +└── ... (see index.ts for full documentation) + +src/renderer/hooks/useInlineWizard.ts # Main hook +src/renderer/contexts/InlineWizardContext.tsx # State provider +``` + +### Customization Points + +| What | Where | +|------|-------| +| Modify inline wizard prompts | `src/prompts/wizard-*.md` | +| Change confidence threshold | `READY_CONFIDENCE_THRESHOLD` in wizardPrompts.ts | +| Modify generation UI | `DocumentGenerationView.tsx`, `AustinFactsDisplay.tsx` | diff --git a/CLAUDE.md b/CLAUDE.md index d2558bf6..ae50a109 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,23 @@ Essential guidance for working with this codebase. For detailed architecture, see [ARCHITECTURE.md](ARCHITECTURE.md). For development setup and processes, see [CONTRIBUTING.md](CONTRIBUTING.md). +## Documentation Index + +This guide has been split into focused sub-documents for progressive disclosure: + +| Document | Description | +|----------|-------------| +| [[CLAUDE-PATTERNS.md]] | Core implementation patterns (process management, settings, modals, themes, Auto Run, SSH) | +| [[CLAUDE-IPC.md]] | IPC API surface (`window.maestro.*` namespaces) | +| [[CLAUDE-PERFORMANCE.md]] | Performance best practices (React optimization, debouncing, batching) | +| [[CLAUDE-WIZARD.md]] | Onboarding Wizard, Inline Wizard, and Tour System | +| [[CLAUDE-FEATURES.md]] | Usage Dashboard and Document Graph features | +| [[CLAUDE-AGENTS.md]] | Supported agents and capabilities | +| [[CLAUDE-SESSION.md]] | Session interface and code conventions | +| [AGENT_SUPPORT.md](AGENT_SUPPORT.md) | Detailed agent integration guide | + +--- + ## Standardized Vernacular Use these terms consistently in code, comments, and documentation: @@ -20,9 +37,24 @@ Use these terms consistently in code, comments, and documentation: - **Red** - No connection/error - **Pulsing Orange** - Connecting +--- + ## Project Overview -Maestro is an Electron desktop app for managing multiple AI coding assistants (Claude Code, OpenAI Codex, Gemini CLI, Qwen3 Coder) simultaneously with a keyboard-first interface. +Maestro is an Electron desktop app for managing multiple AI coding assistants simultaneously with a keyboard-first interface. + +### Supported Agents + +| ID | Name | Status | +|----|------|--------| +| `claude-code` | Claude Code | **Active** | +| `codex` | OpenAI Codex | **Active** | +| `opencode` | OpenCode | **Active** | +| `terminal` | Terminal | Internal | + +See [[CLAUDE-AGENTS.md]] for capabilities and integration details. + +--- ## Quick Commands @@ -39,6 +71,8 @@ npm run test # Run test suite npm run test:watch # Run tests in watch mode ``` +--- + ## Architecture at a Glance ``` @@ -95,7 +129,9 @@ src/ └── *.md # Documentation pages ``` -### Key Files for Common Tasks +--- + +## Key Files for Common Tasks | Task | Primary Files | |------|---------------| @@ -105,7 +141,7 @@ src/ | Add keyboard shortcut | `src/renderer/constants/shortcuts.ts`, `App.tsx` | | Add theme | `src/renderer/constants/themes.ts` | | Add modal | Component + `src/renderer/constants/modalPriorities.ts` | -| Add tab overlay menu | See Tab Hover Overlay Menu pattern in `src/renderer/components/TabBar.tsx` | +| Add tab overlay menu | See Tab Hover Overlay Menu pattern in [[CLAUDE-PATTERNS.md]] | | Add setting | `src/renderer/hooks/useSettings.ts`, `src/main/index.ts` | | Add template variable | `src/shared/templateVariables.ts`, `src/renderer/utils/templateVariables.ts` | | Modify system prompts | `src/prompts/*.md` (wizard, Auto Run, etc.) | @@ -119,7 +155,7 @@ src/ | Add playbook feature | `src/cli/services/playbooks.ts` | | Add marketplace playbook | `src/main/ipc/handlers/marketplace.ts` (import from GitHub) | | Playbook import/export | `src/main/ipc/handlers/playbooks.ts` (ZIP handling with assets) | -| Modify wizard flow | `src/renderer/components/Wizard/` (see Onboarding Wizard section) | +| Modify wizard flow | `src/renderer/components/Wizard/` (see [[CLAUDE-WIZARD.md]]) | | Add tour step | `src/renderer/components/Wizard/tour/tourSteps.ts` | | Modify file linking | `src/renderer/utils/remarkFileLinks.ts` (remark plugin for `[[wiki]]` and path links) | | Add documentation page | `docs/*.md`, `docs/docs.json` (navigation) | @@ -132,1066 +168,7 @@ src/ | Add performance metrics | `src/shared/performance-metrics.ts` | | Add power management | `src/main/power-manager.ts`, `src/main/ipc/handlers/system.ts` | -## Core Patterns - -### 1. Process Management - -Each session runs **two processes** simultaneously: -- AI agent process (Claude Code, etc.) - spawned with `-ai` suffix -- Terminal process (PTY shell) - spawned with `-terminal` suffix - -```typescript -// Session stores both PIDs -session.aiPid // AI agent process -session.terminalPid // Terminal process -``` - -### 2. Security Requirements - -**Always use `execFileNoThrow`** for external commands: -```typescript -import { execFileNoThrow } from './utils/execFile'; -const result = await execFileNoThrow('git', ['status'], cwd); -// Returns: { stdout, stderr, exitCode } - never throws -``` - -**Never use shell-based command execution** - it creates injection vulnerabilities. The `execFileNoThrow` utility is the safe alternative. - -### 3. Settings Persistence - -Add new settings in `useSettings.ts`: -```typescript -// 1. Add state with default value -const [mySetting, setMySettingState] = useState(defaultValue); - -// 2. Add wrapper that persists -const setMySetting = (value) => { - setMySettingState(value); - window.maestro.settings.set('mySetting', value); -}; - -// 3. Load from batch response in useEffect (settings use batch loading) -// In the loadSettings useEffect, extract from allSettings object: -const allSettings = await window.maestro.settings.getAll(); -const savedMySetting = allSettings['mySetting']; -if (savedMySetting !== undefined) setMySettingState(savedMySetting); -``` - -### 4. Adding Modals - -1. Create component in `src/renderer/components/` -2. Add priority in `src/renderer/constants/modalPriorities.ts` -3. Register with layer stack: - -```typescript -import { useLayerStack } from '../contexts/LayerStackContext'; -import { MODAL_PRIORITIES } from '../constants/modalPriorities'; - -const { registerLayer, unregisterLayer } = useLayerStack(); -const onCloseRef = useRef(onClose); -onCloseRef.current = onClose; - -useEffect(() => { - if (isOpen) { - const id = registerLayer({ - type: 'modal', - priority: MODAL_PRIORITIES.YOUR_MODAL, - onEscape: () => onCloseRef.current(), - }); - return () => unregisterLayer(id); - } -}, [isOpen, registerLayer, unregisterLayer]); -``` - -### 5. Theme Colors - -Themes have 13 required colors. Use inline styles for theme colors: -```typescript -style={{ color: theme.colors.textMain }} // Correct -className="text-gray-500" // Wrong for themed text -``` - -### 6. Multi-Tab Sessions - -Sessions support multiple AI conversation tabs: -```typescript -// Each session has an array of tabs -session.aiTabs: AITab[] -session.activeTabId: string - -// Each tab maintains its own conversation -interface AITab { - id: string; - name: string; - logs: LogEntry[]; // Tab-specific history - agentSessionId?: string; // Agent session continuity -} - -// Tab operations -const activeTab = session.aiTabs.find(t => t.id === session.activeTabId); -``` - -### 7. Execution Queue - -Messages are queued when the AI is busy: -```typescript -// Queue items for sequential execution -interface QueuedItem { - type: 'message' | 'slashCommand'; - content: string; - timestamp: number; -} - -// Add to queue instead of sending directly when busy -session.executionQueue.push({ type: 'message', content, timestamp: Date.now() }); -``` - -### 8. Auto Run - -File-based document automation system: -```typescript -// Auto Run state on session -session.autoRunFolderPath?: string; // Document folder path -session.autoRunSelectedFile?: string; // Currently selected document -session.autoRunMode?: 'edit' | 'preview'; - -// API for Auto Run operations -window.maestro.autorun.listDocuments(folderPath); -window.maestro.autorun.readDocument(folderPath, filename); -window.maestro.autorun.saveDocument(folderPath, filename, content); -``` - -**Worktree Support:** Auto Run can operate in a git worktree, allowing users to continue interactive editing in the main repo while Auto Run processes tasks in the background. When `batchRunState.worktreeActive` is true, read-only mode is disabled and a git branch icon appears in the UI. See `useBatchProcessor.ts` for worktree setup logic. - -**Playbook Assets:** Playbooks can include non-markdown assets (config files, YAML, Dockerfiles, scripts) in an `assets/` subfolder. When installing playbooks from the marketplace or importing from ZIP files, Maestro copies the entire folder structure including assets. See the [Maestro-Playbooks repository](https://github.com/pedramamini/Maestro-Playbooks) for the convention documentation. - -``` -playbook-folder/ -├── 01_TASK.md -├── 02_TASK.md -├── README.md -└── assets/ - ├── config.yaml - ├── Dockerfile - └── setup.sh -``` - -Documents can reference assets using `{{AUTORUN_FOLDER}}/assets/filename`. The manifest lists assets explicitly: -```json -{ - "id": "example-playbook", - "documents": [...], - "assets": ["config.yaml", "Dockerfile", "setup.sh"] -} -``` - -### 9. Tab Hover Overlay Menu - -AI conversation tabs display a hover overlay menu after a 400ms delay when hovering over tabs with an established session. The overlay includes tab management and context operations: - -**Menu Structure:** -```typescript -// Tab operations (always shown) -- Copy Session ID (if session exists) -- Star/Unstar Session (if session exists) -- Rename Tab -- Mark as Unread - -// Context management (shown when applicable) -- Context: Compact (if tab has 5+ messages) -- Context: Merge Into (if session exists) -- Context: Send to Agent (if session exists) - -// Tab close actions (always shown) -- Close (disabled if only one tab) -- Close Others (disabled if only one tab) -- Close Tabs to the Left (disabled if first tab) -- Close Tabs to the Right (disabled if last tab) -``` - -**Implementation Pattern:** -```typescript -const [overlayOpen, setOverlayOpen] = useState(false); -const [overlayPosition, setOverlayPosition] = useState<{ top: number; left: number } | null>(null); - -const handleMouseEnter = () => { - if (!tab.agentSessionId) return; // Only for established sessions - - hoverTimeoutRef.current = setTimeout(() => { - if (tabRef.current) { - const rect = tabRef.current.getBoundingClientRect(); - setOverlayPosition({ top: rect.bottom + 4, left: rect.left }); - } - setOverlayOpen(true); - }, 400); -}; - -// Render overlay via portal to escape stacking context -{overlayOpen && overlayPosition && createPortal( -
- {/* Overlay menu items */} -
, - document.body -)} -``` - -**Key Features:** -- Appears after 400ms hover delay (only for tabs with `agentSessionId`) -- Fixed positioning at tab bottom -- Mouse can move from tab to overlay without closing -- Disabled states with visual feedback (opacity-40, cursor-default) -- Theme-aware styling -- Dividers separate action groups - -See `src/renderer/components/TabBar.tsx` (Tab component) for implementation details. - -### 10. SSH Remote Sessions - -Sessions can execute commands on remote hosts via SSH. **Critical:** There are two different SSH identifiers with different lifecycles: - -```typescript -// Set AFTER AI agent spawns (via onSshRemote callback) -session.sshRemoteId: string | undefined - -// Set BEFORE spawn (user configuration) -session.sessionSshRemoteConfig: { - enabled: boolean; - remoteId: string | null; // The SSH config ID - workingDirOverride?: string; -} -``` - -**Common pitfall:** `sshRemoteId` is only populated after the AI agent spawns. For terminal-only SSH sessions (no AI agent), it remains `undefined`. Always use both as fallback: - -```typescript -// WRONG - fails for terminal-only SSH sessions -const sshId = session.sshRemoteId; - -// CORRECT - works for all SSH sessions -const sshId = session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId; -``` - -This applies to any operation that needs to run on the remote: -- `window.maestro.fs.readDir(path, sshId)` -- `gitService.isRepo(path, sshId)` -- Directory existence checks for `cd` command tracking - -Similarly for checking if a session is remote: -```typescript -// WRONG -const isRemote = !!session.sshRemoteId; - -// CORRECT -const isRemote = !!session.sshRemoteId || !!session.sessionSshRemoteConfig?.enabled; -``` - -## Code Conventions - -### TypeScript -- Strict mode enabled -- Interface definitions for all data structures -- Types exported via `preload.ts` for renderer - -### React Components -- Functional components with hooks -- Tailwind for layout, inline styles for theme colors -- `tabIndex={-1}` + `outline-none` for programmatic focus - -### Commit Messages -``` -feat: new feature -fix: bug fix -docs: documentation -refactor: code refactoring -``` - -**IMPORTANT**: Do NOT create a `CHANGELOG.md` file. This project does not use changelogs - all change documentation goes in commit messages and PR descriptions only. - -## Session Interface - -Key fields on the Session object (abbreviated - see `src/renderer/types/index.ts` for full definition): - -```typescript -interface Session { - // Identity - id: string; - name: string; - groupId?: string; // Session grouping - toolType: ToolType; // 'claude-code' | 'aider' | 'terminal' | etc. - state: SessionState; // 'idle' | 'busy' | 'error' | 'connecting' - inputMode: 'ai' | 'terminal'; // Which process receives input - bookmarked?: boolean; // Pinned to top - - // Paths - cwd: string; // Current working directory (can change via cd) - projectRoot: string; // Initial directory (never changes, used for Claude session storage) - fullPath: string; // Full resolved path - - // Processes - aiPid: number; // AI process ID - port: number; // Web server communication port - - // Multi-Tab Support (NEW) - aiTabs: AITab[]; // Multiple Claude Code conversation tabs - activeTabId: string; // Currently active tab - closedTabHistory: ClosedTab[]; // Undo stack for closed tabs - - // Logs (per-tab) - shellLogs: LogEntry[]; // Terminal output history - - // Execution Queue (replaces messageQueue) - executionQueue: QueuedItem[]; // Sequential execution queue - - // Usage & Stats - usageStats?: UsageStats; // Token usage and cost - contextUsage: number; // Context window usage percentage - workLog: WorkLogItem[]; // Work tracking - - // Git Integration - isGitRepo: boolean; // Git features enabled - changedFiles: FileArtifact[]; // Git change tracking - gitBranches?: string[]; // Branch cache for completion - gitTags?: string[]; // Tag cache for completion - - // File Explorer - fileTree: any[]; // File tree structure - fileExplorerExpanded: string[]; // Expanded folder paths - fileExplorerScrollPos: number; // Scroll position - - // Web/Live Sessions (NEW) - isLive: boolean; // Accessible via web interface - liveUrl?: string; // Live session URL - - // Auto Run (NEW) - autoRunFolderPath?: string; // Auto Run document folder - autoRunSelectedFile?: string; // Selected document - autoRunMode?: 'edit' | 'preview'; // Current mode - - // Command History - aiCommandHistory?: string[]; // AI input history - shellCommandHistory?: string[]; // Terminal input history - - // Error Handling (NEW) - agentError?: AgentError; // Current agent error (auth, tokens, rate limit, etc.) - agentErrorPaused?: boolean; // Input blocked while error modal shown -} - -interface AITab { - id: string; - name: string; - logs: LogEntry[]; // Tab-specific conversation history - agentSessionId?: string; // Agent session for this tab - scrollTop?: number; - draftInput?: string; -} -``` - -## IPC API Surface - -The `window.maestro` API exposes: - -### Core APIs -- `settings` - Get/set app settings -- `sessions` / `groups` - Persistence -- `process` - Spawn, write, kill, resize -- `fs` - readDir, readFile -- `dialog` - Folder selection -- `shells` - Detect available shells -- `logger` - System logging - -### Agent & Agent Sessions -- `agents` - Detect, get, config, refresh, custom paths, getCapabilities -- `agentSessions` - Generic agent session storage API (list, read, search, delete) -- `agentError` - Agent error handling (clearError, retryAfterError) -- `claude` - (Deprecated) Claude Code sessions - use `agentSessions` instead - -### Git Integration -- `git` - Status, diff, isRepo, numstat, branches, tags, info -- `git` - Worktree support: worktreeInfo, getRepoRoot, worktreeSetup, worktreeCheckout -- `git` - PR creation: createPR, checkGhCli, getDefaultBranch - -### Web & Live Sessions -- `web` - Broadcast user input, Auto Run state, tab changes to web clients -- `live` - Toggle live sessions, get status, dashboard URL, connected clients -- `webserver` - Get URL, connected client count -- `tunnel` - Cloudflare tunnel: isCloudflaredInstalled, start, stop, getStatus - -### Automation -- `autorun` - Document and image management for Auto Run -- `playbooks` - Batch run configuration management -- `history` - Per-session execution history (see History API below) -- `cli` - CLI activity detection for playbook runs -- `tempfile` - Temporary file management for batch processing - -### Analytics & Visualization -- `stats` - Usage statistics: recordQuery, getAggregatedStats, exportCsv, clearOldData, getDatabaseSize -- `stats` - Auto Run tracking: startAutoRun, endAutoRun, recordTask, getAutoRunSessions -- `stats` - Real-time updates via `stats:updated` event broadcast -- `documentGraph` - File watching: watchFolder, unwatchFolder -- `documentGraph` - Real-time updates via `documentGraph:filesChanged` event - -### History API - -Per-session history storage with 5,000 entries per session (up from 1,000 global). Each session's history is stored as a JSON file in `~/Library/Application Support/Maestro/history/{sessionId}.json`. - -```typescript -window.maestro.history = { - getAll: (projectPath?, sessionId?) => Promise, - add: (entry) => Promise, - clear: (projectPath?, sessionId?) => Promise, - delete: (entryId, sessionId?) => Promise, - update: (entryId, updates, sessionId?) => Promise, - // For AI context integration: - getFilePath: (sessionId) => Promise, - listSessions: () => Promise, - // External change detection: - onExternalChange: (handler) => () => void, - reload: () => Promise, -}; -``` - -**AI Context Integration**: Use `getFilePath(sessionId)` to get the path to a session's history file. This file can be passed directly to AI agents as context, giving them visibility into past completed tasks, decisions, and work patterns. - -### Power Management -- `power` - Sleep prevention: setEnabled, isEnabled, getStatus, addReason, removeReason - -### Utilities -- `fonts` - Font detection -- `notification` - Desktop notifications, text-to-speech -- `devtools` - Developer tools: open, close, toggle -- `attachments` - Image attachment management - -## Available Agents - -| ID | Name | Status | Notes | -|----|------|--------|-------| -| `claude-code` | Claude Code | Active | Primary agent, uses `--print --verbose --output-format stream-json` | -| `opencode` | OpenCode | Stub | Output parser implemented, session storage stub ready | -| `terminal` | Terminal | Internal | Hidden from UI, used for shell sessions | -| `openai-codex` | OpenAI Codex | Planned | Coming soon | -| `gemini-cli` | Gemini CLI | Planned | Coming soon | -| `qwen3-coder` | Qwen3 Coder | Planned | Coming soon | - -Additional `ToolType` values (`aider`, `claude`) are defined in types but not yet implemented in `agent-detector.ts`. - -### Agent Capabilities - -Each agent declares capabilities that control UI feature availability. See `src/main/agent-capabilities.ts` for the full interface. - -| Capability | Description | UI Feature Controlled | -|------------|-------------|----------------------| -| `supportsResume` | Can resume previous sessions | Resume button | -| `supportsReadOnlyMode` | Has plan/read-only mode | Read-only toggle | -| `supportsJsonOutput` | Emits structured JSON | Output parsing | -| `supportsSessionId` | Emits session ID | Session ID pill | -| `supportsImageInput` | Accepts image attachments | Attach image button | -| `supportsSlashCommands` | Has discoverable commands | Slash autocomplete | -| `supportsSessionStorage` | Persists browsable sessions | Sessions browser | -| `supportsCostTracking` | Reports token costs | Cost widget | -| `supportsUsageStats` | Reports token counts | Context window widget | -| `supportsBatchMode` | Runs per-message | Batch processing | -| `supportsStreaming` | Streams output | Real-time display | -| `supportsResultMessages` | Distinguishes final result | Message classification | - -For detailed agent integration guide, see [AGENT_SUPPORT.md](AGENT_SUPPORT.md). - -## Performance Best Practices - -### React Component Optimization - -**Use `React.memo` for list item components:** -```typescript -// Components rendered in arrays (tabs, sessions, list items) should be memoized -const Tab = memo(function Tab({ tab, isActive, ... }: TabProps) { - // Memoize computed values that depend on props - const displayName = useMemo(() => getTabDisplayName(tab), [tab.name, tab.agentSessionId]); - - // Memoize style objects to prevent new references on every render - const tabStyle = useMemo(() => ({ - borderRadius: '6px', - backgroundColor: isActive ? theme.colors.accent : 'transparent', - } as React.CSSProperties), [isActive, theme.colors.accent]); - - return
{displayName}
; -}); -``` - -**Consolidate chained `useMemo` calls:** -```typescript -// BAD: Multiple dependent useMemo calls create cascade re-computations -const filtered = useMemo(() => sessions.filter(...), [sessions]); -const sorted = useMemo(() => filtered.sort(...), [filtered]); -const grouped = useMemo(() => groupBy(sorted, ...), [sorted]); - -// GOOD: Single useMemo with all transformations -const { filtered, sorted, grouped } = useMemo(() => { - const filtered = sessions.filter(...); - const sorted = filtered.sort(...); - const grouped = groupBy(sorted, ...); - return { filtered, sorted, grouped }; -}, [sessions]); -``` - -**Pre-compile regex patterns at module level:** -```typescript -// BAD: Regex compiled on every render -const Component = () => { - const cleaned = text.replace(/^(\p{Emoji})+\s*/u, ''); -}; - -// GOOD: Compile once at module load -const LEADING_EMOJI_REGEX = /^(\p{Emoji})+\s*/u; -const Component = () => { - const cleaned = text.replace(LEADING_EMOJI_REGEX, ''); -}; -``` - -**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:** -```typescript -// BAD: O(n) tree traversal on every markdown render -const result = remarkFileLinks({ fileTree, cwd }); - -// GOOD: Build index once when fileTree changes, pass to renders -const indices = useMemo(() => buildFileTreeIndices(fileTree), [fileTree]); -const result = remarkFileLinks({ indices, cwd }); -``` - -### Main Process (Node.js) - -**Cache expensive lookups:** -```typescript -// BAD: Synchronous file check on every shell spawn -fs.accessSync(shellPath, fs.constants.X_OK); - -// GOOD: Cache resolved paths -const shellPathCache = new Map(); -const cached = shellPathCache.get(shell); -if (cached) return cached; -// ... resolve and cache -shellPathCache.set(shell, resolved); -``` - -**Use async file operations:** -```typescript -// BAD: Blocking the main process -fs.unlinkSync(tempFile); - -// GOOD: Non-blocking cleanup -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. - -### Wizard Architecture - -``` -src/renderer/components/Wizard/ -├── MaestroWizard.tsx # Main orchestrator, screen transitions -├── WizardContext.tsx # State management (useReducer pattern) -├── WizardResumeModal.tsx # Resume incomplete wizard dialog -├── WizardExitConfirmModal.tsx # Exit confirmation dialog -├── ScreenReaderAnnouncement.tsx # Accessibility announcements -├── screens/ # Individual wizard steps -│ ├── AgentSelectionScreen.tsx # Step 1: Choose AI agent -│ ├── DirectorySelectionScreen.tsx # Step 2: Select project folder -│ ├── ConversationScreen.tsx # Step 3: AI project discovery -│ └── PhaseReviewScreen.tsx # Step 4: Review generated plan -├── services/ # Business logic -│ ├── wizardPrompts.ts # System prompts, response parser -│ ├── conversationManager.ts # AI conversation handling -│ └── phaseGenerator.ts # Document generation -└── tour/ # Post-setup walkthrough - ├── TourOverlay.tsx # Spotlight overlay - ├── TourStep.tsx # Step tooltip - ├── tourSteps.ts # Step definitions - └── useTour.tsx # Tour state management -``` - -### Wizard Flow - -1. **Agent Selection** → Select available AI (Claude Code, etc.) and project name -2. **Directory Selection** → Choose project folder, validates Git repo status -3. **Conversation** → AI asks clarifying questions, builds confidence score (0-100) -4. **Phase Review** → View/edit generated Phase 1 document, choose to start tour - -When confidence reaches 80+ and agent signals "ready", user proceeds to Phase Review where Auto Run documents are generated and saved to `Auto Run Docs/Initiation/`. The `Initiation/` subfolder keeps wizard-generated documents separate from user-created playbooks. - -### Triggering the Wizard - -```typescript -// From anywhere with useWizard hook -const { openWizard } = useWizard(); -openWizard(); - -// Keyboard shortcut (default) -Cmd+Shift+N // Opens wizard - -// Also available in: -// - Command K menu: "New Agent Wizard" -// - Hamburger menu: "New Agent Wizard" -``` - -### State Persistence (Resume) - -Wizard state persists to `wizardResumeState` in settings when user advances past step 1. On next app launch, if incomplete state exists, `WizardResumeModal` offers "Resume" or "Start Fresh". - -```typescript -// Check for saved state -const hasState = await hasResumeState(); - -// Load saved state -const savedState = await loadResumeState(); - -// Clear saved state -clearResumeState(); -``` - -#### State Lifecycle - -The Wizard maintains two types of state: - -1. **In-Memory State** (React `useReducer`) - - Managed in `WizardContext.tsx` - - Includes: `currentStep`, `isOpen`, `isComplete`, conversation history, etc. - - Lives only during the app session - - Must be reset when opening wizard after completion - -2. **Persisted State** (Settings) - - Stored in `wizardResumeState` via `window.maestro.settings` - - Enables resume functionality across app restarts - - Automatically saved when advancing past step 1 - - Cleared on completion or when user chooses "Just Quit" - -**State Save Triggers:** -- Auto-save: When `currentStep` changes (step > 1) - `WizardContext.tsx:791` -- Manual save: User clicks "Save & Exit" - `MaestroWizard.tsx:147` - -**State Clear Triggers:** -- Wizard completion: `App.tsx:4681` + `WizardContext.tsx:711` -- User quits: "Just Quit" button - `MaestroWizard.tsx:168` -- User starts fresh: "Start Fresh" in resume modal - `App.tsx` resume handlers - -**Opening Wizard Logic:** -The `openWizard()` function in `WizardContext.tsx:528-535` handles state initialization: -```typescript -// If previous wizard was completed, reset in-memory state first -if (state.isComplete === true) { - dispatch({ type: 'RESET_WIZARD' }); // Clear stale state -} -dispatch({ type: 'OPEN_WIZARD' }); // Show wizard UI -``` - -This ensures: -- **Fresh starts**: Completed wizards don't contaminate new runs -- **Resume works**: Abandoned wizards (isComplete: false) preserve state -- **No race conditions**: Persisted state is checked after wizard opens - -**Important:** The persisted state and in-memory state are independent. Clearing one doesn't automatically clear the other. Both must be managed correctly to prevent state contamination (see Issue #89). - -### Tour System - -The tour highlights UI elements with spotlight cutouts: - -```typescript -// Add data-tour attribute to spotlight elements -
...
- -// Tour steps defined in tourSteps.ts -{ - id: 'autorun-panel', - title: 'Auto Run in Action', - description: '...', - selector: '[data-tour="autorun-panel"]', - position: 'left', // tooltip position - uiActions: [ // UI state changes before spotlight - { type: 'setRightTab', value: 'autorun' }, - ], -} -``` - -### Customization Points - -| What | Where | -|------|-------| -| Add wizard step | `WizardContext.tsx` (WIZARD_TOTAL_STEPS, WizardStep type, STEP_INDEX) | -| Modify wizard prompts | `src/prompts/wizard-*.md` (content), `services/wizardPrompts.ts` (logic) | -| Change confidence threshold | `READY_CONFIDENCE_THRESHOLD` in wizardPrompts.ts (default: 80) | -| Add tour step | `tour/tourSteps.ts` array | -| Modify Auto Run document format | `src/prompts/wizard-document-generation.md` | -| Change wizard keyboard shortcut | `shortcuts.ts` → `openWizard` | - -### Related Settings - -```typescript -// In useSettings.ts -wizardCompleted: boolean // First wizard completion -tourCompleted: boolean // First tour completion -firstAutoRunCompleted: boolean // Triggers celebration modal -``` - -## Inline Wizard (`/wizard`) - -The Inline Wizard creates Auto Run Playbook documents from within an existing agent session. Unlike the full-screen Onboarding Wizard above, it runs inside a single tab. - -### Prerequisites - -- Auto Run document folder must be configured for the session -- If not set, `/wizard` errors with instructions to configure it - -### User Flow - -1. **Start**: Type `/wizard` in any AI tab → tab enters wizard mode -2. **Conversation**: Back-and-forth with agent, confidence gauge builds (0-100%) -3. **Generation**: At 80%+ confidence, generates docs (Austin Facts shown, cancellable) -4. **Completion**: Tab returns to normal with preserved context, docs in unique subfolder - -### Key Behaviors - -- Multiple wizards can run in different tabs simultaneously -- Wizard state is **per-tab** (`AITab.wizardState`), not per-session -- Documents written to unique subfolder under Auto Run folder (e.g., `Auto Run Docs/Project-Name/`) -- On completion, tab renamed to "Project: {SubfolderName}" -- Final AI message summarizes generated docs and next steps -- Same `agentSessionId` preserved for context continuity - -### Architecture - -``` -src/renderer/components/InlineWizard/ -├── WizardConversationView.tsx # Conversation phase UI -├── WizardInputPanel.tsx # Input with confidence gauge -├── DocumentGenerationView.tsx # Generation phase with Austin Facts -└── ... (see index.ts for full documentation) - -src/renderer/hooks/useInlineWizard.ts # Main hook -src/renderer/contexts/InlineWizardContext.tsx # State provider -``` - -### Customization Points - -| What | Where | -|------|-------| -| Modify inline wizard prompts | `src/prompts/wizard-*.md` | -| Change confidence threshold | `READY_CONFIDENCE_THRESHOLD` in wizardPrompts.ts | -| Modify generation UI | `DocumentGenerationView.tsx`, `AustinFactsDisplay.tsx` | - -## Usage Dashboard - -The Usage Dashboard (`src/renderer/components/UsageDashboard/`) provides analytics and visualizations for AI agent usage. - -### Architecture - -``` -src/renderer/components/UsageDashboard/ -├── UsageDashboardModal.tsx # Main modal with view tabs (Overview, Agents, Activity, AutoRun) -├── SummaryCards.tsx # Metric cards (queries, duration, cost, Auto Runs) -├── AgentComparisonChart.tsx # Bar chart comparing agent usage -├── SourceDistributionChart.tsx # Pie chart for user vs auto queries -├── ActivityHeatmap.tsx # Weekly activity heatmap (GitHub-style) -├── DurationTrendsChart.tsx # Line chart for duration over time -├── AutoRunStats.tsx # Auto Run-specific statistics -├── ChartSkeletons.tsx # Loading skeleton components -├── ChartErrorBoundary.tsx # Error boundary with retry -└── EmptyState.tsx # Empty state when no data -``` - -### Backend Components - -``` -src/main/ -├── stats-db.ts # SQLite database (better-sqlite3) with WAL mode -│ ├── query_events table # AI queries with duration, tokens, cost -│ ├── auto_run_sessions table # Auto Run session tracking -│ ├── auto_run_tasks table # Individual task tracking -│ └── _migrations table # Schema migration tracking -├── ipc/handlers/stats.ts # IPC handlers for stats operations -└── utils/statsCache.ts # Query result caching -``` - -### Key Patterns - -**Real-time Updates:** -```typescript -// Backend broadcasts after each database write -mainWindow?.webContents.send('stats:updated'); - -// Frontend subscribes with debouncing -useEffect(() => { - const unsubscribe = window.maestro.stats.onStatsUpdated(() => { - debouncedRefresh(); - }); - return () => unsubscribe?.(); -}, []); -``` - -**Colorblind-Friendly Palettes:** -```typescript -import { COLORBLIND_AGENT_PALETTE, getColorBlindAgentColor } from '../constants/colorblindPalettes'; -// Wong-based palette with high contrast for accessibility -``` - -**Chart Error Boundaries:** -```typescript - - - -``` - -### Related Settings - -```typescript -// In useSettings.ts -statsCollectionEnabled: boolean // Enable/disable stats collection (default: true) -defaultStatsTimeRange: 'day' | 'week' | 'month' | 'year' | 'all' // Default time filter -colorBlindMode: boolean // Use accessible color palettes -preventSleepEnabled: boolean // Prevent system sleep while agents are busy (default: false) -``` - -## Document Graph - -The Document Graph (`src/renderer/components/DocumentGraph/`) visualizes markdown file relationships and wiki-link connections using React Flow. - -### Architecture - -``` -src/renderer/components/DocumentGraph/ -├── DocumentGraphView.tsx # Main modal with React Flow canvas -├── DocumentNode.tsx # Document file node component -├── ExternalLinkNode.tsx # External URL domain node -├── NodeContextMenu.tsx # Right-click context menu -├── NodeBreadcrumb.tsx # Path breadcrumb for selected node -├── GraphLegend.tsx # Collapsible legend explaining node/edge types -├── graphDataBuilder.ts # Scans directory, extracts links, builds graph data -└── layoutAlgorithms.ts # Force-directed and hierarchical layout algorithms - -src/renderer/utils/ -├── markdownLinkParser.ts # Parses [[wiki-links]] and [markdown](links) -└── documentStats.ts # Computes document statistics (word count, etc.) - -src/main/ipc/handlers/ -└── documentGraph.ts # Chokidar file watcher for real-time updates -``` - -### Key Patterns - -**Building Graph Data:** -```typescript -import { buildGraphData } from './graphDataBuilder'; -const { nodes, edges, stats } = await buildGraphData( - rootPath, - showExternalLinks, - maxNodes, - offset, - progressCallback -); -``` - -**Layout Algorithms:** -```typescript -import { applyForceLayout, applyHierarchicalLayout, animateLayoutTransition } from './layoutAlgorithms'; -const positionedNodes = layoutMode === 'force' - ? applyForceLayout(nodes, edges) - : applyHierarchicalLayout(nodes, edges); -animateLayoutTransition(currentNodes, positionedNodes, setNodes, savePositions); -``` - -**Node Animation (additions/removals):** -```typescript -import { diffNodes, animateNodesEntering, animateNodesExiting } from './layoutAlgorithms'; -const { added, removed, stable } = diffNodes(previousNodes, newNodes); -animateNodesExiting(removed, () => animateNodesEntering(added)); -``` - -**Real-time File Watching:** -```typescript -// Backend watches for .md file changes -window.maestro.documentGraph.watchFolder(rootPath); -window.maestro.documentGraph.onFilesChanged((changes) => { - debouncedRebuildGraph(); -}); -// Cleanup on modal close -window.maestro.documentGraph.unwatchFolder(rootPath); -``` - -**Keyboard Navigation:** -```typescript -// Arrow keys navigate to connected nodes (spatial detection) -// Enter opens selected node -// Tab cycles through connected nodes -// Escape closes modal -``` - -### Large File Handling - -Files over 1MB are truncated to first 100KB for link extraction to prevent UI blocking: -```typescript -const LARGE_FILE_THRESHOLD = 1 * 1024 * 1024; // 1MB -const LARGE_FILE_PARSE_LIMIT = 100 * 1024; // 100KB -``` - -### Pagination - -Default limit of 50 nodes with "Load more" for large directories: -```typescript -const DEFAULT_MAX_NODES = 50; -const LOAD_MORE_INCREMENT = 25; -``` - -### Related Settings - -```typescript -// In useSettings.ts -documentGraphShowExternalLinks: boolean // Show external link nodes (default: false) -documentGraphMaxNodes: number // Initial pagination limit (50-1000, default: 50) -``` +--- ## Debugging @@ -1208,6 +185,8 @@ documentGraphMaxNodes: number // Initial pagination limit ( 1. Register with layer stack (don't handle Escape locally) 2. Check priority is set correctly +--- + ## MCP Server Maestro provides a hosted MCP (Model Context Protocol) server for AI applications to search the documentation. diff --git a/src/__tests__/main/ipc/handlers/process.test.ts b/src/__tests__/main/ipc/handlers/process.test.ts index dfabdc22..6d179123 100644 --- a/src/__tests__/main/ipc/handlers/process.test.ts +++ b/src/__tests__/main/ipc/handlers/process.test.ts @@ -321,6 +321,68 @@ describe('process IPC handlers', () => { expect(mockProcessManager.spawn).toHaveBeenCalled(); }); + it('should use sessionCustomPath for local execution when provided', async () => { + // When user sets a custom path for a session, it should be used for the command + // This allows users to use a different binary (e.g., a wrapper script) + const mockAgent = { + id: 'claude-code', + name: 'Claude Code', + binaryName: 'claude', + path: '/usr/local/bin/claude', // Detected path + requiresPty: true, + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true }); + + const handler = handlers.get('process:spawn'); + await handler!({} as any, { + sessionId: 'session-custom-path', + toolType: 'claude-code', + cwd: '/test/project', + command: '/usr/local/bin/claude', // Original detected command + args: ['--print', '--verbose'], + sessionCustomPath: '/home/user/my-claude-wrapper', // User's custom path + }); + + // Should use the custom path, not the original command + expect(mockProcessManager.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: '/home/user/my-claude-wrapper', + }) + ); + }); + + it('should use original command when sessionCustomPath is not provided', async () => { + const mockAgent = { + id: 'claude-code', + name: 'Claude Code', + binaryName: 'claude', + path: '/usr/local/bin/claude', + requiresPty: true, + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true }); + + const handler = handlers.get('process:spawn'); + await handler!({} as any, { + sessionId: 'session-no-custom-path', + toolType: 'claude-code', + cwd: '/test/project', + command: '/usr/local/bin/claude', + args: ['--print', '--verbose'], + // No sessionCustomPath provided + }); + + // Should use the original command + expect(mockProcessManager.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: '/usr/local/bin/claude', + }) + ); + }); + it('should use default shell for terminal sessions', async () => { const mockAgent = { id: 'terminal', requiresPty: true }; diff --git a/src/__tests__/renderer/components/FileExplorerPanel.test.tsx b/src/__tests__/renderer/components/FileExplorerPanel.test.tsx index ffe8cf59..d7ba4bc8 100644 --- a/src/__tests__/renderer/components/FileExplorerPanel.test.tsx +++ b/src/__tests__/renderer/components/FileExplorerPanel.test.tsx @@ -1010,8 +1010,8 @@ describe('FileExplorerPanel', () => { }); describe('Empty State', () => { - it('shows loading message when fileTree is empty and no error', () => { - const session = createMockSession({ fileTree: [] }); + it('shows loading message when fileTreeLoading is true', () => { + const session = createMockSession({ fileTree: [], fileTreeLoading: true }); render( { expect(screen.getByText('Loading files...')).toBeInTheDocument(); }); - it('shows loading message when fileTree is null', () => { - const session = createMockSession({ fileTree: undefined as any }); + it('shows no files found when fileTree is empty and not loading', () => { + const session = createMockSession({ fileTree: [], fileTreeLoading: false }); + render( + + ); + + expect(screen.getByText('No files found')).toBeInTheDocument(); + }); + + it('shows no files found when fileTree is null and not loading', () => { + const session = createMockSession({ fileTree: undefined as any, fileTreeLoading: false }); render( { /> ); - expect(screen.getByText('Loading files...')).toBeInTheDocument(); + expect(screen.getByText('No files found')).toBeInTheDocument(); }); }); @@ -1212,8 +1225,9 @@ describe('FileExplorerPanel', () => { }); it('handles empty filteredFileTree', () => { - render(); - expect(screen.getByText('Loading files...')).toBeInTheDocument(); + const session = createMockSession({ fileTree: [], fileTreeLoading: false }); + render(); + expect(screen.getByText('No files found')).toBeInTheDocument(); }); it('handles null previewFile', () => { diff --git a/src/__tests__/renderer/components/NewInstanceModal.test.tsx b/src/__tests__/renderer/components/NewInstanceModal.test.tsx index 71e3a8c7..f60d5cb4 100644 --- a/src/__tests__/renderer/components/NewInstanceModal.test.tsx +++ b/src/__tests__/renderer/components/NewInstanceModal.test.tsx @@ -205,8 +205,9 @@ describe('NewInstanceModal', () => { }); fireEvent.click(screen.getByText('Claude Code')); + // Path is now pre-filled in the input field, not displayed as separate text await waitFor(() => { - expect(screen.getByText('/usr/bin/claude')).toBeInTheDocument(); + expect(screen.getByDisplayValue('/usr/bin/claude')).toBeInTheDocument(); }); }); @@ -1211,7 +1212,7 @@ describe('NewInstanceModal', () => { }); describe('Custom agent paths', () => { - it('should display custom path input for Claude Code agent', async () => { + it('should display path input for Claude Code agent', async () => { vi.mocked(window.maestro.agents.detect).mockResolvedValue([ createAgentConfig({ id: 'claude-code', name: 'Claude Code', available: true }), ]); @@ -1232,9 +1233,10 @@ describe('NewInstanceModal', () => { }); fireEvent.click(screen.getByText('Claude Code')); + // Path section now shows "Path" label (not "Custom Path (optional)") await waitFor(() => { expect(screen.getByPlaceholderText('/path/to/claude')).toBeInTheDocument(); - expect(screen.getByText('Custom Path (optional)')).toBeInTheDocument(); + expect(screen.getByText('Path')).toBeInTheDocument(); }); }); @@ -1382,8 +1384,9 @@ describe('NewInstanceModal', () => { }); it('should call onCreate with custom path for previously unavailable agent', async () => { + // Agent is unavailable and has no detected path vi.mocked(window.maestro.agents.detect).mockResolvedValue([ - createAgentConfig({ id: 'claude-code', name: 'Claude Code', available: false }), + createAgentConfig({ id: 'claude-code', name: 'Claude Code', available: false, path: null }), ]); render( @@ -1410,7 +1413,7 @@ describe('NewInstanceModal', () => { // Set custom path - this makes the Create button enabled const customPathInput = screen.getByPlaceholderText('/path/to/claude'); - fireEvent.change(customPathInput, { target: { value: '/usr/local/bin/claude' } }); + fireEvent.change(customPathInput, { target: { value: '/custom/bin/claude' } }); // Fill in required fields const nameInput = screen.getByLabelText('Agent Name'); @@ -1431,7 +1434,7 @@ describe('NewInstanceModal', () => { '/my/project', 'Custom Path Agent', undefined, - '/usr/local/bin/claude', + '/custom/bin/claude', undefined, undefined, undefined, @@ -1441,9 +1444,9 @@ describe('NewInstanceModal', () => { ); }); - it('should clear custom path in local state when clear button is clicked', async () => { + it('should reset custom path to detected path when Reset button is clicked', async () => { vi.mocked(window.maestro.agents.detect).mockResolvedValue([ - createAgentConfig({ id: 'claude-code', name: 'Claude Code', available: true }), + createAgentConfig({ id: 'claude-code', name: 'Claude Code', available: true, path: '/detected/bin/claude' }), ]); render( @@ -1462,29 +1465,30 @@ describe('NewInstanceModal', () => { }); fireEvent.click(screen.getByText('Claude Code')); + // Path input should be pre-filled with detected path await waitFor(() => { - expect(screen.getByPlaceholderText('/path/to/claude')).toBeInTheDocument(); + expect(screen.getByDisplayValue('/detected/bin/claude')).toBeInTheDocument(); }); - // Set custom path first - const customPathInput = screen.getByPlaceholderText('/path/to/claude'); + // Set a different custom path + const customPathInput = screen.getByDisplayValue('/detected/bin/claude'); fireEvent.change(customPathInput, { target: { value: '/custom/path' } }); await waitFor(() => { expect(customPathInput).toHaveValue('/custom/path'); }); - // Clear button should appear when there's a value + // Reset button should appear when custom path differs from detected path await waitFor(() => { - expect(screen.getByText('Clear')).toBeInTheDocument(); + expect(screen.getByText('Reset')).toBeInTheDocument(); }); await act(async () => { - fireEvent.click(screen.getByText('Clear')); + fireEvent.click(screen.getByText('Reset')); }); - // Custom path should be cleared in local state - expect(customPathInput).toHaveValue(''); + // Path should be reset to detected path + expect(customPathInput).toHaveValue('/detected/bin/claude'); }); }); diff --git a/src/__tests__/renderer/components/StandingOvationOverlay.test.tsx b/src/__tests__/renderer/components/StandingOvationOverlay.test.tsx index 1d8a0464..ff38c29b 100644 --- a/src/__tests__/renderer/components/StandingOvationOverlay.test.tsx +++ b/src/__tests__/renderer/components/StandingOvationOverlay.test.tsx @@ -892,6 +892,23 @@ describe('StandingOvationOverlay', () => { }) ); }); + + it('does not fire confetti when disableConfetti is true', () => { + mockConfetti.mockClear(); + render( + + ); + + // Confetti should not be called + expect(mockConfetti).not.toHaveBeenCalled(); + }); }); describe('Theme Styling', () => { diff --git a/src/__tests__/renderer/components/shared/AgentConfigPanel.test.tsx b/src/__tests__/renderer/components/shared/AgentConfigPanel.test.tsx index 5a577864..5a22102d 100644 --- a/src/__tests__/renderer/components/shared/AgentConfigPanel.test.tsx +++ b/src/__tests__/renderer/components/shared/AgentConfigPanel.test.tsx @@ -174,17 +174,39 @@ describe('AgentConfigPanel', () => { }); describe('Agent configuration sections', () => { - it('should render detected path when agent.path is provided', () => { + it('should render path input pre-filled with detected path', () => { render(); - expect(screen.getByText(/Detected:/)).toBeInTheDocument(); - expect(screen.getByText('/usr/local/bin/claude')).toBeInTheDocument(); + expect(screen.getByText('Path')).toBeInTheDocument(); + // The input should be pre-filled with the detected path + const pathInput = screen.getByDisplayValue('/usr/local/bin/claude'); + expect(pathInput).toBeInTheDocument(); }); - it('should render custom path input section', () => { - render(); + it('should show custom path when provided, not detected path', () => { + render(); - expect(screen.getByText('Custom Path (optional)')).toBeInTheDocument(); + // The input should show the custom path + const pathInput = screen.getByDisplayValue('/custom/path/to/claude'); + expect(pathInput).toBeInTheDocument(); + }); + + it('should show Reset button when custom path differs from detected path', () => { + render(); + + expect(screen.getByText('Reset')).toBeInTheDocument(); + }); + + it('should NOT show Reset button when custom path matches detected path', () => { + render(); + + expect(screen.queryByText('Reset')).not.toBeInTheDocument(); + }); + + it('should NOT show Reset button when no custom path is set', () => { + render(); + + expect(screen.queryByText('Reset')).not.toBeInTheDocument(); }); it('should render custom arguments input section', () => { diff --git a/src/__tests__/renderer/hooks/useSettings.test.ts b/src/__tests__/renderer/hooks/useSettings.test.ts index 062dad7a..6edcc3ff 100644 --- a/src/__tests__/renderer/hooks/useSettings.test.ts +++ b/src/__tests__/renderer/hooks/useSettings.test.ts @@ -119,6 +119,14 @@ describe('useSettings', () => { expect(result.current.logViewerSelectedLevels).toEqual(['debug', 'info', 'warn', 'error', 'toast']); }); + it('should have correct default values for rendering settings', async () => { + const { result } = renderHook(() => useSettings()); + await waitForSettingsLoaded(result); + + expect(result.current.disableGpuAcceleration).toBe(false); + expect(result.current.disableConfetti).toBe(false); + }); + it('should have default shortcuts', async () => { const { result } = renderHook(() => useSettings()); await waitForSettingsLoaded(result); @@ -349,6 +357,19 @@ describe('useSettings', () => { expect(result.current.logLevel).toBe('debug'); expect(result.current.maxLogBuffer).toBe(10000); }); + + it('should load rendering settings from saved values', async () => { + vi.mocked(window.maestro.settings.getAll).mockResolvedValue({ + disableGpuAcceleration: true, + disableConfetti: true, + }); + + const { result } = renderHook(() => useSettings()); + await waitForSettingsLoaded(result); + + expect(result.current.disableGpuAcceleration).toBe(true); + expect(result.current.disableConfetti).toBe(true); + }); }); describe('setter functions - LLM settings', () => { @@ -681,6 +702,32 @@ describe('useSettings', () => { }); }); + describe('setter functions - rendering settings', () => { + it('should update disableGpuAcceleration and persist to settings', async () => { + const { result } = renderHook(() => useSettings()); + await waitForSettingsLoaded(result); + + act(() => { + result.current.setDisableGpuAcceleration(true); + }); + + expect(result.current.disableGpuAcceleration).toBe(true); + expect(window.maestro.settings.set).toHaveBeenCalledWith('disableGpuAcceleration', true); + }); + + it('should update disableConfetti and persist to settings', async () => { + const { result } = renderHook(() => useSettings()); + await waitForSettingsLoaded(result); + + act(() => { + result.current.setDisableConfetti(true); + }); + + expect(result.current.disableConfetti).toBe(true); + expect(window.maestro.settings.set).toHaveBeenCalledWith('disableConfetti', true); + }); + }); + describe('global stats', () => { it('should update globalStats with setGlobalStats', async () => { const { result } = renderHook(() => useSettings()); diff --git a/src/main/index.ts b/src/main/index.ts index 3cbf9692..31485374 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -112,11 +112,19 @@ console.log(`[STARTUP] productionDataPath (agent configs): ${productionDataPath} // Initialize Sentry for crash reporting // Only enable in production - skip during development to avoid noise from hot-reload artifacts // Check if crash reporting is enabled (default: true for opt-out behavior) -const crashReportingStore = new Store<{ crashReportingEnabled: boolean }>({ +const earlySettingsStore = new Store<{ crashReportingEnabled: boolean; disableGpuAcceleration: boolean }>({ name: 'maestro-settings', cwd: syncPath, // Use same path as main settings store }); -const crashReportingEnabled = crashReportingStore.get('crashReportingEnabled', true); +const crashReportingEnabled = earlySettingsStore.get('crashReportingEnabled', true); + +// Disable GPU hardware acceleration if user has opted out +// Must be called before app.ready event +const disableGpuAcceleration = earlySettingsStore.get('disableGpuAcceleration', false); +if (disableGpuAcceleration) { + app.disableHardwareAcceleration(); + console.log('[STARTUP] GPU hardware acceleration disabled by user preference'); +} if (crashReportingEnabled && !isDevelopment) { Sentry.init({ diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts index 51fd0c48..08fc98bf 100644 --- a/src/main/ipc/handlers/process.ts +++ b/src/main/ipc/handlers/process.ts @@ -222,12 +222,24 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void // Falls back to the agent's configOptions default (e.g., 400000 for Codex, 128000 for OpenCode) const contextWindow = getContextWindowValue(agent, agentConfigValues, config.sessionCustomContextWindow); + // ======================================================================== + // Command Resolution: Apply session-level custom path override if set + // This allows users to override the detected agent path per-session + // ======================================================================== + let commandToSpawn = config.sessionCustomPath || config.command; + let argsToSpawn = finalArgs; + + if (config.sessionCustomPath) { + logger.debug(`Using session-level custom path for ${config.toolType}`, LOG_CONTEXT, { + customPath: config.sessionCustomPath, + originalCommand: config.command, + }); + } + // ======================================================================== // SSH Remote Execution: Detect and wrap command for remote execution // Terminal sessions are always local (they need PTY for shell interaction) // ======================================================================== - let commandToSpawn = config.command; - let argsToSpawn = finalArgs; let sshRemoteUsed: SshRemoteConfig | null = null; // Only consider SSH remote for non-terminal AI agent sessions diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9f3e524c..40257a32 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -335,6 +335,8 @@ function MaestroConsoleInner() { documentGraphMaxNodes, documentGraphPreviewCharLimit, + // Rendering settings + disableConfetti, } = settings; // --- KEYBOARD SHORTCUT HELPERS --- @@ -9843,6 +9845,7 @@ You are taking over this conversation. Based on the context above, provide a bri pendingKeyboardMasteryLevel={pendingKeyboardMasteryLevel} onCloseKeyboardMastery={handleKeyboardMasteryCelebrationClose} shortcuts={shortcuts} + disableConfetti={disableConfetti} /> {/* --- DEVELOPER PLAYGROUND --- */} diff --git a/src/renderer/components/AppOverlays.tsx b/src/renderer/components/AppOverlays.tsx index bb14fdbb..18d72257 100644 --- a/src/renderer/components/AppOverlays.tsx +++ b/src/renderer/components/AppOverlays.tsx @@ -57,6 +57,9 @@ export interface AppOverlaysProps { pendingKeyboardMasteryLevel: number | null; onCloseKeyboardMastery: () => void; shortcuts: Record; + + // Rendering settings + disableConfetti?: boolean; } /** @@ -78,6 +81,7 @@ export function AppOverlays({ pendingKeyboardMasteryLevel, onCloseKeyboardMastery, shortcuts, + disableConfetti = false, }: AppOverlaysProps): JSX.Element { return ( <> @@ -91,6 +95,7 @@ export function AppOverlays({ onClose={onCloseFirstRun} onOpenLeaderboardRegistration={onOpenLeaderboardRegistration} isLeaderboardRegistered={isLeaderboardRegistered} + disableConfetti={disableConfetti} /> )} @@ -101,6 +106,7 @@ export function AppOverlays({ level={pendingKeyboardMasteryLevel} onClose={onCloseKeyboardMastery} shortcuts={shortcuts} + disableConfetti={disableConfetti} /> )} @@ -116,6 +122,7 @@ export function AppOverlays({ onClose={onCloseStandingOvation} onOpenLeaderboardRegistration={onOpenLeaderboardRegistration} isLeaderboardRegistered={isLeaderboardRegistered} + disableConfetti={disableConfetti} /> )} diff --git a/src/renderer/components/FileExplorerPanel.tsx b/src/renderer/components/FileExplorerPanel.tsx index 4b8c94a3..aa456f17 100644 --- a/src/renderer/components/FileExplorerPanel.tsx +++ b/src/renderer/components/FileExplorerPanel.tsx @@ -1003,14 +1003,23 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) { ) : ( <> - {/* Show loading progress when file tree is loading */} - {(session.fileTreeLoading || (!session.fileTree || session.fileTree.length === 0)) && ( + {/* Show loading progress when file tree is actively loading */} + {session.fileTreeLoading && ( )} + {/* Show empty state when loading is complete but no files found */} + {!session.fileTreeLoading && (!session.fileTree || session.fileTree.length === 0) && !fileTreeFilter && ( +
+ +
+ No files found +
+
+ )} {flattenedTree.length > 0 && (
void; /** Whether the user is already registered for the leaderboard */ isLeaderboardRegistered?: boolean; + /** Whether confetti animations are disabled by user preference */ + disableConfetti?: boolean; } /** @@ -84,6 +86,7 @@ export function FirstRunCelebration({ onClose, onOpenLeaderboardRegistration, isLeaderboardRegistered, + disableConfetti = false, }: FirstRunCelebrationProps): JSX.Element { const containerRef = useRef(null); const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack(); @@ -105,6 +108,9 @@ export function FirstRunCelebration({ // Fire confetti burst const fireConfetti = useCallback(() => { + // Skip if disabled by user preference + if (disableConfetti) return; + // Check for reduced motion preference const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (prefersReducedMotion) return; @@ -160,7 +166,7 @@ export function FirstRunCelebration({ }); }, 500); } - }, [isStandingOvation, goldColor]); + }, [isStandingOvation, goldColor, disableConfetti]); // Fire confetti on mount useEffect(() => { diff --git a/src/renderer/components/InlineWizard/GenerationCompleteOverlay.tsx b/src/renderer/components/InlineWizard/GenerationCompleteOverlay.tsx index 0e96dc18..4d3848f1 100644 --- a/src/renderer/components/InlineWizard/GenerationCompleteOverlay.tsx +++ b/src/renderer/components/InlineWizard/GenerationCompleteOverlay.tsx @@ -20,6 +20,8 @@ export interface GenerationCompleteOverlayProps { taskCount: number; /** Called when user clicks Done - triggers confetti and completes wizard */ onDone: () => void; + /** Whether confetti animations are disabled by user preference */ + disableConfetti?: boolean; } /** @@ -36,6 +38,7 @@ export function GenerationCompleteOverlay({ theme, taskCount, onDone, + disableConfetti = false, }: GenerationCompleteOverlayProps): JSX.Element { const [isClosing, setIsClosing] = useState(false); @@ -43,14 +46,14 @@ export function GenerationCompleteOverlay({ if (isClosing) return; // Prevent double-clicks setIsClosing(true); - // Trigger celebratory confetti burst - triggerCelebration(); + // Trigger celebratory confetti burst (if not disabled) + triggerCelebration(disableConfetti); // Wait 500ms for confetti to be visible, then call completion callback setTimeout(() => { onDone(); }, 500); - }, [isClosing, onDone]); + }, [isClosing, onDone, disableConfetti]); return (
void; shortcuts?: Record; + /** Whether confetti animations are disabled by user preference */ + disableConfetti?: boolean; } // Music-themed colors @@ -61,6 +63,7 @@ export function KeyboardMasteryCelebration({ level, onClose, shortcuts, + disableConfetti = false, }: KeyboardMasteryCelebrationProps): JSX.Element { const containerRef = useRef(null); const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack(); @@ -89,6 +92,9 @@ export function KeyboardMasteryCelebration({ // Fire confetti burst - returns timeout ID for cleanup const fireConfetti = useCallback(() => { + // Skip if disabled by user preference + if (disableConfetti) return; + // Check for reduced motion preference const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (prefersReducedMotion) return; @@ -127,7 +133,7 @@ export function KeyboardMasteryCelebration({ }, 300); timeoutsRef.current.push(burstTimeout); } - }, [level, isMaestro]); + }, [level, isMaestro, disableConfetti]); // Fire confetti on mount with cleanup useEffect(() => { diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index 6cff791d..1a79e5dd 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, memo } from 'react'; -import { X, Key, Moon, Sun, Keyboard, Check, Terminal, Bell, Cpu, Settings, Palette, Sparkles, History, Download, Bug, Cloud, FolderSync, RotateCcw, Folder, ChevronDown, Plus, Trash2, Brain, AlertTriangle, FlaskConical, Database, Server, Battery } from 'lucide-react'; +import { X, Key, Moon, Sun, Keyboard, Check, Terminal, Bell, Cpu, Settings, Palette, Sparkles, History, Download, Bug, Cloud, FolderSync, RotateCcw, Folder, ChevronDown, Plus, Trash2, Brain, AlertTriangle, FlaskConical, Database, Server, Battery, Monitor, PartyPopper } from 'lucide-react'; import { useSettings } from '../hooks'; import type { Theme, ThemeColors, ThemeId, Shortcut, ShellInfo, CustomAICommand, LLMProvider } from '../types'; import { CustomThemeBuilder } from './CustomThemeBuilder'; @@ -246,6 +246,11 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro // Power management settings preventSleepEnabled, setPreventSleepEnabled, + // Rendering settings + disableGpuAcceleration, + setDisableGpuAcceleration, + disableConfetti, + setDisableConfetti, } = useSettings(); const [activeTab, setActiveTab] = useState<'general' | 'llm' | 'shortcuts' | 'theme' | 'notifications' | 'aicommands' | 'ssh'>('general'); @@ -1237,6 +1242,102 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
+ {/* Rendering Options */} +
+ +
+ {/* GPU Acceleration Toggle */} +
setDisableGpuAcceleration(!disableGpuAcceleration)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setDisableGpuAcceleration(!disableGpuAcceleration); + } + }} + > +
+
+ Disable GPU acceleration +
+
+ Use software rendering instead of GPU. Requires restart to take effect. +
+
+ +
+ + {/* Confetti Toggle */} +
setDisableConfetti(!disableConfetti)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setDisableConfetti(!disableConfetti); + } + }} + > +
+
+ + Disable confetti animations +
+
+ Skip celebratory confetti effects on achievements and milestones +
+
+ +
+
+
+ {/* Check for Updates on Startup */} void; onOpenLeaderboardRegistration?: () => void; isLeaderboardRegistered?: boolean; + /** Whether confetti animations are disabled by user preference */ + disableConfetti?: boolean; } /** @@ -34,6 +36,7 @@ export function StandingOvationOverlay({ onClose, onOpenLeaderboardRegistration, isLeaderboardRegistered, + disableConfetti = false, }: StandingOvationOverlayProps) { const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack(); const layerIdRef = useRef(); @@ -73,6 +76,9 @@ export function StandingOvationOverlay({ // Fire confetti from multiple origins with playground settings const fireConfetti = useCallback(() => { + // Skip if disabled by user preference + if (disableConfetti) return; + const defaults = { particleCount: 500, angle: 90, @@ -107,7 +113,7 @@ export function StandingOvationOverlay({ ...defaults, origin: { x: 1, y: 1 }, }); - }, [confettiColors]); + }, [confettiColors, disableConfetti]); // Fire confetti on mount only - empty deps to run once useEffect(() => { diff --git a/src/renderer/components/shared/AgentConfigPanel.tsx b/src/renderer/components/shared/AgentConfigPanel.tsx index 7ad03d0e..d561d2ed 100644 --- a/src/renderer/components/shared/AgentConfigPanel.tsx +++ b/src/renderer/components/shared/AgentConfigPanel.tsx @@ -346,40 +346,29 @@ export function AgentConfigPanel({ return (
- {/* Show detected path if available */} - {agent.path && ( -
-
- Detected: {agent.path} -
- {onRefreshAgent && ( - - )} -
- )} - - {/* Custom path input */} + {/* Path input - pre-filled with detected path, editable to override */}
-
diff --git a/src/renderer/hooks/settings/useSettings.ts b/src/renderer/hooks/settings/useSettings.ts index 7cd40d10..b3babc73 100644 --- a/src/renderer/hooks/settings/useSettings.ts +++ b/src/renderer/hooks/settings/useSettings.ts @@ -300,6 +300,12 @@ export interface UseSettingsReturn { // Power management settings preventSleepEnabled: boolean; setPreventSleepEnabled: (value: boolean) => Promise; + + // Rendering settings + disableGpuAcceleration: boolean; + setDisableGpuAcceleration: (value: boolean) => void; + disableConfetti: boolean; + setDisableConfetti: (value: boolean) => void; } export function useSettings(): UseSettingsReturn { @@ -423,6 +429,10 @@ export function useSettings(): UseSettingsReturn { // Power management settings const [preventSleepEnabled, setPreventSleepEnabledState] = useState(false); // Default: disabled + // Rendering settings + const [disableGpuAcceleration, setDisableGpuAccelerationState] = useState(false); // Default: disabled (GPU enabled) + const [disableConfetti, setDisableConfettiState] = useState(false); // Default: disabled (confetti enabled) + // Wrapper functions that persist to electron-store // PERF: All wrapped in useCallback to prevent re-renders const setLlmProvider = useCallback((value: LLMProvider) => { @@ -1166,6 +1176,18 @@ export function useSettings(): UseSettingsReturn { await window.maestro.power.setEnabled(value); }, []); + // GPU acceleration disabled (requires app restart to take effect) + const setDisableGpuAcceleration = useCallback((value: boolean) => { + setDisableGpuAccelerationState(value); + window.maestro.settings.set('disableGpuAcceleration', value); + }, []); + + // Confetti animations disabled + const setDisableConfetti = useCallback((value: boolean) => { + setDisableConfettiState(value); + window.maestro.settings.set('disableConfetti', value); + }, []); + // Load settings from electron-store on mount // PERF: Use batch loading to reduce IPC calls from ~60 to 3 useEffect(() => { @@ -1233,6 +1255,8 @@ export function useSettings(): UseSettingsReturn { const savedStatsCollectionEnabled = allSettings['statsCollectionEnabled']; const savedDefaultStatsTimeRange = allSettings['defaultStatsTimeRange']; const savedPreventSleepEnabled = allSettings['preventSleepEnabled']; + const savedDisableGpuAcceleration = allSettings['disableGpuAcceleration']; + const savedDisableConfetti = allSettings['disableConfetti']; if (savedEnterToSendAI !== undefined) setEnterToSendAIState(savedEnterToSendAI as boolean); if (savedEnterToSendTerminal !== undefined) setEnterToSendTerminalState(savedEnterToSendTerminal as boolean); @@ -1490,6 +1514,15 @@ export function useSettings(): UseSettingsReturn { setPreventSleepEnabledState(savedPreventSleepEnabled as boolean); } + // Rendering settings + // Note: GPU setting is read by the main process before app.ready, so changes require restart. + if (savedDisableGpuAcceleration !== undefined) { + setDisableGpuAccelerationState(savedDisableGpuAcceleration as boolean); + } + if (savedDisableConfetti !== undefined) { + setDisableConfettiState(savedDisableConfetti as boolean); + } + } catch (error) { console.error('[Settings] Failed to load settings:', error); } finally { @@ -1640,6 +1673,10 @@ export function useSettings(): UseSettingsReturn { setDefaultStatsTimeRange, preventSleepEnabled, setPreventSleepEnabled, + disableGpuAcceleration, + setDisableGpuAcceleration, + disableConfetti, + setDisableConfetti, }), [ // State values settingsLoaded, @@ -1771,5 +1808,9 @@ export function useSettings(): UseSettingsReturn { setDefaultStatsTimeRange, preventSleepEnabled, setPreventSleepEnabled, + disableGpuAcceleration, + setDisableGpuAcceleration, + disableConfetti, + setDisableConfetti, ]); } diff --git a/src/renderer/utils/confetti.ts b/src/renderer/utils/confetti.ts index 9bcb1690..1c900ab5 100644 --- a/src/renderer/utils/confetti.ts +++ b/src/renderer/utils/confetti.ts @@ -24,6 +24,8 @@ export interface ConfettiOptions { multiBurst?: boolean; /** Disable for users with reduced motion preference (default: true) */ respectReducedMotion?: boolean; + /** Skip confetti animation entirely (user preference from settings) */ + disabled?: boolean; } /** @@ -80,8 +82,14 @@ export function triggerConfetti(options: ConfettiOptions = {}): void { colors = DEFAULT_COLORS, multiBurst = true, respectReducedMotion = true, + disabled = false, } = options; + // Skip if disabled by user setting + if (disabled) { + return; + } + // Respect reduced motion preference if (respectReducedMotion) { const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; @@ -139,11 +147,18 @@ export function triggerConfetti(options: ConfettiOptions = {}): void { * Triggers an intense celebratory confetti burst with more particles * and additional star shapes. Great for major achievements. * + * @param disabled - Skip confetti animation entirely (user preference from settings) + * * @example * // For wizard completion or major milestones * triggerCelebration(); */ -export function triggerCelebration(): void { +export function triggerCelebration(disabled = false): void { + // Skip if disabled by user setting + if (disabled) { + return; + } + triggerConfetti({ particleCount: 300, spread: 100,