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
This commit is contained in:
Pedram Amini
2025-12-01 07:24:13 -06:00
parent 509fcf61db
commit 6fc1455c53
9 changed files with 279 additions and 268 deletions

View File

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

56
scripts/set-version.mjs Normal file
View File

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

View File

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

View File

@@ -298,59 +298,18 @@ export default function MaestroConsole() {
// Restore a persisted session by respawning its process
const restoreSession = async (session: Session): Promise<Session> => {
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' };
}));
}
}

View File

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

View File

@@ -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<string, number>();
// 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<string, number>();
for (const result of results) {
if (result) {
newCounts.set(result[0], result[1]);
}
}

View File

@@ -564,7 +564,7 @@ export function TabBar({
return (
<div
ref={tabBarRef}
className="flex items-end gap-0.5 px-2 pt-2 border-b overflow-x-auto scrollbar-thin"
className="flex items-end gap-0.5 px-2 pt-2 border-b overflow-x-auto overflow-y-hidden scrollbar-thin"
style={{
backgroundColor: theme.colors.bgSidebar,
borderColor: theme.colors.border

View File

@@ -329,7 +329,7 @@ const LogItemComponent = memo(({
style={{ fontFamily, color: theme.colors.textDim, opacity: 0.6 }}>
{new Date(log.timestamp).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}
</div>
<div className={`flex-1 p-4 rounded-xl border ${isUserMessage ? 'rounded-tr-none' : 'rounded-tl-none'} relative`}
<div className={`flex-1 p-4 ${isUserMessage && log.readOnly ? 'pt-8' : ''} rounded-xl border ${isUserMessage ? 'rounded-tr-none' : 'rounded-tl-none'} relative`}
style={{
backgroundColor: isUserMessage
? isAIMode
@@ -1044,11 +1044,10 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((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

View File

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