diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0b7ace52c..7a8a1bcd3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -417,20 +417,21 @@ Themes defined in `src/renderer/constants/themes.ts`. interface Theme { id: ThemeId; name: string; - mode: 'light' | 'dark'; + mode: 'light' | 'dark' | 'vibe'; colors: { - bgMain: string; // Main content background - bgSidebar: string; // Sidebar background - bgActivity: string; // Accent background - border: string; // Border colors - textMain: string; // Primary text - textDim: string; // Secondary text - accent: string; // Accent color - accentDim: string; // Dimmed accent - accentText: string; // Accent text color - success: string; // Success state (green) - warning: string; // Warning state (yellow) - error: string; // Error state (red) + bgMain: string; // Main content background + bgSidebar: string; // Sidebar background + bgActivity: string; // Accent background + border: string; // Border colors + textMain: string; // Primary text + textDim: string; // Secondary text + accent: string; // Accent color + accentDim: string; // Dimmed accent + accentText: string; // Accent text color + accentForeground: string; // Text ON accent backgrounds (contrast) + success: string; // Success state (green) + warning: string; // Warning state (yellow) + error: string; // Error state (red) }; } ``` @@ -524,6 +525,18 @@ const results = await window.maestro.claude.searchSessions( 'query', 'all' // 'title' | 'user' | 'assistant' | 'all' ); + +// Get global stats across all Claude projects (with streaming updates) +const stats = await window.maestro.claude.getGlobalStats(); +// Returns: { totalSessions, totalMessages, totalInputTokens, totalOutputTokens, +// totalCacheReadTokens, totalCacheCreationTokens, totalCostUsd, totalSizeBytes } + +// Subscribe to streaming updates during stats calculation +const unsubscribe = window.maestro.claude.onGlobalStatsUpdate((stats) => { + console.log(`Progress: ${stats.totalSessions} sessions, $${stats.totalCostUsd.toFixed(2)}`); + if (stats.isComplete) console.log('Stats calculation complete'); +}); +// Call unsubscribe() to stop listening ``` ### UI Access diff --git a/CLAUDE.md b/CLAUDE.md index 73630e051..9b4c92970 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,7 +136,7 @@ useEffect(() => { ### 5. Theme Colors -Themes have 12 required colors. Use inline styles for theme colors: +Themes have 13 required colors. Use inline styles for theme colors: ```typescript style={{ color: theme.colors.textMain }} // Correct className="text-gray-500" // Wrong for themed text @@ -195,7 +195,7 @@ The `window.maestro` API exposes: - `git` - Status, diff, isRepo, numstat - `fs` - readDir, readFile - `agents` - Detect, get, config -- `claude` - List/read/search Claude Code sessions +- `claude` - List/read/search Claude Code sessions, global stats - `logger` - System logging - `dialog` - Folder selection - `shells` - Detect available shells diff --git a/src/main/agent-detector.ts b/src/main/agent-detector.ts index 122c8c30b..ff61ee299 100644 --- a/src/main/agent-detector.ts +++ b/src/main/agent-detector.ts @@ -31,7 +31,7 @@ const AGENT_DEFINITIONS: Omit[] = [ name: 'Claude Code', binaryName: 'claude', command: 'claude', - args: ['--print', '--output-format', 'json'], + args: ['--print', '--verbose', '--output-format', 'stream-json'], configOptions: [ { key: 'yoloMode', diff --git a/src/main/index.ts b/src/main/index.ts index bf71134e5..4daf80389 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -218,6 +218,7 @@ function createWebServer(): WebServer { usageStats: s.usageStats || null, lastResponse, claudeSessionId: s.claudeSessionId || null, + thinkingStartTime: s.thinkingStartTime || null, }; }); }); @@ -259,6 +260,28 @@ function createWebServer(): WebServer { return customCommands; }); + // Set up callback for web server to fetch history entries + server.setGetHistoryCallback((projectPath?: string, sessionId?: string) => { + const allEntries = historyStore.get('entries', []); + let filteredEntries = allEntries; + + // Filter by project path if provided + if (projectPath) { + filteredEntries = filteredEntries.filter( + (entry: HistoryEntry) => entry.projectPath === projectPath + ); + } + + // Filter by session ID if provided (excludes entries from other sessions) + if (sessionId) { + filteredEntries = filteredEntries.filter( + (entry: HistoryEntry) => !entry.sessionId || entry.sessionId === sessionId + ); + } + + return filteredEntries; + }); + // Set up callback for web server to write commands to sessions // Note: Process IDs have -ai or -terminal suffix based on session's inputMode server.setWriteToSessionCallback((sessionId: string, data: string) => { @@ -335,6 +358,20 @@ function createWebServer(): WebServer { return true; }); + // Set up callback for web server to select/switch to a session in the desktop + // This forwards to the renderer which handles state updates and broadcasts + server.setSelectSessionCallback(async (sessionId: string) => { + if (!mainWindow) { + logger.warn('mainWindow is null for selectSession', 'WebServer'); + return false; + } + + // Forward to renderer - it will handle session selection and broadcasts + logger.debug(`Forwarding session selection to renderer: ${sessionId}`, 'WebServer'); + mainWindow.webContents.send('remote:selectSession', sessionId); + return true; + }); + return server; } @@ -605,6 +642,21 @@ function setupIpcHandlers() { return false; }); + // Broadcast AutoRun state to web clients (called when batch processing state changes) + ipcMain.handle('web:broadcastAutoRunState', async (_, sessionId: string, state: { + isRunning: boolean; + totalTasks: number; + completedTasks: number; + currentTaskIndex: number; + isStopping?: boolean; + } | null) => { + if (webServer && webServer.getWebClientCount() > 0) { + webServer.broadcastAutoRunState(sessionId, state); + return true; + } + return false; + }); + // Session/Process management ipcMain.handle('process:spawn', async (_, config: { sessionId: string; @@ -692,6 +744,21 @@ function setupIpcHandlers() { return processManager.resize(sessionId, cols, rows); }); + // Get all active processes managed by the ProcessManager + ipcMain.handle('process:getActiveProcesses', async () => { + if (!processManager) throw new Error('Process manager not initialized'); + const processes = processManager.getAll(); + // Return serializable process info (exclude non-serializable PTY/child process objects) + return processes.map(p => ({ + sessionId: p.sessionId, + toolType: p.toolType, + pid: p.pid, + cwd: p.cwd, + isTerminal: p.isTerminal, + isBatchMode: p.isBatchMode || false, + })); + }); + // Run a single command and capture only stdout/stderr (no PTY echo/prompts) ipcMain.handle('process:runCommand', async (_, config: { sessionId: string; @@ -1223,7 +1290,7 @@ function setupIpcHandlers() { // Logger operations ipcMain.handle('logger:log', async (_event, level: string, message: string, context?: string, data?: unknown) => { - const logLevel = level as 'debug' | 'info' | 'warn' | 'error'; + const logLevel = level as 'debug' | 'info' | 'warn' | 'error' | 'toast'; switch (logLevel) { case 'debug': logger.debug(message, context, data); @@ -1237,12 +1304,15 @@ function setupIpcHandlers() { case 'error': logger.error(message, context, data); break; + case 'toast': + logger.toast(message, context, data); + break; } }); ipcMain.handle('logger:getLogs', async (_event, filter?: { level?: string; context?: string; limit?: number }) => { const typedFilter = filter ? { - level: filter.level as 'debug' | 'info' | 'warn' | 'error' | undefined, + level: filter.level as 'debug' | 'info' | 'warn' | 'error' | 'toast' | undefined, context: filter.context, limit: filter.limit, } : undefined; @@ -1441,6 +1511,175 @@ function setupIpcHandlers() { } }); + // Get global stats across ALL Claude projects (streams updates as it processes) + ipcMain.handle('claude:getGlobalStats', async () => { + // Helper to calculate cost from tokens + const calculateCost = (input: number, output: number, cacheRead: number, cacheCreation: number) => { + const inputCost = (input / 1_000_000) * 3; + const outputCost = (output / 1_000_000) * 15; + const cacheReadCost = (cacheRead / 1_000_000) * 0.30; + const cacheCreationCost = (cacheCreation / 1_000_000) * 3.75; + return inputCost + outputCost + cacheReadCost + cacheCreationCost; + }; + + // Helper to send update to renderer + const sendUpdate = (stats: { + totalSessions: number; + totalMessages: number; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheReadTokens: number; + totalCacheCreationTokens: number; + totalCostUsd: number; + totalSizeBytes: number; + isComplete: boolean; + }) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('claude:globalStatsUpdate', stats); + } + }; + + try { + const os = await import('os'); + const homeDir = os.default.homedir(); + const claudeProjectsDir = path.join(homeDir, '.claude', 'projects'); + + // Check if the projects directory exists + try { + await fs.access(claudeProjectsDir); + } catch { + logger.info('No Claude projects directory found', 'ClaudeSessions'); + const emptyStats = { + totalSessions: 0, + totalMessages: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreationTokens: 0, + totalCostUsd: 0, + totalSizeBytes: 0, + isComplete: true, + }; + sendUpdate(emptyStats); + return emptyStats; + } + + // List all project directories + const projectDirs = await fs.readdir(claudeProjectsDir); + + let totalSessions = 0; + let totalMessages = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens = 0; + let totalCacheCreationTokens = 0; + let totalSizeBytes = 0; + let processedProjects = 0; + + // Process each project directory + for (const projectDir of projectDirs) { + const projectPath = path.join(claudeProjectsDir, projectDir); + + try { + const stat = await fs.stat(projectPath); + if (!stat.isDirectory()) continue; + + // List all .jsonl files in this project + const files = await fs.readdir(projectPath); + const sessionFiles = files.filter(f => f.endsWith('.jsonl')); + totalSessions += sessionFiles.length; + + // Process each session file + for (const filename of sessionFiles) { + const filePath = path.join(projectPath, filename); + + try { + const fileStat = await fs.stat(filePath); + totalSizeBytes += fileStat.size; + + const content = await fs.readFile(filePath, 'utf-8'); + + // Count messages + const userMessageCount = (content.match(/"type"\s*:\s*"user"/g) || []).length; + const assistantMessageCount = (content.match(/"type"\s*:\s*"assistant"/g) || []).length; + totalMessages += userMessageCount + assistantMessageCount; + + // Extract tokens + const inputMatches = content.matchAll(/"input_tokens"\s*:\s*(\d+)/g); + for (const m of inputMatches) totalInputTokens += parseInt(m[1], 10); + + const outputMatches = content.matchAll(/"output_tokens"\s*:\s*(\d+)/g); + for (const m of outputMatches) totalOutputTokens += parseInt(m[1], 10); + + const cacheReadMatches = content.matchAll(/"cache_read_input_tokens"\s*:\s*(\d+)/g); + for (const m of cacheReadMatches) totalCacheReadTokens += parseInt(m[1], 10); + + const cacheCreationMatches = content.matchAll(/"cache_creation_input_tokens"\s*:\s*(\d+)/g); + for (const m of cacheCreationMatches) totalCacheCreationTokens += parseInt(m[1], 10); + } catch (err) { + // Skip files we can't read + } + } + } catch (err) { + // Skip directories we can't read + } + + processedProjects++; + + // Send update after each project (stream progress) + const currentCost = calculateCost(totalInputTokens, totalOutputTokens, totalCacheReadTokens, totalCacheCreationTokens); + sendUpdate({ + totalSessions, + totalMessages, + totalInputTokens, + totalOutputTokens, + totalCacheReadTokens, + totalCacheCreationTokens, + totalCostUsd: currentCost, + totalSizeBytes, + isComplete: false, + }); + } + + // Calculate final cost + const totalCostUsd = calculateCost(totalInputTokens, totalOutputTokens, totalCacheReadTokens, totalCacheCreationTokens); + + logger.info(`Global Claude stats: ${totalSessions} sessions, ${totalMessages} messages, $${totalCostUsd.toFixed(2)}`, 'ClaudeSessions'); + + const finalStats = { + totalSessions, + totalMessages, + totalInputTokens, + totalOutputTokens, + totalCacheReadTokens, + totalCacheCreationTokens, + totalCostUsd, + totalSizeBytes, + isComplete: true, + }; + + // Send final update with isComplete flag + sendUpdate(finalStats); + + return finalStats; + } catch (error) { + logger.error('Error getting global Claude stats', 'ClaudeSessions', error); + const errorStats = { + totalSessions: 0, + totalMessages: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreationTokens: 0, + totalCostUsd: 0, + totalSizeBytes: 0, + isComplete: true, + }; + sendUpdate(errorStats); + return errorStats; + } + }); + ipcMain.handle('claude:readSessionMessages', async (_event, projectPath: string, sessionId: string, options?: { offset?: number; limit?: number }) => { try { const os = await import('os'); @@ -1522,6 +1761,112 @@ function setupIpcHandlers() { } }); + // Delete a message pair (user message and its response) from Claude session + // Can match by UUID or by content (for messages created in current session without UUID) + ipcMain.handle('claude:deleteMessagePair', async ( + _event, + projectPath: string, + sessionId: string, + userMessageUuid: string, + fallbackContent?: string // Optional: message content to match if UUID not found + ) => { + try { + const os = await import('os'); + const homeDir = os.default.homedir(); + const claudeProjectsDir = path.join(homeDir, '.claude', 'projects'); + + const encodedPath = projectPath.replace(/\//g, '-'); + const sessionFile = path.join(claudeProjectsDir, encodedPath, `${sessionId}.jsonl`); + + const content = await fs.readFile(sessionFile, 'utf-8'); + const lines = content.split('\n').filter(l => l.trim()); + + // Parse all lines and find the user message + const parsedLines: Array<{ line: string; entry: any }> = []; + let userMessageIndex = -1; + + for (let i = 0; i < lines.length; i++) { + try { + const entry = JSON.parse(lines[i]); + parsedLines.push({ line: lines[i], entry }); + + // First try to match by UUID + if (entry.uuid === userMessageUuid && entry.type === 'user') { + userMessageIndex = parsedLines.length - 1; + } + } catch { + // Keep malformed lines as-is + parsedLines.push({ line: lines[i], entry: null }); + } + } + + // If UUID match failed and we have fallback content, try matching by content + if (userMessageIndex === -1 && fallbackContent) { + // Normalize content for comparison (trim whitespace) + const normalizedFallback = fallbackContent.trim(); + + // Search from the end (most recent first) for a matching user message + for (let i = parsedLines.length - 1; i >= 0; i--) { + const entry = parsedLines[i].entry; + if (entry?.type === 'user') { + // Extract text content from message + let messageText = ''; + if (entry.message?.content) { + if (typeof entry.message.content === 'string') { + messageText = entry.message.content; + } else if (Array.isArray(entry.message.content)) { + const textBlocks = entry.message.content.filter((b: any) => b.type === 'text'); + messageText = textBlocks.map((b: any) => b.text).join('\n'); + } + } + + if (messageText.trim() === normalizedFallback) { + userMessageIndex = i; + logger.info('Found message by content match', 'ClaudeSessions', { sessionId, index: i }); + break; + } + } + } + } + + if (userMessageIndex === -1) { + logger.warn('User message not found for deletion', 'ClaudeSessions', { sessionId, userMessageUuid, hasFallback: !!fallbackContent }); + return { success: false, error: 'User message not found' }; + } + + // Find the end of the response (next user message or end of file) + // We need to delete from userMessageIndex to the next user message (exclusive) + let endIndex = parsedLines.length; + for (let i = userMessageIndex + 1; i < parsedLines.length; i++) { + if (parsedLines[i].entry?.type === 'user') { + endIndex = i; + break; + } + } + + // Remove the message pair + const linesToKeep = [ + ...parsedLines.slice(0, userMessageIndex), + ...parsedLines.slice(endIndex) + ]; + + // Write back to file + const newContent = linesToKeep.map(p => p.line).join('\n') + '\n'; + await fs.writeFile(sessionFile, newContent, 'utf-8'); + + logger.info(`Deleted message pair from Claude session`, 'ClaudeSessions', { + sessionId, + userMessageUuid, + linesRemoved: endIndex - userMessageIndex + }); + + return { success: true, linesRemoved: endIndex - userMessageIndex }; + } catch (error) { + logger.error('Error deleting message from Claude session', 'ClaudeSessions', { sessionId, userMessageUuid, error }); + return { success: false, error: String(error) }; + } + }); + // Search through Claude session content ipcMain.handle('claude:searchSessions', async ( _event, @@ -1931,9 +2276,21 @@ function setupIpcHandlers() { } }); - // Audio feedback using system TTS command (non-blocking) + // Track active TTS processes by ID for stopping + const activeTtsProcesses = new Map; command: string }>(); + let ttsProcessIdCounter = 0; + + // Audio feedback using system TTS command - pipes text via stdin ipcMain.handle('notification:speak', async (_event, text: string, command?: string) => { console.log('[TTS Main] notification:speak called, text length:', text?.length, 'command:', command); + + // Log the incoming request with full details for debugging + logger.info('TTS speak request received', 'TTS', { + command: command || '(default: say)', + textLength: text?.length || 0, + textPreview: text ? (text.length > 200 ? text.substring(0, 200) + '...' : text) : '(no text)', + }); + try { const { spawn } = await import('child_process'); const fullCommand = command || 'say'; // Default to macOS 'say' command @@ -1945,34 +2302,221 @@ function setupIpcHandlers() { const ttsCommand = parts[0].replace(/^"|"$/g, ''); // Remove surrounding quotes if present const ttsArgs = parts.slice(1).map(arg => arg.replace(/^"|"$/g, '')); // Remove quotes from args - // Add the text as the final argument (this is how most TTS commands work) - ttsArgs.push(text); + console.log('[TTS Main] Spawning:', ttsCommand, 'with args:', ttsArgs); - console.log('[TTS Main] Spawning:', ttsCommand, 'with args count:', ttsArgs.length); + // Log the full command being executed + logger.info('TTS executing command', 'TTS', { + executable: ttsCommand, + argsCount: ttsArgs.length, + fullArgs: ttsArgs, + textLength: text?.length || 0, + }); - // Spawn the TTS process without waiting for it to complete (non-blocking) - // This runs in the background and won't block the main process + // Spawn the TTS process with stdin pipe so we can write text to it const child = spawn(ttsCommand, ttsArgs, { - stdio: ['ignore', 'ignore', 'ignore'], - detached: true, // Run independently + stdio: ['pipe', 'ignore', 'pipe'], // stdin: pipe, stdout: ignore, stderr: pipe for errors }); + // Generate a unique ID for this TTS process + const ttsId = ++ttsProcessIdCounter; + activeTtsProcesses.set(ttsId, { process: child, command: ttsCommand }); + + // Write the text to stdin and close it + if (child.stdin) { + child.stdin.write(text); + child.stdin.end(); + } + child.on('error', (err) => { console.error('[TTS Main] Spawn error:', err); + logger.error('TTS spawn error', 'TTS', { + error: String(err), + command: ttsCommand, + textPreview: text ? (text.length > 100 ? text.substring(0, 100) + '...' : text) : '(no text)', + }); + activeTtsProcesses.delete(ttsId); }); - // Unref to allow the parent to exit independently - child.unref(); + // Capture stderr for debugging + let stderrOutput = ''; + if (child.stderr) { + child.stderr.on('data', (data) => { + stderrOutput += data.toString(); + }); + } - console.log('[TTS Main] Process spawned successfully'); - logger.debug('Started audio feedback', 'Notification', { command: ttsCommand, args: ttsArgs.length, textLength: text.length }); - return { success: true }; + child.on('close', (code) => { + console.log('[TTS Main] Process exited with code:', code); + if (code !== 0 && stderrOutput) { + console.error('[TTS Main] stderr:', stderrOutput); + logger.error('TTS process error output', 'TTS', { + exitCode: code, + stderr: stderrOutput, + command: ttsCommand, + }); + } + activeTtsProcesses.delete(ttsId); + }); + + console.log('[TTS Main] Process spawned successfully with ID:', ttsId); + logger.info('TTS process spawned successfully', 'TTS', { + ttsId, + command: ttsCommand, + argsCount: ttsArgs.length, + textLength: text?.length || 0, + }); + return { success: true, ttsId }; } catch (error) { console.error('[TTS Main] Error starting audio feedback:', error); - logger.error('Error starting audio feedback', 'Notification', error); + logger.error('TTS error starting audio feedback', 'TTS', { + error: String(error), + command: command || '(default: say)', + textPreview: text ? (text.length > 100 ? text.substring(0, 100) + '...' : text) : '(no text)', + }); + return { success: false, error: String(error) }; + } + }); + + // Stop a running TTS process + ipcMain.handle('notification:stopSpeak', async (_event, ttsId: number) => { + console.log('[TTS Main] notification:stopSpeak called for ID:', ttsId); + + const ttsProcess = activeTtsProcesses.get(ttsId); + if (!ttsProcess) { + console.log('[TTS Main] No active TTS process found with ID:', ttsId); + return { success: false, error: 'No active TTS process with that ID' }; + } + + try { + // Kill the process and all its children + ttsProcess.process.kill('SIGTERM'); + activeTtsProcesses.delete(ttsId); + + logger.info('TTS process stopped', 'TTS', { + ttsId, + command: ttsProcess.command, + }); + + console.log('[TTS Main] TTS process killed successfully'); + return { success: true }; + } catch (error) { + console.error('[TTS Main] Error stopping TTS process:', error); + logger.error('TTS error stopping process', 'TTS', { + ttsId, + error: String(error), + }); + return { success: false, error: String(error) }; + } + }); + + // Attachments API - store images per Maestro session + // Images are stored in userData/attachments/{sessionId}/{filename} + ipcMain.handle('attachments:save', async (_event, sessionId: string, base64Data: string, filename: string) => { + try { + const userDataPath = app.getPath('userData'); + const attachmentsDir = path.join(userDataPath, 'attachments', sessionId); + + // Ensure the attachments directory exists + await fs.mkdir(attachmentsDir, { recursive: true }); + + // Extract the base64 content (remove data:image/...;base64, prefix if present) + const base64Match = base64Data.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/); + let buffer: Buffer; + let finalFilename = filename; + + if (base64Match) { + const extension = base64Match[1]; + buffer = Buffer.from(base64Match[2], 'base64'); + // Update filename with correct extension if not already present + if (!filename.includes('.')) { + finalFilename = `${filename}.${extension}`; + } + } else { + // Assume raw base64 + buffer = Buffer.from(base64Data, 'base64'); + } + + const filePath = path.join(attachmentsDir, finalFilename); + await fs.writeFile(filePath, buffer); + + logger.info(`Saved attachment: ${filePath}`, 'Attachments', { sessionId, filename: finalFilename, size: buffer.length }); + return { success: true, path: filePath, filename: finalFilename }; + } catch (error) { + logger.error('Error saving attachment', 'Attachments', error); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle('attachments:load', async (_event, sessionId: string, filename: string) => { + try { + const userDataPath = app.getPath('userData'); + const filePath = path.join(userDataPath, 'attachments', sessionId, filename); + + const buffer = await fs.readFile(filePath); + const base64 = buffer.toString('base64'); + + // Determine MIME type from extension + const ext = path.extname(filename).toLowerCase().slice(1); + const mimeTypes: Record = { + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'svg': 'image/svg+xml', + }; + const mimeType = mimeTypes[ext] || 'image/png'; + + logger.debug(`Loaded attachment: ${filePath}`, 'Attachments', { sessionId, filename, size: buffer.length }); + return { success: true, dataUrl: `data:${mimeType};base64,${base64}` }; + } catch (error) { + logger.error('Error loading attachment', 'Attachments', error); return { success: false, error: String(error) }; } }); + + ipcMain.handle('attachments:delete', async (_event, sessionId: string, filename: string) => { + try { + const userDataPath = app.getPath('userData'); + const filePath = path.join(userDataPath, 'attachments', sessionId, filename); + + await fs.unlink(filePath); + logger.info(`Deleted attachment: ${filePath}`, 'Attachments', { sessionId, filename }); + return { success: true }; + } catch (error) { + logger.error('Error deleting attachment', 'Attachments', error); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle('attachments:list', async (_event, sessionId: string) => { + try { + const userDataPath = app.getPath('userData'); + const attachmentsDir = path.join(userDataPath, 'attachments', sessionId); + + try { + const files = await fs.readdir(attachmentsDir); + const imageFiles = files.filter(f => /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(f)); + logger.debug(`Listed attachments for session: ${sessionId}`, 'Attachments', { count: imageFiles.length }); + return { success: true, files: imageFiles }; + } catch (err: any) { + if (err.code === 'ENOENT') { + // Directory doesn't exist yet - no attachments + return { success: true, files: [] }; + } + throw err; + } + } catch (error) { + logger.error('Error listing attachments', 'Attachments', error); + return { success: false, error: String(error), files: [] }; + } + }); + + ipcMain.handle('attachments:getPath', async (_event, sessionId: string) => { + const userDataPath = app.getPath('userData'); + const attachmentsDir = path.join(userDataPath, 'attachments', sessionId); + return { success: true, path: attachmentsDir }; + }); } // Handle process output streaming (set up after initialization) diff --git a/src/main/preload.ts b/src/main/preload.ts index 05de41d16..d45ab5b0f 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -67,6 +67,9 @@ contextBridge.exposeInMainWorld('maestro', { runCommand: (config: { sessionId: string; command: string; cwd: string; shell?: string }) => ipcRenderer.invoke('process:runCommand', config), + // Get all active processes from ProcessManager + getActiveProcesses: () => ipcRenderer.invoke('process:getActiveProcesses'), + // Event listeners onData: (callback: (sessionId: string, data: string) => void) => { const handler = (_: any, sessionId: string, data: string) => callback(sessionId, data); @@ -106,6 +109,12 @@ contextBridge.exposeInMainWorld('maestro', { ipcRenderer.on('remote:interrupt', handler); return () => ipcRenderer.removeListener('remote:interrupt', handler); }, + // Remote session selection from web interface - forwards to desktop's setActiveSessionId logic + onRemoteSelectSession: (callback: (sessionId: string) => void) => { + const handler = (_: any, sessionId: string) => callback(sessionId); + ipcRenderer.on('remote:selectSession', handler); + return () => ipcRenderer.removeListener('remote:selectSession', handler); + }, // Stderr listener for runCommand (separate stream) onStderr: (callback: (sessionId: string, data: string) => void) => { const handler = (_: any, sessionId: string, data: string) => callback(sessionId, data); @@ -138,6 +147,15 @@ contextBridge.exposeInMainWorld('maestro', { // Broadcast user input to web clients (for keeping web interface in sync) broadcastUserInput: (sessionId: string, command: string, inputMode: 'ai' | 'terminal') => ipcRenderer.invoke('web:broadcastUserInput', sessionId, command, inputMode), + // Broadcast AutoRun state to web clients (for showing task progress on mobile) + broadcastAutoRunState: (sessionId: string, state: { + isRunning: boolean; + totalTasks: number; + completedTasks: number; + currentTaskIndex: number; + isStopping?: boolean; + } | null) => + ipcRenderer.invoke('web:broadcastAutoRunState', sessionId, state), }, // Git API @@ -241,6 +259,23 @@ contextBridge.exposeInMainWorld('maestro', { claude: { listSessions: (projectPath: string) => ipcRenderer.invoke('claude:listSessions', projectPath), + getGlobalStats: () => + ipcRenderer.invoke('claude:getGlobalStats'), + onGlobalStatsUpdate: (callback: (stats: { + totalSessions: number; + totalMessages: number; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheReadTokens: number; + totalCacheCreationTokens: number; + totalCostUsd: number; + totalSizeBytes: number; + isComplete: boolean; + }) => void) => { + const handler = (_: any, stats: any) => callback(stats); + ipcRenderer.on('claude:globalStatsUpdate', handler); + return () => ipcRenderer.removeListener('claude:globalStatsUpdate', handler); + }, readSessionMessages: (projectPath: string, sessionId: string, options?: { offset?: number; limit?: number }) => ipcRenderer.invoke('claude:readSessionMessages', projectPath, sessionId, options), searchSessions: (projectPath: string, query: string, searchMode: 'title' | 'user' | 'assistant' | 'all') => @@ -254,6 +289,8 @@ contextBridge.exposeInMainWorld('maestro', { ipcRenderer.invoke('claude:updateSessionName', projectPath, claudeSessionId, sessionName), getSessionOrigins: (projectPath: string) => ipcRenderer.invoke('claude:getSessionOrigins', projectPath), + deleteMessagePair: (projectPath: string, sessionId: string, userMessageUuid: string, fallbackContent?: string) => + ipcRenderer.invoke('claude:deleteMessagePair', projectPath, sessionId, userMessageUuid, fallbackContent), }, // Temp file API (for batch processing) @@ -284,6 +321,22 @@ contextBridge.exposeInMainWorld('maestro', { ipcRenderer.invoke('notification:show', title, body), speak: (text: string, command?: string) => ipcRenderer.invoke('notification:speak', text, command), + stopSpeak: (ttsId: number) => + ipcRenderer.invoke('notification:stopSpeak', ttsId), + }, + + // Attachments API (per-session image storage for scratchpad) + attachments: { + save: (sessionId: string, base64Data: string, filename: string) => + ipcRenderer.invoke('attachments:save', sessionId, base64Data, filename), + load: (sessionId: string, filename: string) => + ipcRenderer.invoke('attachments:load', sessionId, filename), + delete: (sessionId: string, filename: string) => + ipcRenderer.invoke('attachments:delete', sessionId, filename), + list: (sessionId: string) => + ipcRenderer.invoke('attachments:list', sessionId), + getPath: (sessionId: string) => + ipcRenderer.invoke('attachments:getPath', sessionId), }, }); @@ -309,12 +362,21 @@ export interface MaestroAPI { kill: (sessionId: string) => Promise; resize: (sessionId: string, cols: number, rows: number) => Promise; runCommand: (config: { sessionId: string; command: string; cwd: string; shell?: string }) => Promise<{ exitCode: number }>; + getActiveProcesses: () => Promise>; onData: (callback: (sessionId: string, data: string) => void) => () => void; onExit: (callback: (sessionId: string, code: number) => void) => () => void; onSessionId: (callback: (sessionId: string, claudeSessionId: string) => void) => () => void; onRemoteCommand: (callback: (sessionId: string, command: string) => void) => () => void; onRemoteSwitchMode: (callback: (sessionId: string, mode: 'ai' | 'terminal') => void) => () => void; onRemoteInterrupt: (callback: (sessionId: string) => void) => () => void; + onRemoteSelectSession: (callback: (sessionId: string) => void) => () => void; onStderr: (callback: (sessionId: string, data: string) => void) => () => void; onCommandExit: (callback: (sessionId: string, code: number) => void) => () => void; onUsage: (callback: (sessionId: string, usageStats: { @@ -427,6 +489,17 @@ export interface MaestroAPI { origin?: 'user' | 'auto'; // Maestro session origin, undefined for CLI sessions sessionName?: string; // User-defined session name from Maestro }>>; + onGlobalStatsUpdate: (callback: (stats: { + totalSessions: number; + totalMessages: number; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheReadTokens: number; + totalCacheCreationTokens: number; + totalCostUsd: number; + totalSizeBytes: number; + isComplete: boolean; + }) => void) => () => void; readSessionMessages: (projectPath: string, sessionId: string, options?: { offset?: number; limit?: number }) => Promise<{ messages: Array<{ type: string; @@ -452,6 +525,7 @@ export interface MaestroAPI { registerSessionOrigin: (projectPath: string, claudeSessionId: string, origin: 'user' | 'auto', sessionName?: string) => Promise; updateSessionName: (projectPath: string, claudeSessionId: string, sessionName: string) => Promise; getSessionOrigins: (projectPath: string) => Promise>; + deleteMessagePair: (projectPath: string, sessionId: string, userMessageUuid: string, fallbackContent?: string) => Promise<{ success: boolean; linesRemoved?: number; error?: string }>; }; tempfile: { write: (content: string, filename?: string) => Promise<{ success: boolean; path?: string; error?: string }>; @@ -482,7 +556,15 @@ export interface MaestroAPI { }; notification: { show: (title: string, body: string) => Promise<{ success: boolean; error?: string }>; - speak: (text: string, command?: string) => Promise<{ success: boolean; error?: string }>; + speak: (text: string, command?: string) => Promise<{ success: boolean; ttsId?: number; error?: string }>; + stopSpeak: (ttsId: number) => Promise<{ success: boolean; error?: string }>; + }; + attachments: { + save: (sessionId: string, base64Data: string, filename: string) => Promise<{ success: boolean; path?: string; filename?: string; error?: string }>; + load: (sessionId: string, filename: string) => Promise<{ success: boolean; dataUrl?: string; error?: string }>; + delete: (sessionId: string, filename: string) => Promise<{ success: boolean; error?: string }>; + list: (sessionId: string) => Promise<{ success: boolean; files: string[]; error?: string }>; + getPath: (sessionId: string) => Promise<{ success: boolean; path: string }>; }; } diff --git a/src/main/process-manager.ts b/src/main/process-manager.ts index 86d40db0e..8bf14e622 100644 --- a/src/main/process-manager.ts +++ b/src/main/process-manager.ts @@ -101,9 +101,9 @@ export class ProcessManager extends EventEmitter { let finalArgs: string[]; if (hasImages && prompt) { - // Use stream-json mode for images - prompt will be sent via stdin - // Note: --verbose is required when using --print with --output-format=stream-json - finalArgs = [...args, '--verbose', '--input-format', 'stream-json', '--output-format', 'stream-json', '-p']; + // For images, add stream-json input format (output format and --verbose already in base args) + // The prompt will be sent via stdin as a JSON message with image data + finalArgs = [...args, '--input-format', 'stream-json']; } else if (prompt) { // Regular batch mode - prompt as CLI arg // The -- ensures prompt is treated as positional arg, not a flag (even if it starts with --) @@ -241,7 +241,8 @@ export class ProcessManager extends EventEmitter { }); const isBatchMode = !!prompt; - const isStreamJsonMode = hasImages && !!prompt; + // Detect stream-json mode from args (now default for Claude Code) or when images are present + const isStreamJsonMode = finalArgs.includes('stream-json') || (hasImages && !!prompt); const managedProcess: ManagedProcess = { sessionId, @@ -313,22 +314,42 @@ export class ProcessManager extends EventEmitter { this.emit('session-id', sessionId, msg.session_id); } // Extract usage statistics from stream-json messages (typically in 'result' type) - if (msg.usage || msg.total_cost_usd !== undefined) { + // Note: We need to aggregate token counts from modelUsage for accurate context window tracking + if (msg.modelUsage || msg.usage || msg.total_cost_usd !== undefined) { const usage = msg.usage || {}; - // Extract context window from modelUsage if present + + // Aggregate token counts from modelUsage for accurate context tracking + let aggregatedInputTokens = 0; + let aggregatedOutputTokens = 0; + let aggregatedCacheReadTokens = 0; + let aggregatedCacheCreationTokens = 0; let contextWindow = 200000; // Default for Claude + if (msg.modelUsage) { - const firstModel = Object.values(msg.modelUsage)[0] as any; - if (firstModel?.contextWindow) { - contextWindow = firstModel.contextWindow; + for (const modelStats of Object.values(msg.modelUsage) as any[]) { + aggregatedInputTokens += modelStats.inputTokens || 0; + aggregatedOutputTokens += modelStats.outputTokens || 0; + aggregatedCacheReadTokens += modelStats.cacheReadInputTokens || 0; + aggregatedCacheCreationTokens += modelStats.cacheCreationInputTokens || 0; + if (modelStats.contextWindow && modelStats.contextWindow > contextWindow) { + contextWindow = modelStats.contextWindow; + } } } + // Fall back to top-level usage if modelUsage isn't available + if (aggregatedInputTokens === 0 && aggregatedOutputTokens === 0) { + aggregatedInputTokens = usage.input_tokens || 0; + aggregatedOutputTokens = usage.output_tokens || 0; + aggregatedCacheReadTokens = usage.cache_read_input_tokens || 0; + aggregatedCacheCreationTokens = usage.cache_creation_input_tokens || 0; + } + const usageStats = { - inputTokens: usage.input_tokens || 0, - outputTokens: usage.output_tokens || 0, - cacheReadInputTokens: usage.cache_read_input_tokens || 0, - cacheCreationInputTokens: usage.cache_creation_input_tokens || 0, + inputTokens: aggregatedInputTokens, + outputTokens: aggregatedOutputTokens, + cacheReadInputTokens: aggregatedCacheReadTokens, + cacheCreationInputTokens: aggregatedCacheCreationTokens, totalCostUsd: msg.total_cost_usd || 0, contextWindow }; @@ -394,22 +415,47 @@ export class ProcessManager extends EventEmitter { } // Extract and emit usage statistics - if (jsonResponse.usage || jsonResponse.total_cost_usd !== undefined) { + // Note: We need to aggregate token counts from modelUsage for accurate context window tracking + // The top-level usage object shows billable/new tokens, not total context tokens + if (jsonResponse.modelUsage || jsonResponse.usage || jsonResponse.total_cost_usd !== undefined) { const usage = jsonResponse.usage || {}; - // Extract context window from modelUsage (first model found) + + // Aggregate token counts from modelUsage for accurate context tracking + // modelUsage contains per-model breakdown with actual context tokens (including cache hits) + let aggregatedInputTokens = 0; + let aggregatedOutputTokens = 0; + let aggregatedCacheReadTokens = 0; + let aggregatedCacheCreationTokens = 0; let contextWindow = 200000; // Default for Claude + if (jsonResponse.modelUsage) { - const firstModel = Object.values(jsonResponse.modelUsage)[0] as any; - if (firstModel?.contextWindow) { - contextWindow = firstModel.contextWindow; + for (const modelStats of Object.values(jsonResponse.modelUsage) as any[]) { + // inputTokens in modelUsage includes the full context (not just new tokens) + aggregatedInputTokens += modelStats.inputTokens || 0; + aggregatedOutputTokens += modelStats.outputTokens || 0; + aggregatedCacheReadTokens += modelStats.cacheReadInputTokens || 0; + aggregatedCacheCreationTokens += modelStats.cacheCreationInputTokens || 0; + // Use the highest context window from any model + if (modelStats.contextWindow && modelStats.contextWindow > contextWindow) { + contextWindow = modelStats.contextWindow; + } } } + // Fall back to top-level usage if modelUsage isn't available + // This handles older CLI versions or different output formats + if (aggregatedInputTokens === 0 && aggregatedOutputTokens === 0) { + aggregatedInputTokens = usage.input_tokens || 0; + aggregatedOutputTokens = usage.output_tokens || 0; + aggregatedCacheReadTokens = usage.cache_read_input_tokens || 0; + aggregatedCacheCreationTokens = usage.cache_creation_input_tokens || 0; + } + const usageStats = { - inputTokens: usage.input_tokens || 0, - outputTokens: usage.output_tokens || 0, - cacheReadInputTokens: usage.cache_read_input_tokens || 0, - cacheCreationInputTokens: usage.cache_creation_input_tokens || 0, + inputTokens: aggregatedInputTokens, + outputTokens: aggregatedOutputTokens, + cacheReadInputTokens: aggregatedCacheReadTokens, + cacheCreationInputTokens: aggregatedCacheCreationTokens, totalCostUsd: jsonResponse.total_cost_usd || 0, contextWindow }; diff --git a/src/main/themes.ts b/src/main/themes.ts index c529b503b..5faedffc0 100644 --- a/src/main/themes.ts +++ b/src/main/themes.ts @@ -23,6 +23,7 @@ export const THEMES: Record = { accent: '#6366f1', accentDim: 'rgba(99, 102, 241, 0.2)', accentText: '#a5b4fc', + accentForeground: '#ffffff', success: '#22c55e', warning: '#eab308', error: '#ef4444' @@ -42,6 +43,7 @@ export const THEMES: Record = { accent: '#fd971f', accentDim: 'rgba(253, 151, 31, 0.2)', accentText: '#fdbf6f', + accentForeground: '#1e1f1c', success: '#a6e22e', warning: '#e6db74', error: '#f92672' @@ -61,6 +63,7 @@ export const THEMES: Record = { accent: '#88c0d0', accentDim: 'rgba(136, 192, 208, 0.2)', accentText: '#8fbcbb', + accentForeground: '#2e3440', success: '#a3be8c', warning: '#ebcb8b', error: '#bf616a' @@ -80,6 +83,7 @@ export const THEMES: Record = { accent: '#7aa2f7', accentDim: 'rgba(122, 162, 247, 0.2)', accentText: '#7dcfff', + accentForeground: '#1a1b26', success: '#9ece6a', warning: '#e0af68', error: '#f7768e' @@ -99,6 +103,7 @@ export const THEMES: Record = { accent: '#89b4fa', accentDim: 'rgba(137, 180, 250, 0.2)', accentText: '#89dceb', + accentForeground: '#1e1e2e', success: '#a6e3a1', warning: '#f9e2af', error: '#f38ba8' @@ -118,6 +123,7 @@ export const THEMES: Record = { accent: '#83a598', accentDim: 'rgba(131, 165, 152, 0.2)', accentText: '#8ec07c', + accentForeground: '#1d2021', success: '#b8bb26', warning: '#fabd2f', error: '#fb4934' @@ -138,6 +144,7 @@ export const THEMES: Record = { accent: '#0969da', accentDim: 'rgba(9, 105, 218, 0.1)', accentText: '#0969da', + accentForeground: '#ffffff', success: '#1a7f37', warning: '#9a6700', error: '#cf222e' @@ -157,6 +164,7 @@ export const THEMES: Record = { accent: '#2aa198', accentDim: 'rgba(42, 161, 152, 0.1)', accentText: '#2aa198', + accentForeground: '#fdf6e3', success: '#859900', warning: '#b58900', error: '#dc322f' @@ -176,6 +184,7 @@ export const THEMES: Record = { accent: '#4078f2', accentDim: 'rgba(64, 120, 242, 0.1)', accentText: '#4078f2', + accentForeground: '#ffffff', success: '#50a14f', warning: '#c18401', error: '#e45649' @@ -195,6 +204,7 @@ export const THEMES: Record = { accent: '#458588', accentDim: 'rgba(69, 133, 136, 0.1)', accentText: '#076678', + accentForeground: '#fbf1c7', success: '#98971a', warning: '#d79921', error: '#cc241d' @@ -214,6 +224,7 @@ export const THEMES: Record = { accent: '#1e66f5', accentDim: 'rgba(30, 102, 245, 0.1)', accentText: '#1e66f5', + accentForeground: '#ffffff', success: '#40a02b', warning: '#df8e1d', error: '#d20f39' @@ -233,6 +244,7 @@ export const THEMES: Record = { accent: '#55b4d4', accentDim: 'rgba(85, 180, 212, 0.1)', accentText: '#399ee6', + accentForeground: '#1a1a1a', success: '#86b300', warning: '#f2ae49', error: '#f07171' @@ -250,11 +262,12 @@ export const THEMES: Record = { border: '#4a2a6a', textMain: '#e8d5f5', textDim: '#b89fd0', - accent: '#d4af37', - accentDim: 'rgba(212, 175, 55, 0.25)', - accentText: '#ffd700', + accent: '#ff69b4', + accentDim: 'rgba(255, 105, 180, 0.25)', + accentText: '#ff8dc7', + accentForeground: '#1a0f24', success: '#7cb342', - warning: '#ff69b4', + warning: '#d4af37', error: '#da70d6' } }, @@ -263,18 +276,19 @@ export const THEMES: Record = { name: "Maestro's Choice", mode: 'vibe', colors: { - bgMain: '#0a0a0f', - bgSidebar: '#05050a', - bgActivity: '#12121a', - border: '#2a2a3a', - textMain: '#f0e6d3', - textDim: '#8a8078', - accent: '#c9a227', - accentDim: 'rgba(201, 162, 39, 0.2)', - accentText: '#e6b830', - success: '#4a9c6d', - warning: '#c9a227', - error: '#8b2942' + bgMain: '#1a1a24', + bgSidebar: '#141420', + bgActivity: '#24243a', + border: '#3a3a5a', + textMain: '#fff8e8', + textDim: '#a8a0a0', + accent: '#f4c430', + accentDim: 'rgba(244, 196, 48, 0.25)', + accentText: '#ffd54f', + accentForeground: '#1a1a24', + success: '#66d9a0', + warning: '#f4c430', + error: '#e05070' } }, 'dre-synth': { @@ -285,14 +299,15 @@ export const THEMES: Record = { bgMain: '#0d0221', bgSidebar: '#0a0118', bgActivity: '#150530', - border: '#2a1050', + border: '#00d4aa', textMain: '#f0e6ff', - textDim: '#9080b0', - accent: '#ff2a6d', - accentDim: 'rgba(255, 42, 109, 0.25)', - accentText: '#ff6b9d', - success: '#05ffa1', - warning: '#00f5d4', + textDim: '#60e0d0', + accent: '#00ffcc', + accentDim: 'rgba(0, 255, 204, 0.25)', + accentText: '#40ffdd', + accentForeground: '#0d0221', + success: '#00ffcc', + warning: '#ff2a6d', error: '#ff2a6d' } }, @@ -310,6 +325,7 @@ export const THEMES: Record = { accent: '#cc0033', accentDim: 'rgba(204, 0, 51, 0.25)', accentText: '#ff3355', + accentForeground: '#ffffff', success: '#f5f5f5', warning: '#cc0033', error: '#cc0033' diff --git a/src/main/utils/logger.ts b/src/main/utils/logger.ts index 11a4f7dc9..e1d380d7a 100644 --- a/src/main/utils/logger.ts +++ b/src/main/utils/logger.ts @@ -3,7 +3,7 @@ * Logs are stored in memory and can be retrieved via IPC */ -export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'toast'; export interface LogEntry { timestamp: number; @@ -23,6 +23,7 @@ class Logger { info: 1, warn: 2, error: 3, + toast: 1, // Toast notifications always logged at info priority (always visible) }; setLogLevel(level: LogLevel): void { @@ -75,6 +76,10 @@ class Logger { case 'debug': console.log(message, entry.data || ''); break; + case 'toast': + // Toast notifications logged with info styling (purple in LogViewer) + console.info(message, entry.data || ''); + break; } } @@ -122,6 +127,17 @@ class Logger { }); } + toast(message: string, context?: string, data?: unknown): void { + // Toast notifications are always logged (they're user-facing notifications) + this.addLog({ + timestamp: Date.now(), + level: 'toast', + message, + context, + data, + }); + } + getLogs(filter?: { level?: LogLevel; context?: string; limit?: number }): LogEntry[] { let filtered = [...this.logs]; diff --git a/src/main/web-server.ts b/src/main/web-server.ts index 6efa18f08..a25342cde 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -103,6 +103,7 @@ export type GetSessionsCallback = () => Array<{ usageStats?: SessionUsageStats | null; lastResponse?: LastResponsePreview | null; claudeSessionId?: string | null; + thinkingStartTime?: number | null; // Timestamp when AI started thinking (for elapsed time display) }>; // Session detail type for single session endpoint @@ -150,6 +151,10 @@ export type SwitchModeCallback = ( mode: 'ai' | 'terminal' ) => Promise; +// Callback type for selecting/switching to a session in the desktop app +// This forwards to the renderer which handles state updates and broadcasts +export type SelectSessionCallback = (sessionId: string) => Promise; + // Re-export Theme type from shared for backwards compatibility export type { Theme } from '../shared/theme-types'; @@ -167,6 +172,32 @@ export interface CustomAICommand { // Callback type for fetching custom AI commands export type GetCustomCommandsCallback = () => CustomAICommand[]; +// History entry type for the history API +export interface HistoryEntryData { + id: string; + type: 'AUTO' | 'USER'; + timestamp: number; + summary: string; + fullResponse?: string; + claudeSessionId?: string; + projectPath: string; + sessionId?: string; + contextUsage?: number; + usageStats?: { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + totalCostUsd: number; + contextWindow: number; + }; + success?: boolean; + elapsedTimeMs?: number; +} + +// Callback type for fetching history entries +export type GetHistoryCallback = (projectPath?: string, sessionId?: string) => HistoryEntryData[]; + // Default rate limit configuration const DEFAULT_RATE_LIMIT_CONFIG: RateLimitConfig = { max: 100, // 100 requests per minute for GET endpoints @@ -190,6 +221,8 @@ export class WebServer { private executeCommandCallback: ExecuteCommandCallback | null = null; private interruptSessionCallback: InterruptSessionCallback | null = null; private switchModeCallback: SwitchModeCallback | null = null; + private selectSessionCallback: SelectSessionCallback | null = null; + private getHistoryCallback: GetHistoryCallback | null = null; private webAssetsPath: string | null = null; // Security token - regenerated on each app startup @@ -447,6 +480,22 @@ export class WebServer { this.switchModeCallback = callback; } + /** + * Set the callback function for selecting/switching to a session in the desktop + * This forwards to the renderer which handles state updates and broadcasts + */ + setSelectSessionCallback(callback: SelectSessionCallback) { + this.selectSessionCallback = callback; + } + + /** + * Set the callback function for fetching history entries + * This is called by the /api/history endpoint + */ + setGetHistoryCallback(callback: GetHistoryCallback) { + this.getHistoryCallback = callback; + } + /** * Set the rate limiting configuration */ @@ -791,6 +840,45 @@ export class WebServer { }); } }); + + // History endpoint - returns history entries filtered by project/session + this.server.get(`/${token}/api/history`, { + config: { + rateLimit: { + max: this.rateLimitConfig.max, + timeWindow: this.rateLimitConfig.timeWindow, + }, + }, + }, async (request, reply) => { + if (!this.getHistoryCallback) { + return reply.code(503).send({ + error: 'Service Unavailable', + message: 'History service not configured', + timestamp: Date.now(), + }); + } + + // Extract optional projectPath and sessionId from query params + const { projectPath, sessionId } = request.query as { + projectPath?: string; + sessionId?: string; + }; + + try { + const entries = this.getHistoryCallback(projectPath, sessionId); + return { + entries, + count: entries.length, + timestamp: Date.now(), + }; + } catch (error: any) { + return reply.code(500).send({ + error: 'Internal Server Error', + message: `Failed to fetch history: ${error.message}`, + timestamp: Date.now(), + }); + } + }); } /** @@ -1045,6 +1133,54 @@ export class WebServer { break; } + case 'select_session': { + // Select/switch to a session in the desktop app + const sessionId = message.sessionId as string; + + if (!sessionId) { + client.socket.send(JSON.stringify({ + type: 'error', + message: 'Missing sessionId', + timestamp: Date.now(), + })); + return; + } + + if (!this.selectSessionCallback) { + client.socket.send(JSON.stringify({ + type: 'error', + message: 'Session selection not configured', + timestamp: Date.now(), + })); + return; + } + + // Forward to desktop's session selection logic + logger.info(`[Web] Selecting session ${sessionId} in desktop app`, LOG_CONTEXT); + this.selectSessionCallback(sessionId) + .then((success) => { + client.socket.send(JSON.stringify({ + type: 'select_session_result', + success, + sessionId, + timestamp: Date.now(), + })); + if (success) { + logger.debug(`Session ${sessionId} selected in desktop`, LOG_CONTEXT); + } else { + logger.warn(`Failed to select session ${sessionId} in desktop`, LOG_CONTEXT); + } + }) + .catch((error) => { + client.socket.send(JSON.stringify({ + type: 'error', + message: `Failed to select session: ${error.message}`, + timestamp: Date.now(), + })); + }); + break; + } + case 'get_sessions': { // Request updated sessions list - returns all sessions (not just "live" ones) // The security token already protects access to this endpoint @@ -1230,6 +1366,25 @@ export class WebServer { }); } + /** + * Broadcast AutoRun state to all connected web clients + * Called when batch processing starts, progresses, or stops + */ + broadcastAutoRunState(sessionId: string, state: { + isRunning: boolean; + totalTasks: number; + completedTasks: number; + currentTaskIndex: number; + isStopping?: boolean; + } | null) { + this.broadcastToWebClients({ + type: 'autorun_state', + sessionId, + state, + timestamp: Date.now(), + }); + } + /** * Broadcast user input to web clients subscribed to a session * Called when a command is sent from the desktop app so web clients stay in sync diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 76435b19d..2df296464 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -21,7 +21,8 @@ import { BatchRunnerModal } from './components/BatchRunnerModal'; // Import custom hooks import { useBatchProcessor } from './hooks/useBatchProcessor'; -import { useSettings, useActivityTracker } from './hooks'; +import { useSettings, useActivityTracker, useMobileLandscape } from './hooks'; +import { useTabCompletion } from './hooks/useTabCompletion'; // Import contexts import { useLayerStack } from './contexts/LayerStackContext'; @@ -43,6 +44,21 @@ import { fuzzyMatch } from './utils/search'; import { shouldOpenExternally, loadFileTree, getAllFolderPaths, flattenTree } from './utils/fileExplorer'; import { substituteTemplateVariables } from './utils/templateVariables'; +// Strip leading emojis from a string for alphabetical sorting +// Matches common emoji patterns at the start of the string +const stripLeadingEmojis = (str: string): string => { + // Match emojis at the start: emoji characters, variation selectors, ZWJ sequences, etc. + const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F?|\p{Emoji_Modifier_Base}\p{Emoji_Modifier}?)+\s*/gu; + return str.replace(emojiRegex, '').trim(); +}; + +// Compare two names, ignoring leading emojis for alphabetization +const compareNamesIgnoringEmojis = (a: string, b: string): number => { + const aStripped = stripLeadingEmojis(a); + const bStripped = stripLeadingEmojis(b); + return aStripped.localeCompare(bStripped); +}; + export default function MaestroConsole() { // --- LAYER STACK (for blocking shortcuts when modals are open) --- const { hasOpenLayers, hasOpenModal } = useLayerStack(); @@ -50,6 +66,9 @@ export default function MaestroConsole() { // --- TOAST NOTIFICATIONS --- const { addToast, setDefaultDuration: setToastDefaultDuration } = useToast(); + // --- MOBILE LANDSCAPE MODE (reading-only view) --- + const isMobileLandscape = useMobileLandscape(); + // --- SETTINGS (from useSettings hook) --- const settings = useSettings(); const { @@ -88,7 +107,16 @@ export default function MaestroConsole() { // Track if initial data has been loaded to prevent overwriting on mount const initialLoadComplete = useRef(false); - const [activeSessionId, setActiveSessionId] = useState(sessions[0]?.id || 's1'); + const [activeSessionId, setActiveSessionIdInternal] = useState(sessions[0]?.id || 's1'); + + // Track current position in visual order for cycling (allows same session to appear twice) + const cyclePositionRef = useRef(-1); + + // Wrapper that resets cycle position when session is changed via click (not cycling) + const setActiveSessionId = useCallback((id: string) => { + cyclePositionRef.current = -1; // Reset so next cycle finds first occurrence + setActiveSessionIdInternal(id); + }, []); // Input State - separate for AI and terminal modes const [aiInputValue, setAiInputValue] = useState(''); @@ -101,6 +129,7 @@ export default function MaestroConsole() { const [rightPanelOpen, setRightPanelOpen] = useState(true); const [activeRightTab, setActiveRightTab] = useState('files'); const [activeFocus, setActiveFocus] = useState('main'); + const [bookmarksCollapsed, setBookmarksCollapsed] = useState(false); // File Explorer State const [previewFile, setPreviewFile] = useState<{name: string; content: string; path: string} | null>(null); @@ -157,13 +186,6 @@ export default function MaestroConsole() { const [agentSessionsOpen, setAgentSessionsOpen] = useState(false); const [activeClaudeSessionId, setActiveClaudeSessionId] = useState(null); - // Recent Claude sessions for quick access (breadcrumbs when session hopping) - const [recentClaudeSessions, setRecentClaudeSessions] = useState>([]); - // Batch Runner Modal State const [batchRunnerModalOpen, setBatchRunnerModalOpen] = useState(false); const [renameGroupId, setRenameGroupId] = useState(null); @@ -180,6 +202,10 @@ export default function MaestroConsole() { const [commandHistoryFilter, setCommandHistoryFilter] = useState(''); const [commandHistorySelectedIndex, setCommandHistorySelectedIndex] = useState(0); + // Tab Completion State (terminal mode only) + const [tabCompletionOpen, setTabCompletionOpen] = useState(false); + const [selectedTabCompletionIndex, setSelectedTabCompletionIndex] = useState(0); + // Flash notification state (for inline notifications like "Commands disabled while agent is working") const [flashNotification, setFlashNotification] = useState(null); @@ -425,6 +451,11 @@ export default function MaestroConsole() { } finally { // Mark initial load as complete to enable persistence initialLoadComplete.current = true; + + // Hide the splash screen now that the app is ready + if (typeof window.__hideSplash === 'function') { + window.__hideSplash(); + } } }; loadSessionsAndGroups(); @@ -548,6 +579,8 @@ export default function MaestroConsole() { // React 18 StrictMode may call state updater functions multiple times let toastData: { title: string; summary: string; groupName: string; projectName: string; duration: number } | null = null; let queuedMessageToProcess: { sessionId: string; message: LogEntry } | null = null; + // Track if we need to run synopsis after completion (for /commit and other AI commands) + let synopsisData: { sessionId: string; cwd: string; claudeSessionId: string; command: string; groupName: string; projectName: string } | null = null; if (isFromAi) { const currentSession = sessionsRef.current.find(s => s.id === actualSessionId); @@ -564,8 +597,10 @@ export default function MaestroConsole() { const lastAiLog = currentSession.aiLogs.filter(log => log.source === 'stdout' || log.source === 'ai').pop(); const duration = currentSession.thinkingStartTime ? Date.now() - currentSession.thinkingStartTime : 0; - // Get group name for this session - const sessionGroup = groupsRef.current.find((g: any) => g.sessionIds?.includes(actualSessionId)); + // Get group name for this session (sessions have groupId, groups have id) + const sessionGroup = currentSession.groupId + ? groupsRef.current.find((g: any) => g.id === currentSession.groupId) + : null; const groupName = sessionGroup?.name || 'Ungrouped'; const projectName = currentSession.name || currentSession.cwd.split('/').pop() || 'Unknown'; @@ -590,6 +625,18 @@ export default function MaestroConsole() { } toastData = { title, summary, groupName, projectName, duration }; + + // Check if this was a custom AI command that should trigger synopsis + if (currentSession.pendingAICommandForSynopsis && currentSession.claudeSessionId) { + synopsisData = { + sessionId: actualSessionId, + cwd: currentSession.cwd, + claudeSessionId: currentSession.claudeSessionId, + command: currentSession.pendingAICommandForSynopsis, + groupName, + projectName + }; + } } } } @@ -605,17 +652,20 @@ export default function MaestroConsole() { return { ...s, state: 'busy' as SessionState, + busySource: 'ai', aiLogs: [...s.aiLogs, nextMessage], messageQueue: remainingQueue, thinkingStartTime: Date.now() }; } - // Task complete + // Task complete - also clear pending AI command flag return { ...s, state: 'idle' as SessionState, - thinkingStartTime: undefined + busySource: undefined, + thinkingStartTime: undefined, + pendingAICommandForSynopsis: undefined }; } @@ -630,6 +680,7 @@ export default function MaestroConsole() { return { ...s, state: 'idle' as SessionState, + busySource: undefined, shellLogs: [...s.shellLogs, exitLog] }; })); @@ -651,6 +702,46 @@ export default function MaestroConsole() { }); }, 0); } + + // Run synopsis in parallel if this was a custom AI command (like /commit) + // This creates a USER history entry to track the work + if (synopsisData && spawnBackgroundSynopsisRef.current && addHistoryEntryRef.current) { + const SYNOPSIS_PROMPT = 'Synopsize our recent work in 2-3 sentences max.'; + const startTime = Date.now(); + + spawnBackgroundSynopsisRef.current( + synopsisData.sessionId, + synopsisData.cwd, + synopsisData.claudeSessionId, + SYNOPSIS_PROMPT + ).then(result => { + const duration = Date.now() - startTime; + if (result.success && result.response && addHistoryEntryRef.current) { + addHistoryEntryRef.current({ + type: 'USER', + summary: result.response, + claudeSessionId: synopsisData!.claudeSessionId + }); + + // Show toast for synopsis completion + addToastRef.current({ + type: 'info', + title: `Synopsis (${synopsisData!.command})`, + message: result.response, + group: synopsisData!.groupName, + project: synopsisData!.projectName, + taskDuration: duration, + }); + + // Refresh history panel if available + if (rightPanelRef.current) { + rightPanelRef.current.refreshHistoryPanel(); + } + } + }).catch(err => { + console.error('[onProcessExit] Synopsis failed:', err); + }); + } }); // Handle Claude session ID capture for interactive sessions only @@ -764,11 +855,12 @@ export default function MaestroConsole() { return { ...s, state: 'idle' as SessionState, + busySource: undefined, shellLogs: [...s.shellLogs, exitLog] }; } - return { ...s, state: 'idle' as SessionState }; + return { ...s, state: 'idle' as SessionState, busySource: undefined }; })); }); @@ -889,6 +981,9 @@ export default function MaestroConsole() { ); const theme = THEMES[activeThemeId]; + // Tab completion hook for terminal mode + const { getSuggestions: getTabCompletionSuggestions } = useTabCompletion(activeSession); + // Broadcast active session change to web clients useEffect(() => { if (activeSessionId && isLiveMode) { @@ -1014,6 +1109,29 @@ export default function MaestroConsole() { }; }, []); + // Handle remote session selection from web interface + // This allows web clients to switch the active session in the desktop app + useEffect(() => { + const unsubscribeSelectSession = window.maestro.process.onRemoteSelectSession((sessionId: string) => { + console.log('[Remote] Received session selection from web interface:', { sessionId }); + + // Check if session exists + const session = sessionsRef.current.find(s => s.id === sessionId); + if (!session) { + console.log('[Remote] Session not found for selection:', sessionId); + return; + } + + // Switch to the session (same as clicking in SessionList) + setActiveSessionId(sessionId); + console.log('[Remote] Switched to session:', sessionId); + }); + + return () => { + unsubscribeSelectSession(); + }; + }, []); + // Combine built-in slash commands with custom AI commands for autocomplete const allSlashCommands = useMemo(() => { const customCommandsAsSlash = customAICommands.map(cmd => ({ @@ -1033,6 +1151,14 @@ export default function MaestroConsole() { const stagedImages = aiStagedImages; const setStagedImages = setAiStagedImages; + // Tab completion suggestions (must be after inputValue is defined) + const tabCompletionSuggestions = useMemo(() => { + if (!tabCompletionOpen || !activeSession || activeSession.inputMode !== 'terminal') { + return []; + } + return getTabCompletionSuggestions(inputValue); + }, [tabCompletionOpen, activeSession, inputValue, getTabCompletionSuggestions]); + // --- 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 }> => { @@ -1050,7 +1176,7 @@ export default function MaestroConsole() { // Set session to busy with thinking start time setSessions(prev => prev.map(s => - s.id === sessionId ? { ...s, state: 'busy' as SessionState, thinkingStartTime: Date.now() } : s + s.id === sessionId ? { ...s, state: 'busy' as SessionState, busySource: 'ai', thinkingStartTime: Date.now() } : s )); // Create a promise that resolves when the agent completes @@ -1090,7 +1216,7 @@ export default function MaestroConsole() { // Reset session state to idle, but do NOT overwrite the main session's claudeSessionId // The batch task's claudeSessionId is separate and returned via resolve() for tracking purposes setSessions(prev => prev.map(s => - s.id === sessionId ? { ...s, state: 'idle' as SessionState, thinkingStartTime: undefined } : s + s.id === sessionId ? { ...s, state: 'idle' as SessionState, busySource: undefined, thinkingStartTime: undefined } : s )); resolve({ success: true, response: responseText, claudeSessionId }); @@ -1257,8 +1383,6 @@ export default function MaestroConsole() { activeBatchSessionIds, startBatchRun, stopBatchRun, - customPrompts, - setCustomPrompt } = useBatchProcessor({ sessions, onUpdateSession: (sessionId, updates) => { @@ -1274,6 +1398,34 @@ export default function MaestroConsole() { }); // Refresh history panel to show the new entry rightPanelRef.current?.refreshHistoryPanel(); + }, + onComplete: (info) => { + // Find group name for the session + const sessionGroup = groups.find(g => g.sessionIds?.includes(info.sessionId)); + const groupName = sessionGroup?.name || 'Ungrouped'; + + // Determine toast type and message based on completion status + const isSuccess = info.completedTasks > 0 && !info.wasStopped; + const toastType = info.wasStopped ? 'warning' : (info.completedTasks === info.totalTasks ? 'success' : 'info'); + + // Build message + let message: string; + if (info.wasStopped) { + message = `Stopped after completing ${info.completedTasks} of ${info.totalTasks} tasks`; + } else if (info.completedTasks === info.totalTasks) { + message = `All ${info.totalTasks} ${info.totalTasks === 1 ? 'task' : 'tasks'} completed successfully`; + } else { + message = `Completed ${info.completedTasks} of ${info.totalTasks} tasks`; + } + + addToast({ + type: toastType, + title: 'Auto-Run Complete', + message, + group: groupName, + project: info.sessionName, + taskDuration: info.elapsedTimeMs, + }); } }); @@ -1314,6 +1466,48 @@ export default function MaestroConsole() { } }, [activeSession]); + // Handler to resume a Claude session directly (loads messages into main panel) + const handleResumeSession = useCallback(async (claudeSessionId: string) => { + if (!activeSession?.cwd) return; + + try { + // Load the session messages + const result = await window.maestro.claude.readSessionMessages( + activeSession.cwd, + claudeSessionId, + { offset: 0, limit: 100 } + ); + + // Convert to log entries + const messages: LogEntry[] = result.messages.map((msg: { type: string; content: string; timestamp: string; uuid: string }) => ({ + id: msg.uuid || generateId(), + timestamp: new Date(msg.timestamp).getTime(), + source: msg.type === 'user' ? 'user' as const : 'stdout' as const, + text: msg.content || '' + })); + + // Update the session + setSessions(prev => prev.map(s => { + if (s.id !== activeSession.id) return s; + // Move the session to front of recent list if it exists + const existingRecent = s.recentClaudeSessions || []; + const recentSession = existingRecent.find(r => r.sessionId === claudeSessionId); + const firstMessage = messages.find(m => m.source === 'user')?.text || ''; + const newRecentEntry = { + sessionId: claudeSessionId, + firstMessage: firstMessage.slice(0, 100), + timestamp: new Date().toISOString() + }; + const filtered = existingRecent.filter(r => r.sessionId !== claudeSessionId); + const updatedRecent = [recentSession ? { ...recentSession, timestamp: new Date().toISOString() } : newRecentEntry, ...filtered].slice(0, 10); + return { ...s, claudeSessionId, aiLogs: messages, state: 'idle', inputMode: 'ai', recentClaudeSessions: updatedRecent }; + })); + setActiveClaudeSessionId(claudeSessionId); + } catch (error) { + console.error('Failed to resume session:', error); + } + }, [activeSession?.cwd, activeSession?.id]); + // Handler to open lightbox with optional context images for navigation const handleSetLightboxImage = useCallback((image: string | null, contextImages?: string[]) => { setLightboxImage(image); @@ -1321,22 +1515,23 @@ export default function MaestroConsole() { }, []); // Create sorted sessions array that matches visual display order (includes ALL sessions) + // Note: sorting ignores leading emojis for proper alphabetization const sortedSessions = useMemo(() => { const sorted: Session[] = []; - // First, add sessions from sorted groups - const sortedGroups = [...groups].sort((a, b) => a.name.localeCompare(b.name)); + // First, add sessions from sorted groups (ignoring leading emojis) + const sortedGroups = [...groups].sort((a, b) => compareNamesIgnoringEmojis(a.name, b.name)); sortedGroups.forEach(group => { const groupSessions = sessions .filter(s => s.groupId === group.id) - .sort((a, b) => a.name.localeCompare(b.name)); + .sort((a, b) => compareNamesIgnoringEmojis(a.name, b.name)); sorted.push(...groupSessions); }); - // Then, add ungrouped sessions (sorted alphabetically) + // Then, add ungrouped sessions (sorted alphabetically, ignoring leading emojis) const ungroupedSessions = sessions .filter(s => !s.groupId) - .sort((a, b) => a.name.localeCompare(b.name)); + .sort((a, b) => compareNamesIgnoringEmojis(a.name, b.name)); sorted.push(...ungroupedSessions); return sorted; @@ -1476,16 +1671,22 @@ export default function MaestroConsole() { if (e.key === 'Tab') return; const isCycleShortcut = (e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === '[' || e.key === ']'); + // Allow sidebar toggle shortcuts (Alt+Cmd+Arrow) even when modals are open + const isLayoutShortcut = e.altKey && (e.metaKey || e.ctrlKey) && (e.key === 'ArrowLeft' || e.key === 'ArrowRight'); if (hasOpenModal()) { - // TRUE MODAL is open - block ALL shortcuts from App.tsx + // TRUE MODAL is open - block most shortcuts from App.tsx // The modal's own handler will handle Cmd+Shift+[] if it supports it - return; + // BUT allow layout shortcuts (sidebar toggles) to work + if (!isLayoutShortcut) { + return; + } + // Fall through to handle layout shortcuts below } else { // Only OVERLAYS are open (FilePreview, LogViewer, etc.) // Allow Cmd+Shift+[] to fall through to App.tsx handler // (which will cycle right panel tabs when previewFile is set) - if (!isCycleShortcut) { + if (!isCycleShortcut && !isLayoutShortcut) { return; } // Fall through to cyclePrev/cycleNext logic below @@ -1498,15 +1699,15 @@ export default function MaestroConsole() { } // Sidebar navigation with arrow keys (works when sidebar has focus) - if (activeFocus === 'sidebar' && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft')) { + if (activeFocus === 'sidebar' && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === ' ')) { e.preventDefault(); if (sortedSessions.length === 0) return; // Get the currently selected session const currentSession = sortedSessions[selectedSidebarIndex]; - // ArrowLeft: Close the current group and jump to nearest visible session - if (e.key === 'ArrowLeft' && currentSession?.groupId) { + // Space: Close the current group and jump to nearest visible session + if (e.key === ' ' && currentSession?.groupId) { const currentGroup = groups.find(g => g.id === currentSession.groupId); if (currentGroup && !currentGroup.collapsed) { // Collapse the group @@ -1770,6 +1971,7 @@ export default function MaestroConsole() { else if (isShortcut(e, 'agentSessions')) { e.preventDefault(); if (activeSession?.toolType === 'claude-code') { + setActiveClaudeSessionId(null); setAgentSessionsOpen(true); } } @@ -1782,7 +1984,7 @@ export default function MaestroConsole() { }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [shortcuts, activeFocus, activeRightTab, sessions, selectedSidebarIndex, activeSessionId, quickActionOpen, settingsModalOpen, shortcutsHelpOpen, newInstanceModalOpen, aboutModalOpen, processMonitorOpen, logViewerOpen, createGroupModalOpen, confirmModalOpen, renameInstanceModalOpen, renameGroupModalOpen, activeSession, previewFile, fileTreeFilter, fileTreeFilterOpen, gitDiffPreview, gitLogOpen, lightboxImage, hasOpenLayers, hasOpenModal, visibleSessions, sortedSessions, groups]); + }, [shortcuts, activeFocus, activeRightTab, sessions, selectedSidebarIndex, activeSessionId, quickActionOpen, settingsModalOpen, shortcutsHelpOpen, newInstanceModalOpen, aboutModalOpen, processMonitorOpen, logViewerOpen, createGroupModalOpen, confirmModalOpen, renameInstanceModalOpen, renameGroupModalOpen, activeSession, previewFile, fileTreeFilter, fileTreeFilterOpen, gitDiffPreview, gitLogOpen, lightboxImage, hasOpenLayers, hasOpenModal, visibleSessions, sortedSessions, groups, bookmarksCollapsed, leftSidebarOpen]); // Sync selectedSidebarIndex with activeSessionId // IMPORTANT: Only sync when activeSessionId changes, NOT when sortedSessions changes @@ -1824,26 +2026,78 @@ export default function MaestroConsole() { // --- ACTIONS --- const cycleSession = (dir: 'next' | 'prev') => { - // When left sidebar is collapsed, cycle through ALL sessions (groups not visible) - // When left sidebar is open, only cycle through visible sessions (not in collapsed groups) - const visibleSessions = leftSidebarOpen - ? sortedSessions.filter(session => { - if (!session.groupId) return true; // Ungrouped sessions are always visible - const group = groups.find(g => g.id === session.groupId); - return group && !group.collapsed; // Only include if group is not collapsed - }) - : sortedSessions; // All sessions when sidebar is collapsed - - if (visibleSessions.length === 0) return; - - const currentIndex = visibleSessions.findIndex(s => s.id === activeSessionId); + // Build the visual order of sessions as they appear in the sidebar. + // This matches the actual rendering order in SessionList.tsx: + // 1. Bookmarks section (if open) - sorted alphabetically + // 2. Groups (sorted alphabetically) - each with sessions sorted alphabetically + // 3. Ungrouped sessions - sorted alphabetically + // + // A bookmarked session visually appears in BOTH the bookmarks section AND its + // regular location (group or ungrouped). The same session can appear twice in + // the visual order. We track the current position with cyclePositionRef to + // allow cycling through duplicate occurrences correctly. + + const visualOrder: Session[] = []; + + if (leftSidebarOpen) { + // Bookmarks section (if expanded and has bookmarked sessions) + if (!bookmarksCollapsed) { + const bookmarkedSessions = sessions + .filter(s => s.bookmarked) + .sort((a, b) => compareNamesIgnoringEmojis(a.name, b.name)); + visualOrder.push(...bookmarkedSessions); + } + + // Groups (sorted alphabetically), with each group's sessions + const sortedGroups = [...groups].sort((a, b) => compareNamesIgnoringEmojis(a.name, b.name)); + for (const group of sortedGroups) { + if (!group.collapsed) { + const groupSessions = sessions + .filter(s => s.groupId === group.id) + .sort((a, b) => compareNamesIgnoringEmojis(a.name, b.name)); + visualOrder.push(...groupSessions); + } + } + + // Ungrouped sessions (sorted alphabetically) + const ungroupedSessions = sessions + .filter(s => !s.groupId) + .sort((a, b) => compareNamesIgnoringEmojis(a.name, b.name)); + visualOrder.push(...ungroupedSessions); + } else { + // Sidebar collapsed: cycle through all sessions in their sorted order + visualOrder.push(...sortedSessions); + } + + if (visualOrder.length === 0) return; + + // Determine current position in visual order + // If cyclePositionRef is valid and points to our current session, use it + // Otherwise, find the first occurrence of our current session + let currentIndex = cyclePositionRef.current; + if (currentIndex < 0 || currentIndex >= visualOrder.length || + visualOrder[currentIndex].id !== activeSessionId) { + // Position is invalid or doesn't match current session - find first occurrence + currentIndex = visualOrder.findIndex(s => s.id === activeSessionId); + } + + if (currentIndex === -1) { + // Current session not visible, select first visible session + cyclePositionRef.current = 0; + setActiveSessionIdInternal(visualOrder[0].id); + return; + } + + // Move to next/prev in visual order let nextIndex; if (dir === 'next') { - nextIndex = currentIndex === visibleSessions.length - 1 ? 0 : currentIndex + 1; + nextIndex = currentIndex === visualOrder.length - 1 ? 0 : currentIndex + 1; } else { - nextIndex = currentIndex === 0 ? visibleSessions.length - 1 : currentIndex - 1; + nextIndex = currentIndex === 0 ? visualOrder.length - 1 : currentIndex - 1; } - setActiveSessionId(visibleSessions[nextIndex].id); + + cyclePositionRef.current = nextIndex; + setActiveSessionIdInternal(visualOrder[nextIndex].id); }; const showConfirmation = (message: string, onConfirm: () => void) => { @@ -1981,6 +2235,9 @@ export default function MaestroConsole() { if (s.id !== activeSessionId) return s; return { ...s, inputMode: s.inputMode === 'ai' ? 'terminal' : 'ai' }; })); + // Close any open dropdowns when switching modes + setTabCompletionOpen(false); + setSlashCommandOpen(false); }; // Toggle global live mode (enables web interface for all sessions) @@ -2265,7 +2522,32 @@ export default function MaestroConsole() { { session: activeSession, gitBranch } ); + // Queue the command if AI is busy (same as regular messages) + if (activeSession.state === 'busy') { + const queuedEntry: LogEntry = { + id: generateId(), + timestamp: Date.now(), + source: 'user', + text: substitutedPrompt, + aiCommand: { + command: matchingCustomCommand.command, + description: matchingCustomCommand.description + } + }; + + setSessions(prev => prev.map(s => { + if (s.id !== activeSessionId) return s; + return { + ...s, + messageQueue: [...s.messageQueue, queuedEntry], + aiCommandHistory: Array.from(new Set([...(s.aiCommandHistory || []), commandText])).slice(-50), + }; + })); + return; + } + // Add user log showing the command with its interpolated prompt + // Also track this command for automatic synopsis on completion setSessions(prev => prev.map(s => { if (s.id !== activeSessionId) return s; return { @@ -2280,7 +2562,9 @@ export default function MaestroConsole() { description: matchingCustomCommand.description } }], - aiCommandHistory: Array.from(new Set([...(s.aiCommandHistory || []), commandText])).slice(-50) + aiCommandHistory: Array.from(new Set([...(s.aiCommandHistory || []), commandText])).slice(-50), + // Track this command so we can run synopsis on completion + pendingAICommandForSynopsis: matchingCustomCommand.command }; })); @@ -2388,6 +2672,7 @@ export default function MaestroConsole() { ...s, [targetLogKey]: [...s[targetLogKey], newEntry], state: 'busy', + busySource: currentMode, thinkingStartTime: currentMode === 'ai' ? Date.now() : s.thinkingStartTime, contextUsage: Math.min(s.contextUsage + 5, 100), shellCwd: newShellCwd, @@ -2572,6 +2857,7 @@ export default function MaestroConsole() { return { ...s, state: 'busy' as SessionState, + busySource: 'terminal', shellLogs: [...s.shellLogs, { id: generateId(), timestamp: Date.now(), @@ -2598,6 +2884,7 @@ export default function MaestroConsole() { return { ...s, state: 'idle' as SessionState, + busySource: undefined, shellLogs: [...s.shellLogs, { id: generateId(), timestamp: Date.now(), @@ -2769,6 +3056,7 @@ export default function MaestroConsole() { return { ...s, state: 'busy' as SessionState, + busySource: 'ai', thinkingStartTime: Date.now(), aiLogs: [...s.aiLogs, { id: generateId(), @@ -2802,6 +3090,7 @@ export default function MaestroConsole() { return { ...s, state: 'idle' as SessionState, + busySource: undefined, aiLogs: [...s.aiLogs, { id: generateId(), timestamp: Date.now(), @@ -2941,6 +3230,39 @@ export default function MaestroConsole() { return; // Let the modal handle keys } + // Handle tab completion dropdown (terminal mode only) + if (tabCompletionOpen && activeSession?.inputMode === 'terminal') { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedTabCompletionIndex(prev => + Math.min(prev + 1, tabCompletionSuggestions.length - 1) + ); + return; + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedTabCompletionIndex(prev => Math.max(prev - 1, 0)); + return; + } else if (e.key === 'Tab') { + e.preventDefault(); + if (tabCompletionSuggestions[selectedTabCompletionIndex]) { + setInputValue(tabCompletionSuggestions[selectedTabCompletionIndex].value); + } + setTabCompletionOpen(false); + return; + } else if (e.key === 'Enter') { + e.preventDefault(); + if (tabCompletionSuggestions[selectedTabCompletionIndex]) { + setInputValue(tabCompletionSuggestions[selectedTabCompletionIndex].value); + } + setTabCompletionOpen(false); + return; + } else if (e.key === 'Escape') { + e.preventDefault(); + setTabCompletionOpen(false); + return; + } + } + // Handle slash command autocomplete if (slashCommandOpen) { const isTerminalMode = activeSession.inputMode === 'terminal'; @@ -3064,6 +3386,23 @@ export default function MaestroConsole() { setCommandHistoryFilter(inputValue); setCommandHistorySelectedIndex(0); } + } else if (e.key === 'Tab') { + // Tab completion only in terminal mode when not showing slash commands + if (activeSession?.inputMode === 'terminal' && !slashCommandOpen && inputValue.trim()) { + e.preventDefault(); + // Get suggestions and show dropdown if there are any + const suggestions = getTabCompletionSuggestions(inputValue); + if (suggestions.length > 0) { + // If only one suggestion, auto-complete it + if (suggestions.length === 1) { + setInputValue(suggestions[0].value); + } else { + // Show dropdown for multiple suggestions + setSelectedTabCompletionIndex(0); + setTabCompletionOpen(true); + } + } + } } }; @@ -3393,7 +3732,7 @@ export default function MaestroConsole() { }, [activeFocus, activeRightTab, flatFileList, selectedFileIndex, activeSession?.fileExplorerExpanded, activeSessionId, setSessions, toggleFolder, handleFileClick]); return ( -
- {/* --- DRAGGABLE TITLE BAR --- */} + {/* --- DRAGGABLE TITLE BAR (hidden in mobile landscape) --- */} + {!isMobileLandscape && (
+ )} {/* --- MODALS --- */} {quickActionOpen && ( @@ -3444,6 +3785,7 @@ export default function MaestroConsole() { setProcessMonitorOpen={setProcessMonitorOpen} setActiveRightTab={setActiveRightTab} setAgentSessionsOpen={setAgentSessionsOpen} + setActiveClaudeSessionId={setActiveClaudeSessionId} setGitDiffPreview={setGitDiffPreview} setGitLogOpen={setGitLogOpen} startFreshSession={() => { @@ -3515,7 +3857,6 @@ export default function MaestroConsole() { setAboutModalOpen(false)} /> )} @@ -3586,52 +3927,56 @@ export default function MaestroConsole() { /> )} - {/* --- LEFT SIDEBAR --- */} - - - + {/* --- LEFT SIDEBAR (hidden in mobile landscape) --- */} + {!isMobileLandscape && ( + + + + )} {/* --- CENTER WORKSPACE --- */} { + onResumeClaudeSession={(claudeSessionId: string, messages: LogEntry[], sessionName?: string) => { // Update the active session with the selected Claude session ID and load messages // Also reset state to 'idle' since we're just loading historical messages // Switch to AI mode since we're resuming an AI session console.log('[onResumeClaudeSession] Resuming session:', claudeSessionId, 'activeSession:', activeSession?.id, activeSession?.claudeSessionId); if (activeSession) { + // Track this session in recent sessions list + const firstMessage = messages.find(m => m.source === 'user')?.text || ''; + const newRecentSession = { + sessionId: claudeSessionId, + firstMessage: firstMessage.slice(0, 100), + timestamp: new Date().toISOString(), + sessionName + }; + setSessions(prev => { console.log('[onResumeClaudeSession] Updating sessions, looking for id:', activeSession.id); - const updated = prev.map(s => - s.id === activeSession.id ? { ...s, claudeSessionId, aiLogs: messages, state: 'idle', inputMode: 'ai' } : s - ); + const updated = prev.map(s => { + if (s.id !== activeSession.id) return s; + // Update recent sessions: remove if exists, add to front, keep max 10 + const existingRecent = s.recentClaudeSessions || []; + const filtered = existingRecent.filter(r => r.sessionId !== claudeSessionId); + const updatedRecent = [newRecentSession, ...filtered].slice(0, 10); + return { ...s, claudeSessionId, aiLogs: messages, state: 'idle', inputMode: 'ai', recentClaudeSessions: updatedRecent }; + }); const updatedSession = updated.find(s => s.id === activeSession.id); console.log('[onResumeClaudeSession] Updated session claudeSessionId:', updatedSession?.claudeSessionId); return updated; }); setActiveClaudeSessionId(claudeSessionId); - - // Track this session in recent sessions list - const firstMessage = messages.find(m => m.source === 'user')?.text || ''; - setRecentClaudeSessions(prev => { - // Remove if already exists - const filtered = prev.filter(s => s.sessionId !== claudeSessionId); - // Add to front - return [ - { sessionId: claudeSessionId, firstMessage: firstMessage.slice(0, 100), timestamp: new Date().toISOString() }, - ...filtered - ].slice(0, 10); // Keep only last 10 - }); } }} onNewClaudeSession={() => { @@ -3734,6 +4083,11 @@ export default function MaestroConsole() { setCommandHistorySelectedIndex={setCommandHistorySelectedIndex} setSlashCommandOpen={setSlashCommandOpen} setSelectedSlashCommandIndex={setSelectedSlashCommandIndex} + tabCompletionOpen={tabCompletionOpen} + setTabCompletionOpen={setTabCompletionOpen} + tabCompletionSuggestions={tabCompletionSuggestions} + selectedTabCompletionIndex={selectedTabCompletionIndex} + setSelectedTabCompletionIndex={setSelectedTabCompletionIndex} setPreviewFile={setPreviewFile} setMarkdownRawMode={setMarkdownRawMode} setAboutModalOpen={setAboutModalOpen} @@ -3757,17 +4111,20 @@ export default function MaestroConsole() { onDeleteLog={(logId: string): number | null => { if (!activeSession) return null; + const isAIMode = activeSession.inputMode === 'ai'; + const logs = isAIMode ? activeSession.aiLogs : activeSession.shellLogs; + // Find the log entry and its index - const logIndex = activeSession.shellLogs.findIndex(log => log.id === logId); + const logIndex = logs.findIndex(log => log.id === logId); if (logIndex === -1) return null; - const log = activeSession.shellLogs[logIndex]; - if (log.source !== 'user') return null; // Only delete user commands + const log = logs[logIndex]; + if (log.source !== 'user') return null; // Only delete user commands/messages // Find the next user command index (or end of array) - let endIndex = activeSession.shellLogs.length; - for (let i = logIndex + 1; i < activeSession.shellLogs.length; i++) { - if (activeSession.shellLogs[i].source === 'user') { + let endIndex = logs.length; + for (let i = logIndex + 1; i < logs.length; i++) { + if (logs[i].source === 'user') { endIndex = i; break; } @@ -3775,8 +4132,8 @@ export default function MaestroConsole() { // Remove logs from logIndex to endIndex (exclusive) const newLogs = [ - ...activeSession.shellLogs.slice(0, logIndex), - ...activeSession.shellLogs.slice(endIndex) + ...logs.slice(0, logIndex), + ...logs.slice(endIndex) ]; // Find the index of the next user command in the NEW array @@ -3798,17 +4155,53 @@ export default function MaestroConsole() { } } - // Also remove from shell command history (this is for terminal mode) - const commandText = log.text.trim(); - const newShellCommandHistory = (activeSession.shellCommandHistory || []).filter( - cmd => cmd !== commandText - ); + if (isAIMode) { + // For AI mode, also delete from the Claude session JSONL file + // This ensures the context is actually removed for future interactions + if (activeSession.claudeSessionId && activeSession.cwd) { + // Delete asynchronously - don't block the UI update + window.maestro.claude.deleteMessagePair( + activeSession.cwd, + activeSession.claudeSessionId, + logId, // This is the UUID if loaded from Claude session + log.text // Fallback: match by content if UUID doesn't match + ).then(result => { + if (result.success) { + console.log('[onDeleteLog] Deleted message pair from Claude session', { + linesRemoved: result.linesRemoved + }); + } else { + console.warn('[onDeleteLog] Failed to delete from Claude session:', result.error); + } + }).catch(err => { + console.error('[onDeleteLog] Error deleting from Claude session:', err); + }); + } - setSessions(sessions.map(s => - s.id === activeSession.id - ? { ...s, shellLogs: newLogs, shellCommandHistory: newShellCommandHistory } - : s - )); + // Update aiLogs and aiCommandHistory + const commandText = log.text.trim(); + const newAICommandHistory = (activeSession.aiCommandHistory || []).filter( + cmd => cmd !== commandText + ); + + setSessions(sessions.map(s => + s.id === activeSession.id + ? { ...s, aiLogs: newLogs, aiCommandHistory: newAICommandHistory } + : s + )); + } else { + // Terminal mode - update shellLogs and shellCommandHistory + const commandText = log.text.trim(); + const newShellCommandHistory = (activeSession.shellCommandHistory || []).filter( + cmd => cmd !== commandText + ); + + setSessions(sessions.map(s => + s.id === activeSession.id + ? { ...s, shellLogs: newLogs, shellCommandHistory: newShellCommandHistory } + : s + )); + } return nextUserCommandIndex; }} @@ -3823,7 +4216,7 @@ export default function MaestroConsole() { })); }} audioFeedbackCommand={audioFeedbackCommand} - recentClaudeSessions={recentClaudeSessions} + recentClaudeSessions={activeSession?.recentClaudeSessions || []} onResumeRecentSession={async (sessionId: string) => { // Resume a session from the recent sessions list if (!activeSession?.cwd) return; @@ -3844,65 +4237,70 @@ export default function MaestroConsole() { text: msg.content || '' })); - // Update the session - setSessions(prev => prev.map(s => - s.id === activeSession.id ? { ...s, claudeSessionId: sessionId, aiLogs: messages, state: 'idle', inputMode: 'ai' } : s - )); + // Update the session and move to front of recent list + setSessions(prev => prev.map(s => { + if (s.id !== activeSession.id) return s; + // Move the session to front of recent list + const existingRecent = s.recentClaudeSessions || []; + const recentSession = existingRecent.find(r => r.sessionId === sessionId); + if (!recentSession) { + // Session not in recent list, just update the session + return { ...s, claudeSessionId: sessionId, aiLogs: messages, state: 'idle', inputMode: 'ai' }; + } + const filtered = existingRecent.filter(r => r.sessionId !== sessionId); + const updatedRecent = [{ ...recentSession, timestamp: new Date().toISOString() }, ...filtered]; + return { ...s, claudeSessionId: sessionId, aiLogs: messages, state: 'idle', inputMode: 'ai', recentClaudeSessions: updatedRecent }; + })); setActiveClaudeSessionId(sessionId); - - // Move to front of recent list - setRecentClaudeSessions(prev => { - const session = prev.find(s => s.sessionId === sessionId); - if (!session) return prev; - const filtered = prev.filter(s => s.sessionId !== sessionId); - return [{ ...session, timestamp: new Date().toISOString() }, ...filtered]; - }); } catch (error) { console.error('Failed to resume session:', error); } }} /> - {/* --- RIGHT PANEL --- */} - - - + {/* --- RIGHT PANEL (hidden in mobile landscape) --- */} + {!isMobileLandscape && ( + + + + )} {/* --- BATCH RUNNER MODAL --- */} {batchRunnerModalOpen && activeSession && ( @@ -3910,12 +4308,16 @@ export default function MaestroConsole() { theme={theme} onClose={() => setBatchRunnerModalOpen(false)} onGo={(prompt) => { - // Save the custom prompt for this session - setCustomPrompt(activeSession.id, prompt); // Start the batch run handleStartBatchRun(prompt); }} - initialPrompt={customPrompts[activeSession.id] || ''} + onSave={(prompt) => { + // Save the custom prompt to the session (persisted across restarts) + setSessions(prev => prev.map(s => + s.id === activeSession.id ? { ...s, batchRunnerPrompt: prompt } : s + )); + }} + initialPrompt={activeSession.batchRunnerPrompt || ''} showConfirmation={showConfirmation} /> )} diff --git a/src/renderer/assets.d.ts b/src/renderer/assets.d.ts index 7be146e52..a36db3927 100644 --- a/src/renderer/assets.d.ts +++ b/src/renderer/assets.d.ts @@ -30,3 +30,10 @@ declare module '*.webp' { // Vite-injected build-time constants declare const __APP_VERSION__: string; + +// Splash screen global functions (defined in index.html) +interface Window { + __hideSplash?: () => void; + __splashProgress?: () => number; + __splashInterval?: ReturnType; +} diff --git a/src/renderer/components/AICommandsPanel.tsx b/src/renderer/components/AICommandsPanel.tsx index f2e947927..505765f29 100644 --- a/src/renderer/components/AICommandsPanel.tsx +++ b/src/renderer/components/AICommandsPanel.tsx @@ -152,7 +152,7 @@ export function AICommandsPanel({ theme, customAICommands, setCustomAICommands } className="flex items-center gap-2 px-4 py-2 rounded text-sm font-medium transition-all" style={{ backgroundColor: theme.colors.accent, - color: 'white' + color: theme.colors.accentForeground }} > @@ -200,8 +200,8 @@ export function AICommandsPanel({ theme, customAICommands, setCustomAICommands } value={newCommand.prompt} onChange={(e) => setNewCommand({ ...newCommand, prompt: e.target.value })} placeholder="The actual prompt sent to the AI agent when this command is invoked..." - rows={4} - className="w-full p-2 rounded border bg-transparent outline-none text-sm resize-none scrollbar-thin" + rows={10} + className="w-full p-2 rounded border bg-transparent outline-none text-sm resize-y scrollbar-thin min-h-[150px]" style={{ borderColor: theme.colors.border, color: theme.colors.textMain }} />
@@ -243,8 +243,8 @@ export function AICommandsPanel({ theme, customAICommands, setCustomAICommands } style={{ backgroundColor: theme.colors.bgMain, borderColor: theme.colors.border }} > {editingCommand?.id === cmd.id ? ( - // Editing mode -
+ // Editing mode - expanded to maximize space +
@@ -267,13 +267,13 @@ export function AICommandsPanel({ theme, customAICommands, setCustomAICommands } />
-
+