From 6fc1455c531bbfa9ae285f39bbb8c7adc3e9e103 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Mon, 1 Dec 2025 07:24:13 -0600 Subject: [PATCH] refactor: Cross-platform build scripts and session format cleanup - Add scripts/set-version.mjs for cross-platform VITE_APP_VERSION setting (replaces bash-specific env var syntax that failed on Windows) - Extract magic numbers into CLAUDE_SESSION_PARSE_LIMITS and CLAUDE_PRICING constants for better maintainability - Remove legacy session format migration code - sessions now require aiTabs - Fix session ID regex patterns to properly parse -ai-{tabId} format - Remove deprecated aiLogs fallbacks - logs are exclusively in aiTabs now Claude ID: bfd92ffb-a375-47be-94c5-fe4186325092 Maestro ID: b9bc0d08-5be2-4fdf-93cd-5618a8d53b35 --- package.json | 8 +- scripts/set-version.mjs | 56 ++++ src/main/index.ts | 80 ++++-- src/renderer/App.tsx | 320 +++++++++------------ src/renderer/components/MainPanel.tsx | 4 +- src/renderer/components/SessionList.tsx | 25 +- src/renderer/components/TabBar.tsx | 2 +- src/renderer/components/TerminalOutput.tsx | 7 +- src/renderer/hooks/useSessionManager.ts | 45 +-- 9 files changed, 279 insertions(+), 268 deletions(-) create mode 100644 scripts/set-version.mjs diff --git a/package.json b/package.json index 16ce8088..53dde7e8 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,10 @@ "build:main": "tsc -p tsconfig.main.json", "build:renderer": "vite build", "build:web": "vite build --config vite.config.web.mts", - "package": "VITE_APP_VERSION=\"LOCAL $(git rev-parse --short=8 HEAD)\" npm run build && electron-builder --mac --win --linux", - "package:mac": "VITE_APP_VERSION=\"LOCAL $(git rev-parse --short=8 HEAD)\" npm run build && electron-builder --mac", - "package:win": "VITE_APP_VERSION=\"LOCAL $(git rev-parse --short=8 HEAD)\" npm run build && electron-builder --win", - "package:linux": "VITE_APP_VERSION=\"LOCAL $(git rev-parse --short=8 HEAD)\" npm run build && electron-builder --linux", + "package": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --mac --win --linux", + "package:mac": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --mac", + "package:win": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --win", + "package:linux": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --linux", "start": "electron .", "clean": "rm -rf dist release node_modules/.vite", "postinstall": "electron-rebuild -f -w node-pty" diff --git a/scripts/set-version.mjs b/scripts/set-version.mjs new file mode 100644 index 00000000..cedefc4f --- /dev/null +++ b/scripts/set-version.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/** + * Cross-platform script to set VITE_APP_VERSION environment variable + * with the local git hash. Works on Windows, macOS, and Linux. + * + * Usage: node scripts/set-version.mjs [args...] + * Example: node scripts/set-version.mjs npm run build + */ + +import { execFileSync, spawn } from 'child_process'; +import process from 'process'; + +function getGitHash() { + try { + const hash = execFileSync('git', ['rev-parse', '--short=8', 'HEAD'], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + return hash; + } catch { + return 'unknown'; + } +} + +const gitHash = getGitHash(); +const version = `LOCAL ${gitHash}`; + +// Set environment variable +process.env.VITE_APP_VERSION = version; + +// Get the command and args to run +const [,, ...args] = process.argv; + +if (args.length === 0) { + console.log(`VITE_APP_VERSION=${version}`); + process.exit(0); +} + +// Run the command with the environment variable set +const command = args[0]; +const commandArgs = args.slice(1); + +const child = spawn(command, commandArgs, { + stdio: 'inherit', + shell: true, + env: { ...process.env, VITE_APP_VERSION: version } +}); + +child.on('close', (code) => { + process.exit(code ?? 0); +}); + +child.on('error', (err) => { + console.error(`Failed to run command: ${err.message}`); + process.exit(1); +}); diff --git a/src/main/index.ts b/src/main/index.ts index ed5c5237..37e02c7d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,6 +10,28 @@ import { detectShells } from './utils/shellDetector'; import { getThemeById } from './themes'; import Store from 'electron-store'; +// Constants for Claude session parsing +const CLAUDE_SESSION_PARSE_LIMITS = { + /** Max lines to scan from start of file to find first user message */ + FIRST_MESSAGE_SCAN_LINES: 20, + /** Max lines to scan from end of file to find last timestamp */ + LAST_TIMESTAMP_SCAN_LINES: 10, + /** Max lines to scan for oldest timestamp in stats calculation */ + OLDEST_TIMESTAMP_SCAN_LINES: 5, + /** Batch size for processing session files (allows UI updates) */ + STATS_BATCH_SIZE: 20, + /** Max characters for first message preview */ + FIRST_MESSAGE_PREVIEW_LENGTH: 200, +}; + +// Claude API pricing (per million tokens) +const CLAUDE_PRICING = { + INPUT_PER_MILLION: 3, + OUTPUT_PER_MILLION: 15, + CACHE_READ_PER_MILLION: 0.30, + CACHE_CREATION_PER_MILLION: 3.75, +}; + // Type definitions interface MaestroSettings { activeThemeId: string; @@ -1503,7 +1525,7 @@ function setupIpcHandlers() { const messageCount = userMessageCount + assistantMessageCount; // Extract first user message content - parse only first few lines - for (let i = 0; i < Math.min(lines.length, 20); i++) { + for (let i = 0; i < Math.min(lines.length, CLAUDE_SESSION_PARSE_LIMITS.FIRST_MESSAGE_SCAN_LINES); i++) { try { const entry = JSON.parse(lines[i]); if (entry.type === 'user' && entry.message?.content) { @@ -1540,18 +1562,16 @@ function setupIpcHandlers() { const cacheCreationMatches = content.matchAll(/"cache_creation_input_tokens"\s*:\s*(\d+)/g); for (const m of cacheCreationMatches) totalCacheCreationTokens += parseInt(m[1], 10); - // Calculate cost estimate using Claude Sonnet 4 pricing: - // Input: $3 per million tokens, Output: $15 per million tokens - // Cache read: $0.30 per million, Cache creation: $3.75 per million - const inputCost = (totalInputTokens / 1_000_000) * 3; - const outputCost = (totalOutputTokens / 1_000_000) * 15; - const cacheReadCost = (totalCacheReadTokens / 1_000_000) * 0.30; - const cacheCreationCost = (totalCacheCreationTokens / 1_000_000) * 3.75; + // Calculate cost estimate using Claude Sonnet 4 pricing + const inputCost = (totalInputTokens / 1_000_000) * CLAUDE_PRICING.INPUT_PER_MILLION; + const outputCost = (totalOutputTokens / 1_000_000) * CLAUDE_PRICING.OUTPUT_PER_MILLION; + const cacheReadCost = (totalCacheReadTokens / 1_000_000) * CLAUDE_PRICING.CACHE_READ_PER_MILLION; + const cacheCreationCost = (totalCacheCreationTokens / 1_000_000) * CLAUDE_PRICING.CACHE_CREATION_PER_MILLION; const costUsd = inputCost + outputCost + cacheReadCost + cacheCreationCost; // Extract last timestamp from the session to calculate duration let lastTimestamp = timestamp; - for (let i = lines.length - 1; i >= Math.max(0, lines.length - 10); i--) { + for (let i = lines.length - 1; i >= Math.max(0, lines.length - CLAUDE_SESSION_PARSE_LIMITS.LAST_TIMESTAMP_SCAN_LINES); i--) { try { const entry = JSON.parse(lines[i]); if (entry.timestamp) { @@ -1573,7 +1593,7 @@ function setupIpcHandlers() { projectPath, timestamp, modifiedAt: stats.mtime.toISOString(), - firstMessage: firstUserMessage.slice(0, 200), // Truncate for display + firstMessage: firstUserMessage.slice(0, CLAUDE_SESSION_PARSE_LIMITS.FIRST_MESSAGE_PREVIEW_LENGTH), // Truncate for display messageCount, sizeBytes: stats.size, costUsd, @@ -1708,7 +1728,7 @@ function setupIpcHandlers() { const messageCount = userMessageCount + assistantMessageCount; // Extract first user message content - parse only first few lines - for (let i = 0; i < Math.min(lines.length, 20); i++) { + for (let i = 0; i < Math.min(lines.length, CLAUDE_SESSION_PARSE_LIMITS.FIRST_MESSAGE_SCAN_LINES); i++) { try { const entry = JSON.parse(lines[i]); if (entry.type === 'user' && entry.message?.content) { @@ -1742,15 +1762,15 @@ function setupIpcHandlers() { for (const m of cacheCreationMatches) totalCacheCreationTokens += parseInt(m[1], 10); // Calculate cost estimate - const inputCost = (totalInputTokens / 1_000_000) * 3; - const outputCost = (totalOutputTokens / 1_000_000) * 15; - const cacheReadCost = (totalCacheReadTokens / 1_000_000) * 0.30; - const cacheCreationCost = (totalCacheCreationTokens / 1_000_000) * 3.75; + const inputCost = (totalInputTokens / 1_000_000) * CLAUDE_PRICING.INPUT_PER_MILLION; + const outputCost = (totalOutputTokens / 1_000_000) * CLAUDE_PRICING.OUTPUT_PER_MILLION; + const cacheReadCost = (totalCacheReadTokens / 1_000_000) * CLAUDE_PRICING.CACHE_READ_PER_MILLION; + const cacheCreationCost = (totalCacheCreationTokens / 1_000_000) * CLAUDE_PRICING.CACHE_CREATION_PER_MILLION; const costUsd = inputCost + outputCost + cacheReadCost + cacheCreationCost; // Extract last timestamp for duration let lastTimestamp = timestamp; - for (let i = lines.length - 1; i >= Math.max(0, lines.length - 10); i--) { + for (let i = lines.length - 1; i >= Math.max(0, lines.length - CLAUDE_SESSION_PARSE_LIMITS.LAST_TIMESTAMP_SCAN_LINES); i--) { try { const entry = JSON.parse(lines[i]); if (entry.timestamp) { @@ -1776,7 +1796,7 @@ function setupIpcHandlers() { projectPath, timestamp, modifiedAt: new Date(fileInfo.modifiedAt).toISOString(), - firstMessage: firstUserMessage.slice(0, 200), + firstMessage: firstUserMessage.slice(0, CLAUDE_SESSION_PARSE_LIMITS.FIRST_MESSAGE_PREVIEW_LENGTH), messageCount, sizeBytes: fileInfo.sizeBytes, costUsd, @@ -1858,10 +1878,10 @@ function setupIpcHandlers() { let processedCount = 0; // Process files in batches to allow UI updates - const BATCH_SIZE = 20; + const batchSize = CLAUDE_SESSION_PARSE_LIMITS.STATS_BATCH_SIZE; - for (let i = 0; i < sessionFiles.length; i += BATCH_SIZE) { - const batch = sessionFiles.slice(i, i + BATCH_SIZE); + for (let i = 0; i < sessionFiles.length; i += batchSize) { + const batch = sessionFiles.slice(i, i + batchSize); await Promise.all( batch.map(async (filename) => { @@ -1896,15 +1916,15 @@ function setupIpcHandlers() { for (const m of cacheCreationMatches) cacheCreationTokens += parseInt(m[1], 10); // Calculate cost - const inputCost = (inputTokens / 1_000_000) * 3; - const outputCost = (outputTokens / 1_000_000) * 15; - const cacheReadCost = (cacheReadTokens / 1_000_000) * 0.30; - const cacheCreationCost = (cacheCreationTokens / 1_000_000) * 3.75; + const inputCost = (inputTokens / 1_000_000) * CLAUDE_PRICING.INPUT_PER_MILLION; + const outputCost = (outputTokens / 1_000_000) * CLAUDE_PRICING.OUTPUT_PER_MILLION; + const cacheReadCost = (cacheReadTokens / 1_000_000) * CLAUDE_PRICING.CACHE_READ_PER_MILLION; + const cacheCreationCost = (cacheCreationTokens / 1_000_000) * CLAUDE_PRICING.CACHE_CREATION_PER_MILLION; totalCostUsd += inputCost + outputCost + cacheReadCost + cacheCreationCost; // Find oldest timestamp const lines = content.split('\n').filter(l => l.trim()); - for (let j = 0; j < Math.min(lines.length, 5); j++) { + for (let j = 0; j < Math.min(lines.length, CLAUDE_SESSION_PARSE_LIMITS.OLDEST_TIMESTAMP_SCAN_LINES); j++) { try { const entry = JSON.parse(lines[j]); if (entry.timestamp) { @@ -1923,7 +1943,7 @@ function setupIpcHandlers() { }) ); - processedCount = Math.min(i + BATCH_SIZE, sessionFiles.length); + processedCount = Math.min(i + batchSize, sessionFiles.length); // Send progressive update sendUpdate({ @@ -3009,8 +3029,9 @@ function setupProcessListeners() { return; } - const baseSessionId = sessionId.replace(/-ai$|-batch-\d+$|-synopsis-\d+$/, ''); - const isAiOutput = sessionId.endsWith('-ai') || sessionId.includes('-batch-') || sessionId.includes('-synopsis-'); + // Extract base session ID from formats: {id}-ai-{tabId}, {id}-batch-{timestamp}, {id}-synopsis-{timestamp} + const baseSessionId = sessionId.replace(/-ai-[^-]+$|-batch-\d+$|-synopsis-\d+$/, ''); + const isAiOutput = sessionId.includes('-ai-') || sessionId.includes('-batch-') || sessionId.includes('-synopsis-'); const msgId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; console.log(`[WebBroadcast] Broadcasting session_output: msgId=${msgId}, session=${baseSessionId}, source=${isAiOutput ? 'ai' : 'terminal'}, dataLen=${data.length}`); webServer.broadcastToSessionClients(baseSessionId, { @@ -3029,7 +3050,8 @@ function setupProcessListeners() { // Broadcast exit to web clients if (webServer) { - const baseSessionId = sessionId.replace(/-ai$|-terminal$|-batch-\d+$|-synopsis-\d+$/, ''); + // Extract base session ID from formats: {id}-ai-{tabId}, {id}-terminal, {id}-batch-{timestamp}, {id}-synopsis-{timestamp} + const baseSessionId = sessionId.replace(/-ai-[^-]+$|-terminal$|-batch-\d+$|-synopsis-\d+$/, ''); webServer.broadcastToSessionClients(baseSessionId, { type: 'session_exit', sessionId: baseSessionId, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 703739fa..cccfeb8e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -298,59 +298,18 @@ export default function MaestroConsole() { // Restore a persisted session by respawning its process const restoreSession = async (session: Session): Promise => { try { - // ===== Migration: Convert old session format to new aiTabs format ===== - // If session lacks aiTabs array, migrate from legacy fields + // Sessions must have aiTabs - if missing, this is a data corruption issue if (!session.aiTabs || session.aiTabs.length === 0) { - // Look up starred status and session name from existing stores - let isStarred = false; - let sessionName: string | null = null; - - if (session.claudeSessionId && session.cwd) { - try { - // Look up session metadata from Claude session origins (name and starred) - const origins = await window.maestro.claude.getSessionOrigins(session.cwd); - const originData = origins[session.claudeSessionId]; - if (originData && typeof originData === 'object') { - if (originData.sessionName) { - sessionName = originData.sessionName; - } - if (originData.starred !== undefined) { - isStarred = originData.starred; - } - } - } catch (error) { - console.warn('[restoreSession] Failed to lookup starred/named status during migration:', error); - } - } - - // Create initial tab from legacy data - const initialTab: AITab = { - id: generateId(), - claudeSessionId: session.claudeSessionId || null, - name: sessionName, - starred: isStarred, - logs: session.aiLogs || [], - inputValue: '', - stagedImages: [], - usageStats: session.usageStats, - createdAt: Date.now(), - state: 'idle' - }; - - session = { + console.error('[restoreSession] Session has no aiTabs - data corruption, skipping:', session.id); + return { ...session, - aiTabs: [initialTab], - activeTabId: initialTab.id, - closedTabHistory: [] + aiPid: -1, + terminalPid: -1, + state: 'error' as SessionState, + isLive: false, + liveUrl: undefined }; - - console.log('[restoreSession] Migrated session to aiTabs format:', session.id, { - claudeSessionId: initialTab.claudeSessionId, - name: sessionName, - starred: isStarred - }); } - // ===== End Migration ===== // Detect and fix inputMode/toolType mismatch // The AI agent should never use 'terminal' as toolType @@ -362,16 +321,19 @@ export default function MaestroConsole() { console.warn(`[restoreSession] Session has toolType='terminal', using default agent for AI process`); aiAgentType = defaultAgent as ToolType; - const targetLogKey = 'aiLogs'; - correctedSession[targetLogKey] = [ - ...correctedSession[targetLogKey], - { - id: generateId(), - timestamp: Date.now(), - source: 'system', - text: '⚠️ Using default AI agent (Claude Code) for this session.' - } - ]; + // Add warning to the active tab's logs + const warningLog: LogEntry = { + id: generateId(), + timestamp: Date.now(), + source: 'system', + text: '⚠️ Using default AI agent (Claude Code) for this session.' + }; + const activeTabIndex = correctedSession.aiTabs.findIndex(tab => tab.id === correctedSession.activeTabId); + if (activeTabIndex >= 0) { + correctedSession.aiTabs = correctedSession.aiTabs.map((tab, i) => + i === activeTabIndex ? { ...tab, logs: [...tab.logs, warningLog] } : tab + ); + } } // Get agent definitions for both processes @@ -407,10 +369,12 @@ export default function MaestroConsole() { let aiSpawnResult = { pid: 0, success: true }; // Default for batch mode if (!isClaudeBatchMode) { - // Only spawn for non-batch-mode agents + // Only spawn for non-batch-mode agents (Aider, etc.) + // Include active tab ID in session ID to match batch mode format + const activeTabId = correctedSession.activeTabId || correctedSession.aiTabs?.[0]?.id || 'default'; // Use agent.path (full path) if available for better cross-environment compatibility aiSpawnResult = await window.maestro.process.spawn({ - sessionId: `${correctedSession.id}-ai`, + sessionId: `${correctedSession.id}-ai-${activeTabId}`, toolType: aiAgentType, cwd: correctedSession.cwd, command: agent.path || agent.command, @@ -444,7 +408,7 @@ export default function MaestroConsole() { isGitRepo, // Update Git status isLive: false, // Always start offline on app restart liveUrl: undefined, // Clear any stale URL - aiLogs: correctedSession.aiLogs, // Preserve existing AI Terminal logs + aiLogs: [], // Deprecated - logs are now in aiTabs shellLogs: correctedSession.shellLogs, // Preserve existing Command Terminal logs executionQueue: correctedSession.executionQueue || [], // Ensure backwards compatibility activeTimeMs: correctedSession.activeTimeMs || 0 // Ensure backwards compatibility @@ -583,7 +547,7 @@ export default function MaestroConsole() { // Set up process event listeners for real-time output useEffect(() => { // Handle process output data - // sessionId will be in format: "{id}-ai-{tabId}", "{id}-ai" (legacy), "{id}-terminal", "{id}-batch-{timestamp}", etc. + // sessionId will be in format: "{id}-ai-{tabId}", "{id}-terminal", "{id}-batch-{timestamp}", etc. const unsubscribeData = window.maestro.process.onData((sessionId: string, data: string) => { console.log('[onData] Received data for session:', sessionId, 'DataLen:', data.length, 'Preview:', data.substring(0, 200)); @@ -592,15 +556,12 @@ export default function MaestroConsole() { let isFromAi: boolean; let tabIdFromSession: string | undefined; - // Check for new format with tab ID: sessionId-ai-tabId + // Format: sessionId-ai-tabId const aiTabMatch = sessionId.match(/^(.+)-ai-(.+)$/); if (aiTabMatch) { actualSessionId = aiTabMatch[1]; tabIdFromSession = aiTabMatch[2]; isFromAi = true; - } else if (sessionId.endsWith('-ai')) { - actualSessionId = sessionId.slice(0, -3); // Remove "-ai" suffix (legacy format) - isFromAi = true; } else if (sessionId.endsWith('-terminal')) { // Ignore PTY terminal output - we use runCommand for terminal commands, // which emits data with plain session ID (not -terminal suffix) @@ -659,10 +620,9 @@ export default function MaestroConsole() { targetTab = getWriteModeTab(s) || getActiveTab(s); } if (!targetTab) { - // Fallback: no tabs exist, use deprecated aiLogs (shouldn't happen normally) - console.warn('[onData] No target tab found, falling back to aiLogs'); - const newLog: LogEntry = { id: generateId(), timestamp: Date.now(), source: 'stdout', text: data }; - return { ...s, aiLogs: [...s.aiLogs, newLog] }; + // No tabs exist - this is a bug, sessions must have aiTabs + console.error('[onData] No target tab found - session has no aiTabs, this should not happen'); + return s; } const existingLogs = targetTab.logs; @@ -723,20 +683,17 @@ export default function MaestroConsole() { // Handle process exit const unsubscribeExit = window.maestro.process.onExit((sessionId: string, code: number) => { // Parse sessionId to determine which process exited - // Format: {id}-ai-{tabId}, {id}-ai (legacy), {id}-terminal, {id}-batch-{timestamp} + // Format: {id}-ai-{tabId}, {id}-terminal, {id}-batch-{timestamp} let actualSessionId: string; let isFromAi: boolean; let tabIdFromSession: string | undefined; - // Check for new format with tab ID: sessionId-ai-tabId + // Format: sessionId-ai-tabId const aiTabMatch = sessionId.match(/^(.+)-ai-(.+)$/); if (aiTabMatch) { actualSessionId = aiTabMatch[1]; tabIdFromSession = aiTabMatch[2]; isFromAi = true; - } else if (sessionId.endsWith('-ai')) { - actualSessionId = sessionId.slice(0, -3); - isFromAi = true; } else if (sessionId.endsWith('-terminal')) { actualSessionId = sessionId.slice(0, -9); isFromAi = false; @@ -785,7 +742,7 @@ export default function MaestroConsole() { const completedTab = tabIdFromSession ? currentSession.aiTabs?.find(tab => tab.id === tabIdFromSession) : getActiveTab(currentSession); - const logs = completedTab?.logs || currentSession.aiLogs; + const logs = completedTab?.logs || []; const lastUserLog = logs.filter(log => log.source === 'user').pop(); const lastAiLog = logs.filter(log => log.source === 'stdout' || log.source === 'ai').pop(); const duration = currentSession.thinkingStartTime ? Date.now() - currentSession.thinkingStartTime : 0; @@ -1089,20 +1046,15 @@ export default function MaestroConsole() { } // Parse sessionId to get actual session ID and tab ID - // Format: ${sessionId}-ai-${tabId} or legacy ${sessionId}-ai + // Format: ${sessionId}-ai-${tabId} let actualSessionId: string; let tabId: string | undefined; - // Check for new format with tab ID: sessionId-ai-tabId const aiTabMatch = sessionId.match(/^(.+)-ai-(.+)$/); if (aiTabMatch) { actualSessionId = aiTabMatch[1]; tabId = aiTabMatch[2]; - console.log('[onSessionId] Parsed tab format - actualSessionId:', actualSessionId, 'tabId:', tabId); - } else if (sessionId.endsWith('-ai')) { - // Legacy format without tab ID - actualSessionId = sessionId.slice(0, -3); - console.log('[onSessionId] Parsed legacy format - actualSessionId:', actualSessionId); + console.log('[onSessionId] Parsed - actualSessionId:', actualSessionId, 'tabId:', tabId); } else { actualSessionId = sessionId; console.log('[onSessionId] No format match - using as-is:', actualSessionId); @@ -1155,8 +1107,9 @@ export default function MaestroConsole() { } if (!targetTab) { - // Fallback: no tabs exist, use deprecated session-level field - console.warn('[onSessionId] No target tab found, storing at session level (deprecated)'); + // No tabs exist - this is a bug, sessions must have aiTabs + // Still store at session-level for web API compatibility + console.error('[onSessionId] No target tab found - session has no aiTabs, storing at session level only'); return { ...s, claudeSessionId }; } @@ -1777,9 +1730,9 @@ export default function MaestroConsole() { : getActiveTab(s); if (!targetTab) { - // Fallback: no tabs exist, use deprecated aiLogs - console.warn('[addLogToTab] No target tab found, using aiLogs (deprecated)'); - return { ...s, aiLogs: [...s.aiLogs, entry] }; + // No tabs exist - this is a bug, sessions must have aiTabs + console.error('[addLogToTab] No target tab found - session has no aiTabs, this should not happen'); + return s; } // Update target tab's logs @@ -3131,9 +3084,10 @@ export default function MaestroConsole() { }, [shortcutsHelpOpen]); // Auto-scroll logs + const activeTabLogs = activeSession ? getActiveTab(activeSession)?.logs : undefined; useEffect(() => { logsEndRef.current?.scrollIntoView({ behavior: 'instant' }); - }, [activeSession?.aiLogs, activeSession?.shellLogs, activeSession?.inputMode]); + }, [activeTabLogs, activeSession?.shellLogs, activeSession?.inputMode]); // --- ACTIONS --- const cycleSession = (dir: 'next' | 'prev') => { @@ -3324,7 +3278,7 @@ export default function MaestroConsole() { cwd: workingDir, fullPath: workingDir, isGitRepo, - aiLogs: [{ id: generateId(), timestamp: Date.now(), source: 'system', text: `${name} ready.` }], + aiLogs: [], // Deprecated - logs are now in aiTabs shellLogs: [{ id: generateId(), timestamp: Date.now(), source: 'system', text: 'Shell Session Ready.' }], workLog: [], scratchPadContent: '', @@ -3613,18 +3567,27 @@ export default function MaestroConsole() { if (existingCommand) { // Command exists but not available in this mode - show error and don't send to AI const modeLabel = isTerminalMode ? 'AI' : 'terminal'; - const targetLogKey = activeSession.inputMode === 'ai' ? 'aiLogs' : 'shellLogs'; + const errorLog: LogEntry = { + id: generateId(), + timestamp: Date.now(), + source: 'system', + text: `${commandText} is only available in ${modeLabel} mode.` + }; setSessions(prev => prev.map(s => { if (s.id !== activeSessionId) return s; - return { - ...s, - [targetLogKey]: [...s[targetLogKey], { - id: generateId(), - timestamp: Date.now(), - source: 'system', - text: `${commandText} is only available in ${modeLabel} mode.` - }] - }; + if (activeSession.inputMode === 'ai') { + // Add to active tab's logs + const activeTab = getActiveTab(s); + if (!activeTab) return s; + return { + ...s, + aiTabs: s.aiTabs.map(tab => + tab.id === activeTab.id ? { ...tab, logs: [...tab.logs, errorLog] } : tab + ) + }; + } else { + return { ...s, shellLogs: [...s.shellLogs, errorLog] }; + } })); setInputValue(''); setSlashCommandOpen(false); @@ -3728,7 +3691,6 @@ export default function MaestroConsole() { } const currentMode = activeSession.inputMode; - const targetLogKey = currentMode === 'ai' ? 'aiLogs' : 'shellLogs'; // Queue messages when AI is busy (only in AI mode) // For read-only mode tabs: only queue if THIS TAB is busy (allows parallel execution) @@ -3874,22 +3836,12 @@ export default function MaestroConsole() { }; } - // For AI mode, add to ACTIVE TAB's logs (not session.aiLogs) + // For AI mode, add to ACTIVE TAB's logs const activeTab = getActiveTab(s); if (!activeTab) { - // Fallback: no tabs exist, use deprecated aiLogs - console.warn('[processInput] No active tab found, using aiLogs (deprecated)'); - return { - ...s, - aiLogs: [...s.aiLogs, newEntry], - state: 'busy', - busySource: currentMode, - thinkingStartTime: Date.now(), - currentCycleTokens: 0, - contextUsage: Math.min(s.contextUsage + 5, 100), - shellCwd: newShellCwd, - [historyKey]: newHistory - }; + // No tabs exist - this is a bug, sessions must have aiTabs + console.error('[processInput] No active tab found - session has no aiTabs, this should not happen'); + return s; } // Update the active tab's logs and state to 'busy' for write-mode tracking @@ -4014,23 +3966,25 @@ export default function MaestroConsole() { }); } catch (error) { console.error('Failed to spawn Claude batch process:', error); + const errorLog: LogEntry = { + id: generateId(), + timestamp: Date.now(), + source: 'system', + text: `Error: Failed to spawn Claude process - ${error.message}` + }; setSessions(prev => prev.map(s => { if (s.id !== activeSessionId) return s; - // Reset active tab's state to 'idle' for write-mode tracking + // Reset active tab's state to 'idle' and add error log const updatedAiTabs = s.aiTabs?.length > 0 ? s.aiTabs.map(tab => - tab.id === s.activeTabId ? { ...tab, state: 'idle' as const } : tab + tab.id === s.activeTabId + ? { ...tab, state: 'idle' as const, logs: [...tab.logs, errorLog] } + : tab ) : s.aiTabs; return { ...s, state: 'idle', - [targetLogKey]: [...s[targetLogKey], { - id: generateId(), - timestamp: Date.now(), - source: 'system', - text: `Error: Failed to spawn Claude process - ${error.message}` - }], aiTabs: updatedAiTabs }; })); @@ -4070,23 +4024,25 @@ export default function MaestroConsole() { // AI mode: Write to stdin window.maestro.process.write(targetSessionId, capturedInputValue).catch(error => { console.error('Failed to write to process:', error); + const errorLog: LogEntry = { + id: generateId(), + timestamp: Date.now(), + source: 'system', + text: `Error: Failed to write to process - ${error.message}` + }; setSessions(prev => prev.map(s => { if (s.id !== activeSessionId) return s; - // Reset active tab's state to 'idle' for write-mode tracking (if tabs exist) + // Reset active tab's state to 'idle' and add error log const updatedAiTabs = s.aiTabs?.length > 0 ? s.aiTabs.map(tab => - tab.id === s.activeTabId ? { ...tab, state: 'idle' as const } : tab + tab.id === s.activeTabId + ? { ...tab, state: 'idle' as const, logs: [...tab.logs, errorLog] } + : tab ) : s.aiTabs; return { ...s, state: 'idle', - [targetLogKey]: [...s[targetLogKey], { - id: generateId(), - timestamp: Date.now(), - source: 'system', - text: `Error: Failed to write to process - ${error.message}` - }], aiTabs: updatedAiTabs }; })); @@ -4341,21 +4297,10 @@ export default function MaestroConsole() { ) : s.aiTabs; - // Fallback: if no active tab, use deprecated aiLogs if (!activeTab) { - return { - ...s, - state: 'busy' as SessionState, - busySource: 'ai', - thinkingStartTime: Date.now(), - currentCycleTokens: 0, - currentCycleBytes: 0, - aiLogs: [...s.aiLogs, userLogEntry], - ...(commandMetadata && { - aiCommandHistory: Array.from(new Set([...(s.aiCommandHistory || []), command.trim()])).slice(-50) - }), - aiTabs: updatedAiTabs - }; + // No tabs exist - this is a bug, sessions must have aiTabs + console.error('[runAICommand] No active tab found - session has no aiTabs, this should not happen'); + return s; } return { @@ -4404,15 +4349,10 @@ export default function MaestroConsole() { ) : s.aiTabs; - // Fallback: if no active tab, use deprecated aiLogs if (!activeTab) { - return { - ...s, - state: 'idle' as SessionState, - busySource: undefined, - aiLogs: [...s.aiLogs, errorLogEntry], - aiTabs: updatedAiTabs - }; + // No tabs exist - this is a bug, sessions must have aiTabs + console.error('[runAICommand error] No active tab found - session has no aiTabs, this should not happen'); + return s; } return { @@ -4567,14 +4507,10 @@ export default function MaestroConsole() { ) : s.aiTabs; - // Fallback: if no active tab, use deprecated aiLogs if (!activeTab) { - return { - ...s, - state: 'idle', - aiLogs: [...s.aiLogs, errorLogEntry], - aiTabs: updatedAiTabs - }; + // No tabs exist - this is a bug, sessions must have aiTabs + console.error('[processQueuedItem error] No active tab found - session has no aiTabs, this should not happen'); + return s; } return { @@ -4593,8 +4529,10 @@ export default function MaestroConsole() { if (!activeSession) return; const currentMode = activeSession.inputMode; - const targetSessionId = currentMode === 'ai' ? `${activeSession.id}-ai` : `${activeSession.id}-terminal`; - const targetLogKey = currentMode === 'ai' ? 'aiLogs' : 'shellLogs'; + const activeTab = getActiveTab(activeSession); + const targetSessionId = currentMode === 'ai' + ? `${activeSession.id}-ai-${activeTab?.id || 'default'}` + : `${activeSession.id}-terminal`; try { // Send interrupt signal (Ctrl+C) @@ -4621,33 +4559,49 @@ export default function MaestroConsole() { try { await window.maestro.process.kill(targetSessionId); + const killLog: LogEntry = { + id: generateId(), + timestamp: Date.now(), + source: 'system', + text: 'Process forcefully terminated' + }; setSessions(prev => prev.map(s => { if (s.id !== activeSession.id) return s; - return { - ...s, - [targetLogKey]: [...s[targetLogKey], { - id: generateId(), - timestamp: Date.now(), - source: 'system', - text: 'Process forcefully terminated' - }], - state: 'idle' - }; + if (currentMode === 'ai') { + const tab = getActiveTab(s); + if (!tab) return { ...s, state: 'idle' }; + return { + ...s, + state: 'idle', + aiTabs: s.aiTabs.map(t => + t.id === tab.id ? { ...t, logs: [...t.logs, killLog] } : t + ) + }; + } + return { ...s, shellLogs: [...s.shellLogs, killLog], state: 'idle' }; })); } catch (killError) { console.error('Failed to kill process:', killError); + const errorLog: LogEntry = { + id: generateId(), + timestamp: Date.now(), + source: 'system', + text: `Error: Failed to terminate process - ${killError.message}` + }; setSessions(prev => prev.map(s => { if (s.id !== activeSession.id) return s; - return { - ...s, - [targetLogKey]: [...s[targetLogKey], { - id: generateId(), - timestamp: Date.now(), - source: 'system', - text: `Error: Failed to terminate process - ${killError.message}` - }], - state: 'idle' - }; + if (currentMode === 'ai') { + const tab = getActiveTab(s); + if (!tab) return { ...s, state: 'idle' }; + return { + ...s, + state: 'idle', + aiTabs: s.aiTabs.map(t => + t.id === tab.id ? { ...t, logs: [...t.logs, errorLog] } : t + ) + }; + } + return { ...s, shellLogs: [...s.shellLogs, errorLog], state: 'idle' }; })); } } diff --git a/src/renderer/components/MainPanel.tsx b/src/renderer/components/MainPanel.tsx index b271bebe..e223afb9 100644 --- a/src/renderer/components/MainPanel.tsx +++ b/src/renderer/components/MainPanel.tsx @@ -229,8 +229,8 @@ export function MainPanel(props: MainPanelProps) { }; fetchGitInfo(); - // Refresh git info every 10 seconds - const interval = setInterval(fetchGitInfo, 10000); + // Refresh git info every 30 seconds (reduced from 10s for performance) + const interval = setInterval(fetchGitInfo, 30000); return () => clearInterval(interval); }, [activeSession?.id, activeSession?.isGitRepo, activeSession?.cwd, activeSession?.shellCwd, activeSession?.inputMode]); diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index 2857afb3..02bf93d4 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -199,15 +199,24 @@ export function SessionList(props: SessionListProps) { const gitSessions = sessions.filter(s => s.isGitRepo); if (gitSessions.length === 0) return; - const newCounts = new Map(); + // Parallelize git status calls for better performance + // Sequential calls with 10 sessions = 1-2s, parallel = 200-300ms + const results = await Promise.all( + gitSessions.map(async (session) => { + try { + const cwd = session.inputMode === 'terminal' ? (session.shellCwd || session.cwd) : session.cwd; + const status = await gitService.getStatus(cwd); + return [session.id, status.files.length] as const; + } catch { + return null; + } + }) + ); - for (const session of gitSessions) { - try { - const cwd = session.inputMode === 'terminal' ? (session.shellCwd || session.cwd) : session.cwd; - const status = await gitService.getStatus(cwd); - newCounts.set(session.id, status.files.length); - } catch (error) { - // Ignore errors, don't show indicator if we can't get status + const newCounts = new Map(); + for (const result of results) { + if (result) { + newCounts.set(result[0], result[1]); } } diff --git a/src/renderer/components/TabBar.tsx b/src/renderer/components/TabBar.tsx index 611fc730..76c5920a 100644 --- a/src/renderer/components/TabBar.tsx +++ b/src/renderer/components/TabBar.tsx @@ -564,7 +564,7 @@ export function TabBar({ return (
{new Date(log.timestamp).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}
-
((p }); }, [theme]); - // In AI mode, use the active tab's logs if tabs exist, otherwise fall back to session.aiLogs - // This supports the new multi-tab feature while maintaining backwards compatibility + // In AI mode, use the active tab's logs const activeTab = session.inputMode === 'ai' ? getActiveTab(session) : undefined; const activeLogs: LogEntry[] = session.inputMode === 'ai' - ? (activeTab?.logs ?? session.aiLogs) + ? (activeTab?.logs ?? []) : session.shellLogs; // In AI mode, collapse consecutive non-user entries into single response blocks diff --git a/src/renderer/hooks/useSessionManager.ts b/src/renderer/hooks/useSessionManager.ts index d60624cc..ee928a44 100644 --- a/src/renderer/hooks/useSessionManager.ts +++ b/src/renderer/hooks/useSessionManager.ts @@ -1,6 +1,5 @@ import { useState, useEffect, useMemo } from 'react'; -import type { Session, Group, ToolType, LogEntry, AITab } from '../types'; -import { generateId } from '../utils/ids'; +import type { Session, Group } from '../types'; import { gitService } from '../services/git'; // Maximum number of log entries to persist per AI tab @@ -18,40 +17,14 @@ const compareNamesIgnoringEmojis = (a: string, b: string): number => { }; /** - * Migrate a session from old format (without aiTabs) to new format. - * Creates a single tab from the legacy claudeSessionId, aiLogs, etc. - * This is a basic migration; starred/named status can be looked up later in restoreSession. + * Prepare a session for loading by resetting runtime-only fields. */ -const migrateSessionToTabFormat = (session: Session): Session => { - // If session already has aiTabs, just ensure closedTabHistory is initialized - if (session.aiTabs && session.aiTabs.length > 0) { - return { - ...session, - // closedTabHistory is runtime-only and should not be persisted - // Always reset to empty array on load - closedTabHistory: [] - }; - } - - // Create initial tab from legacy data - const initialTab: AITab = { - id: generateId(), - claudeSessionId: session.claudeSessionId || null, - name: null, // Name will be looked up in restoreSession if needed - starred: false, // Starred will be looked up in restoreSession if needed - logs: session.aiLogs || [], - inputValue: '', - stagedImages: [], - usageStats: session.usageStats, - createdAt: Date.now(), - state: 'idle' - }; - +const prepareSessionForLoad = (session: Session): Session => { return { ...session, - aiTabs: [initialTab], - activeTabId: initialTab.id, - closedTabHistory: [] // Runtime-only, always empty on load + // closedTabHistory is runtime-only and should not be persisted + // Always reset to empty array on load + closedTabHistory: [] }; }; @@ -141,13 +114,11 @@ export function useSessionManager(): UseSessionManagerReturn { // Handle sessions if (savedSessions && savedSessions.length > 0) { - // Check Git repository status and migrate to aiTabs format for all loaded sessions + // Check Git repository status and prepare sessions for load const sessionsWithGitStatus = await Promise.all( savedSessions.map(async (session) => { const isGitRepo = await gitService.isRepo(session.cwd); - // Migrate to aiTabs format and ensure closedTabHistory is reset - const migratedSession = migrateSessionToTabFormat({ ...session, isGitRepo }); - return migratedSession; + return prepareSessionForLoad({ ...session, isGitRepo }); }) ); setSessions(sessionsWithGitStatus);