## CHANGES

- Split monolithic CLAUDE.md into focused, indexed sub-docs for faster onboarding 📚
- Added deep Agent support guide: capabilities, flags, parsers, storage, and adding agents 🤖
- Documented full `window.maestro` IPC surface with clearer namespaces and History API 📡
- Captured core implementation patterns: processes, security, settings, modals, SSH, Auto Run 🧭
- Published performance playbook for React, IPC batching, caching, and debouncing 🚀
- Formalized Session interface docs, including multi-tab, queues, stats, and error fields 🧩
- Session-level custom agent path now overrides detected binary during spawn 🛠️
- New rendering settings: disable GPU acceleration (early startup) and disable confetti 🖥️
- Confetti effects now respect user preference across celebration overlays and wizard completion 🎊
- File explorer now distinguishes “loading” vs “no files found” empty states 🗂️
This commit is contained in:
Pedram Amini
2026-01-13 11:14:59 -06:00
parent 34db91b8e4
commit f87d072f96
27 changed files with 1616 additions and 1136 deletions

73
CLAUDE-AGENTS.md Normal file
View File

@@ -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 <session-id>`
- **Read-only:** `--permission-mode plan`
- **Session Storage:** `~/.claude/projects/<encoded-path>/`
### OpenAI Codex
- **Binary:** `codex`
- **JSON Output:** `--json`
- **Batch Mode:** `exec` subcommand
- **Resume:** `resume <thread_id>` (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 <session-id>`
- **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.

175
CLAUDE-FEATURES.md Normal file
View File

@@ -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
<ChartErrorBoundary chartName="Agent Comparison" onRetry={handleRetry}>
<AgentComparisonChart data={data} colorBlindMode={colorBlindMode} />
</ChartErrorBoundary>
```
### 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)
```

86
CLAUDE-IPC.md Normal file
View File

@@ -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<HistoryEntry[]>,
add: (entry) => Promise<boolean>,
clear: (projectPath?, sessionId?) => Promise<boolean>,
delete: (entryId, sessionId?) => Promise<boolean>,
update: (entryId, updates, sessionId?) => Promise<boolean>,
// For AI context integration:
getFilePath: (sessionId) => Promise<string | null>,
listSessions: () => Promise<string[]>,
// External change detection:
onExternalChange: (handler) => () => void,
reload: () => Promise<boolean>,
};
```
**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

254
CLAUDE-PATTERNS.md Normal file
View File

@@ -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(
<div style={{ top: overlayPosition.top, left: overlayPosition.left }}>
{/* Overlay menu items */}
</div>,
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;
```

232
CLAUDE-PERFORMANCE.md Normal file
View File

@@ -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 <div style={tabStyle}>{displayName}</div>;
});
```
**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<string, string>();
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 <Context.Provider value={{ sessions, updateSession }}>{children}</Context.Provider>;
// GOOD: Memoized value only changes when dependencies change
const contextValue = useMemo(() => ({
sessions,
updateSession,
}), [sessions, updateSession]);
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
```
## Event Listener Cleanup
**Always clean up event listeners:**
```typescript
useEffect(() => {
const handler = (e: Event) => { /* ... */ };
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, []);
```

106
CLAUDE-SESSION.md Normal file
View File

@@ -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.

202
CLAUDE-WIZARD.md Normal file
View File

@@ -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
<div data-tour="autorun-panel">...</div>
// 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` |

1107
CLAUDE.md

File diff suppressed because it is too large Load Diff

View File

@@ -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 };

View File

@@ -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(
<FileExplorerPanel
{...defaultProps}
@@ -1023,8 +1023,21 @@ describe('FileExplorerPanel', () => {
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(
<FileExplorerPanel
{...defaultProps}
session={session}
filteredFileTree={[]}
/>
);
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(
<FileExplorerPanel
{...defaultProps}
@@ -1033,7 +1046,7 @@ describe('FileExplorerPanel', () => {
/>
);
expect(screen.getByText('Loading files...')).toBeInTheDocument();
expect(screen.getByText('No files found')).toBeInTheDocument();
});
});
@@ -1212,8 +1225,9 @@ describe('FileExplorerPanel', () => {
});
it('handles empty filteredFileTree', () => {
render(<FileExplorerPanel {...defaultProps} filteredFileTree={[]} />);
expect(screen.getByText('Loading files...')).toBeInTheDocument();
const session = createMockSession({ fileTree: [], fileTreeLoading: false });
render(<FileExplorerPanel {...defaultProps} session={session} filteredFileTree={[]} />);
expect(screen.getByText('No files found')).toBeInTheDocument();
});
it('handles null previewFile', () => {

View File

@@ -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');
});
});

View File

@@ -892,6 +892,23 @@ describe('StandingOvationOverlay', () => {
})
);
});
it('does not fire confetti when disableConfetti is true', () => {
mockConfetti.mockClear();
render(
<StandingOvationOverlay
theme={createTheme()}
themeMode="dark"
badge={createBadge()}
cumulativeTimeMs={3600000}
onClose={mockOnClose}
disableConfetti={true}
/>
);
// Confetti should not be called
expect(mockConfetti).not.toHaveBeenCalled();
});
});
describe('Theme Styling', () => {

View File

@@ -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(<AgentConfigPanel {...createDefaultProps()} />);
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(<AgentConfigPanel {...createDefaultProps()} />);
it('should show custom path when provided, not detected path', () => {
render(<AgentConfigPanel {...createDefaultProps({ customPath: '/custom/path/to/claude' })} />);
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(<AgentConfigPanel {...createDefaultProps({ customPath: '/custom/path/to/claude' })} />);
expect(screen.getByText('Reset')).toBeInTheDocument();
});
it('should NOT show Reset button when custom path matches detected path', () => {
render(<AgentConfigPanel {...createDefaultProps({ customPath: '/usr/local/bin/claude' })} />);
expect(screen.queryByText('Reset')).not.toBeInTheDocument();
});
it('should NOT show Reset button when no custom path is set', () => {
render(<AgentConfigPanel {...createDefaultProps({ customPath: '' })} />);
expect(screen.queryByText('Reset')).not.toBeInTheDocument();
});
it('should render custom arguments input section', () => {

View File

@@ -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());

View File

@@ -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({

View File

@@ -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

View File

@@ -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 --- */}

View File

@@ -57,6 +57,9 @@ export interface AppOverlaysProps {
pendingKeyboardMasteryLevel: number | null;
onCloseKeyboardMastery: () => void;
shortcuts: Record<string, Shortcut>;
// 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}
/>
)}
</>

View File

@@ -1003,14 +1003,23 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) {
</div>
) : (
<>
{/* 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 && (
<FileTreeLoadingProgress
theme={theme}
progress={session.fileTreeLoadingProgress}
isRemote={!!(session.sshRemoteId || session.sessionSshRemoteConfig?.enabled)}
/>
)}
{/* Show empty state when loading is complete but no files found */}
{!session.fileTreeLoading && (!session.fileTree || session.fileTree.length === 0) && !fileTreeFilter && (
<div className="flex flex-col items-center justify-center gap-2 py-8">
<Folder className="w-8 h-8 opacity-30" style={{ color: theme.colors.textDim }} />
<div className="text-xs opacity-50 text-center" style={{ color: theme.colors.textDim }}>
No files found
</div>
</div>
)}
{flattenedTree.length > 0 && (
<div
ref={parentRef}

View File

@@ -44,6 +44,8 @@ interface FirstRunCelebrationProps {
onOpenLeaderboardRegistration?: () => 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<HTMLDivElement>(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(() => {

View File

@@ -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 (
<div

View File

@@ -20,6 +20,8 @@ interface KeyboardMasteryCelebrationProps {
level: number; // 0-4 (Beginner, Student, Performer, Virtuoso, Maestro)
onClose: () => void;
shortcuts?: Record<string, Shortcut>;
/** 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<HTMLDivElement>(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(() => {

View File

@@ -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
</div>
</div>
{/* Rendering Options */}
<div>
<label className="block text-xs font-bold opacity-70 uppercase mb-2 flex items-center gap-2">
<Monitor className="w-3 h-3" />
Rendering Options
</label>
<div
className="p-3 rounded border space-y-3"
style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}
>
{/* GPU Acceleration Toggle */}
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => setDisableGpuAcceleration(!disableGpuAcceleration)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setDisableGpuAcceleration(!disableGpuAcceleration);
}
}}
>
<div className="flex-1 pr-3">
<div className="font-medium" style={{ color: theme.colors.textMain }}>
Disable GPU acceleration
</div>
<div className="text-xs opacity-50 mt-0.5" style={{ color: theme.colors.textDim }}>
Use software rendering instead of GPU. Requires restart to take effect.
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setDisableGpuAcceleration(!disableGpuAcceleration);
}}
className="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
style={{
backgroundColor: disableGpuAcceleration ? theme.colors.accent : theme.colors.bgActivity,
}}
role="switch"
aria-checked={disableGpuAcceleration}
>
<span
className={`absolute left-0 top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
disableGpuAcceleration ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
</div>
{/* Confetti Toggle */}
<div
className="flex items-center justify-between cursor-pointer pt-3 border-t"
style={{ borderColor: theme.colors.border }}
onClick={() => setDisableConfetti(!disableConfetti)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setDisableConfetti(!disableConfetti);
}
}}
>
<div className="flex-1 pr-3">
<div className="font-medium flex items-center gap-2" style={{ color: theme.colors.textMain }}>
<PartyPopper className="w-4 h-4" />
Disable confetti animations
</div>
<div className="text-xs opacity-50 mt-0.5" style={{ color: theme.colors.textDim }}>
Skip celebratory confetti effects on achievements and milestones
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setDisableConfetti(!disableConfetti);
}}
className="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
style={{
backgroundColor: disableConfetti ? theme.colors.accent : theme.colors.bgActivity,
}}
role="switch"
aria-checked={disableConfetti}
>
<span
className={`absolute left-0 top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
disableConfetti ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</div>
</div>
{/* Check for Updates on Startup */}
<SettingCheckbox
icon={Download}

View File

@@ -18,6 +18,8 @@ interface StandingOvationOverlayProps {
onClose: () => 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<string>();
@@ -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(() => {

View File

@@ -346,40 +346,29 @@ export function AgentConfigPanel({
return (
<div className={spacing}>
{/* Show detected path if available */}
{agent.path && (
<div
className="text-xs font-mono px-3 py-2 rounded flex items-center justify-between"
style={{ backgroundColor: theme.colors.bgActivity, color: theme.colors.textDim }}
>
<div>
<span className="opacity-60">Detected:</span> {agent.path}
</div>
{onRefreshAgent && (
<button
onClick={onRefreshAgent}
className="p-1 rounded hover:bg-white/10 transition-colors ml-2"
title="Refresh detection"
style={{ color: theme.colors.textDim }}
>
<RefreshCw className={`w-3 h-3 ${refreshingAgent ? 'animate-spin' : ''}`} />
</button>
)}
</div>
)}
{/* Custom path input */}
{/* Path input - pre-filled with detected path, editable to override */}
<div
className={`${padding} rounded border`}
style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}
>
<label className="block text-xs font-medium mb-2" style={{ color: theme.colors.textDim }}>
Custom Path (optional)
<label className="block text-xs font-medium mb-2 flex items-center justify-between" style={{ color: theme.colors.textDim }}>
<span>Path</span>
{onRefreshAgent && (
<button
onClick={onRefreshAgent}
className="p-1 rounded hover:bg-white/10 transition-colors flex items-center gap-1"
title="Re-detect agent path"
style={{ color: theme.colors.textDim }}
>
<RefreshCw className={`w-3 h-3 ${refreshingAgent ? 'animate-spin' : ''}`} />
<span className="text-xs">Detect</span>
</button>
)}
</label>
<div className="flex gap-2">
<input
type="text"
value={customPath}
value={customPath || agent.path || ''}
onChange={(e) => onCustomPathChange(e.target.value)}
onBlur={onCustomPathBlur}
onClick={(e) => e.stopPropagation()}
@@ -387,7 +376,7 @@ export function AgentConfigPanel({
className="flex-1 p-2 rounded border bg-transparent outline-none text-xs font-mono"
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
/>
{customPath && (
{customPath && customPath !== agent.path && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -395,13 +384,14 @@ export function AgentConfigPanel({
}}
className="px-2 py-1.5 rounded text-xs"
style={{ backgroundColor: theme.colors.bgActivity, color: theme.colors.textDim }}
title="Reset to detected path"
>
Clear
Reset
</button>
)}
</div>
<p className="text-xs opacity-50 mt-2">
Specify a custom path if the agent is not in your PATH
Path to the {agent.binaryName} binary. Edit to override the auto-detected path.
</p>
</div>

View File

@@ -300,6 +300,12 @@ export interface UseSettingsReturn {
// Power management settings
preventSleepEnabled: boolean;
setPreventSleepEnabled: (value: boolean) => Promise<void>;
// 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,
]);
}

View File

@@ -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,