diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml new file mode 100644 index 000000000..eb488911d --- /dev/null +++ b/.github/workflows/auto-assign.yml @@ -0,0 +1,25 @@ +name: Auto Assign + +on: + issues: + types: [opened] + pull_request: + types: [opened] + +jobs: + assign: + runs-on: ubuntu-latest + steps: + - name: Assign to pedramamini + uses: actions/github-script@v7 + with: + script: | + const issueNumber = context.issue?.number || context.payload.pull_request?.number; + if (issueNumber) { + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + assignees: ['pedramamini'] + }); + } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 48ffebd79..a74899c05 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -14,6 +14,7 @@ Deep technical documentation for Maestro's architecture and design patterns. For - [Theme System](#theme-system) - [Settings Persistence](#settings-persistence) - [Claude Sessions API](#claude-sessions-api) +- [Auto Run System](#auto-run-system) - [Error Handling Patterns](#error-handling-patterns) --- @@ -92,7 +93,7 @@ React frontend with no direct Node.js access: | Directory | Purpose | |-----------|---------| | `components/` | React UI components | -| `hooks/` | Custom React hooks (useSettings, useSessionManager, useFileExplorer) | +| `hooks/` | Custom React hooks (useSettings, useSessionManager, useFileExplorer, useBatchProcessor) | | `services/` | IPC wrappers (git.ts, process.ts) | | `contexts/` | React contexts (LayerStackContext) | | `constants/` | Themes, shortcuts, modal priorities | @@ -594,6 +595,187 @@ const unsubscribe = window.maestro.claude.onGlobalStatsUpdate((stats) => { --- +## Auto Run System + +File-based document runner for automating multi-step tasks. Users configure a folder of markdown documents containing checkbox tasks that are processed sequentially by AI agents. + +### Component Architecture + +| Component | Purpose | +|-----------|---------| +| `AutoRun.tsx` | Main panel showing current document with edit/preview modes | +| `AutoRunSetupModal.tsx` | First-time setup for selecting the Runner Docs folder | +| `AutoRunDocumentSelector.tsx` | Dropdown for switching between markdown documents | +| `BatchRunnerModal.tsx` | Configuration modal for multi-document batch execution | +| `PlaybookNameModal.tsx` | Modal for naming saved playbook configurations | +| `PlaybookDeleteConfirmModal.tsx` | Confirmation modal for playbook deletion | +| `useBatchProcessor.ts` | Hook managing batch execution logic | + +### Data Types + +```typescript +// Document entry in the batch run queue (supports duplicates) +interface BatchDocumentEntry { + id: string; // Unique ID for drag-drop and duplicates + filename: string; // Document filename (without .md) + resetOnCompletion: boolean; // Uncheck all boxes when done + isDuplicate: boolean; // True if this is a duplicate entry +} + +// Git worktree configuration for parallel work +interface WorktreeConfig { + enabled: boolean; // Whether to use a worktree + path: string; // Absolute path for the worktree + branchName: string; // Branch name to use/create + createPROnCompletion: boolean; // Create PR when Auto Run finishes +} + +// Configuration for starting a batch run +interface BatchRunConfig { + documents: BatchDocumentEntry[]; // Ordered list of docs to run + prompt: string; // Agent prompt template + loopEnabled: boolean; // Loop back to first doc when done + worktree?: WorktreeConfig; // Optional worktree configuration +} + +// Runtime batch processing state +interface BatchRunState { + isRunning: boolean; + isStopping: boolean; + documents: string[]; // Document filenames in order + currentDocumentIndex: number; // Which document we're on (0-based) + currentDocTasksTotal: number; + currentDocTasksCompleted: number; + totalTasksAcrossAllDocs: number; + completedTasksAcrossAllDocs: number; + loopEnabled: boolean; + loopIteration: number; // How many times we've looped + folderPath: string; + worktreeActive: boolean; + worktreePath?: string; + worktreeBranch?: string; +} + +// Saved playbook configuration +interface Playbook { + id: string; + name: string; + createdAt: number; + updatedAt: number; + documents: PlaybookDocumentEntry[]; + loopEnabled: boolean; + prompt: string; + worktreeSettings?: { + branchNameTemplate: string; + createPROnCompletion: boolean; + }; +} +``` + +### Session Fields + +Auto Run state is stored per-session: + +```typescript +// In Session interface +autoRunFolderPath?: string; // Persisted folder path for Runner Docs +autoRunSelectedFile?: string; // Currently selected markdown filename +autoRunMode?: 'edit' | 'preview'; // Current editing mode +autoRunEditScrollPos?: number; // Scroll position in edit mode +autoRunPreviewScrollPos?: number; // Scroll position in preview mode +autoRunCursorPosition?: number; // Cursor position in edit mode +batchRunnerPrompt?: string; // Custom batch runner prompt +batchRunnerPromptModifiedAt?: number; +``` + +### IPC Handlers + +```typescript +// List markdown files in a directory +'autorun:listDocs': (folderPath: string) => Promise<{ success, files, error? }> + +// Read a markdown document +'autorun:readDoc': (folderPath: string, filename: string) => Promise<{ success, content, error? }> + +// Write a markdown document +'autorun:writeDoc': (folderPath: string, filename: string, content: string) => Promise<{ success, error? }> + +// Save image to folder +'autorun:saveImage': (folderPath: string, docName: string, base64Data: string, extension: string) => + Promise<{ success, relativePath, error? }> + +// Delete image +'autorun:deleteImage': (folderPath: string, relativePath: string) => Promise<{ success, error? }> + +// List images for a document +'autorun:listImages': (folderPath: string, docName: string) => Promise<{ success, images, error? }> + +// Playbook CRUD operations +'playbooks:list': (sessionId: string) => Promise<{ success, playbooks, error? }> +'playbooks:create': (sessionId: string, playbook) => Promise<{ success, playbook, error? }> +'playbooks:update': (sessionId: string, playbookId: string, updates) => Promise<{ success, playbook, error? }> +'playbooks:delete': (sessionId: string, playbookId: string) => Promise<{ success, error? }> +``` + +### Git Worktree Integration + +When worktree is enabled, Auto Run operates in an isolated directory: + +```typescript +// Check if worktree exists and get branch info +'git:worktreeInfo': (worktreePath: string) => Promise<{ + success: boolean; + exists: boolean; + isWorktree: boolean; + currentBranch?: string; + repoRoot?: string; +}> + +// Create or reuse a worktree +'git:worktreeSetup': (mainRepoCwd: string, worktreePath: string, branchName: string) => Promise<{ + success: boolean; + created: boolean; + currentBranch: string; + branchMismatch: boolean; +}> + +// Checkout a branch in a worktree +'git:worktreeCheckout': (worktreePath: string, branchName: string, createIfMissing: boolean) => Promise<{ + success: boolean; + hasUncommittedChanges: boolean; +}> + +// Create PR from worktree branch +'git:createPR': (worktreePath: string, baseBranch: string, title: string, body: string) => Promise<{ + success: boolean; + prUrl?: string; +}> +``` + +### Execution Flow + +1. **Setup**: User selects Runner Docs folder via `AutoRunSetupModal` +2. **Document Selection**: Documents appear in `AutoRunDocumentSelector` dropdown +3. **Editing**: `AutoRun` component provides edit/preview modes with auto-save (5s debounce) +4. **Batch Configuration**: `BatchRunnerModal` allows ordering documents, enabling loop/reset, configuring worktree +5. **Playbooks**: Save/load configurations for repeated batch runs +6. **Execution**: `useBatchProcessor` hook processes documents sequentially +7. **Progress**: RightPanel shows document and task-level progress + +### Write Queue Integration + +Without worktree mode, Auto Run tasks queue through the existing execution queue: +- Auto Run tasks are marked as write operations (`readOnlyMode: false`) +- Manual write messages queue behind Auto Run (sequential) +- Read-only operations from other tabs can run in parallel + +With worktree mode: +- Auto Run operates in a separate directory +- No queue conflicts with main workspace +- True parallelization enabled + +--- + ## Error Handling Patterns ### IPC Handlers (Main Process) diff --git a/CLAUDE.md b/CLAUDE.md index 3d925fbcc..723233308 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ Use these terms consistently in code, comments, and documentation: ### UI Components - **Left Bar** - Left sidebar with session list and groups (`SessionList.tsx`) -- **Right Bar** - Right sidebar with Files, History, Scratchpad tabs (`RightPanel.tsx`) +- **Right Bar** - Right sidebar with Files, History, Auto Run tabs (`RightPanel.tsx`) - **Main Window** - Center workspace (`MainPanel.tsx`) - **AI Terminal** - Main window in AI mode (interacting with AI agents) - **Command Terminal** - Main window in terminal/shell mode @@ -172,7 +172,8 @@ interface Session { toolType: ToolType; // 'claude-code' | 'aider' | 'terminal' | etc. state: SessionState; // 'idle' | 'busy' | 'error' | 'connecting' inputMode: 'ai' | 'terminal'; // Which process receives input - cwd: string; // Working directory + cwd: string; // Current working directory (can change via cd) + projectRoot: string; // Initial working directory (never changes, used for Claude session storage) aiPid: number; // AI process ID terminalPid: number; // Terminal process ID aiLogs: LogEntry[]; // AI output history @@ -223,3 +224,59 @@ The `window.maestro` API exposes: ### Modal Escape Not Working 1. Register with layer stack (don't handle Escape locally) 2. Check priority is set correctly + +## Keyboard Shortcuts + +Key shortcuts for navigation and common actions. Full list available via `⌘+/` (Help). + +### Panel Navigation +| Shortcut | Action | +|----------|--------| +| `⌥⌘←` | Toggle Left Panel | +| `⌥⌘→` | Toggle Right Panel | +| `⌘⇧F` | Go to Files Tab | +| `⌘⇧H` | Go to History Tab | +| `⌘⇧1` | Go to Auto Run Tab | + +### Session Management +| Shortcut | Action | +|----------|--------| +| `⌘N` | New Agent | +| `⌘[` / `⌘]` | Previous/Next Agent | +| `⌥⌘1-0` | Jump to Session 1-10 | +| `⌘⇧⌫` | Remove Agent | +| `⌘⇧M` | Move Session to Group | + +### Tab Management (AI Mode) +| Shortcut | Action | +|----------|--------| +| `⌘T` | New Tab | +| `⌘W` | Close Tab | +| `⌘⇧T` | Reopen Closed Tab | +| `⌘1-9` | Go to Tab 1-9 | +| `⌘0` | Go to Last Tab | +| `⌥⌘T` | Tab Switcher | + +### Mode & Focus +| Shortcut | Action | +|----------|--------| +| `⌘J` | Switch AI/Shell Mode | +| `⌘.` | Focus Input Field | +| `⌘⇧A` | Focus Left Panel | +| `⌘R` | Toggle Read-Only Mode | +| `⌘E` | Toggle Markdown Raw/Preview | + +### Git & System +| Shortcut | Action | +|----------|--------| +| `⌘⇧D` | View Git Diff | +| `⌘⇧G` | View Git Log | +| `⌥⌘L` | System Log Viewer | +| `⌥⌘P` | System Process Monitor | + +### Quick Access +| Shortcut | Action | +|----------|--------| +| `⌘K` | Quick Actions | +| `⌘,` | Open Settings | +| `⌘/` | Show All Shortcuts | diff --git a/README.md b/README.md index 03fa444de..52884eb2e 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Download the latest release for your platform from the [Releases](https://github - 🔀 **Git Integration** - Automatic git status, diff tracking, and workspace detection - 📁 **File Explorer** - Browse project files with syntax highlighting and markdown preview - 📋 **Session Management** - Group, rename, bookmark, and organize your sessions -- 📝 **Scratchpad** - Built-in markdown editor with live preview for task management +- 📝 **Auto Run** - File-system-based document runner for automated task management with playbooks - ⚡ **Slash Commands** - Extensible command system with autocomplete - 📬 **Message Queueing** - Queue messages while AI is busy; they're sent automatically when ready - 🌐 **Mobile Remote Control** - Access agents from your phone with QR codes, live agents, and a mobile-optimized web interface @@ -133,9 +133,9 @@ Each session shows a color-coded status indicator: |--------|-------|---------------| | Go to Files Tab | `Cmd+Shift+F` | `Ctrl+Shift+F` | | Go to History Tab | `Cmd+Shift+H` | `Ctrl+Shift+H` | -| Go to Scratchpad | `Cmd+Shift+S` | `Ctrl+Shift+S` | +| Go to Auto Run Tab | `Cmd+Shift+1` | `Ctrl+Shift+1` | | Toggle Markdown Raw/Preview | `Cmd+E` | `Ctrl+E` | -| Insert Checkbox (Scratchpad) | `Cmd+L` | `Ctrl+L` | +| Insert Checkbox (Auto Run) | `Cmd+L` | `Ctrl+L` | ### Input & Output @@ -238,15 +238,23 @@ Summarize what I worked on yesterday and suggest priorities for today. See the full list of available variables in the **Template Variables** section within the Custom AI Commands panel. -## Automatic Runner +## Auto Run -The Automatic Runner lets you batch-process tasks using AI agents. Define your tasks as markdown checkboxes in the Scratchpad, and Maestro will work through them one by one, spawning a fresh AI session for each task. +Auto Run is a file-system-based document runner that lets you batch-process tasks using AI agents. Select a folder containing markdown documents with task checkboxes, and Maestro will work through them one by one, spawning a fresh AI session for each task. + +### Setting Up Auto Run + +1. Navigate to the **Auto Run** tab in the right panel (`Cmd+Shift+1`) +2. Select a folder containing your markdown task documents +3. Each `.md` file becomes a selectable document ### Creating Tasks -Use markdown checkboxes in the Scratchpad tab: +Use markdown checkboxes in your documents: ```markdown +# Feature Implementation Plan + - [ ] Implement user authentication - [ ] Add unit tests for the login flow - [ ] Update API documentation @@ -254,16 +262,51 @@ Use markdown checkboxes in the Scratchpad tab: **Tip**: Press `Cmd+L` (Mac) or `Ctrl+L` (Windows/Linux) to quickly insert a new checkbox at your cursor position. -### Running the Automation +### Running Single Documents + +1. Select a document from the dropdown +2. Click the **Run** button (or the ▶ icon) +3. Customize the agent prompt if needed, then click **Go** + +### Multi-Document Batch Runs + +Auto Run supports running multiple documents in sequence: + +1. Click **Run** to open the Batch Runner Modal +2. Click **+ Add Docs** to add more documents to the queue +3. Drag to reorder documents as needed +4. Configure options per document: + - **Reset on Completion** - Uncheck all boxes when document completes (for repeatable tasks) + - **Duplicate** - Add the same document multiple times +5. Enable **Loop Mode** to cycle back to the first document after completing the last +6. Click **Go** to start the batch run + +### Playbooks -1. Navigate to the **Scratchpad** tab in the right panel -2. Add your tasks as unchecked markdown checkboxes (`- [ ]`) -3. Click the **Run** button (or the ▶ icon) -4. Customize the agent prompt if needed, then click **Go** +Save your batch configurations for reuse: + +1. Configure your documents, order, and options +2. Click **Save as Playbook** and enter a name +3. Load saved playbooks from the **Load Playbook** dropdown +4. Update or discard changes to loaded playbooks + +### Git Worktree Support + +For parallel work without file conflicts: + +1. Enable **Worktree** in the Batch Runner Modal +2. Specify a worktree path and branch name +3. Auto Run operates in the isolated worktree +4. Optionally create a PR when the batch completes + +Without a worktree, Auto Run queues with other write operations to prevent conflicts. + +### Progress Tracking The runner will: - Process tasks serially from top to bottom -- Spawn a fresh AI session for each task +- Skip documents with no unchecked tasks +- Show progress: "Document X of Y" and "Task X of Y" - Mark tasks as complete (`- [x]`) when done - Log each completion to the **History** panel @@ -280,11 +323,13 @@ Each completed task is logged to the History panel with: - `Enter` - View full response - `Esc` - Close detail view and return to list -### Read-Only Mode +### Auto-Save + +Documents auto-save after 5 seconds of inactivity, and immediately when switching documents. Full undo/redo support with `Cmd+Z` / `Cmd+Shift+Z`. -While automation is running, the AI operates in **read-only/plan mode**. You can still send messages to review progress, but the agent won't make changes. This prevents conflicts between manual interactions and automated tasks. +### Image Support -The input area shows a **READ-ONLY** indicator with a warning-tinted background during automation. +Paste images directly into your documents. Images are saved to an `images/` subfolder with relative paths for portability. ### Stopping the Runner @@ -295,7 +340,7 @@ Click the **Stop** button at any time. The runner will: ### Parallel Batches -You can run separate batch processes in different Maestro sessions simultaneously. Each session maintains its own independent batch state. +You can run separate batch processes in different Maestro sessions simultaneously. Each session maintains its own independent batch state. With Git worktrees enabled, you can work on the main branch while Auto Run operates in an isolated worktree. ## Configuration diff --git a/src/main/index.ts b/src/main/index.ts index 42303423b..a88cf757b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,7 @@ import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'; import path from 'path'; +import os from 'os'; +import crypto from 'crypto'; import fs from 'fs/promises'; import { ProcessManager } from './process-manager'; import { WebServer } from './web-server'; @@ -698,6 +700,10 @@ function setupIpcHandlers() { }); ipcMain.handle('sessions:setAll', async (_, sessions: any[]) => { + // Debug: log autoRunFolderPath values received from renderer + const autoRunPaths = sessions.map((s: any) => ({ id: s.id, name: s.name, autoRunFolderPath: s.autoRunFolderPath })); + logger.debug('[Sessions:setAll] Received sessions with autoRunFolderPaths:', 'Sessions', autoRunPaths); + // Get previous sessions to detect changes const previousSessions = sessionsStore.get('sessions', []); const previousSessionMap = new Map(previousSessions.map((s: any) => [s.id, s])); @@ -742,6 +748,12 @@ function setupIpcHandlers() { } sessionsStore.set('sessions', sessions); + + // Debug: verify what was stored + const storedSessions = sessionsStore.get('sessions', []); + const storedAutoRunPaths = storedSessions.map((s: any) => ({ id: s.id, name: s.name, autoRunFolderPath: s.autoRunFolderPath })); + logger.debug('[Sessions:setAll] After store, autoRunFolderPaths:', 'Sessions', storedAutoRunPaths); + return true; }); @@ -1129,7 +1141,293 @@ function setupIpcHandlers() { } }); + // Git worktree operations for Auto Run parallelization + + // Get information about a worktree at a given path + ipcMain.handle('git:worktreeInfo', async (_, worktreePath: string) => { + try { + // Check if the path exists + try { + await fs.access(worktreePath); + } catch { + return { success: true, exists: false, isWorktree: false }; + } + + // Check if it's a git directory (could be main repo or worktree) + const isInsideWorkTree = await execFileNoThrow('git', ['rev-parse', '--is-inside-work-tree'], worktreePath); + if (isInsideWorkTree.exitCode !== 0) { + return { success: true, exists: true, isWorktree: false }; + } + + // Get the git directory path + const gitDirResult = await execFileNoThrow('git', ['rev-parse', '--git-dir'], worktreePath); + if (gitDirResult.exitCode !== 0) { + return { success: false, error: 'Failed to get git directory' }; + } + const gitDir = gitDirResult.stdout.trim(); + + // A worktree's .git is a file pointing to the main repo, not a directory + // Check if this is a worktree by looking for .git file (not directory) or checking git-common-dir + const gitCommonDirResult = await execFileNoThrow('git', ['rev-parse', '--git-common-dir'], worktreePath); + const gitCommonDir = gitCommonDirResult.exitCode === 0 ? gitCommonDirResult.stdout.trim() : gitDir; + + // If git-dir and git-common-dir are different, this is a worktree + const isWorktree = gitDir !== gitCommonDir; + + // Get the current branch + const branchResult = await execFileNoThrow('git', ['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath); + const currentBranch = branchResult.exitCode === 0 ? branchResult.stdout.trim() : undefined; + + // Get the repository root (of the main repository) + const repoRootResult = await execFileNoThrow('git', ['rev-parse', '--show-toplevel'], worktreePath); + let repoRoot: string | undefined; + + if (isWorktree && gitCommonDir) { + // For worktrees, we need to find the main repo root from the common dir + // The common dir points to the .git folder of the main repo + // The main repo root is the parent of the .git folder + const path = require('path'); + const commonDirAbs = path.isAbsolute(gitCommonDir) + ? gitCommonDir + : path.resolve(worktreePath, gitCommonDir); + repoRoot = path.dirname(commonDirAbs); + } else if (repoRootResult.exitCode === 0) { + repoRoot = repoRootResult.stdout.trim(); + } + + return { + success: true, + exists: true, + isWorktree, + currentBranch, + repoRoot + }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + }); + + // Get the root directory of the git repository + ipcMain.handle('git:getRepoRoot', async (_, cwd: string) => { + try { + const result = await execFileNoThrow('git', ['rev-parse', '--show-toplevel'], cwd); + if (result.exitCode !== 0) { + return { success: false, error: result.stderr || 'Not a git repository' }; + } + return { success: true, root: result.stdout.trim() }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + }); + + // Create or reuse a worktree + ipcMain.handle('git:worktreeSetup', async (_, mainRepoCwd: string, worktreePath: string, branchName: string) => { + try { + const path = require('path'); + + // First check if the worktree path already exists + let pathExists = true; + try { + await fs.access(worktreePath); + } catch { + pathExists = false; + } + + if (pathExists) { + // Check if it's already a worktree of this repo + const worktreeInfoResult = await execFileNoThrow('git', ['rev-parse', '--is-inside-work-tree'], worktreePath); + if (worktreeInfoResult.exitCode !== 0) { + return { success: false, error: 'Path exists but is not a git worktree or repository' }; + } + + // Get the common dir to check if it's the same repo + const gitCommonDirResult = await execFileNoThrow('git', ['rev-parse', '--git-common-dir'], worktreePath); + const mainGitDirResult = await execFileNoThrow('git', ['rev-parse', '--git-dir'], mainRepoCwd); + + if (gitCommonDirResult.exitCode === 0 && mainGitDirResult.exitCode === 0) { + const worktreeCommonDir = path.resolve(worktreePath, gitCommonDirResult.stdout.trim()); + const mainGitDir = path.resolve(mainRepoCwd, mainGitDirResult.stdout.trim()); + + // Normalize paths for comparison + const normalizedWorktreeCommon = path.normalize(worktreeCommonDir); + const normalizedMainGit = path.normalize(mainGitDir); + + if (normalizedWorktreeCommon !== normalizedMainGit) { + return { success: false, error: 'Worktree path belongs to a different repository' }; + } + } + + // Get current branch in the existing worktree + const currentBranchResult = await execFileNoThrow('git', ['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath); + const currentBranch = currentBranchResult.exitCode === 0 ? currentBranchResult.stdout.trim() : ''; + + return { + success: true, + created: false, + currentBranch, + requestedBranch: branchName, + branchMismatch: currentBranch !== branchName && branchName !== '' + }; + } + + // Worktree doesn't exist, create it + // First check if the branch exists + const branchExistsResult = await execFileNoThrow('git', ['rev-parse', '--verify', branchName], mainRepoCwd); + const branchExists = branchExistsResult.exitCode === 0; + + let createResult; + if (branchExists) { + // Branch exists, just add worktree pointing to it + createResult = await execFileNoThrow('git', ['worktree', 'add', worktreePath, branchName], mainRepoCwd); + } else { + // Branch doesn't exist, create it with -b flag + createResult = await execFileNoThrow('git', ['worktree', 'add', '-b', branchName, worktreePath], mainRepoCwd); + } + + if (createResult.exitCode !== 0) { + return { success: false, error: createResult.stderr || 'Failed to create worktree' }; + } + + return { + success: true, + created: true, + currentBranch: branchName, + requestedBranch: branchName, + branchMismatch: false + }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + }); + + // Checkout a branch in a worktree (with uncommitted changes check) + ipcMain.handle('git:worktreeCheckout', async (_, worktreePath: string, branchName: string, createIfMissing: boolean) => { + try { + // Check for uncommitted changes + const statusResult = await execFileNoThrow('git', ['status', '--porcelain'], worktreePath); + if (statusResult.exitCode !== 0) { + return { success: false, hasUncommittedChanges: false, error: 'Failed to check git status' }; + } + + const hasUncommittedChanges = statusResult.stdout.trim().length > 0; + if (hasUncommittedChanges) { + return { + success: false, + hasUncommittedChanges: true, + error: 'Worktree has uncommitted changes. Please commit or stash them first.' + }; + } + + // Check if branch exists + const branchExistsResult = await execFileNoThrow('git', ['rev-parse', '--verify', branchName], worktreePath); + const branchExists = branchExistsResult.exitCode === 0; + + let checkoutResult; + if (branchExists) { + checkoutResult = await execFileNoThrow('git', ['checkout', branchName], worktreePath); + } else if (createIfMissing) { + checkoutResult = await execFileNoThrow('git', ['checkout', '-b', branchName], worktreePath); + } else { + return { success: false, hasUncommittedChanges: false, error: `Branch '${branchName}' does not exist` }; + } + + if (checkoutResult.exitCode !== 0) { + return { success: false, hasUncommittedChanges: false, error: checkoutResult.stderr || 'Checkout failed' }; + } + + return { success: true, hasUncommittedChanges: false }; + } catch (error) { + return { success: false, hasUncommittedChanges: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + }); + + // Create a PR from the worktree branch to a base branch + ipcMain.handle('git:createPR', async (_, worktreePath: string, baseBranch: string, title: string, body: string) => { + try { + // First, push the current branch to origin + const pushResult = await execFileNoThrow('git', ['push', '-u', 'origin', 'HEAD'], worktreePath); + if (pushResult.exitCode !== 0) { + return { success: false, error: `Failed to push branch: ${pushResult.stderr}` }; + } + + // Create the PR using gh CLI + const prResult = await execFileNoThrow('gh', [ + 'pr', 'create', + '--base', baseBranch, + '--title', title, + '--body', body + ], worktreePath); + + if (prResult.exitCode !== 0) { + // Check if gh CLI is not installed + if (prResult.stderr.includes('command not found') || prResult.stderr.includes('not recognized')) { + return { success: false, error: 'GitHub CLI (gh) is not installed. Please install it to create PRs.' }; + } + return { success: false, error: prResult.stderr || 'Failed to create PR' }; + } + + // The PR URL is typically in stdout + const prUrl = prResult.stdout.trim(); + return { success: true, prUrl }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + }); + + // Check if GitHub CLI (gh) is installed and authenticated + ipcMain.handle('git:checkGhCli', async () => { + try { + // Check if gh is installed by running gh --version + const versionResult = await execFileNoThrow('gh', ['--version']); + if (versionResult.exitCode !== 0) { + return { installed: false, authenticated: false }; + } + + // Check if gh is authenticated by running gh auth status + const authResult = await execFileNoThrow('gh', ['auth', 'status']); + const authenticated = authResult.exitCode === 0; + + return { installed: true, authenticated }; + } catch { + return { installed: false, authenticated: false }; + } + }); + + // Get the default branch name (main or master) + ipcMain.handle('git:getDefaultBranch', async (_, cwd: string) => { + try { + // First try to get the default branch from remote + const remoteResult = await execFileNoThrow('git', ['remote', 'show', 'origin'], cwd); + if (remoteResult.exitCode === 0) { + // Parse "HEAD branch: main" from the output + const match = remoteResult.stdout.match(/HEAD branch:\s*(\S+)/); + if (match) { + return { success: true, branch: match[1] }; + } + } + + // Fallback: check if main or master exists locally + const mainResult = await execFileNoThrow('git', ['rev-parse', '--verify', 'main'], cwd); + if (mainResult.exitCode === 0) { + return { success: true, branch: 'main' }; + } + + const masterResult = await execFileNoThrow('git', ['rev-parse', '--verify', 'master'], cwd); + if (masterResult.exitCode === 0) { + return { success: true, branch: 'master' }; + } + + return { success: false, error: 'Could not determine default branch' }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + }); + // File system operations + ipcMain.handle('fs:homeDir', () => { + return os.homedir(); + }); + ipcMain.handle('fs:readDir', async (_, dirPath: string) => { const entries = await fs.readdir(dirPath, { withFileTypes: true }); // Convert Dirent objects to plain objects for IPC serialization @@ -2924,23 +3222,40 @@ function setupIpcHandlers() { // Get all named sessions across all projects (for Tab Switcher "All Named" view) ipcMain.handle('claude:getAllNamedSessions', async () => { + const os = await import('os'); + const homeDir = os.default.homedir(); + const claudeProjectsDir = path.join(homeDir, '.claude', 'projects'); + const allOrigins = claudeSessionOriginsStore.get('origins', {}); const namedSessions: Array<{ claudeSessionId: string; projectPath: string; sessionName: string; starred?: boolean; + lastActivityAt?: number; }> = []; for (const [projectPath, sessions] of Object.entries(allOrigins)) { for (const [claudeSessionId, info] of Object.entries(sessions)) { // Handle both old string format and new object format if (typeof info === 'object' && info.sessionName) { + // Try to get last activity time from the session file + let lastActivityAt: number | undefined; + try { + const encodedPath = encodeClaudeProjectPath(projectPath); + const sessionFile = path.join(claudeProjectsDir, encodedPath, `${claudeSessionId}.jsonl`); + const stats = await fs.stat(sessionFile); + lastActivityAt = stats.mtime.getTime(); + } catch { + // Session file may not exist or be inaccessible + } + namedSessions.push({ claudeSessionId, projectPath, sessionName: info.sessionName, starred: info.starred, + lastActivityAt, }); } } @@ -3209,13 +3524,526 @@ function setupIpcHandlers() { const attachmentsDir = path.join(userDataPath, 'attachments', sessionId); return { success: true, path: attachmentsDir }; }); + + // ============================================ + // Auto Run IPC Handlers + // ============================================ + + // List markdown files in a directory for Auto Run (with recursive subfolder support) + ipcMain.handle('autorun:listDocs', async (_event, folderPath: string) => { + try { + // Validate the folder path exists + const folderStat = await fs.stat(folderPath); + if (!folderStat.isDirectory()) { + return { success: false, files: [], tree: [], error: 'Path is not a directory' }; + } + + // Recursive function to build tree structure + interface TreeNode { + name: string; + type: 'file' | 'folder'; + path: string; // Relative path from root folder + children?: TreeNode[]; + } + + const scanDirectory = async (dirPath: string, relativePath: string = ''): Promise => { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + const nodes: TreeNode[] = []; + + // Sort entries: folders first, then files, both alphabetically + const sortedEntries = entries + .filter(entry => !entry.name.startsWith('.')) + .sort((a, b) => { + if (a.isDirectory() && !b.isDirectory()) return -1; + if (!a.isDirectory() && b.isDirectory()) return 1; + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + for (const entry of sortedEntries) { + const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name; + + if (entry.isDirectory()) { + // Recursively scan subdirectory + const children = await scanDirectory(path.join(dirPath, entry.name), entryRelativePath); + // Only include folders that contain .md files (directly or in subfolders) + if (children.length > 0) { + nodes.push({ + name: entry.name, + type: 'folder', + path: entryRelativePath, + children + }); + } + } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) { + // Add .md file (without extension in name, but keep in path) + nodes.push({ + name: entry.name.slice(0, -3), + type: 'file', + path: entryRelativePath.slice(0, -3) // Remove .md from path too + }); + } + } + + return nodes; + }; + + const tree = await scanDirectory(folderPath); + + // Also build flat list for backwards compatibility + const flattenTree = (nodes: TreeNode[]): string[] => { + const files: string[] = []; + for (const node of nodes) { + if (node.type === 'file') { + files.push(node.path); + } else if (node.children) { + files.push(...flattenTree(node.children)); + } + } + return files; + }; + + const files = flattenTree(tree); + + logger.info(`Listed ${files.length} markdown files in ${folderPath} (with subfolders)`, 'AutoRun'); + return { success: true, files, tree }; + } catch (error) { + logger.error('Error listing Auto Run docs', 'AutoRun', error); + return { success: false, files: [], tree: [], error: String(error) }; + } + }); + + // Read a markdown document for Auto Run (supports subdirectories) + ipcMain.handle( + 'autorun:readDoc', + async (_event, folderPath: string, filename: string) => { + try { + // Reject obvious traversal attempts + if (filename.includes('..')) { + return { success: false, content: '', error: 'Invalid filename' }; + } + + // Ensure filename has .md extension + const fullFilename = filename.endsWith('.md') + ? filename + : `${filename}.md`; + + const filePath = path.join(folderPath, fullFilename); + + // Validate the file is within the folder path (prevent traversal) + const resolvedPath = path.resolve(filePath); + const resolvedFolder = path.resolve(folderPath); + if (!resolvedPath.startsWith(resolvedFolder + path.sep) && resolvedPath !== resolvedFolder) { + return { success: false, content: '', error: 'Invalid file path' }; + } + + // Check if file exists + try { + await fs.access(filePath); + } catch { + return { success: false, content: '', error: 'File not found' }; + } + + // Read the file + const content = await fs.readFile(filePath, 'utf-8'); + + logger.info(`Read Auto Run doc: ${fullFilename}`, 'AutoRun'); + return { success: true, content }; + } catch (error) { + logger.error('Error reading Auto Run doc', 'AutoRun', error); + return { success: false, content: '', error: String(error) }; + } + } + ); + + // Write a markdown document for Auto Run (supports subdirectories) + ipcMain.handle( + 'autorun:writeDoc', + async (_event, folderPath: string, filename: string, content: string) => { + try { + // Reject obvious traversal attempts + if (filename.includes('..')) { + return { success: false, error: 'Invalid filename' }; + } + + // Ensure filename has .md extension + const fullFilename = filename.endsWith('.md') + ? filename + : `${filename}.md`; + + const filePath = path.join(folderPath, fullFilename); + + // Validate the file is within the folder path (prevent traversal) + const resolvedPath = path.resolve(filePath); + const resolvedFolder = path.resolve(folderPath); + if (!resolvedPath.startsWith(resolvedFolder + path.sep) && resolvedPath !== resolvedFolder) { + return { success: false, error: 'Invalid file path' }; + } + + // Ensure the parent directory exists (create if needed for subdirectories) + const parentDir = path.dirname(filePath); + try { + await fs.access(parentDir); + } catch { + // Parent dir doesn't exist - create it if it's within folderPath + const resolvedParent = path.resolve(parentDir); + if (resolvedParent.startsWith(resolvedFolder)) { + await fs.mkdir(parentDir, { recursive: true }); + } else { + return { success: false, error: 'Invalid parent directory' }; + } + } + + // Write the file + await fs.writeFile(filePath, content, 'utf-8'); + + logger.info(`Wrote Auto Run doc: ${fullFilename}`, 'AutoRun'); + return { success: true }; + } catch (error) { + logger.error('Error writing Auto Run doc', 'AutoRun', error); + return { success: false, error: String(error) }; + } + } + ); + + // Save image to Auto Run folder + ipcMain.handle( + 'autorun:saveImage', + async ( + _event, + folderPath: string, + docName: string, + base64Data: string, + extension: string + ) => { + try { + // Sanitize docName to prevent directory traversal + const sanitizedDocName = path.basename(docName).replace(/\.md$/i, ''); + if (sanitizedDocName.includes('..') || sanitizedDocName.includes('/')) { + return { success: false, error: 'Invalid document name' }; + } + + // Validate extension (only allow common image formats) + const allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg']; + const sanitizedExtension = extension.toLowerCase().replace(/[^a-z]/g, ''); + if (!allowedExtensions.includes(sanitizedExtension)) { + return { success: false, error: 'Invalid image extension' }; + } + + // Create images subdirectory if it doesn't exist + const imagesDir = path.join(folderPath, 'images'); + try { + await fs.mkdir(imagesDir, { recursive: true }); + } catch { + // Directory might already exist, that's fine + } + + // Generate filename: {docName}-{timestamp}.{ext} + const timestamp = Date.now(); + const filename = `${sanitizedDocName}-${timestamp}.${sanitizedExtension}`; + const filePath = path.join(imagesDir, filename); + + // Validate the file is within the folder path (prevent traversal) + const resolvedPath = path.resolve(filePath); + const resolvedFolder = path.resolve(folderPath); + if (!resolvedPath.startsWith(resolvedFolder)) { + return { success: false, error: 'Invalid file path' }; + } + + // Decode and write the image + const buffer = Buffer.from(base64Data, 'base64'); + await fs.writeFile(filePath, buffer); + + // Return the relative path for markdown insertion + const relativePath = `images/${filename}`; + logger.info(`Saved Auto Run image: ${relativePath}`, 'AutoRun'); + return { success: true, relativePath }; + } catch (error) { + logger.error('Error saving Auto Run image', 'AutoRun', error); + return { success: false, error: String(error) }; + } + } + ); + + // Delete image from Auto Run folder + ipcMain.handle( + 'autorun:deleteImage', + async (_event, folderPath: string, relativePath: string) => { + try { + // Sanitize relativePath to prevent directory traversal + const normalizedPath = path.normalize(relativePath); + if ( + normalizedPath.includes('..') || + path.isAbsolute(normalizedPath) || + !normalizedPath.startsWith('images/') + ) { + return { success: false, error: 'Invalid image path' }; + } + + const filePath = path.join(folderPath, normalizedPath); + + // Validate the file is within the folder path (prevent traversal) + const resolvedPath = path.resolve(filePath); + const resolvedFolder = path.resolve(folderPath); + if (!resolvedPath.startsWith(resolvedFolder)) { + return { success: false, error: 'Invalid file path' }; + } + + // Check if file exists + try { + await fs.access(filePath); + } catch { + return { success: false, error: 'Image file not found' }; + } + + // Delete the file + await fs.unlink(filePath); + logger.info(`Deleted Auto Run image: ${relativePath}`, 'AutoRun'); + return { success: true }; + } catch (error) { + logger.error('Error deleting Auto Run image', 'AutoRun', error); + return { success: false, error: String(error) }; + } + } + ); + + // List images for a document (by prefix match) + ipcMain.handle( + 'autorun:listImages', + async (_event, folderPath: string, docName: string) => { + try { + // Sanitize docName to prevent directory traversal + const sanitizedDocName = path.basename(docName).replace(/\.md$/i, ''); + if (sanitizedDocName.includes('..') || sanitizedDocName.includes('/')) { + return { success: false, error: 'Invalid document name' }; + } + + const imagesDir = path.join(folderPath, 'images'); + + // Check if images directory exists + try { + await fs.access(imagesDir); + } catch { + // No images directory means no images + return { success: true, images: [] }; + } + + // Read directory contents + const files = await fs.readdir(imagesDir); + + // Filter files that start with the docName prefix + const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg']; + const images = files + .filter((file) => { + // Check if filename starts with docName- + if (!file.startsWith(`${sanitizedDocName}-`)) { + return false; + } + // Check if it has a valid image extension + const ext = file.split('.').pop()?.toLowerCase(); + return ext && imageExtensions.includes(ext); + }) + .map((file) => ({ + filename: file, + relativePath: `images/${file}`, + })); + + return { success: true, images }; + } catch (error) { + logger.error('Error listing Auto Run images', 'AutoRun', error); + return { success: false, error: String(error) }; + } + } + ); + + // ============================================ + // Playbook IPC Handlers + // ============================================ + + // Helper: Get path to playbooks file for a session + function getPlaybooksFilePath(sessionId: string): string { + const userDataPath = app.getPath('userData'); + return path.join(userDataPath, 'playbooks', `${sessionId}.json`); + } + + // Helper: Read playbooks from file + async function readPlaybooks(sessionId: string): Promise { + const filePath = getPlaybooksFilePath(sessionId); + try { + const content = await fs.readFile(filePath, 'utf-8'); + const data = JSON.parse(content); + return Array.isArray(data.playbooks) ? data.playbooks : []; + } catch { + // File doesn't exist or is invalid, return empty array + return []; + } + } + + // Helper: Write playbooks to file + async function writePlaybooks(sessionId: string, playbooks: any[]): Promise { + const filePath = getPlaybooksFilePath(sessionId); + const dir = path.dirname(filePath); + + // Ensure the playbooks directory exists + await fs.mkdir(dir, { recursive: true }); + + // Write the playbooks file + await fs.writeFile(filePath, JSON.stringify({ playbooks }, null, 2), 'utf-8'); + } + + // List all playbooks for a session + ipcMain.handle('playbooks:list', async (_event, sessionId: string) => { + try { + const playbooks = await readPlaybooks(sessionId); + logger.info(`Listed ${playbooks.length} playbooks for session ${sessionId}`, 'Playbooks'); + return { success: true, playbooks }; + } catch (error) { + logger.error('Error listing playbooks', 'Playbooks', error); + return { success: false, playbooks: [], error: String(error) }; + } + }); + + // Create a new playbook + ipcMain.handle( + 'playbooks:create', + async ( + _event, + sessionId: string, + playbook: { + name: string; + documents: any[]; + loopEnabled: boolean; + prompt: string; + worktreeSettings?: { + branchNameTemplate: string; + createPROnCompletion: boolean; + prTargetBranch?: string; + }; + } + ) => { + try { + const playbooks = await readPlaybooks(sessionId); + + // Create new playbook with generated ID and timestamps + const now = Date.now(); + const newPlaybook: { + id: string; + name: string; + createdAt: number; + updatedAt: number; + documents: any[]; + loopEnabled: boolean; + prompt: string; + worktreeSettings?: { + branchNameTemplate: string; + createPROnCompletion: boolean; + prTargetBranch?: string; + }; + } = { + id: crypto.randomUUID(), + name: playbook.name, + createdAt: now, + updatedAt: now, + documents: playbook.documents, + loopEnabled: playbook.loopEnabled, + prompt: playbook.prompt, + }; + + // Include worktree settings if provided + if (playbook.worktreeSettings) { + newPlaybook.worktreeSettings = playbook.worktreeSettings; + } + + // Add to list and save + playbooks.push(newPlaybook); + await writePlaybooks(sessionId, playbooks); + + logger.info(`Created playbook "${playbook.name}" for session ${sessionId}`, 'Playbooks'); + return { success: true, playbook: newPlaybook }; + } catch (error) { + logger.error('Error creating playbook', 'Playbooks', error); + return { success: false, error: String(error) }; + } + } + ); + + // Update an existing playbook + ipcMain.handle( + 'playbooks:update', + async ( + _event, + sessionId: string, + playbookId: string, + updates: Partial<{ + name: string; + documents: any[]; + loopEnabled: boolean; + prompt: string; + updatedAt: number; + worktreeSettings?: { + branchNameTemplate: string; + createPROnCompletion: boolean; + prTargetBranch?: string; + }; + }> + ) => { + try { + const playbooks = await readPlaybooks(sessionId); + + // Find the playbook to update + const index = playbooks.findIndex((p: any) => p.id === playbookId); + if (index === -1) { + return { success: false, error: 'Playbook not found' }; + } + + // Update the playbook + const updatedPlaybook = { + ...playbooks[index], + ...updates, + updatedAt: Date.now(), + }; + playbooks[index] = updatedPlaybook; + + await writePlaybooks(sessionId, playbooks); + + logger.info(`Updated playbook "${updatedPlaybook.name}" for session ${sessionId}`, 'Playbooks'); + return { success: true, playbook: updatedPlaybook }; + } catch (error) { + logger.error('Error updating playbook', 'Playbooks', error); + return { success: false, error: String(error) }; + } + } + ); + + // Delete a playbook + ipcMain.handle('playbooks:delete', async (_event, sessionId: string, playbookId: string) => { + try { + const playbooks = await readPlaybooks(sessionId); + + // Find the playbook to delete + const index = playbooks.findIndex((p: any) => p.id === playbookId); + if (index === -1) { + return { success: false, error: 'Playbook not found' }; + } + + const deletedName = playbooks[index].name; + + // Remove from list and save + playbooks.splice(index, 1); + await writePlaybooks(sessionId, playbooks); + + logger.info(`Deleted playbook "${deletedName}" from session ${sessionId}`, 'Playbooks'); + return { success: true }; + } catch (error) { + logger.error('Error deleting playbook', 'Playbooks', error); + return { success: false, error: String(error) }; + } + }); } // Handle process output streaming (set up after initialization) function setupProcessListeners() { if (processManager) { processManager.on('data', (sessionId: string, data: string) => { - console.log('[IPC] Forwarding process:data to renderer:', { sessionId, dataLength: data.length, hasMainWindow: !!mainWindow }); mainWindow?.webContents.send('process:data', sessionId, data); // Broadcast to web clients - extract base session ID (remove -ai or -terminal suffix) @@ -3283,7 +4111,6 @@ function setupProcessListeners() { totalCostUsd: number; contextWindow: number; }) => { - console.log('[IPC] Forwarding process:usage to renderer:', { sessionId, usageStats }); mainWindow?.webContents.send('process:usage', sessionId, usageStats); }); } diff --git a/src/main/preload.ts b/src/main/preload.ts index be04a3f3e..74def9077 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -219,10 +219,59 @@ contextBridge.exposeInMainWorld('maestro', { show: (cwd: string, hash: string) => ipcRenderer.invoke('git:show', cwd, hash), showFile: (cwd: string, ref: string, filePath: string) => ipcRenderer.invoke('git:showFile', cwd, ref, filePath) as Promise<{ content?: string; error?: string }>, + // Git worktree operations for Auto Run parallelization + worktreeInfo: (worktreePath: string) => + ipcRenderer.invoke('git:worktreeInfo', worktreePath) as Promise<{ + success: boolean; + exists?: boolean; + isWorktree?: boolean; + currentBranch?: string; + repoRoot?: string; + error?: string; + }>, + getRepoRoot: (cwd: string) => + ipcRenderer.invoke('git:getRepoRoot', cwd) as Promise<{ + success: boolean; + root?: string; + error?: string; + }>, + worktreeSetup: (mainRepoCwd: string, worktreePath: string, branchName: string) => + ipcRenderer.invoke('git:worktreeSetup', mainRepoCwd, worktreePath, branchName) as Promise<{ + success: boolean; + created?: boolean; + currentBranch?: string; + requestedBranch?: string; + branchMismatch?: boolean; + error?: string; + }>, + worktreeCheckout: (worktreePath: string, branchName: string, createIfMissing: boolean) => + ipcRenderer.invoke('git:worktreeCheckout', worktreePath, branchName, createIfMissing) as Promise<{ + success: boolean; + hasUncommittedChanges: boolean; + error?: string; + }>, + createPR: (worktreePath: string, baseBranch: string, title: string, body: string) => + ipcRenderer.invoke('git:createPR', worktreePath, baseBranch, title, body) as Promise<{ + success: boolean; + prUrl?: string; + error?: string; + }>, + getDefaultBranch: (cwd: string) => + ipcRenderer.invoke('git:getDefaultBranch', cwd) as Promise<{ + success: boolean; + branch?: string; + error?: string; + }>, + checkGhCli: () => + ipcRenderer.invoke('git:checkGhCli') as Promise<{ + installed: boolean; + authenticated: boolean; + }>, }, // File System API fs: { + homeDir: () => ipcRenderer.invoke('fs:homeDir') as Promise, readDir: (dirPath: string) => ipcRenderer.invoke('fs:readDir', dirPath), readFile: (filePath: string) => ipcRenderer.invoke('fs:readFile', filePath), stat: (filePath: string) => ipcRenderer.invoke('fs:stat', filePath), @@ -375,6 +424,7 @@ contextBridge.exposeInMainWorld('maestro', { projectPath: string; sessionName: string; starred?: boolean; + lastActivityAt?: number; }>>, deleteMessagePair: (projectPath: string, sessionId: string, userMessageUuid: string, fallbackContent?: string) => ipcRenderer.invoke('claude:deleteMessagePair', projectPath, sessionId, userMessageUuid, fallbackContent), @@ -432,6 +482,68 @@ contextBridge.exposeInMainWorld('maestro', { getPath: (sessionId: string) => ipcRenderer.invoke('attachments:getPath', sessionId), }, + + // Auto Run API (file-system-based document runner) + autorun: { + listDocs: (folderPath: string) => + ipcRenderer.invoke('autorun:listDocs', folderPath), + readDoc: (folderPath: string, filename: string) => + ipcRenderer.invoke('autorun:readDoc', folderPath, filename), + writeDoc: (folderPath: string, filename: string, content: string) => + ipcRenderer.invoke('autorun:writeDoc', folderPath, filename, content), + saveImage: ( + folderPath: string, + docName: string, + base64Data: string, + extension: string + ) => + ipcRenderer.invoke( + 'autorun:saveImage', + folderPath, + docName, + base64Data, + extension + ), + deleteImage: (folderPath: string, relativePath: string) => + ipcRenderer.invoke('autorun:deleteImage', folderPath, relativePath), + listImages: (folderPath: string, docName: string) => + ipcRenderer.invoke('autorun:listImages', folderPath, docName), + }, + + // Playbooks API (saved batch run configurations) + playbooks: { + list: (sessionId: string) => + ipcRenderer.invoke('playbooks:list', sessionId), + create: ( + sessionId: string, + playbook: { + name: string; + documents: Array<{ filename: string; resetOnCompletion: boolean }>; + loopEnabled: boolean; + prompt: string; + worktreeSettings?: { + branchNameTemplate: string; + createPROnCompletion: boolean; + }; + } + ) => ipcRenderer.invoke('playbooks:create', sessionId, playbook), + update: ( + sessionId: string, + playbookId: string, + updates: Partial<{ + name: string; + documents: Array<{ filename: string; resetOnCompletion: boolean }>; + loopEnabled: boolean; + prompt: string; + worktreeSettings?: { + branchNameTemplate: string; + createPROnCompletion: boolean; + }; + }> + ) => ipcRenderer.invoke('playbooks:update', sessionId, playbookId, updates), + delete: (sessionId: string, playbookId: string) => + ipcRenderer.invoke('playbooks:delete', sessionId, playbookId), + }, }); // Type definitions for TypeScript @@ -509,8 +621,50 @@ export interface MaestroAPI { }>; show: (cwd: string, hash: string) => Promise<{ stdout: string; stderr: string }>; showFile: (cwd: string, ref: string, filePath: string) => Promise<{ content?: string; error?: string }>; + // Git worktree operations for Auto Run parallelization + worktreeInfo: (worktreePath: string) => Promise<{ + success: boolean; + exists?: boolean; + isWorktree?: boolean; + currentBranch?: string; + repoRoot?: string; + error?: string; + }>; + getRepoRoot: (cwd: string) => Promise<{ + success: boolean; + root?: string; + error?: string; + }>; + worktreeSetup: (mainRepoCwd: string, worktreePath: string, branchName: string) => Promise<{ + success: boolean; + created?: boolean; + currentBranch?: string; + requestedBranch?: string; + branchMismatch?: boolean; + error?: string; + }>; + worktreeCheckout: (worktreePath: string, branchName: string, createIfMissing: boolean) => Promise<{ + success: boolean; + hasUncommittedChanges: boolean; + error?: string; + }>; + createPR: (worktreePath: string, baseBranch: string, title: string, body: string) => Promise<{ + success: boolean; + prUrl?: string; + error?: string; + }>; + getDefaultBranch: (cwd: string) => Promise<{ + success: boolean; + branch?: string; + error?: string; + }>; + checkGhCli: () => Promise<{ + installed: boolean; + authenticated: boolean; + }>; }; fs: { + homeDir: () => Promise; readDir: (dirPath: string) => Promise; readFile: (filePath: string) => Promise; stat: (filePath: string) => Promise<{ @@ -717,6 +871,100 @@ export interface MaestroAPI { list: (sessionId: string) => Promise<{ success: boolean; files: string[]; error?: string }>; getPath: (sessionId: string) => Promise<{ success: boolean; path: string }>; }; + autorun: { + listDocs: ( + folderPath: string + ) => Promise<{ success: boolean; files: string[]; error?: string }>; + readDoc: ( + folderPath: string, + filename: string + ) => Promise<{ success: boolean; content?: string; error?: string }>; + writeDoc: ( + folderPath: string, + filename: string, + content: string + ) => Promise<{ success: boolean; error?: string }>; + saveImage: ( + folderPath: string, + docName: string, + base64Data: string, + extension: string + ) => Promise<{ success: boolean; relativePath?: string; error?: string }>; + deleteImage: ( + folderPath: string, + relativePath: string + ) => Promise<{ success: boolean; error?: string }>; + listImages: ( + folderPath: string, + docName: string + ) => Promise<{ + success: boolean; + images?: { filename: string; relativePath: string }[]; + error?: string; + }>; + }; + playbooks: { + list: (sessionId: string) => Promise<{ + success: boolean; + playbooks: Array<{ + id: string; + name: string; + createdAt: number; + updatedAt: number; + documents: Array<{ filename: string; resetOnCompletion: boolean }>; + loopEnabled: boolean; + prompt: string; + }>; + error?: string; + }>; + create: ( + sessionId: string, + playbook: { + name: string; + documents: Array<{ filename: string; resetOnCompletion: boolean }>; + loopEnabled: boolean; + prompt: string; + } + ) => Promise<{ + success: boolean; + playbook?: { + id: string; + name: string; + createdAt: number; + updatedAt: number; + documents: Array<{ filename: string; resetOnCompletion: boolean }>; + loopEnabled: boolean; + prompt: string; + }; + error?: string; + }>; + update: ( + sessionId: string, + playbookId: string, + updates: Partial<{ + name: string; + documents: Array<{ filename: string; resetOnCompletion: boolean }>; + loopEnabled: boolean; + prompt: string; + }> + ) => Promise<{ + success: boolean; + playbook?: { + id: string; + name: string; + createdAt: number; + updatedAt: number; + documents: Array<{ filename: string; resetOnCompletion: boolean }>; + loopEnabled: boolean; + prompt: string; + }; + error?: string; + }>; + delete: (sessionId: string, playbookId: string) => Promise<{ + success: boolean; + error?: string; + }>; + }; } declare global { diff --git a/src/main/process-manager.ts b/src/main/process-manager.ts index ff3c191de..e56ab9057 100644 --- a/src/main/process-manager.ts +++ b/src/main/process-manager.ts @@ -157,20 +157,19 @@ export class ProcessManager extends EventEmitter { } // Build environment for PTY process - // For terminal sessions, we want the shell to build its own PATH from startup files - // (.zprofile, .zshrc, etc.) rather than inheriting Electron's limited PATH. - // Electron launched from Finder gets a minimal PATH from launchd, not the user's shell PATH. + // For terminal sessions, pass minimal env with base system PATH. + // Shell startup files (.zprofile, .zshrc) will prepend user paths (homebrew, go, etc.) + // We need the base system paths or commands like sort, find, head won't work. let ptyEnv: NodeJS.ProcessEnv; if (isTerminal) { - // For terminals: pass minimal env, let shell startup files set PATH - // This ensures the terminal has the same environment as a regular terminal app ptyEnv = { HOME: process.env.HOME, USER: process.env.USER, SHELL: process.env.SHELL, TERM: 'xterm-256color', LANG: process.env.LANG || 'en_US.UTF-8', - // Don't pass PATH - let the shell build it from scratch via .zprofile/.zshrc + // Provide base system PATH - shell startup files will prepend user paths + PATH: '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', }; } else { // For AI agents in PTY mode: pass full env (they need NODE_PATH, etc.) @@ -297,18 +296,8 @@ export class ProcessManager extends EventEmitter { console.error('[ProcessManager] stdout error:', err); }); childProcess.stdout.on('data', (data: Buffer | string) => { - console.log('[ProcessManager] >>> STDOUT EVENT FIRED <<<'); - console.log('[ProcessManager] stdout event fired for session:', sessionId); const output = data.toString(); - console.log('[ProcessManager] stdout data received:', { - sessionId, - isBatchMode, - isStreamJsonMode, - dataLength: output.length, - dataPreview: output.substring(0, 200) - }); - if (isStreamJsonMode) { // In stream-json mode, each line is a JSONL message // Accumulate and process complete lines @@ -379,12 +368,10 @@ export class ProcessManager extends EventEmitter { contextWindow }; - console.log('[ProcessManager] Emitting usage stats from stream-json:', usageStats); this.emit('usage', sessionId, usageStats); } } catch (e) { // If it's not valid JSON, emit as raw text - console.log('[ProcessManager] Non-JSON line in stream-json mode:', line.substring(0, 100)); this.emit('data', sessionId, line); } } @@ -487,18 +474,8 @@ export class ProcessManager extends EventEmitter { contextWindow }; - console.log('[ProcessManager] Emitting usage stats:', usageStats); this.emit('usage', sessionId, usageStats); } - - // Emit full response for debugging - console.log('[ProcessManager] Batch mode JSON response:', { - sessionId, - hasResult: !!jsonResponse.result, - hasSessionId: !!jsonResponse.session_id, - sessionIdValue: jsonResponse.session_id, - hasCost: jsonResponse.total_cost_usd !== undefined - }); } catch (error) { console.error('[ProcessManager] Failed to parse JSON response:', error); // Emit raw buffer as fallback @@ -706,31 +683,32 @@ export class ProcessManager extends EventEmitter { // Fish auto-sources config.fish, just run the command wrappedCommand = command; } else if (shellName === 'zsh') { - // Source .zshrc for aliases, then use eval to parse command AFTER aliases are loaded - // Without eval, the shell parses the command before .zshrc is sourced, so aliases aren't available + // Source both .zprofile (login shell - PATH setup) and .zshrc (interactive - aliases, functions) + // This matches what a login interactive shell does (zsh -l -i) + // Without eval, the shell parses the command before configs are sourced, so aliases aren't available const escapedCommand = command.replace(/'/g, "'\\''"); - wrappedCommand = `source ~/.zshrc 2>/dev/null; eval '${escapedCommand}'`; + wrappedCommand = `source ~/.zprofile 2>/dev/null; source ~/.zshrc 2>/dev/null; eval '${escapedCommand}'`; } else if (shellName === 'bash') { - // Source .bashrc for aliases, use eval for same reason as zsh + // Source both .bash_profile (login shell) and .bashrc (interactive) const escapedCommand = command.replace(/'/g, "'\\''"); - wrappedCommand = `source ~/.bashrc 2>/dev/null; eval '${escapedCommand}'`; + wrappedCommand = `source ~/.bash_profile 2>/dev/null; source ~/.bashrc 2>/dev/null; eval '${escapedCommand}'`; } else { // Other POSIX-compatible shells wrappedCommand = command; } - // Ensure PATH includes standard binary locations - // Electron's main process may have a stripped-down PATH - const env = { ...process.env }; - const standardPaths = '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin'; - if (env.PATH) { - // Prepend standard paths if not already present - if (!env.PATH.includes('/bin')) { - env.PATH = `${standardPaths}:${env.PATH}`; - } - } else { - env.PATH = standardPaths; - } + // Pass minimal environment with a base PATH for essential system commands. + // Shell startup files (.zprofile, .zshrc) will prepend user paths to this. + // We need the base system paths or commands like sort, find, head won't work. + const env: NodeJS.ProcessEnv = { + HOME: process.env.HOME, + USER: process.env.USER, + SHELL: process.env.SHELL, + TERM: 'xterm-256color', + LANG: process.env.LANG || 'en_US.UTF-8', + // Provide base system PATH - shell startup files will prepend user paths (homebrew, go, etc.) + PATH: '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', + }; // Resolve shell to full path - Electron's internal PATH may not include /bin // where common shells like zsh and bash are located diff --git a/src/main/tunnel-manager.ts b/src/main/tunnel-manager.ts index 5e6d6957e..c9925f327 100644 --- a/src/main/tunnel-manager.ts +++ b/src/main/tunnel-manager.ts @@ -1,5 +1,6 @@ import { ChildProcess, spawn } from 'child_process'; import { logger } from './utils/logger'; +import { getCloudflaredPath, isCloudflaredInstalled } from './utils/cliDetection'; export interface TunnelStatus { isRunning: boolean; @@ -27,10 +28,18 @@ class TunnelManager { // Stop any existing tunnel first await this.stop(); + // Ensure cloudflared is installed and get its path + const installed = await isCloudflaredInstalled(); + if (!installed) { + return { success: false, error: 'cloudflared is not installed' }; + } + + const cloudflaredBinary = getCloudflaredPath() || 'cloudflared'; + return new Promise((resolve) => { - logger.info(`Starting cloudflared tunnel for port ${port}`, 'TunnelManager'); + logger.info(`Starting cloudflared tunnel for port ${port} using ${cloudflaredBinary}`, 'TunnelManager'); - this.process = spawn('cloudflared', [ + this.process = spawn(cloudflaredBinary, [ 'tunnel', '--url', `http://localhost:${port}` ]); diff --git a/src/main/utils/cliDetection.ts b/src/main/utils/cliDetection.ts index 67acf66e5..bd21e950a 100644 --- a/src/main/utils/cliDetection.ts +++ b/src/main/utils/cliDetection.ts @@ -1,6 +1,42 @@ import { execFileNoThrow } from './execFile'; +import * as os from 'os'; let cloudflaredInstalledCache: boolean | null = null; +let cloudflaredPathCache: string | null = null; + +/** + * Build an expanded PATH that includes common binary installation locations. + * This is necessary because packaged Electron apps don't inherit shell environment. + */ +function getExpandedEnv(): NodeJS.ProcessEnv { + const home = os.homedir(); + const env = { ...process.env }; + + const additionalPaths = [ + '/opt/homebrew/bin', // Homebrew on Apple Silicon + '/opt/homebrew/sbin', + '/usr/local/bin', // Homebrew on Intel, common install location + '/usr/local/sbin', + `${home}/.local/bin`, // User local installs + `${home}/bin`, // User bin directory + '/usr/bin', + '/bin', + '/usr/sbin', + '/sbin', + ]; + + const currentPath = env.PATH || ''; + const pathParts = currentPath.split(':'); + + for (const p of additionalPaths) { + if (!pathParts.includes(p)) { + pathParts.unshift(p); + } + } + + env.PATH = pathParts.join(':'); + return env; +} export async function isCloudflaredInstalled(): Promise { // Return cached result if available @@ -10,12 +46,23 @@ export async function isCloudflaredInstalled(): Promise { // Use 'which' on macOS/Linux, 'where' on Windows const command = process.platform === 'win32' ? 'where' : 'which'; - const result = await execFileNoThrow(command, ['cloudflared']); - cloudflaredInstalledCache = result.exitCode === 0; + const env = getExpandedEnv(); + const result = await execFileNoThrow(command, ['cloudflared'], undefined, env); + + if (result.exitCode === 0 && result.stdout.trim()) { + cloudflaredInstalledCache = true; + cloudflaredPathCache = result.stdout.trim().split('\n')[0]; + } else { + cloudflaredInstalledCache = false; + } return cloudflaredInstalledCache; } +export function getCloudflaredPath(): string | null { + return cloudflaredPathCache; +} + export function clearCloudflaredCache(): void { cloudflaredInstalledCache = null; } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 7b98a9277..9dd1a3cf0 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -24,6 +24,7 @@ import { PromptComposerModal } from './components/PromptComposerModal'; import { ExecutionQueueBrowser } from './components/ExecutionQueueBrowser'; import { StandingOvationOverlay } from './components/StandingOvationOverlay'; import { PlaygroundPanel } from './components/PlaygroundPanel'; +import { AutoRunSetupModal } from './components/AutoRunSetupModal'; import { CONDUCTOR_BADGES } from './constants/conductorBadges'; // Import custom hooks @@ -43,7 +44,7 @@ import { gitService } from './services/git'; // Import types and constants import type { ToolType, SessionState, RightPanelTab, - FocusArea, LogEntry, Session, Group, AITab, UsageStats, QueuedItem + FocusArea, LogEntry, Session, Group, AITab, UsageStats, QueuedItem, BatchRunConfig } from './types'; import { THEMES } from './constants/themes'; import { generateId } from './utils/ids'; @@ -51,7 +52,7 @@ import { getContextColor } from './utils/theme'; import { fuzzyMatch } from './utils/search'; import { setActiveTab, createTab, closeTab, reopenClosedTab, getActiveTab, getWriteModeTab, navigateToNextTab, navigateToPrevTab, navigateToTabByIndex, navigateToLastTab } from './utils/tabHelpers'; import { TAB_SHORTCUTS } from './constants/shortcuts'; -import { shouldOpenExternally, loadFileTree, getAllFolderPaths, flattenTree } from './utils/fileExplorer'; +import { shouldOpenExternally, loadFileTree, getAllFolderPaths, flattenTree, compareFileTrees, FileTreeChanges } from './utils/fileExplorer'; import { substituteTemplateVariables } from './utils/templateVariables'; // Strip leading emojis from a string for alphabetical sorting @@ -232,6 +233,9 @@ export default function MaestroConsole() { // Batch Runner Modal State const [batchRunnerModalOpen, setBatchRunnerModalOpen] = useState(false); + // Auto Run Setup Modal State + const [autoRunSetupModalOpen, setAutoRunSetupModalOpen] = useState(false); + // Tab Switcher Modal State const [tabSwitcherOpen, setTabSwitcherOpen] = useState(false); @@ -274,6 +278,12 @@ export default function MaestroConsole() { const [isLiveMode, setIsLiveMode] = useState(false); const [webInterfaceUrl, setWebInterfaceUrl] = useState(null); + // Auto Run document management state + const [autoRunDocumentList, setAutoRunDocumentList] = useState([]); + const [autoRunDocumentTree, setAutoRunDocumentTree] = useState>([]); + const [autoRunContent, setAutoRunContent] = useState(''); + const [autoRunIsLoadingDocuments, setAutoRunIsLoadingDocuments] = useState(false); + // Restore focus when LogViewer closes to ensure global hotkeys work useEffect(() => { // When LogViewer closes, restore focus to main container or input @@ -328,6 +338,11 @@ export default function MaestroConsole() { // Restore a persisted session by respawning its process const restoreSession = async (session: Session): Promise => { try { + // Migration: ensure projectRoot is set (for sessions created before this field was added) + if (!session.projectRoot) { + session = { ...session, projectRoot: session.cwd }; + } + // Sessions must have aiTabs - if missing, this is a data corruption issue if (!session.aiTabs || session.aiTabs.length === 0) { console.error('[restoreSession] Session has no aiTabs - data corruption, skipping:', session.id); @@ -655,7 +670,14 @@ export default function MaestroConsole() { }); // Handle process exit - const unsubscribeExit = window.maestro.process.onExit((sessionId: string, code: number) => { + const unsubscribeExit = window.maestro.process.onExit(async (sessionId: string, code: number) => { + // Log all exit events to help diagnose thinking pill disappearing prematurely + console.log('[onExit] Process exit event received:', { + rawSessionId: sessionId, + exitCode: code, + timestamp: new Date().toISOString() + }); + // Parse sessionId to determine which process exited // Format: {id}-ai-{tabId}, {id}-terminal, {id}-batch-{timestamp} let actualSessionId: string; @@ -679,6 +701,26 @@ export default function MaestroConsole() { isFromAi = false; } + // SAFETY CHECK: Verify the process is actually gone before transitioning to idle + // This prevents the thinking pill from disappearing while the process is still running + // (which can happen if we receive a stale/duplicate exit event) + if (isFromAi) { + try { + const activeProcesses = await window.maestro.process.getActiveProcesses(); + const processStillRunning = activeProcesses.some(p => p.sessionId === sessionId); + if (processStillRunning) { + console.warn('[onExit] Process still running despite exit event, ignoring:', { + sessionId, + activeProcesses: activeProcesses.map(p => p.sessionId) + }); + return; + } + } catch (error) { + console.error('[onExit] Failed to verify process status:', error); + // Continue with exit handling if we can't verify - better than getting stuck + } + } + // For AI exits, gather toast data BEFORE state update to avoid side effects in updater // React 18 StrictMode may call state updater functions multiple times let toastData: { @@ -896,6 +938,18 @@ export default function MaestroConsole() { const newState = anyTabStillBusy ? 'busy' as SessionState : 'idle' as SessionState; const newBusySource = anyTabStillBusy ? s.busySource : undefined; + // Log state transition for debugging thinking pill issues + console.log('[onExit] Session state transition:', { + sessionId: s.id.substring(0, 8), + tabIdFromSession: tabIdFromSession?.substring(0, 8), + previousState: s.state, + newState, + previousBusySource: s.busySource, + newBusySource, + anyTabStillBusy, + tabStates: updatedAiTabs.map(t => ({ id: t.id.substring(0, 8), state: t.state })) + }); + // Task complete - also clear pending AI command flag return { ...s, @@ -1181,9 +1235,15 @@ export default function MaestroConsole() { setSessions(prev => prev.map(s => { if (s.id !== actualSessionId) return s; - // Check if any AI tabs are still busy - don't clear session state if so + // Check if any AI tabs are still busy const anyAiTabBusy = s.aiTabs?.some(tab => tab.state === 'busy') || false; + // Determine new state: + // - If AI tabs are busy, session stays busy with busySource 'ai' + // - Otherwise, session becomes idle + const newState = anyAiTabBusy ? 'busy' as SessionState : 'idle' as SessionState; + const newBusySource = anyAiTabBusy ? 'ai' as const : undefined; + // Only show exit code if non-zero (error) if (code !== 0) { const exitLog: LogEntry = { @@ -1194,18 +1254,16 @@ export default function MaestroConsole() { }; return { ...s, - // Only clear session state if no AI tabs are busy - state: anyAiTabBusy ? s.state : 'idle' as SessionState, - busySource: anyAiTabBusy ? s.busySource : undefined, + state: newState, + busySource: newBusySource, shellLogs: [...s.shellLogs, exitLog] }; } return { ...s, - // Only clear session state if no AI tabs are busy - state: anyAiTabBusy ? s.state : 'idle' as SessionState, - busySource: anyAiTabBusy ? s.busySource : undefined + state: newState, + busySource: newBusySource }; })); }); @@ -1677,7 +1735,8 @@ export default function MaestroConsole() { // Save the current AI input to the PREVIOUS tab before loading new tab's input // This ensures we don't lose draft input when clicking directly on another tab - if (prevTabId && aiInputValueLocal) { + // Also ensures clearing the input (empty string) is persisted when switching away + if (prevTabId) { setSessions(prev => prev.map(s => ({ ...s, aiTabs: s.aiTabs.map(tab => @@ -1882,10 +1941,14 @@ export default function MaestroConsole() { // --- BATCH PROCESSOR --- // Helper to spawn a Claude agent and wait for completion (for a specific session) - const spawnAgentForSession = useCallback(async (sessionId: string, prompt: string): Promise<{ success: boolean; response?: string; claudeSessionId?: string; usageStats?: UsageStats }> => { + // cwdOverride allows running in a different directory (e.g., for worktree mode) + const spawnAgentForSession = useCallback(async (sessionId: string, prompt: string, cwdOverride?: string): Promise<{ success: boolean; response?: string; claudeSessionId?: string; usageStats?: UsageStats }> => { const session = sessions.find(s => s.id === sessionId); if (!session) return { success: false }; + // Use override cwd if provided (worktree mode), otherwise use session's cwd + const effectiveCwd = cwdOverride || session.cwd; + // This spawns a new Claude session and waits for completion try { const agent = await window.maestro.agents.get('claude-code'); @@ -1971,17 +2034,18 @@ export default function MaestroConsole() { cleanupExit = window.maestro.process.onExit((sid: string) => { if (sid === targetSessionId) { - // Clean up listeners and resolve + // Clean up listeners cleanup(); // Check for queued items BEFORE updating state (using sessionsRef for latest state) const currentSession = sessionsRef.current.find(s => s.id === sessionId); let queuedItemToProcess: { sessionId: string; item: QueuedItem } | null = null; + const hasQueuedItems = currentSession && currentSession.executionQueue.length > 0; - if (currentSession && currentSession.executionQueue.length > 0) { + if (hasQueuedItems) { queuedItemToProcess = { sessionId: sessionId, - item: currentSession.executionQueue[0] + item: currentSession!.executionQueue[0] }; } @@ -2063,17 +2127,41 @@ export default function MaestroConsole() { }, 0); } - resolve({ success: true, response: responseText, claudeSessionId, usageStats: taskUsageStats }); + // For batch processing (Auto Run): if there are queued items from manual writes, + // wait for the queue to drain before resolving. This ensures batch tasks don't + // race with queued manual writes. Worktree mode can skip this since it operates + // in a separate directory with no file conflicts. + // Note: cwdOverride is set when worktree is enabled + if (hasQueuedItems && !cwdOverride) { + // Wait for queue to drain by polling session state + // The queue is processed sequentially, so we wait until session becomes idle + const waitForQueueDrain = () => { + const checkSession = sessionsRef.current.find(s => s.id === sessionId); + if (!checkSession || checkSession.state === 'idle' || checkSession.executionQueue.length === 0) { + // Queue drained or session idle - safe to continue batch + resolve({ success: true, response: responseText, claudeSessionId, usageStats: taskUsageStats }); + } else { + // Queue still processing - check again + setTimeout(waitForQueueDrain, 100); + } + }; + // Start polling after a short delay to let state update propagate + setTimeout(waitForQueueDrain, 50); + } else { + // No queued items or worktree mode - resolve immediately + resolve({ success: true, response: responseText, claudeSessionId, usageStats: taskUsageStats }); + } } }); // Spawn the agent with permission-mode plan for batch processing + // Use effectiveCwd which may be a worktree path for parallel execution const commandToUse = agent.path || agent.command; const spawnArgs = [...(agent.args || []), '--permission-mode', 'plan']; window.maestro.process.spawn({ sessionId: targetSessionId, toolType: 'claude-code', - cwd: session.cwd, + cwd: effectiveCwd, command: commandToUse, args: spawnArgs, prompt @@ -2307,6 +2395,34 @@ export default function MaestroConsole() { } } } + }, + onPRResult: (info) => { + // Find group name for the session + const session = sessions.find(s => s.id === info.sessionId); + const sessionGroup = session?.groupId ? groups.find(g => g.id === session.groupId) : null; + const groupName = sessionGroup?.name || 'Ungrouped'; + + if (info.success) { + // PR created successfully - show success toast with PR URL + addToast({ + type: 'success', + title: 'PR Created', + message: info.prUrl || 'Pull request created successfully', + group: groupName, + project: info.sessionName, + sessionId: info.sessionId, + }); + } else { + // PR creation failed - show warning (not error, since the auto-run itself succeeded) + addToast({ + type: 'warning', + title: 'PR Creation Failed', + message: info.error || 'Failed to create pull request', + group: groupName, + project: info.sessionName, + sessionId: info.sessionId, + }); + } } }); @@ -2321,13 +2437,76 @@ export default function MaestroConsole() { setBatchRunnerModalOpen(true); }, []); - // Handler to start batch run from modal - const handleStartBatchRun = useCallback((prompt: string) => { + // Handler for switching to autorun tab - shows setup modal if no folder configured + const handleSetActiveRightTab = useCallback((tab: RightPanelTab) => { + if (tab === 'autorun' && activeSession && !activeSession.autoRunFolderPath) { + // No folder configured - show setup modal + setAutoRunSetupModalOpen(true); + // Still switch to the tab (it will show an empty state or the modal) + setActiveRightTab(tab); + } else { + setActiveRightTab(tab); + } + }, [activeSession]); + + // Handler for auto run folder selection from setup modal + const handleAutoRunFolderSelected = useCallback(async (folderPath: string) => { if (!activeSession) return; + + // Load the document list from the folder + const result = await window.maestro.autorun.listDocs(folderPath); + if (result.success) { + setAutoRunDocumentList(result.files || []); + setAutoRunDocumentTree((result.tree as Array<{ name: string; type: 'file' | 'folder'; path: string; children?: unknown[] }>) || []); + // Auto-select first document if available + const firstFile = result.files?.[0] || null; + setSessions(prev => prev.map(s => + s.id === activeSession.id + ? { + ...s, + autoRunFolderPath: folderPath, + autoRunSelectedFile: firstFile, + } + : s + )); + // Load content of first document + if (firstFile) { + const contentResult = await window.maestro.autorun.readDoc(folderPath, firstFile + '.md'); + if (contentResult.success) { + setAutoRunContent(contentResult.content || ''); + } + } + } else { + setSessions(prev => prev.map(s => + s.id === activeSession.id + ? { ...s, autoRunFolderPath: folderPath } + : s + )); + } + setAutoRunSetupModalOpen(false); + // Switch to the autorun tab now that folder is configured + setActiveRightTab('autorun'); + setRightPanelOpen(true); + setActiveFocus('right'); + }, [activeSession]); + + // Handler to start batch run from modal with multi-document support + const handleStartBatchRun = useCallback((config: BatchRunConfig) => { + if (!activeSession || !activeSession.autoRunFolderPath) return; setBatchRunnerModalOpen(false); - startBatchRun(activeSession.id, activeSession.scratchPadContent, prompt); + startBatchRun(activeSession.id, config, activeSession.autoRunFolderPath); }, [activeSession, startBatchRun]); + // Memoized function to get task count for a document (used by BatchRunnerModal) + const getDocumentTaskCount = useCallback(async (filename: string) => { + if (!activeSession?.autoRunFolderPath) return 0; + const result = await window.maestro.autorun.readDoc(activeSession.autoRunFolderPath, filename + '.md'); + if (!result.success || !result.content) return 0; + // Count unchecked tasks: - [ ] pattern + const matches = result.content.match(/^[\s]*-\s*\[\s*\]\s*.+$/gm); + return matches ? matches.length : 0; + }, [activeSession?.autoRunFolderPath]); + // Handler to stop batch run for active session (with confirmation) const handleStopBatchRun = useCallback(() => { if (!activeSession) return; @@ -2390,14 +2569,17 @@ export default function MaestroConsole() { // Use provided messages or fetch them let messages: LogEntry[]; if (providedMessages && providedMessages.length > 0) { + console.log('[handleResumeSession] Using provided messages:', providedMessages.length); messages = providedMessages; } else { + console.log('[handleResumeSession] Loading messages for:', claudeSessionId, 'cwd:', activeSession.cwd); // Load the session messages const result = await window.maestro.claude.readSessionMessages( activeSession.cwd, claudeSessionId, { offset: 0, limit: 100 } ); + console.log('[handleResumeSession] Loaded result:', result); // Convert to log entries messages = result.messages.map((msg: { type: string; content: string; timestamp: string; uuid: string }) => ({ @@ -2406,6 +2588,7 @@ export default function MaestroConsole() { source: msg.type === 'user' ? 'user' as const : 'stdout' as const, text: msg.content || '' })); + console.log('[handleResumeSession] Converted to', messages.length, 'log entries'); } // Look up starred status and session name from stores if not provided @@ -2430,20 +2613,23 @@ export default function MaestroConsole() { } } - // Create a new tab with the session data using the helper function - const { session: updatedSession } = createTab(activeSession, { - claudeSessionId, - logs: messages, - name, - starred: isStarred - }); - // Update the session and switch to AI mode - setSessions(prev => prev.map(s => - s.id === activeSession.id - ? { ...updatedSession, inputMode: 'ai' } - : s - )); + // IMPORTANT: Use functional update to get fresh session state and avoid race conditions + console.log('[handleResumeSession] Creating tab with', messages.length, 'logs, name:', name, 'starred:', isStarred); + setSessions(prev => prev.map(s => { + if (s.id !== activeSession.id) return s; + + // Create tab from the CURRENT session state (not stale closure value) + const { session: updatedSession, tab: newTab } = createTab(s, { + claudeSessionId, + logs: messages, + name, + starred: isStarred + }); + + console.log('[handleResumeSession] Created tab:', newTab.id, 'with', newTab.logs.length, 'logs, activeTabId:', updatedSession.activeTabId); + return { ...updatedSession, inputMode: 'ai' }; + })); setActiveClaudeSessionId(claudeSessionId); } catch (error) { console.error('Failed to resume session:', error); @@ -2608,6 +2794,13 @@ export default function MaestroConsole() { // Handle Shift+[ producing { and Shift+] producing } if (mainKey === '[' && (key === '[' || key === '{')) return true; if (mainKey === ']' && (key === ']' || key === '}')) return true; + // Handle Shift+number producing symbol (US keyboard layout) + // Shift+1='!', Shift+2='@', Shift+3='#', etc. + const shiftNumberMap: Record = { + '!': '1', '@': '2', '#': '3', '$': '4', '%': '5', + '^': '6', '&': '7', '*': '8', '(': '9', ')': '0' + }; + if (shiftNumberMap[key] === mainKey) return true; return key === mainKey; }; @@ -2994,9 +3187,9 @@ export default function MaestroConsole() { } else if (ctx.isShortcut(e, 'help')) ctx.setShortcutsHelpOpen(true); else if (ctx.isShortcut(e, 'settings')) { ctx.setSettingsModalOpen(true); ctx.setSettingsTab('general'); } - else if (ctx.isShortcut(e, 'goToFiles')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.setActiveRightTab('files'); ctx.setActiveFocus('right'); } - else if (ctx.isShortcut(e, 'goToHistory')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.setActiveRightTab('history'); ctx.setActiveFocus('right'); } - else if (ctx.isShortcut(e, 'goToScratchpad')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.setActiveRightTab('scratchpad'); ctx.setActiveFocus('right'); } + else if (ctx.isShortcut(e, 'goToFiles')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.handleSetActiveRightTab('files'); ctx.setActiveFocus('right'); } + else if (ctx.isShortcut(e, 'goToHistory')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.handleSetActiveRightTab('history'); ctx.setActiveFocus('right'); } + else if (ctx.isShortcut(e, 'goToAutoRun')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.handleSetActiveRightTab('autorun'); ctx.setActiveFocus('right'); } else if (ctx.isShortcut(e, 'openImageCarousel')) { e.preventDefault(); if (ctx.stagedImages.length > 0) { @@ -3047,6 +3240,16 @@ export default function MaestroConsole() { // Jump to the bottom of the current main panel output (AI logs or terminal output) ctx.logsEndRef.current?.scrollIntoView({ behavior: 'instant' }); } + else if (ctx.isShortcut(e, 'toggleMarkdownMode')) { + // Toggle markdown raw mode for AI message history + // Skip when in AutoRun panel (it has its own Cmd+E handler for edit/preview toggle) + // Skip when FilePreview is open (it handles its own Cmd+E) + const isInAutoRunPanel = ctx.activeFocus === 'right' && ctx.activeRightTab === 'autorun'; + if (!isInAutoRunPanel && !ctx.previewFile) { + e.preventDefault(); + ctx.setMarkdownRawMode(!ctx.markdownRawMode); + } + } // Opt+Cmd+NUMBER: Jump to visible session by number (1-9, 0=10th) // Use e.code instead of e.key because Option key on macOS produces special characters @@ -3158,9 +3361,9 @@ export default function MaestroConsole() { )); } } - // Cmd+1 through Cmd+8: Jump to specific tab by index (disabled in unread-only mode) + // Cmd+1 through Cmd+9: Jump to specific tab by index (disabled in unread-only mode) if (!ctx.showUnreadOnly) { - for (let i = 1; i <= 8; i++) { + for (let i = 1; i <= 9; i++) { if (ctx.isTabShortcut(e, `goToTab${i}`)) { e.preventDefault(); const result = ctx.navigateToTabByIndex(ctx.activeSession, i - 1); @@ -3172,7 +3375,7 @@ export default function MaestroConsole() { break; } } - // Cmd+9: Jump to last tab + // Cmd+0: Jump to last tab if (ctx.isTabShortcut(e, 'goToLastTab')) { e.preventDefault(); const result = ctx.navigateToLastTab(ctx.activeSession); @@ -3264,6 +3467,44 @@ export default function MaestroConsole() { } }, [shortcutsHelpOpen]); + // Load Auto Run document list and content when session changes or autorun tab becomes active + useEffect(() => { + const loadAutoRunData = async () => { + if (!activeSession?.autoRunFolderPath) { + setAutoRunDocumentList([]); + setAutoRunDocumentTree([]); + setAutoRunContent(''); + return; + } + + // Load document list + setAutoRunIsLoadingDocuments(true); + const listResult = await window.maestro.autorun.listDocs(activeSession.autoRunFolderPath); + if (listResult.success) { + setAutoRunDocumentList(listResult.files || []); + setAutoRunDocumentTree((listResult.tree as Array<{ name: string; type: 'file' | 'folder'; path: string; children?: unknown[] }>) || []); + } + setAutoRunIsLoadingDocuments(false); + + // Load content of selected document + if (activeSession.autoRunSelectedFile) { + const contentResult = await window.maestro.autorun.readDoc( + activeSession.autoRunFolderPath, + activeSession.autoRunSelectedFile + '.md' + ); + if (contentResult.success) { + setAutoRunContent(contentResult.content || ''); + } else { + setAutoRunContent(''); + } + } else { + setAutoRunContent(''); + } + }; + + loadAutoRunData(); + }, [activeSessionId, activeSession?.autoRunFolderPath, activeSession?.autoRunSelectedFile]); + // Auto-scroll logs const activeTabLogs = activeSession ? getActiveTab(activeSession)?.logs : undefined; useEffect(() => { @@ -3452,6 +3693,7 @@ export default function MaestroConsole() { state: 'idle', cwd: workingDir, fullPath: workingDir, + projectRoot: workingDir, // Store the initial directory (never changes) isGitRepo, gitBranches, gitTags, @@ -3459,7 +3701,6 @@ export default function MaestroConsole() { aiLogs: [], // Deprecated - logs are now in aiTabs shellLogs: [{ id: generateId(), timestamp: Date.now(), source: 'system', text: 'Shell Session Ready.' }], workLog: [], - scratchPadContent: '', contextUsage: 0, inputMode: agentId === 'terminal' ? 'terminal' : 'ai', // AI process PID (terminal uses runCommand which spawns fresh shells) @@ -3617,16 +3858,16 @@ export default function MaestroConsole() { processMonitorOpen, logViewerOpen, createGroupModalOpen, confirmModalOpen, renameInstanceModalOpen, renameGroupModalOpen, activeSession, previewFile, fileTreeFilter, fileTreeFilterOpen, gitDiffPreview, gitLogOpen, lightboxImage, hasOpenLayers, hasOpenModal, visibleSessions, sortedSessions, groups, - bookmarksCollapsed, leftSidebarOpen, editingSessionId, editingGroupId, + bookmarksCollapsed, leftSidebarOpen, editingSessionId, editingGroupId, markdownRawMode, setLeftSidebarOpen, setRightPanelOpen, addNewSession, deleteSession, setQuickActionInitialMode, setQuickActionOpen, cycleSession, toggleInputMode, setShortcutsHelpOpen, setSettingsModalOpen, - setSettingsTab, setActiveRightTab, setActiveFocus, setBookmarksCollapsed, setGroups, + setSettingsTab, setActiveRightTab, handleSetActiveRightTab, setActiveFocus, setBookmarksCollapsed, setGroups, setSelectedSidebarIndex, setActiveSessionId, handleViewGitDiff, setGitLogOpen, setActiveClaudeSessionId, setAgentSessionsOpen, setLogViewerOpen, setProcessMonitorOpen, logsEndRef, inputRef, terminalOutputRef, setSessions, createTab, closeTab, reopenClosedTab, getActiveTab, setRenameTabId, setRenameTabInitialName, setRenameTabModalOpen, navigateToNextTab, navigateToPrevTab, navigateToTabByIndex, navigateToLastTab, setFileTreeFilterOpen, isShortcut, isTabShortcut, handleNavBack, handleNavForward, toggleUnreadFilter, - setTabSwitcherOpen, showUnreadOnly, stagedImages, handleSetLightboxImage + setTabSwitcherOpen, showUnreadOnly, stagedImages, handleSetLightboxImage, setMarkdownRawMode }; const toggleGroup = (groupId: string) => { @@ -3720,38 +3961,177 @@ export default function MaestroConsole() { } }; - const updateScratchPad = useCallback((content: string) => { - setSessions(prev => prev.map(s => s.id === activeSessionId ? { ...s, scratchPadContent: content } : s)); - }, [activeSessionId]); + // Auto Run document content change handler + const handleAutoRunContentChange = useCallback(async (content: string) => { + setAutoRunContent(content); + // Auto-save to file + if (activeSession?.autoRunFolderPath && activeSession?.autoRunSelectedFile) { + await window.maestro.autorun.writeDoc( + activeSession.autoRunFolderPath, + activeSession.autoRunSelectedFile + '.md', + content + ); + } + }, [activeSession?.autoRunFolderPath, activeSession?.autoRunSelectedFile]); - const updateScratchPadState = useCallback((state: { + // Auto Run mode change handler + const handleAutoRunModeChange = useCallback((mode: 'edit' | 'preview') => { + if (!activeSession) return; + setSessions(prev => prev.map(s => + s.id === activeSession.id ? { ...s, autoRunMode: mode } : s + )); + }, [activeSession]); + + // File tree auto-refresh interval change handler + const handleAutoRefreshChange = useCallback((interval: number) => { + if (!activeSession) return; + setSessions(prev => prev.map(s => + s.id === activeSession.id ? { ...s, fileTreeAutoRefreshInterval: interval } : s + )); + }, [activeSession]); + + // Auto Run state change handler (scroll/cursor positions) + const handleAutoRunStateChange = useCallback((state: { mode: 'edit' | 'preview'; cursorPosition: number; editScrollPos: number; previewScrollPos: number; }) => { - setSessions(prev => prev.map(s => s.id === activeSessionId ? { - ...s, - scratchPadMode: state.mode, - scratchPadCursorPosition: state.cursorPosition, - scratchPadEditScrollPos: state.editScrollPos, - scratchPadPreviewScrollPos: state.previewScrollPos - } : s)); - }, [activeSessionId]); + if (!activeSession) return; + setSessions(prev => prev.map(s => + s.id === activeSession.id ? { + ...s, + autoRunMode: state.mode, + autoRunCursorPosition: state.cursorPosition, + autoRunEditScrollPos: state.editScrollPos, + autoRunPreviewScrollPos: state.previewScrollPos, + } : s + )); + }, [activeSession]); - const processInput = async (overrideInputValue?: string) => { - const effectiveInputValue = overrideInputValue ?? inputValue; - if (!activeSession || (!effectiveInputValue.trim() && stagedImages.length === 0)) { + // Auto Run document selection handler + const handleAutoRunSelectDocument = useCallback(async (filename: string) => { + if (!activeSession?.autoRunFolderPath) return; + + // Save current document first if there's content + if (activeSession.autoRunSelectedFile && autoRunContent) { + await window.maestro.autorun.writeDoc( + activeSession.autoRunFolderPath, + activeSession.autoRunSelectedFile + '.md', + autoRunContent + ); + } + + // Load new document content FIRST (before updating selectedFile) + // This ensures content prop updates atomically with the file selection + const result = await window.maestro.autorun.readDoc( + activeSession.autoRunFolderPath, + filename + '.md' + ); + const newContent = result.success ? (result.content || '') : ''; + + // Update content first, then selected file + // This prevents the AutoRun component from seeing mismatched file/content + setAutoRunContent(newContent); + + // Then update the selected file + setSessions(prev => prev.map(s => + s.id === activeSession.id ? { ...s, autoRunSelectedFile: filename } : s + )); + }, [activeSession, autoRunContent]); + + // Auto Run refresh handler - reload document list and show flash notification + const handleAutoRunRefresh = useCallback(async () => { + if (!activeSession?.autoRunFolderPath) return; + const previousCount = autoRunDocumentList.length; + setAutoRunIsLoadingDocuments(true); + const result = await window.maestro.autorun.listDocs(activeSession.autoRunFolderPath); + if (result.success) { + const newFiles = result.files || []; + setAutoRunDocumentList(newFiles); + setAutoRunIsLoadingDocuments(false); + + // Show flash notification with result + const diff = newFiles.length - previousCount; + let message: string; + if (diff > 0) { + message = `Found ${diff} new document${diff === 1 ? '' : 's'}`; + } else if (diff < 0) { + message = `${Math.abs(diff)} document${Math.abs(diff) === 1 ? '' : 's'} removed`; + } else { + message = 'Refresh complete, no new documents'; + } + setSuccessFlashNotification(message); + setTimeout(() => setSuccessFlashNotification(null), 2000); return; } + setAutoRunIsLoadingDocuments(false); + }, [activeSession?.autoRunFolderPath, autoRunDocumentList.length]); + + // Auto Run open setup handler + const handleAutoRunOpenSetup = useCallback(() => { + setAutoRunSetupModalOpen(true); + }, []); + + // Auto Run create new document handler + const handleAutoRunCreateDocument = useCallback(async (filename: string): Promise => { + if (!activeSession?.autoRunFolderPath) return false; + + try { + // Create the document with empty content so placeholder hint shows + const result = await window.maestro.autorun.writeDoc( + activeSession.autoRunFolderPath, + filename + '.md', + '' + ); + + if (result.success) { + // Refresh the document list + const listResult = await window.maestro.autorun.listDocs(activeSession.autoRunFolderPath); + if (listResult.success) { + setAutoRunDocumentList(listResult.files || []); + } + + // Select the new document and switch to edit mode + setSessions(prev => prev.map(s => + s.id === activeSession.id ? { ...s, autoRunSelectedFile: filename, autoRunMode: 'edit' } : s + )); + + // Load the new document content + const contentResult = await window.maestro.autorun.readDoc( + activeSession.autoRunFolderPath, + filename + '.md' + ); + if (contentResult.success) { + setAutoRunContent(contentResult.content || ''); + } + + return true; + } + return false; + } catch (error) { + console.error('Failed to create document:', error); + return false; + } + }, [activeSession]); - // Block slash commands when agent is busy (in AI mode) - if (effectiveInputValue.trim().startsWith('/') && activeSession.state === 'busy' && activeSession.inputMode === 'ai') { - showFlashNotification('Commands disabled while agent is working'); + const processInput = async (overrideInputValue?: string) => { + const effectiveInputValue = overrideInputValue ?? inputValue; + console.log('[processInput] Called with:', { + overrideInputValue, + inputValue, + effectiveInputValue, + activeSessionId: activeSession?.id, + inputMode: activeSession?.inputMode, + stagedImagesCount: stagedImages.length + }); + if (!activeSession || (!effectiveInputValue.trim() && stagedImages.length === 0)) { + console.log('[processInput] Early return - no session or empty input'); return; } // Handle slash commands (custom AI commands only - built-in commands have been removed) + // Note: slash commands are queued like regular messages when agent is busy if (effectiveInputValue.trim().startsWith('/')) { const commandText = effectiveInputValue.trim(); const isTerminalMode = activeSession.inputMode === 'terminal'; @@ -3803,6 +4183,8 @@ export default function MaestroConsole() { // If session is busy, just add to queue - it will be processed when current item finishes if (sessionIsIdle) { // Set up session and tab state for immediate processing + // NOTE: Don't add to executionQueue when processing immediately - it's not actually queued, + // and adding it would cause duplicate display (once as sent message, once in queue section) setSessions(prev => prev.map(s => { if (s.id !== activeSessionId) return s; @@ -3821,8 +4203,7 @@ export default function MaestroConsole() { currentCycleTokens: 0, currentCycleBytes: 0, aiTabs: updatedAiTabs, - // Add to queue - it will be removed by exit handler or stay for display - executionQueue: [...s.executionQueue, queuedItem], + // Don't add to queue - we're processing immediately, not queuing aiCommandHistory: Array.from(new Set([...(s.aiCommandHistory || []), commandText])).slice(-50), }; })); @@ -4829,152 +5210,13 @@ export default function MaestroConsole() { } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedSlashCommandIndex(prev => Math.max(prev - 1, 0)); - } else if (e.key === 'Tab') { - // Tab just fills in the command text - e.preventDefault(); - setInputValue(filteredCommands[selectedSlashCommandIndex]?.command || inputValue); - setSlashCommandOpen(false); - } else if (e.key === 'Enter' && filteredCommands.length > 0) { - // Enter executes the command directly + } else if (e.key === 'Tab' || e.key === 'Enter') { + // Tab or Enter fills in the command text (user can then press Enter again to execute) e.preventDefault(); - const selectedCommand = filteredCommands[selectedSlashCommandIndex]; - if (selectedCommand) { + if (filteredCommands[selectedSlashCommandIndex]) { + setInputValue(filteredCommands[selectedSlashCommandIndex].command); setSlashCommandOpen(false); - setInputValue(''); - if (inputRef.current) inputRef.current.style.height = 'auto'; - - // Execute the custom AI command (substitute template variables and send to agent) - if ('prompt' in selectedCommand && selectedCommand.prompt) { - // Use the same spawn logic as processInput for proper tab-based session ID tracking - (async () => { - let gitBranch: string | undefined; - if (activeSession.isGitRepo) { - try { - const status = await gitService.getStatus(activeSession.cwd); - gitBranch = status.branch; - } catch { - // Ignore git errors - } - } - const substitutedPrompt = substituteTemplateVariables( - selectedCommand.prompt, - { session: activeSession, gitBranch } - ); - - // Get the active tab for proper targeting - const activeTab = getActiveTab(activeSession); - if (!activeTab) { - console.error('[handleInputKeyDown] No active tab for slash command'); - return; - } - - // Build target session ID using tab ID (same pattern as processInput) - const targetSessionId = `${activeSessionId}-ai-${activeTab.id}`; - const isNewSession = !activeTab.claudeSessionId; - - // Add user log showing the command with its interpolated prompt to active tab - const newEntry: LogEntry = { - id: generateId(), - timestamp: Date.now(), - source: 'user', - text: substitutedPrompt, - aiCommand: { - command: selectedCommand.command, - description: selectedCommand.description - } - }; - - // Update session state: add log, set busy, set awaitingSessionId for new sessions - setSessions(prev => prev.map(s => { - if (s.id !== activeSessionId) return s; - - // Update the active tab's logs and state - const updatedAiTabs = s.aiTabs.map(tab => - tab.id === activeTab.id - ? { - ...tab, - logs: [...tab.logs, newEntry], - state: 'busy' as const, - thinkingStartTime: Date.now(), - awaitingSessionId: isNewSession ? true : tab.awaitingSessionId - } - : tab - ); - - return { - ...s, - state: 'busy' as SessionState, - busySource: 'ai', - thinkingStartTime: Date.now(), - currentCycleTokens: 0, - currentCycleBytes: 0, - aiCommandHistory: Array.from(new Set([...(s.aiCommandHistory || []), selectedCommand.command])).slice(-50), - pendingAICommandForSynopsis: selectedCommand.command, - aiTabs: updatedAiTabs - }; - })); - - // Spawn the agent with proper session ID format (same as processInput) - try { - const agent = await window.maestro.agents.get('claude-code'); - if (!agent) throw new Error('Claude Code agent not found'); - - // Get fresh session state to avoid stale closure - const freshSession = sessionsRef.current.find(s => s.id === activeSessionId); - if (!freshSession) throw new Error('Session not found'); - - const freshActiveTab = getActiveTab(freshSession); - const tabClaudeSessionId = freshActiveTab?.claudeSessionId; - - // Build spawn args with resume if we have a session ID - const spawnArgs = [...(agent.args || [])]; - if (tabClaudeSessionId) { - spawnArgs.push('--resume', tabClaudeSessionId); - } - - // Add read-only mode if tab has it enabled - if (freshActiveTab?.readOnlyMode) { - spawnArgs.push('--permission-mode', 'plan'); - } - - const commandToUse = agent.path || agent.command; - await window.maestro.process.spawn({ - sessionId: targetSessionId, - toolType: 'claude-code', - cwd: freshSession.cwd, - command: commandToUse, - args: spawnArgs, - prompt: substitutedPrompt - }); - } catch (error: any) { - console.error('[handleInputKeyDown] Failed to spawn Claude for slash command:', error); - setSessions(prev => prev.map(s => { - if (s.id !== activeSessionId) return s; - const errorEntry: LogEntry = { - id: generateId(), - timestamp: Date.now(), - source: 'system', - text: `Error: Failed to run ${selectedCommand.command} - ${error.message}` - }; - const updatedAiTabs = s.aiTabs.map(tab => - tab.id === activeTab.id - ? { ...tab, state: 'idle' as const, logs: [...tab.logs, errorEntry] } - : tab - ); - return { - ...s, - state: 'idle' as SessionState, - busySource: undefined, - aiTabs: updatedAiTabs - }; - })); - } - })(); - } else { - // Claude Code slash command (no prompt property) - send raw command text - // Claude Code will expand the command itself from .claude/commands/*.md - processInput(selectedCommand.command); - } + inputRef.current?.focus(); } } else if (e.key === 'Escape') { e.preventDefault(); @@ -5160,16 +5402,21 @@ export default function MaestroConsole() { })); }; - // Refresh file tree for a session - const refreshFileTree = useCallback(async (sessionId: string) => { + // Refresh file tree for a session and return the changes detected + const refreshFileTree = useCallback(async (sessionId: string): Promise => { const session = sessions.find(s => s.id === sessionId); - if (!session) return; + if (!session) return undefined; try { - const tree = await loadFileTree(session.cwd); + const oldTree = session.fileTree || []; + const newTree = await loadFileTree(session.cwd); + const changes = compareFileTrees(oldTree, newTree); + setSessions(prev => prev.map(s => - s.id === sessionId ? { ...s, fileTree: tree, fileTreeError: undefined } : s + s.id === sessionId ? { ...s, fileTree: newTree, fileTreeError: undefined } : s )); + + return changes; } catch (error) { console.error('File tree refresh error:', error); const errorMsg = (error as Error)?.message || 'Unknown error'; @@ -5180,6 +5427,7 @@ export default function MaestroConsole() { fileTreeError: `Cannot access directory: ${session.cwd}\n${errorMsg}` } : s )); + return undefined; } }, [sessions]); @@ -5569,6 +5817,20 @@ export default function MaestroConsole() { setTimeout(() => setSuccessFlashNotification(null), 2000); } }} + onDebugReleaseQueuedItem={() => { + if (!activeSession || activeSession.executionQueue.length === 0) return; + const [nextItem, ...remainingQueue] = activeSession.executionQueue; + // Update state to remove item from queue + setSessions(prev => prev.map(s => { + if (s.id !== activeSessionId) return s; + return { ...s, executionQueue: remainingQueue }; + })); + // Process the item + processQueuedItem(activeSessionId, nextItem); + console.log('[Debug] Released queued item:', nextItem); + }} + markdownRawMode={markdownRawMode} + onToggleMarkdownRawMode={() => setMarkdownRawMode(!markdownRawMode)} /> )} {lightboxImage && ( @@ -5855,6 +6117,7 @@ export default function MaestroConsole() { setActiveClaudeSessionId={setActiveClaudeSessionId} onResumeClaudeSession={(claudeSessionId: string, messages: LogEntry[], sessionName?: string, starred?: boolean) => { // Opens the Claude session as a new tab (or switches to existing tab if duplicate) + console.log('[onResumeClaudeSession] Called with:', claudeSessionId, 'messages:', messages.length, 'name:', sessionName, 'starred:', starred); handleResumeSession(claudeSessionId, messages, sessionName, starred); }} onNewClaudeSession={() => { @@ -6237,7 +6500,7 @@ export default function MaestroConsole() { rightPanelWidth={rightPanelWidth} setRightPanelWidthState={setRightPanelWidth} activeRightTab={activeRightTab} - setActiveRightTab={setActiveRightTab} + setActiveRightTab={handleSetActiveRightTab} activeFocus={activeFocus} setActiveFocus={setActiveFocus} fileTreeFilter={fileTreeFilter} @@ -6257,8 +6520,18 @@ export default function MaestroConsole() { updateSessionWorkingDirectory={updateSessionWorkingDirectory} refreshFileTree={refreshFileTree} setSessions={setSessions} - updateScratchPad={updateScratchPad} - updateScratchPadState={updateScratchPadState} + onAutoRefreshChange={handleAutoRefreshChange} + autoRunDocumentList={autoRunDocumentList} + autoRunDocumentTree={autoRunDocumentTree} + autoRunContent={autoRunContent} + autoRunIsLoadingDocuments={autoRunIsLoadingDocuments} + onAutoRunContentChange={handleAutoRunContentChange} + onAutoRunModeChange={handleAutoRunModeChange} + onAutoRunStateChange={handleAutoRunStateChange} + onAutoRunSelectDocument={handleAutoRunSelectDocument} + onAutoRunCreateDocument={handleAutoRunCreateDocument} + onAutoRunRefresh={handleAutoRunRefresh} + onAutoRunOpenSetup={handleAutoRunOpenSetup} batchRunState={activeBatchRunState} onOpenBatchRunner={handleOpenBatchRunner} onStopBatchRun={handleStopBatchRun} @@ -6269,15 +6542,23 @@ export default function MaestroConsole() { )} + {/* --- AUTO RUN SETUP MODAL --- */} + {autoRunSetupModalOpen && ( + setAutoRunSetupModalOpen(false)} + onFolderSelected={handleAutoRunFolderSelected} + currentFolder={activeSession?.autoRunFolderPath} + sessionName={activeSession?.name} + /> + )} + {/* --- BATCH RUNNER MODAL --- */} - {batchRunnerModalOpen && activeSession && ( + {batchRunnerModalOpen && activeSession && activeSession.autoRunFolderPath && ( setBatchRunnerModalOpen(false)} - onGo={(prompt) => { - // Start the batch run - handleStartBatchRun(prompt); - }} + onGo={handleStartBatchRun} onSave={(prompt) => { // Save the custom prompt and modification timestamp to the session (persisted across restarts) setSessions(prev => prev.map(s => @@ -6287,7 +6568,12 @@ export default function MaestroConsole() { initialPrompt={activeSession.batchRunnerPrompt || ''} lastModifiedAt={activeSession.batchRunnerPromptModifiedAt} showConfirmation={showConfirmation} - scratchpadContent={activeSession.scratchPadContent} + folderPath={activeSession.autoRunFolderPath} + currentDocument={activeSession.autoRunSelectedFile || ''} + allDocuments={autoRunDocumentList} + getDocumentTaskCount={getDocumentTaskCount} + sessionId={activeSession.id} + sessionCwd={activeSession.cwd} /> )} @@ -6297,25 +6583,17 @@ export default function MaestroConsole() { theme={theme} tabs={activeSession.aiTabs} activeTabId={activeSession.activeTabId} - cwd={activeSession.cwd} + projectRoot={activeSession.projectRoot} shortcut={TAB_SHORTCUTS.tabSwitcher} onTabSelect={(tabId) => { setSessions(prev => prev.map(s => s.id === activeSession.id ? { ...s, activeTabId: tabId } : s )); }} - onNamedSessionSelect={(claudeSessionId, projectPath, sessionName) => { - // Open a closed named session as a new tab in the current session - // Note: The session might be from a different project - we'll open it anyway - // and let Claude Code handle the context appropriately - const result = createTab(activeSession, { - claudeSessionId, - name: sessionName, - logs: [], // Will be populated when the session is resumed - }); - setSessions(prev => prev.map(s => - s.id === activeSession.id ? result.session : s - )); + onNamedSessionSelect={(claudeSessionId, _projectPath, sessionName, starred) => { + // Open a closed named session as a new tab - use handleResumeSession to properly load messages + console.log('[onNamedSessionSelect] Opening session:', claudeSessionId, 'name:', sessionName, 'starred:', starred); + handleResumeSession(claudeSessionId, [], sessionName, starred); // Focus input so user can start interacting immediately setActiveFocus('main'); setTimeout(() => inputRef.current?.focus(), 50); @@ -6331,6 +6609,12 @@ export default function MaestroConsole() { theme={theme} initialValue={inputValue} onSubmit={(value) => setInputValue(value)} + onSend={(value) => { + // Set the input value and trigger send + setInputValue(value); + // Use setTimeout to ensure state updates before processing + setTimeout(() => processInput(value), 0); + }} sessionName={activeSession?.name} /> diff --git a/src/renderer/components/AboutModal.tsx b/src/renderer/components/AboutModal.tsx index af1027b51..fbd36f5d4 100644 --- a/src/renderer/components/AboutModal.tsx +++ b/src/renderer/components/AboutModal.tsx @@ -192,6 +192,7 @@ export function AboutModal({ theme, sessions, autoRunStats, onClose }: AboutModa { badgeEscapeHandlerRef.current = handler; }} /> diff --git a/src/renderer/components/AchievementCard.tsx b/src/renderer/components/AchievementCard.tsx index 77e97c7ea..a2f1eb326 100644 --- a/src/renderer/components/AchievementCard.tsx +++ b/src/renderer/components/AchievementCard.tsx @@ -125,9 +125,22 @@ function interpolateColor(color1: string, color2: string, t: number): string { return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; } +interface ClaudeGlobalStats { + totalSessions: number; + totalMessages: number; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheReadTokens: number; + totalCacheCreationTokens: number; + totalCostUsd: number; + totalSizeBytes: number; + isComplete?: boolean; +} + interface AchievementCardProps { theme: Theme; autoRunStats: AutoRunStats; + globalStats?: ClaudeGlobalStats | null; } interface BadgeTooltipProps { @@ -242,7 +255,7 @@ function BadgeTooltip({ badge, theme, isUnlocked, position, onClose }: BadgeTool * Achievement card component for displaying in the About modal * Shows current badge, progress to next level, and stats */ -export function AchievementCard({ theme, autoRunStats, onEscapeWithBadgeOpen }: AchievementCardProps & { onEscapeWithBadgeOpen?: (handler: (() => boolean) | null) => void }) { +export function AchievementCard({ theme, autoRunStats, globalStats, onEscapeWithBadgeOpen }: AchievementCardProps & { onEscapeWithBadgeOpen?: (handler: (() => boolean) | null) => void }) { const [selectedBadge, setSelectedBadge] = useState(null); const [historyExpanded, setHistoryExpanded] = useState(false); const [shareMenuOpen, setShareMenuOpen] = useState(false); @@ -345,117 +358,275 @@ export function AchievementCard({ theme, autoRunStats, onEscapeWithBadgeOpen }: return lines; }; + // Format token count with K/M suffix + const formatTokens = (count: number): string => { + if (count >= 1_000_000) { + return `${(count / 1_000_000).toFixed(1)}M`; + } + if (count >= 1_000) { + return `${(count / 1_000).toFixed(1)}K`; + } + return count.toString(); + }; + + // Draw the maestro conductor silhouette + const drawMaestroSilhouette = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number) => { + ctx.save(); + ctx.translate(x, y); + const scale = size / 512; + ctx.scale(scale, scale); + + ctx.fillStyle = '#FFFFFF'; + + // Head - profile facing left + ctx.beginPath(); + ctx.ellipse(180, 80, 55, 60, 0, 0, Math.PI * 2); + ctx.fill(); + + // Nose bump + ctx.beginPath(); + ctx.ellipse(118, 85, 12, 8, 0, 0, Math.PI * 2); + ctx.fill(); + + // Hair/top of head + ctx.beginPath(); + ctx.ellipse(185, 35, 40, 25, 0, 0, Math.PI * 2); + ctx.fill(); + + // Neck + ctx.fillRect(155, 130, 50, 40); + + // Collar area + ctx.beginPath(); + ctx.moveTo(140, 165); + ctx.lineTo(260, 165); + ctx.lineTo(250, 185); + ctx.lineTo(150, 185); + ctx.closePath(); + ctx.fill(); + + // Bow tie + ctx.beginPath(); + ctx.ellipse(200, 175, 25, 10, 0, 0, Math.PI * 2); + ctx.fill(); + + // Torso + ctx.beginPath(); + ctx.moveTo(140, 185); + ctx.lineTo(120, 350); + ctx.lineTo(280, 350); + ctx.lineTo(260, 185); + ctx.closePath(); + ctx.fill(); + + // Left arm + ctx.beginPath(); + ctx.moveTo(140, 200); + ctx.quadraticCurveTo(90, 220, 80, 280); + ctx.quadraticCurveTo(75, 300, 90, 310); + ctx.lineTo(110, 305); + ctx.quadraticCurveTo(120, 290, 115, 260); + ctx.quadraticCurveTo(130, 240, 145, 230); + ctx.closePath(); + ctx.fill(); + + // Left hand + ctx.beginPath(); + ctx.ellipse(85, 295, 18, 22, 0, 0, Math.PI * 2); + ctx.fill(); + + // Right arm raised with baton + ctx.beginPath(); + ctx.moveTo(260, 200); + ctx.quadraticCurveTo(300, 180, 330, 130); + ctx.quadraticCurveTo(340, 115, 355, 100); + ctx.lineTo(365, 110); + ctx.quadraticCurveTo(350, 130, 340, 145); + ctx.quadraticCurveTo(315, 195, 270, 220); + ctx.closePath(); + ctx.fill(); + + // Right hand + ctx.beginPath(); + ctx.ellipse(350, 95, 15, 18, 0, 0, Math.PI * 2); + ctx.fill(); + + // Baton + ctx.strokeStyle = '#FFFFFF'; + ctx.lineWidth = 6; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(360, 85); + ctx.lineTo(395, 20); + ctx.stroke(); + + ctx.restore(); + }; + // Generate shareable achievement card as canvas const generateShareImage = useCallback(async (): Promise => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d')!; const width = 600; - const height = 400; + const height = 380; canvas.width = width; canvas.height = height; - // Background gradient - const bgGradient = ctx.createLinearGradient(0, 0, width, height); - bgGradient.addColorStop(0, '#1a1a2e'); - bgGradient.addColorStop(1, '#16213e'); + // Background gradient matching app icon (radial gradient from center) + const bgGradient = ctx.createRadialGradient( + width / 2, height / 2, 0, + width / 2, height / 2, width * 0.7 + ); + bgGradient.addColorStop(0, '#2d1f4e'); // Lighter purple center + bgGradient.addColorStop(1, '#1a1a2e'); // Dark purple edges ctx.fillStyle = bgGradient; - ctx.roundRect(0, 0, width, height, 16); + ctx.roundRect(0, 0, width, height, 20); + ctx.fill(); + + // Subtle gradient overlay for depth + const overlayGradient = ctx.createLinearGradient(0, 0, 0, height); + overlayGradient.addColorStop(0, 'rgba(139, 92, 246, 0.15)'); + overlayGradient.addColorStop(0.5, 'rgba(0, 0, 0, 0)'); + overlayGradient.addColorStop(1, 'rgba(0, 0, 0, 0.2)'); + ctx.fillStyle = overlayGradient; + ctx.roundRect(0, 0, width, height, 20); ctx.fill(); - // Border - ctx.strokeStyle = goldColor; - ctx.lineWidth = 3; - ctx.roundRect(0, 0, width, height, 16); + // Border with purple glow effect + ctx.strokeStyle = '#8B5CF6'; + ctx.lineWidth = 2; + ctx.roundRect(0, 0, width, height, 20); + ctx.stroke(); + + // Outer glow border + ctx.strokeStyle = 'rgba(139, 92, 246, 0.3)'; + ctx.lineWidth = 4; + ctx.roundRect(-2, -2, width + 4, height + 4, 22); ctx.stroke(); - // Header accent - const headerGradient = ctx.createLinearGradient(0, 0, width, 100); - headerGradient.addColorStop(0, `${purpleAccent}40`); - headerGradient.addColorStop(1, 'transparent'); - ctx.fillStyle = headerGradient; - ctx.fillRect(0, 0, width, 100); + // Draw conductor silhouette in top left (subtle, as background element) + ctx.save(); + ctx.globalAlpha = 0.08; + drawMaestroSilhouette(ctx, -20, 20, 280); + ctx.restore(); + + // Trophy icon circle with gradient + const trophyX = width / 2; + const trophyY = 52; + const trophyRadius = 32; - // Trophy icon (simplified circle) ctx.beginPath(); - ctx.arc(width / 2, 60, 30, 0, Math.PI * 2); - const trophyGradient = ctx.createRadialGradient(width / 2, 60, 0, width / 2, 60, 30); - trophyGradient.addColorStop(0, '#FFA500'); - trophyGradient.addColorStop(1, goldColor); + ctx.arc(trophyX, trophyY, trophyRadius, 0, Math.PI * 2); + const trophyGradient = ctx.createRadialGradient(trophyX, trophyY - 10, 0, trophyX, trophyY, trophyRadius); + trophyGradient.addColorStop(0, '#FFE066'); + trophyGradient.addColorStop(1, '#F59E0B'); ctx.fillStyle = trophyGradient; ctx.fill(); // Trophy emoji - ctx.fillStyle = '#FFFFFF'; - ctx.font = 'bold 28px system-ui'; + ctx.font = 'bold 32px system-ui'; ctx.textAlign = 'center'; - ctx.fillText('🏆', width / 2, 70); + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#78350F'; + ctx.fillText('🏆', trophyX, trophyY + 2); // Title - ctx.font = 'bold 24px system-ui'; - ctx.fillStyle = goldColor; - ctx.fillText('MAESTRO ACHIEVEMENTS', width / 2, 120); + ctx.font = 'bold 18px system-ui'; + ctx.fillStyle = '#F472B6'; // Pink accent like in screenshots + ctx.letterSpacing = '2px'; + ctx.fillText('MAESTRO ACHIEVEMENTS', width / 2, 105); if (currentBadge) { - // Level badge - ctx.font = 'bold 18px system-ui'; + // Level indicator with stars + ctx.font = 'bold 14px system-ui'; ctx.fillStyle = goldColor; - ctx.fillText(`⭐ Level ${currentBadge.level} of 11 ⭐`, width / 2, 155); - - // Badge name - ctx.font = 'bold 28px system-ui'; - ctx.fillStyle = purpleAccent; - ctx.fillText(currentBadge.name, width / 2, 190); - - // Flavor text - ctx.font = 'italic 14px system-ui'; - ctx.fillStyle = '#CCCCCC'; - const flavorLines = wrapText(ctx, `"${currentBadge.flavorText}"`, width - 80); - let yOffset = 225; + ctx.fillText(`★ Level ${currentBadge.level} of 11 ★`, width / 2, 130); + + // Badge name - larger and more prominent + ctx.font = 'bold 26px system-ui'; + ctx.fillStyle = '#F472B6'; + ctx.fillText(currentBadge.name, width / 2, 162); + + // Flavor text in quotes + ctx.font = 'italic 13px system-ui'; + ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; + const flavorLines = wrapText(ctx, `"${currentBadge.flavorText}"`, width - 100); + let yOffset = 190; flavorLines.forEach(line => { ctx.fillText(line, width / 2, yOffset); yOffset += 18; }); } else { // No badge yet - ctx.font = 'bold 20px system-ui'; - ctx.fillStyle = '#AAAAAA'; - ctx.fillText('Journey Just Beginning...', width / 2, 170); + ctx.font = 'bold 22px system-ui'; + ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; + ctx.fillText('Journey Just Beginning...', width / 2, 155); ctx.font = '14px system-ui'; - ctx.fillStyle = '#888888'; - ctx.fillText('Complete 15 minutes of AutoRun to unlock first badge', width / 2, 200); + ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.fillText('Complete 15 minutes of AutoRun to unlock first badge', width / 2, 185); } - // Stats box - const statsY = 300; - ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; - ctx.roundRect(50, statsY - 10, width - 100, 50, 8); + // Stats container with dark background + const statsY = 240; + const statsHeight = 85; + ctx.fillStyle = 'rgba(0, 0, 0, 0.35)'; + ctx.roundRect(30, statsY, width - 60, statsHeight, 12); ctx.fill(); - ctx.font = '14px system-ui'; - ctx.fillStyle = '#AAAAAA'; - ctx.textAlign = 'left'; - ctx.fillText('Total AutoRun:', 70, statsY + 15); - ctx.fillStyle = '#FFFFFF'; - ctx.font = 'bold 14px system-ui'; - ctx.fillText(formatCumulativeTime(autoRunStats.cumulativeTimeMs), 180, statsY + 15); + // Stats border + ctx.strokeStyle = 'rgba(139, 92, 246, 0.3)'; + ctx.lineWidth = 1; + ctx.roundRect(30, statsY, width - 60, statsHeight, 12); + ctx.stroke(); - ctx.fillStyle = '#AAAAAA'; - ctx.font = '14px system-ui'; - ctx.fillText('Longest Run:', 350, statsY + 15); - ctx.fillStyle = '#FFFFFF'; - ctx.font = 'bold 14px system-ui'; - ctx.fillText(formatCumulativeTime(autoRunStats.longestRunMs), 450, statsY + 15); + // Stats grid - 4 columns + const statsColWidth = (width - 60) / 4; + const statsCenterY = statsY + statsHeight / 2; + + // Helper to draw a stat + const drawStat = (x: number, value: string, label: string) => { + ctx.font = 'bold 20px system-ui'; + ctx.fillStyle = '#FFFFFF'; + ctx.textAlign = 'center'; + ctx.fillText(value, x, statsCenterY - 5); + + ctx.font = '11px system-ui'; + ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; + ctx.fillText(label, x, statsCenterY + 15); + }; + + // Column 1: Total AutoRun + drawStat(30 + statsColWidth * 0.5, formatCumulativeTime(autoRunStats.cumulativeTimeMs), 'Total AutoRun'); - // Footer branding - ctx.font = 'bold 12px system-ui'; - ctx.fillStyle = '#666666'; + // Column 2: Longest Run + drawStat(30 + statsColWidth * 1.5, formatCumulativeTime(autoRunStats.longestRunMs), 'Longest Run'); + + // Column 3: Sessions (from globalStats) + const sessionsValue = globalStats?.totalSessions?.toLocaleString() || '—'; + drawStat(30 + statsColWidth * 2.5, sessionsValue, 'Sessions'); + + // Column 4: Total Tokens (from globalStats) + const totalTokens = globalStats + ? globalStats.totalInputTokens + globalStats.totalOutputTokens + : 0; + const tokensValue = totalTokens > 0 ? formatTokens(totalTokens) : '—'; + drawStat(30 + statsColWidth * 3.5, tokensValue, 'Total Tokens'); + + // Footer with branding and GitHub + ctx.font = 'bold 11px system-ui'; + ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; ctx.textAlign = 'center'; - ctx.fillText('MAESTRO • Agent Orchestration Command Center', width / 2, height - 20); + ctx.fillText('MAESTRO • Agent Orchestration Command Center', width / 2, height - 30); + + // GitHub link + ctx.font = '10px system-ui'; + ctx.fillStyle = 'rgba(139, 92, 246, 0.8)'; + ctx.fillText('github.com/pedramamini/Maestro', width / 2, height - 14); return canvas; - }, [currentBadge, autoRunStats.cumulativeTimeMs, autoRunStats.longestRunMs, purpleAccent]); + }, [currentBadge, autoRunStats.cumulativeTimeMs, autoRunStats.longestRunMs, globalStats]); // Copy to clipboard const copyToClipboard = useCallback(async () => { @@ -518,7 +689,7 @@ export function AchievementCard({ theme, autoRunStats, onEscapeWithBadgeOpen }: {shareMenuOpen && (
{copySuccess ? ( - + ) : ( - + )} {copySuccess ? 'Copied!' : 'Copy to Clipboard'} @@ -545,9 +716,9 @@ export function AchievementCard({ theme, autoRunStats, onEscapeWithBadgeOpen }: downloadImage(); setShareMenuOpen(false); }} - className="w-full flex items-center gap-2 px-3 py-2 rounded text-sm hover:bg-white/10 transition-colors" + className="w-full flex items-center gap-2 px-3 py-2 rounded text-sm whitespace-nowrap hover:bg-white/10 transition-colors" > - + Save as Image
diff --git a/src/renderer/components/AgentPromptComposerModal.tsx b/src/renderer/components/AgentPromptComposerModal.tsx new file mode 100644 index 000000000..38e293446 --- /dev/null +++ b/src/renderer/components/AgentPromptComposerModal.tsx @@ -0,0 +1,220 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { X, FileText, Variable, ChevronDown, ChevronRight } from 'lucide-react'; +import type { Theme } from '../types'; +import { useLayerStack } from '../contexts/LayerStackContext'; +import { MODAL_PRIORITIES } from '../constants/modalPriorities'; +import { TEMPLATE_VARIABLES } from '../utils/templateVariables'; + +interface AgentPromptComposerModalProps { + isOpen: boolean; + onClose: () => void; + theme: Theme; + initialValue: string; + onSubmit: (value: string) => void; +} + +// Simple token estimation (roughly 4 chars per token for English text) +function estimateTokenCount(text: string): number { + if (!text) return 0; + return Math.ceil(text.length / 4); +} + +export function AgentPromptComposerModal({ + isOpen, + onClose, + theme, + initialValue, + onSubmit, +}: AgentPromptComposerModalProps) { + const [value, setValue] = useState(initialValue); + const [variablesExpanded, setVariablesExpanded] = useState(false); + const textareaRef = useRef(null); + const { registerLayer, unregisterLayer } = useLayerStack(); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + const onSubmitRef = useRef(onSubmit); + onSubmitRef.current = onSubmit; + const valueRef = useRef(value); + valueRef.current = value; + + // Sync value when modal opens with new initialValue + useEffect(() => { + if (isOpen) { + setValue(initialValue); + } + }, [isOpen, initialValue]); + + // Focus textarea when modal opens + useEffect(() => { + if (isOpen && textareaRef.current) { + textareaRef.current.focus(); + // Move cursor to end + textareaRef.current.selectionStart = textareaRef.current.value.length; + textareaRef.current.selectionEnd = textareaRef.current.value.length; + } + }, [isOpen]); + + // Register with layer stack for Escape handling + useEffect(() => { + if (isOpen) { + const id = registerLayer({ + type: 'modal', + priority: MODAL_PRIORITIES.AGENT_PROMPT_COMPOSER, + onEscape: () => { + // Save the current value back before closing + onSubmitRef.current(valueRef.current); + onCloseRef.current(); + }, + }); + return () => unregisterLayer(id); + } + }, [isOpen, registerLayer, unregisterLayer]); + + if (!isOpen) return null; + + const handleDone = () => { + onSubmit(value); + onClose(); + }; + + const tokenCount = estimateTokenCount(value); + + return ( +
{ + if (e.target === e.currentTarget) { + onSubmit(value); + onClose(); + } + }} + > +
+ {/* Header */} +
+
+ + + Agent Prompt Editor + +
+
+ +
+
+ + {/* Template Variables - Collapsible */} +
+ + {variablesExpanded && ( +
+

+ Use these variables in your prompt. They will be replaced with actual values at runtime. +

+
+ {TEMPLATE_VARIABLES.map(({ variable, description }) => ( +
+ { + // Insert variable at cursor position + if (textareaRef.current) { + const start = textareaRef.current.selectionStart; + const end = textareaRef.current.selectionEnd; + const newValue = value.substring(0, start) + variable + value.substring(end); + setValue(newValue); + // Restore focus and set cursor position after the inserted variable + requestAnimationFrame(() => { + if (textareaRef.current) { + textareaRef.current.focus(); + textareaRef.current.selectionStart = start + variable.length; + textareaRef.current.selectionEnd = start + variable.length; + } + }); + } + }} + title="Click to insert" + > + {variable} + + + {description} + +
+ ))} +
+
+ )} +
+ + {/* Textarea */} +
+