mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## 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:
73
CLAUDE-AGENTS.md
Normal file
73
CLAUDE-AGENTS.md
Normal 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
175
CLAUDE-FEATURES.md
Normal 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
86
CLAUDE-IPC.md
Normal 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
254
CLAUDE-PATTERNS.md
Normal 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
232
CLAUDE-PERFORMANCE.md
Normal 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
106
CLAUDE-SESSION.md
Normal 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
202
CLAUDE-WIZARD.md
Normal 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` |
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 --- */}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user