mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## CHANGES
- Reset-on-completion now uses `/runs/` working copies, preserving originals always 🗂️ - Added `autorun:createWorkingCopy` IPC API with path validation safeguards 🔐 - Web UI now hides thinking/tool logs for cleaner conversations 🧹 - Git worktree directory scans run in parallel for huge speedups ⚡ - Legacy worktree discovery scans only on focus, not constant polling 👀 - Mermaid rendering revamped for safer, smoother SVG insertion flow 🧩 - Mobile session selection updates refs first, avoiding WebSocket race bugs 📡 - Mobile search auto-expands groups containing matches for faster navigation 🔎 - Mobile AI input supports Cmd/Ctrl+Enter submit while Enter adds newline ⌨️ - Auto Run UI simplified: removed “stopping” state visuals, consistent pulsing 🤖
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "maestro",
|
||||
"version": "0.12.0",
|
||||
"version": "0.12.1",
|
||||
"description": "Run AI coding agents autonomously for days.",
|
||||
"main": "dist/main/index.js",
|
||||
"author": {
|
||||
|
||||
@@ -135,6 +135,7 @@ describe('autorun IPC handlers', () => {
|
||||
'autorun:createBackup',
|
||||
'autorun:restoreBackup',
|
||||
'autorun:deleteBackups',
|
||||
'autorun:createWorkingCopy',
|
||||
];
|
||||
|
||||
for (const channel of expectedChannels) {
|
||||
|
||||
@@ -339,11 +339,14 @@ function createWebServer(): WebServer {
|
||||
|
||||
// Get the requested tab's logs (or active tab if no tabId provided)
|
||||
// Tabs are the source of truth for AI conversation history
|
||||
// Filter out thinking and tool logs - these should never be shown on the web interface
|
||||
let aiLogs: any[] = [];
|
||||
const targetTabId = tabId || session.activeTabId;
|
||||
if (session.aiTabs && session.aiTabs.length > 0) {
|
||||
const targetTab = session.aiTabs.find((t: any) => t.id === targetTabId) || session.aiTabs[0];
|
||||
aiLogs = targetTab?.logs || [];
|
||||
const rawLogs = targetTab?.logs || [];
|
||||
// Web interface should never show thinking/tool logs regardless of desktop settings
|
||||
aiLogs = rawLogs.filter((log: any) => log.source !== 'thinking' && log.source !== 'tool');
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -569,6 +569,71 @@ export function registerAutorunHandlers(deps: {
|
||||
})
|
||||
);
|
||||
|
||||
// Create a working copy of a document for reset-on-completion loops
|
||||
// Working copies are stored in /runs/ subdirectory with format: {name}-{timestamp}-loop-{N}.md
|
||||
ipcMain.handle(
|
||||
'autorun:createWorkingCopy',
|
||||
createIpcHandler(
|
||||
handlerOpts('createWorkingCopy'),
|
||||
async (folderPath: string, filename: string, loopNumber: number) => {
|
||||
// Reject obvious traversal attempts
|
||||
if (filename.includes('..')) {
|
||||
throw new Error('Invalid filename');
|
||||
}
|
||||
|
||||
// Ensure filename has .md extension for source, remove for naming
|
||||
const fullFilename = filename.endsWith('.md') ? filename : `${filename}.md`;
|
||||
const baseName = filename.endsWith('.md') ? filename.slice(0, -3) : filename;
|
||||
|
||||
// Handle subdirectory paths (e.g., "Ingest-Loop/0_DISCOVER_NEW")
|
||||
const pathParts = baseName.split('/');
|
||||
const docName = pathParts[pathParts.length - 1];
|
||||
const subDir = pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : '';
|
||||
|
||||
const sourcePath = path.join(folderPath, fullFilename);
|
||||
|
||||
// Validate source path is within folder
|
||||
if (!validatePathWithinFolder(sourcePath, folderPath)) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
|
||||
// Check if source file exists
|
||||
try {
|
||||
await fs.access(sourcePath);
|
||||
} catch {
|
||||
throw new Error('Source file not found');
|
||||
}
|
||||
|
||||
// Create runs directory (with subdirectory if needed)
|
||||
const runsDir = subDir
|
||||
? path.join(folderPath, 'runs', subDir)
|
||||
: path.join(folderPath, 'runs');
|
||||
await fs.mkdir(runsDir, { recursive: true });
|
||||
|
||||
// Generate working copy filename: {name}-{timestamp}-loop-{N}.md
|
||||
const timestamp = Date.now();
|
||||
const workingCopyName = `${docName}-${timestamp}-loop-${loopNumber}.md`;
|
||||
const workingCopyPath = path.join(runsDir, workingCopyName);
|
||||
|
||||
// Validate working copy path is within folder
|
||||
if (!validatePathWithinFolder(workingCopyPath, folderPath)) {
|
||||
throw new Error('Invalid working copy path');
|
||||
}
|
||||
|
||||
// Copy the source to working copy
|
||||
await fs.copyFile(sourcePath, workingCopyPath);
|
||||
|
||||
// Return the relative path (without .md for consistency with other APIs)
|
||||
const relativePath = subDir
|
||||
? `runs/${subDir}/${workingCopyName.slice(0, -3)}`
|
||||
: `runs/${workingCopyName.slice(0, -3)}`;
|
||||
|
||||
logger.info(`Created Auto Run working copy: ${relativePath}`, LOG_CONTEXT);
|
||||
return { workingCopyPath: relativePath, originalPath: baseName };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Delete all backup files in a folder
|
||||
ipcMain.handle(
|
||||
'autorun:deleteBackups',
|
||||
|
||||
@@ -665,17 +665,10 @@ export function registerGitHandlers(): void {
|
||||
|
||||
// Scan a directory for subdirectories that are git repositories or worktrees
|
||||
// This is used for auto-discovering worktrees in a parent directory
|
||||
// PERFORMANCE: Parallelized git operations to avoid blocking UI (was sequential before)
|
||||
ipcMain.handle('git:scanWorktreeDirectory', createIpcHandler(
|
||||
handlerOpts('scanWorktreeDirectory'),
|
||||
async (parentPath: string) => {
|
||||
const gitSubdirs: Array<{
|
||||
path: string;
|
||||
name: string;
|
||||
isWorktree: boolean;
|
||||
branch: string | null;
|
||||
repoRoot: string | null;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
// Read directory contents
|
||||
const entries = await fs.readdir(parentPath, { withFileTypes: true });
|
||||
@@ -683,26 +676,27 @@ export function registerGitHandlers(): void {
|
||||
// Filter to only directories (excluding hidden directories)
|
||||
const subdirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.'));
|
||||
|
||||
// Check each subdirectory for git status
|
||||
for (const subdir of subdirs) {
|
||||
// Process all subdirectories in parallel instead of sequentially
|
||||
// This dramatically reduces the time for directories with many worktrees
|
||||
const results = await Promise.all(subdirs.map(async (subdir) => {
|
||||
const subdirPath = path.join(parentPath, subdir.name);
|
||||
|
||||
// Check if it's inside a git work tree
|
||||
const isInsideWorkTree = await execFileNoThrow('git', ['rev-parse', '--is-inside-work-tree'], subdirPath);
|
||||
if (isInsideWorkTree.exitCode !== 0) {
|
||||
continue; // Not a git repo
|
||||
return null; // Not a git repo
|
||||
}
|
||||
|
||||
// Check if it's a worktree (git-dir != git-common-dir)
|
||||
const gitDirResult = await execFileNoThrow('git', ['rev-parse', '--git-dir'], subdirPath);
|
||||
const gitCommonDirResult = await execFileNoThrow('git', ['rev-parse', '--git-common-dir'], subdirPath);
|
||||
// Run remaining git commands in parallel for each subdirectory
|
||||
const [gitDirResult, gitCommonDirResult, branchResult] = await Promise.all([
|
||||
execFileNoThrow('git', ['rev-parse', '--git-dir'], subdirPath),
|
||||
execFileNoThrow('git', ['rev-parse', '--git-common-dir'], subdirPath),
|
||||
execFileNoThrow('git', ['rev-parse', '--abbrev-ref', 'HEAD'], subdirPath),
|
||||
]);
|
||||
|
||||
const gitDir = gitDirResult.exitCode === 0 ? gitDirResult.stdout.trim() : '';
|
||||
const gitCommonDir = gitCommonDirResult.exitCode === 0 ? gitCommonDirResult.stdout.trim() : gitDir;
|
||||
const isWorktree = gitDir !== gitCommonDir;
|
||||
|
||||
// Get current branch
|
||||
const branchResult = await execFileNoThrow('git', ['rev-parse', '--abbrev-ref', 'HEAD'], subdirPath);
|
||||
const branch = branchResult.exitCode === 0 ? branchResult.stdout.trim() : null;
|
||||
|
||||
// Get repo root
|
||||
@@ -719,19 +713,23 @@ export function registerGitHandlers(): void {
|
||||
}
|
||||
}
|
||||
|
||||
gitSubdirs.push({
|
||||
return {
|
||||
path: subdirPath,
|
||||
name: subdir.name,
|
||||
isWorktree,
|
||||
branch,
|
||||
repoRoot,
|
||||
});
|
||||
}
|
||||
};
|
||||
}));
|
||||
|
||||
// Filter out null results (non-git directories)
|
||||
const gitSubdirs = results.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||
|
||||
return { gitSubdirs };
|
||||
} catch (err) {
|
||||
logger.error(`Failed to scan directory ${parentPath}: ${err}`, LOG_CONTEXT);
|
||||
return { gitSubdirs: [] };
|
||||
}
|
||||
|
||||
return { gitSubdirs };
|
||||
}
|
||||
));
|
||||
|
||||
|
||||
@@ -147,10 +147,16 @@ contextBridge.exposeInMainWorld('maestro', {
|
||||
// This allows web commands to go through the same code path as desktop commands
|
||||
// inputMode is optional - if provided, renderer should use it instead of session state
|
||||
onRemoteCommand: (callback: (sessionId: string, command: string, inputMode?: 'ai' | 'terminal') => void) => {
|
||||
console.log('[Preload] Registering onRemoteCommand listener');
|
||||
console.log('[Preload] Registering onRemoteCommand listener, callback type:', typeof callback);
|
||||
const handler = (_: any, sessionId: string, command: string, inputMode?: 'ai' | 'terminal') => {
|
||||
console.log('[Preload] Received remote:executeCommand IPC:', { sessionId, command: command?.substring(0, 50), inputMode });
|
||||
callback(sessionId, command, inputMode);
|
||||
console.log('[Preload] About to invoke callback, callback type:', typeof callback);
|
||||
try {
|
||||
callback(sessionId, command, inputMode);
|
||||
console.log('[Preload] Callback invoked successfully');
|
||||
} catch (error) {
|
||||
console.error('[Preload] Error invoking remote command callback:', error);
|
||||
}
|
||||
};
|
||||
ipcRenderer.on('remote:executeCommand', handler);
|
||||
return () => ipcRenderer.removeListener('remote:executeCommand', handler);
|
||||
@@ -1027,13 +1033,17 @@ contextBridge.exposeInMainWorld('maestro', {
|
||||
ipcRenderer.on('autorun:fileChanged', wrappedHandler);
|
||||
return () => ipcRenderer.removeListener('autorun:fileChanged', wrappedHandler);
|
||||
},
|
||||
// Backup operations for reset-on-completion documents
|
||||
// Backup operations for reset-on-completion documents (legacy)
|
||||
createBackup: (folderPath: string, filename: string) =>
|
||||
ipcRenderer.invoke('autorun:createBackup', folderPath, filename),
|
||||
restoreBackup: (folderPath: string, filename: string) =>
|
||||
ipcRenderer.invoke('autorun:restoreBackup', folderPath, filename),
|
||||
deleteBackups: (folderPath: string) =>
|
||||
ipcRenderer.invoke('autorun:deleteBackups', folderPath),
|
||||
// Working copy operations for reset-on-completion documents (preferred)
|
||||
// Creates a copy in /runs/ subdirectory: {name}-{timestamp}-loop-{N}.md
|
||||
createWorkingCopy: (folderPath: string, filename: string, loopNumber: number): Promise<{ workingCopyPath: string; originalPath: string }> =>
|
||||
ipcRenderer.invoke('autorun:createWorkingCopy', folderPath, filename, loopNumber),
|
||||
},
|
||||
|
||||
// Playbooks API (saved batch run configurations)
|
||||
@@ -1927,6 +1937,7 @@ export interface MaestroAPI {
|
||||
createBackup: (folderPath: string, filename: string) => Promise<{ success: boolean; backupFilename?: string; error?: string }>;
|
||||
restoreBackup: (folderPath: string, filename: string) => Promise<{ success: boolean; error?: string }>;
|
||||
deleteBackups: (folderPath: string) => Promise<{ success: boolean; deletedCount?: number; error?: string }>;
|
||||
createWorkingCopy: (folderPath: string, filename: string, loopNumber: number) => Promise<{ workingCopyPath: string; originalPath: string }>;
|
||||
};
|
||||
playbooks: {
|
||||
list: (sessionId: string) => Promise<{
|
||||
|
||||
@@ -3315,7 +3315,6 @@ function MaestroConsoleInner() {
|
||||
batchRunStates: _batchRunStates,
|
||||
getBatchState,
|
||||
activeBatchSessionIds,
|
||||
stoppingBatchSessionIds,
|
||||
startBatchRun,
|
||||
stopBatchRun,
|
||||
// Error handling (Phase 5.10)
|
||||
@@ -3914,159 +3913,183 @@ function MaestroConsoleInner() {
|
||||
defaultSaveToHistory
|
||||
]);
|
||||
|
||||
// Legacy: Periodic scanner for sessions using old worktreeParentPath
|
||||
// TODO: Remove after migration to new parent/child model
|
||||
// Legacy: Scanner for sessions using old worktreeParentPath
|
||||
// TODO: Remove after migration to new parent/child model (use worktreeConfig with file watchers instead)
|
||||
// PERFORMANCE: Only scan on app focus (visibility change) instead of continuous polling
|
||||
// This avoids blocking the main thread every 30 seconds during active use
|
||||
useEffect(() => {
|
||||
// Check if any sessions use the legacy worktreeParentPath model
|
||||
const hasLegacyWorktreeSessions = sessions.some(s => s.worktreeParentPath);
|
||||
if (!hasLegacyWorktreeSessions) return;
|
||||
|
||||
// Track if we're currently scanning to avoid overlapping scans
|
||||
let isScanning = false;
|
||||
|
||||
const scanWorktreeParents = async () => {
|
||||
// Find sessions that have worktreeParentPath set (legacy model)
|
||||
const worktreeParentSessions = sessions.filter(s => s.worktreeParentPath);
|
||||
if (worktreeParentSessions.length === 0) return;
|
||||
if (isScanning) return;
|
||||
isScanning = true;
|
||||
|
||||
// Collect all new sessions to add in a single batch (avoids stale closure issues)
|
||||
const newSessionsToAdd: Session[] = [];
|
||||
// Track paths we're about to add to avoid duplicates within this scan
|
||||
const pathsBeingAdded = new Set<string>();
|
||||
try {
|
||||
// Find sessions that have worktreeParentPath set (legacy model)
|
||||
const worktreeParentSessions = sessionsRef.current.filter(s => s.worktreeParentPath);
|
||||
if (worktreeParentSessions.length === 0) return;
|
||||
|
||||
for (const session of worktreeParentSessions) {
|
||||
try {
|
||||
const result = await window.maestro.git.scanWorktreeDirectory(session.worktreeParentPath!);
|
||||
const { gitSubdirs } = result;
|
||||
// Collect all new sessions to add in a single batch (avoids stale closure issues)
|
||||
const newSessionsToAdd: Session[] = [];
|
||||
// Track paths we're about to add to avoid duplicates within this scan
|
||||
const pathsBeingAdded = new Set<string>();
|
||||
|
||||
for (const subdir of gitSubdirs) {
|
||||
// Skip if this path was manually removed by the user (use ref for current value)
|
||||
const currentRemovedPaths = removedWorktreePathsRef.current;
|
||||
if (currentRemovedPaths.has(subdir.path)) {
|
||||
continue;
|
||||
for (const session of worktreeParentSessions) {
|
||||
try {
|
||||
const result = await window.maestro.git.scanWorktreeDirectory(session.worktreeParentPath!);
|
||||
const { gitSubdirs } = result;
|
||||
|
||||
for (const subdir of gitSubdirs) {
|
||||
// Skip if this path was manually removed by the user (use ref for current value)
|
||||
const currentRemovedPaths = removedWorktreePathsRef.current;
|
||||
if (currentRemovedPaths.has(subdir.path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if session already exists (check current sessions via ref)
|
||||
const currentSessions = sessionsRef.current;
|
||||
const existingSession = currentSessions.find(s => s.cwd === subdir.path || s.projectRoot === subdir.path);
|
||||
if (existingSession) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if we're already adding this path in this scan batch
|
||||
if (pathsBeingAdded.has(subdir.path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found a new worktree - prepare session creation
|
||||
pathsBeingAdded.add(subdir.path);
|
||||
|
||||
const sessionName = subdir.branch
|
||||
? `${subdir.name} (${subdir.branch})`
|
||||
: subdir.name;
|
||||
|
||||
const newId = generateId();
|
||||
const initialTabId = generateId();
|
||||
const initialTab: AITab = {
|
||||
id: initialTabId,
|
||||
agentSessionId: null,
|
||||
name: null,
|
||||
starred: false,
|
||||
logs: [],
|
||||
inputValue: '',
|
||||
stagedImages: [],
|
||||
createdAt: Date.now(),
|
||||
state: 'idle',
|
||||
saveToHistory: defaultSaveToHistory
|
||||
};
|
||||
|
||||
// Fetch git info
|
||||
let gitBranches: string[] | undefined;
|
||||
let gitTags: string[] | undefined;
|
||||
let gitRefsCacheTime: number | undefined;
|
||||
|
||||
try {
|
||||
[gitBranches, gitTags] = await Promise.all([
|
||||
gitService.getBranches(subdir.path),
|
||||
gitService.getTags(subdir.path)
|
||||
]);
|
||||
gitRefsCacheTime = Date.now();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
const newSession: Session = {
|
||||
id: newId,
|
||||
name: sessionName,
|
||||
groupId: session.groupId,
|
||||
toolType: session.toolType,
|
||||
state: 'idle',
|
||||
cwd: subdir.path,
|
||||
fullPath: subdir.path,
|
||||
projectRoot: subdir.path,
|
||||
isGitRepo: true,
|
||||
gitBranches,
|
||||
gitTags,
|
||||
gitRefsCacheTime,
|
||||
worktreeParentPath: session.worktreeParentPath,
|
||||
aiLogs: [],
|
||||
shellLogs: [{ id: generateId(), timestamp: Date.now(), source: 'system', text: 'Shell Session Ready.' }],
|
||||
workLog: [],
|
||||
contextUsage: 0,
|
||||
inputMode: session.inputMode,
|
||||
aiPid: 0,
|
||||
terminalPid: 0,
|
||||
port: 3000 + Math.floor(Math.random() * 100),
|
||||
isLive: false,
|
||||
changedFiles: [],
|
||||
fileTree: [],
|
||||
fileExplorerExpanded: [],
|
||||
fileExplorerScrollPos: 0,
|
||||
fileTreeAutoRefreshInterval: 180,
|
||||
shellCwd: subdir.path,
|
||||
aiCommandHistory: [],
|
||||
shellCommandHistory: [],
|
||||
executionQueue: [],
|
||||
activeTimeMs: 0,
|
||||
aiTabs: [initialTab],
|
||||
activeTabId: initialTabId,
|
||||
closedTabHistory: [],
|
||||
customPath: session.customPath,
|
||||
customArgs: session.customArgs,
|
||||
customEnvVars: session.customEnvVars,
|
||||
customModel: session.customModel
|
||||
};
|
||||
|
||||
newSessionsToAdd.push(newSession);
|
||||
}
|
||||
|
||||
// Skip if session already exists (check current sessions)
|
||||
const existingSession = sessions.find(s => s.cwd === subdir.path || s.projectRoot === subdir.path);
|
||||
if (existingSession) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if we're already adding this path in this scan batch
|
||||
if (pathsBeingAdded.has(subdir.path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found a new worktree - prepare session creation
|
||||
pathsBeingAdded.add(subdir.path);
|
||||
|
||||
const sessionName = subdir.branch
|
||||
? `${subdir.name} (${subdir.branch})`
|
||||
: subdir.name;
|
||||
|
||||
const newId = generateId();
|
||||
const initialTabId = generateId();
|
||||
const initialTab: AITab = {
|
||||
id: initialTabId,
|
||||
agentSessionId: null,
|
||||
name: null,
|
||||
starred: false,
|
||||
logs: [],
|
||||
inputValue: '',
|
||||
stagedImages: [],
|
||||
createdAt: Date.now(),
|
||||
state: 'idle',
|
||||
saveToHistory: defaultSaveToHistory
|
||||
};
|
||||
|
||||
// Fetch git info
|
||||
let gitBranches: string[] | undefined;
|
||||
let gitTags: string[] | undefined;
|
||||
let gitRefsCacheTime: number | undefined;
|
||||
|
||||
try {
|
||||
[gitBranches, gitTags] = await Promise.all([
|
||||
gitService.getBranches(subdir.path),
|
||||
gitService.getTags(subdir.path)
|
||||
]);
|
||||
gitRefsCacheTime = Date.now();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
const newSession: Session = {
|
||||
id: newId,
|
||||
name: sessionName,
|
||||
groupId: session.groupId,
|
||||
toolType: session.toolType,
|
||||
state: 'idle',
|
||||
cwd: subdir.path,
|
||||
fullPath: subdir.path,
|
||||
projectRoot: subdir.path,
|
||||
isGitRepo: true,
|
||||
gitBranches,
|
||||
gitTags,
|
||||
gitRefsCacheTime,
|
||||
worktreeParentPath: session.worktreeParentPath,
|
||||
aiLogs: [],
|
||||
shellLogs: [{ id: generateId(), timestamp: Date.now(), source: 'system', text: 'Shell Session Ready.' }],
|
||||
workLog: [],
|
||||
contextUsage: 0,
|
||||
inputMode: session.inputMode,
|
||||
aiPid: 0,
|
||||
terminalPid: 0,
|
||||
port: 3000 + Math.floor(Math.random() * 100),
|
||||
isLive: false,
|
||||
changedFiles: [],
|
||||
fileTree: [],
|
||||
fileExplorerExpanded: [],
|
||||
fileExplorerScrollPos: 0,
|
||||
fileTreeAutoRefreshInterval: 180,
|
||||
shellCwd: subdir.path,
|
||||
aiCommandHistory: [],
|
||||
shellCommandHistory: [],
|
||||
executionQueue: [],
|
||||
activeTimeMs: 0,
|
||||
aiTabs: [initialTab],
|
||||
activeTabId: initialTabId,
|
||||
closedTabHistory: [],
|
||||
customPath: session.customPath,
|
||||
customArgs: session.customArgs,
|
||||
customEnvVars: session.customEnvVars,
|
||||
customModel: session.customModel
|
||||
};
|
||||
|
||||
newSessionsToAdd.push(newSession);
|
||||
} catch (error) {
|
||||
console.error(`[WorktreeScanner] Error scanning ${session.worktreeParentPath}:`, error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[WorktreeScanner] Error scanning ${session.worktreeParentPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Add all new sessions in a single update (uses functional update to get fresh state)
|
||||
if (newSessionsToAdd.length > 0) {
|
||||
setSessions(prev => {
|
||||
// Double-check against current state to avoid duplicates
|
||||
const currentPaths = new Set(prev.map(s => s.cwd));
|
||||
const trulyNew = newSessionsToAdd.filter(s => !currentPaths.has(s.cwd));
|
||||
if (trulyNew.length === 0) return prev;
|
||||
return [...prev, ...trulyNew];
|
||||
});
|
||||
|
||||
for (const session of newSessionsToAdd) {
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'New Worktree Discovered',
|
||||
message: session.name,
|
||||
// Add all new sessions in a single update (uses functional update to get fresh state)
|
||||
if (newSessionsToAdd.length > 0) {
|
||||
setSessions(prev => {
|
||||
// Double-check against current state to avoid duplicates
|
||||
const currentPaths = new Set(prev.map(s => s.cwd));
|
||||
const trulyNew = newSessionsToAdd.filter(s => !currentPaths.has(s.cwd));
|
||||
if (trulyNew.length === 0) return prev;
|
||||
return [...prev, ...trulyNew];
|
||||
});
|
||||
|
||||
for (const session of newSessionsToAdd) {
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'New Worktree Discovered',
|
||||
message: session.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isScanning = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Scan immediately on mount if there are worktree parents
|
||||
// Scan once on mount
|
||||
scanWorktreeParents();
|
||||
|
||||
// Set up interval to scan every 30 seconds
|
||||
const intervalId = setInterval(scanWorktreeParents, 30000);
|
||||
// Scan when app regains focus (visibility change) instead of polling
|
||||
// This is much more efficient - only scans when user returns to app
|
||||
const handleVisibilityChange = () => {
|
||||
if (!document.hidden) {
|
||||
scanWorktreeParents();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
|
||||
}, [sessions.length, defaultSaveToHistory]); // Re-run when session count changes (removedWorktreePaths accessed via ref)
|
||||
|
||||
}, [sessions.some(s => s.worktreeParentPath), defaultSaveToHistory]); // Only re-run when legacy sessions exist/don't exist
|
||||
|
||||
// Handler to open batch runner modal
|
||||
const handleOpenBatchRunner = useCallback(() => {
|
||||
@@ -4128,10 +4151,12 @@ function MaestroConsoleInner() {
|
||||
const sessionId = targetSessionId
|
||||
?? (activeBatchSessionIds.length > 0 ? activeBatchSessionIds[0] : activeSession?.id);
|
||||
if (!sessionId) return;
|
||||
setConfirmModalMessage('Stop Auto Run after the current task completes?');
|
||||
const session = sessions.find(s => s.id === sessionId);
|
||||
const agentName = session?.name || 'this session';
|
||||
setConfirmModalMessage(`Stop Auto Run for "${agentName}" after the current task completes?`);
|
||||
setConfirmModalOnConfirm(() => () => stopBatchRun(sessionId));
|
||||
setConfirmModalOpen(true);
|
||||
}, [activeBatchSessionIds, activeSession, stopBatchRun]);
|
||||
}, [activeBatchSessionIds, activeSession, sessions, stopBatchRun]);
|
||||
|
||||
// Error handling callbacks for Auto Run (Phase 5.10)
|
||||
const handleSkipCurrentDocument = useCallback(() => {
|
||||
@@ -8443,7 +8468,6 @@ function MaestroConsoleInner() {
|
||||
));
|
||||
}}
|
||||
activeBatchSessionIds={activeBatchSessionIds}
|
||||
stoppingBatchSessionIds={stoppingBatchSessionIds}
|
||||
showSessionJumpNumbers={showSessionJumpNumbers}
|
||||
visibleSessions={visibleSessions}
|
||||
autoRunStats={autoRunStats}
|
||||
|
||||
@@ -51,7 +51,7 @@ export function DeleteWorktreeModal({
|
||||
return (
|
||||
<Modal
|
||||
theme={theme}
|
||||
title="Delete Worktree"
|
||||
title="Remove Worktree"
|
||||
priority={MODAL_PRIORITIES.CONFIRM}
|
||||
onClose={onClose}
|
||||
width={500}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import mermaid from 'mermaid';
|
||||
import DOMPurify from 'dompurify';
|
||||
import type { Theme } from '../types';
|
||||
|
||||
// Track if mermaid has been initialized
|
||||
let mermaidInitialized = false;
|
||||
|
||||
interface MermaidRendererProps {
|
||||
chart: string;
|
||||
theme: Theme;
|
||||
@@ -183,86 +186,68 @@ const initMermaid = (theme: Theme) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Sanitize and parse SVG into safe DOM nodes
|
||||
const createSanitizedSvgElement = (svgString: string): Node | null => {
|
||||
// First sanitize with DOMPurify configured for SVG
|
||||
const sanitized = DOMPurify.sanitize(svgString, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ['foreignObject'],
|
||||
ADD_ATTR: ['xmlns', 'xmlns:xlink', 'xlink:href', 'dominant-baseline', 'text-anchor'],
|
||||
RETURN_DOM: true
|
||||
});
|
||||
|
||||
// Return the first child (the SVG element)
|
||||
return sanitized.firstChild;
|
||||
};
|
||||
|
||||
export function MermaidRenderer({ chart, theme }: MermaidRendererProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [svgContent, setSvgContent] = useState<string | null>(null);
|
||||
|
||||
// Use useLayoutEffect to ensure DOM is ready before we try to render
|
||||
useLayoutEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
useEffect(() => {
|
||||
const renderChart = async () => {
|
||||
if (!containerRef.current || !chart.trim()) return;
|
||||
if (!chart.trim()) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSvgContent(null);
|
||||
|
||||
// Initialize mermaid with the app's theme colors
|
||||
// Initialize mermaid with the app's theme colors (only once, or when theme changes)
|
||||
initMermaid(theme);
|
||||
mermaidInitialized = true;
|
||||
|
||||
try {
|
||||
// Generate a unique ID for this diagram
|
||||
const id = `mermaid-${Math.random().toString(36).substring(2, 11)}`;
|
||||
|
||||
// Render the diagram
|
||||
const { svg: renderedSvg } = await mermaid.render(id, chart.trim());
|
||||
// Render the diagram - mermaid.render returns { svg: string }
|
||||
const result = await mermaid.render(id, chart.trim());
|
||||
|
||||
// Create sanitized DOM element from SVG string
|
||||
const svgElement = createSanitizedSvgElement(renderedSvg);
|
||||
if (cancelled) return;
|
||||
|
||||
// Clear container and append sanitized SVG
|
||||
while (containerRef.current.firstChild) {
|
||||
containerRef.current.removeChild(containerRef.current.firstChild);
|
||||
if (result && result.svg) {
|
||||
// Sanitize the SVG before setting it
|
||||
const sanitizedSvg = DOMPurify.sanitize(result.svg, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ['foreignObject'],
|
||||
ADD_ATTR: ['xmlns', 'xmlns:xlink', 'xlink:href', 'dominant-baseline', 'text-anchor'],
|
||||
});
|
||||
setSvgContent(sanitizedSvg);
|
||||
setError(null);
|
||||
} else {
|
||||
setError('Mermaid returned empty result');
|
||||
}
|
||||
|
||||
if (svgElement) {
|
||||
containerRef.current.appendChild(svgElement);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.error('Mermaid rendering error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to render diagram');
|
||||
|
||||
// Clear container on error
|
||||
if (containerRef.current) {
|
||||
while (containerRef.current.firstChild) {
|
||||
containerRef.current.removeChild(containerRef.current.firstChild);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderChart();
|
||||
}, [chart, theme]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="p-4 rounded-lg text-center text-sm"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
color: theme.colors.textDim
|
||||
}}
|
||||
>
|
||||
Rendering diagram...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chart, theme]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -297,12 +282,53 @@ export function MermaidRenderer({ chart, theme }: MermaidRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Update container with SVG when content changes
|
||||
useLayoutEffect(() => {
|
||||
if (containerRef.current && svgContent) {
|
||||
// Parse sanitized SVG and append to container
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(svgContent, 'image/svg+xml');
|
||||
const svgElement = doc.documentElement;
|
||||
|
||||
// Clear existing content
|
||||
while (containerRef.current.firstChild) {
|
||||
containerRef.current.removeChild(containerRef.current.firstChild);
|
||||
}
|
||||
|
||||
// Append new SVG
|
||||
if (svgElement && svgElement.tagName === 'svg') {
|
||||
containerRef.current.appendChild(document.importNode(svgElement, true));
|
||||
}
|
||||
}
|
||||
}, [svgContent]);
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="mermaid-container p-4 rounded-lg overflow-x-auto"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
minHeight: '60px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-center text-sm"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
>
|
||||
Rendering diagram...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render container - SVG will be inserted via the effect above
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="mermaid-container p-4 rounded-lg overflow-x-auto"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -34,7 +34,6 @@ export interface SessionItemProps {
|
||||
groupId?: string; // The group ID context for generating editing key
|
||||
gitFileCount?: number;
|
||||
isInBatch?: boolean;
|
||||
isBatchStopping?: boolean; // Whether the batch is in stopping state
|
||||
jumpNumber?: string | null; // Session jump shortcut number (1-9, 0)
|
||||
|
||||
// Handlers
|
||||
@@ -75,7 +74,6 @@ export const SessionItem = memo(function SessionItem({
|
||||
groupId,
|
||||
gitFileCount,
|
||||
isInBatch = false,
|
||||
isBatchStopping = false,
|
||||
jumpNumber,
|
||||
onSelect,
|
||||
onDragStart,
|
||||
@@ -212,15 +210,15 @@ export const SessionItem = memo(function SessionItem({
|
||||
{/* AUTO Mode Indicator */}
|
||||
{isInBatch && (
|
||||
<div
|
||||
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${isBatchStopping ? '' : 'animate-pulse'}`}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase animate-pulse"
|
||||
style={{
|
||||
backgroundColor: isBatchStopping ? theme.colors.error + '30' : theme.colors.warning + '30',
|
||||
color: isBatchStopping ? theme.colors.error : theme.colors.warning
|
||||
backgroundColor: theme.colors.warning + '30',
|
||||
color: theme.colors.warning
|
||||
}}
|
||||
title={isBatchStopping ? 'Auto Run stopping...' : 'Auto Run active'}
|
||||
title="Auto Run active"
|
||||
>
|
||||
<Bot className="w-2.5 h-2.5" />
|
||||
{isBatchStopping ? 'STOPPING' : 'AUTO'}
|
||||
AUTO
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -270,11 +268,11 @@ export const SessionItem = memo(function SessionItem({
|
||||
{/* AI Status Indicator with Unread Badge - ml-auto ensures it aligns to right edge */}
|
||||
<div className="relative ml-auto">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${session.state === 'connecting' ? 'animate-pulse' : (session.state === 'busy' ? 'animate-pulse' : '')}`}
|
||||
className={`w-2 h-2 rounded-full ${session.state === 'connecting' ? 'animate-pulse' : ((session.state === 'busy' || isInBatch) ? 'animate-pulse' : '')}`}
|
||||
style={
|
||||
session.toolType === 'claude' && !session.agentSessionId
|
||||
session.toolType === 'claude' && !session.agentSessionId && !isInBatch
|
||||
? { border: `1.5px solid ${theme.colors.textDim}`, backgroundColor: 'transparent' }
|
||||
: { backgroundColor: getStatusColor(session.state, theme) }
|
||||
: { backgroundColor: isInBatch ? theme.colors.warning : getStatusColor(session.state, theme) }
|
||||
}
|
||||
title={
|
||||
session.toolType === 'claude' && !session.agentSessionId ? 'No active Claude session' :
|
||||
|
||||
@@ -308,7 +308,7 @@ function SessionContextMenu({
|
||||
style={{ color: theme.colors.error }}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Delete Worktree
|
||||
Remove Worktree
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -496,7 +496,6 @@ interface SessionTooltipContentProps {
|
||||
gitFileCount?: number;
|
||||
groupName?: string; // Optional group name (for skinny mode)
|
||||
isInBatch?: boolean; // Whether session is running in auto mode
|
||||
isBatchStopping?: boolean; // Whether batch is in stopping state
|
||||
}
|
||||
|
||||
function SessionTooltipContent({
|
||||
@@ -505,7 +504,6 @@ function SessionTooltipContent({
|
||||
gitFileCount,
|
||||
groupName,
|
||||
isInBatch = false,
|
||||
isBatchStopping = false,
|
||||
}: SessionTooltipContentProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -530,14 +528,14 @@ function SessionTooltipContent({
|
||||
{/* AUTO Mode Indicator */}
|
||||
{isInBatch && (
|
||||
<span
|
||||
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${isBatchStopping ? '' : 'animate-pulse'}`}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase animate-pulse"
|
||||
style={{
|
||||
backgroundColor: isBatchStopping ? theme.colors.error + '30' : theme.colors.warning + '30',
|
||||
color: isBatchStopping ? theme.colors.error : theme.colors.warning
|
||||
backgroundColor: theme.colors.warning + '30',
|
||||
color: theme.colors.warning
|
||||
}}
|
||||
>
|
||||
<Bot className="w-2.5 h-2.5" />
|
||||
{isBatchStopping ? 'STOPPING' : 'AUTO'}
|
||||
AUTO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -703,7 +701,6 @@ interface SessionListProps {
|
||||
|
||||
// Auto mode props
|
||||
activeBatchSessionIds?: string[]; // Session IDs that are running in auto mode
|
||||
stoppingBatchSessionIds?: string[]; // Session IDs that are in stopping state
|
||||
|
||||
// Session jump shortcut props (Opt+Cmd+NUMBER)
|
||||
showSessionJumpNumbers?: boolean;
|
||||
@@ -768,7 +765,6 @@ export function SessionList(props: SessionListProps) {
|
||||
onOpenWorktreeConfig,
|
||||
onDeleteWorktree,
|
||||
activeBatchSessionIds = [],
|
||||
stoppingBatchSessionIds = [],
|
||||
showSessionJumpNumbers = false,
|
||||
visibleSessions = [],
|
||||
autoRunStats,
|
||||
@@ -960,15 +956,16 @@ export function SessionList(props: SessionListProps) {
|
||||
const hasUnreadTabs = s.aiTabs?.some(tab => tab.hasUnread);
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === allSessions.length - 1;
|
||||
const isInBatch = activeBatchSessionIds.includes(s.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${keyPrefix}-part-${s.id}`}
|
||||
className="group/segment relative flex-1 h-full"
|
||||
className={`group/segment relative flex-1 h-full ${isInBatch ? 'animate-pulse' : ''}`}
|
||||
style={{
|
||||
...(s.toolType === 'claude' && !s.agentSessionId
|
||||
...(s.toolType === 'claude' && !s.agentSessionId && !isInBatch
|
||||
? { border: `1px solid ${theme.colors.textDim}`, backgroundColor: 'transparent' }
|
||||
: { backgroundColor: getStatusColor(s.state, theme) }),
|
||||
: { backgroundColor: isInBatch ? theme.colors.warning : getStatusColor(s.state, theme) }),
|
||||
// Rounded ends only on first/last
|
||||
borderRadius: hasWorktrees
|
||||
? `${isFirst ? '9999px' : '0'} ${isLast ? '9999px' : '0'} ${isLast ? '9999px' : '0'} ${isFirst ? '9999px' : '0'}`
|
||||
@@ -1003,8 +1000,7 @@ export function SessionList(props: SessionListProps) {
|
||||
session={s}
|
||||
theme={theme}
|
||||
gitFileCount={gitFileCounts.get(s.id)}
|
||||
isInBatch={activeBatchSessionIds.includes(s.id)}
|
||||
isBatchStopping={stoppingBatchSessionIds.includes(s.id)}
|
||||
isInBatch={isInBatch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1047,7 +1043,6 @@ export function SessionList(props: SessionListProps) {
|
||||
groupId={options.groupId}
|
||||
gitFileCount={gitFileCounts.get(session.id)}
|
||||
isInBatch={activeBatchSessionIds.includes(session.id)}
|
||||
isBatchStopping={stoppingBatchSessionIds.includes(session.id)}
|
||||
jumpNumber={getSessionJumpNumber(session.id)}
|
||||
onSelect={() => setActiveSessionId(session.id)}
|
||||
onDragStart={() => handleDragStart(session.id)}
|
||||
@@ -1107,7 +1102,6 @@ export function SessionList(props: SessionListProps) {
|
||||
leftSidebarOpen={leftSidebarOpen}
|
||||
gitFileCount={gitFileCounts.get(child.id)}
|
||||
isInBatch={activeBatchSessionIds.includes(child.id)}
|
||||
isBatchStopping={stoppingBatchSessionIds.includes(child.id)}
|
||||
jumpNumber={getSessionJumpNumber(child.id)}
|
||||
onSelect={() => setActiveSessionId(child.id)}
|
||||
onDragStart={() => handleDragStart(child.id)}
|
||||
@@ -2040,16 +2034,14 @@ export function SessionList(props: SessionListProps) {
|
||||
<div className="flex-1 flex flex-col items-center py-4 gap-2 overflow-y-auto overflow-x-visible no-scrollbar">
|
||||
{sortedSessions.map(session => {
|
||||
const isInBatch = activeBatchSessionIds.includes(session.id);
|
||||
const isBatchStopping = stoppingBatchSessionIds.includes(session.id);
|
||||
const hasUnreadTabs = session.aiTabs?.some(tab => tab.hasUnread);
|
||||
// Sessions in Auto Run mode should show yellow/warning color, red if stopping
|
||||
// Sessions in Auto Run mode should show yellow/warning color
|
||||
const effectiveStatusColor = isInBatch
|
||||
? (isBatchStopping ? theme.colors.error : theme.colors.warning)
|
||||
? theme.colors.warning
|
||||
: (session.toolType === 'claude' && !session.agentSessionId
|
||||
? undefined // Will use border style instead
|
||||
: getStatusColor(session.state, theme));
|
||||
// Don't pulse when stopping
|
||||
const shouldPulse = (session.state === 'busy' || isInBatch) && !isBatchStopping;
|
||||
const shouldPulse = session.state === 'busy' || isInBatch;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -2095,7 +2087,6 @@ export function SessionList(props: SessionListProps) {
|
||||
gitFileCount={gitFileCounts.get(session.id)}
|
||||
groupName={groups.find(g => g.id === session.groupId)?.name}
|
||||
isInBatch={isInBatch}
|
||||
isBatchStopping={isBatchStopping}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,7 +187,7 @@ const AutoRunPill = memo(({
|
||||
className="text-xs font-semibold shrink-0"
|
||||
style={{ color: theme.colors.accent }}
|
||||
>
|
||||
{isStopping ? 'AutoRun Stopping...' : 'AutoRun'}
|
||||
AutoRun
|
||||
</span>
|
||||
|
||||
{/* Worktree indicator */}
|
||||
|
||||
5
src/renderer/global.d.ts
vendored
5
src/renderer/global.d.ts
vendored
@@ -776,10 +776,13 @@ interface MaestroAPI {
|
||||
watchFolder: (folderPath: string) => Promise<{ success: boolean; error?: string }>;
|
||||
unwatchFolder: (folderPath: string) => Promise<{ success: boolean; error?: string }>;
|
||||
onFileChanged: (handler: (data: { folderPath: string; filename: string; eventType: string }) => void) => () => void;
|
||||
// Backup operations for reset-on-completion documents
|
||||
// Backup operations for reset-on-completion documents (legacy)
|
||||
createBackup: (folderPath: string, filename: string) => Promise<{ success: boolean; backupFilename?: string; error?: string }>;
|
||||
restoreBackup: (folderPath: string, filename: string) => Promise<{ success: boolean; error?: string }>;
|
||||
deleteBackups: (folderPath: string) => Promise<{ success: boolean; deletedCount?: number; error?: string }>;
|
||||
// Working copy operations for reset-on-completion documents (preferred)
|
||||
// Creates a copy in /runs/ subdirectory: {name}-{timestamp}-loop-{N}.md
|
||||
createWorkingCopy: (folderPath: string, filename: string, loopNumber: number) => Promise<{ workingCopyPath: string; originalPath: string }>;
|
||||
};
|
||||
// Playbooks API (saved batch run configurations)
|
||||
playbooks: {
|
||||
|
||||
@@ -556,67 +556,9 @@ export function useBatchProcessor({
|
||||
// Track stalled documents (document filename -> stall reason)
|
||||
const stalledDocuments: Map<string, string> = new Map();
|
||||
|
||||
// Track which reset documents have active backups (for cleanup on interruption)
|
||||
const activeBackups: Set<string> = new Set();
|
||||
|
||||
// Track the current document being processed (for interruption handling)
|
||||
let currentResetDocFilename: string | null = null;
|
||||
|
||||
// Helper to clean up all backups
|
||||
const cleanupBackups = async () => {
|
||||
if (activeBackups.size > 0) {
|
||||
try {
|
||||
await window.maestro.autorun.deleteBackups(folderPath);
|
||||
activeBackups.clear();
|
||||
} catch {
|
||||
// Ignore backup cleanup errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to restore current reset doc and clean up (for interruption)
|
||||
const handleInterruptionCleanup = async () => {
|
||||
// If we were mid-processing a reset doc, restore it to original state
|
||||
if (currentResetDocFilename) {
|
||||
// Find the document entry to check if it's reset-on-completion
|
||||
const docEntry = documents.find(d => d.filename === currentResetDocFilename);
|
||||
const isResetOnCompletion = docEntry?.resetOnCompletion ?? false;
|
||||
|
||||
if (isResetOnCompletion) {
|
||||
// Try to restore from backup first
|
||||
if (activeBackups.has(currentResetDocFilename)) {
|
||||
try {
|
||||
await window.maestro.autorun.restoreBackup(folderPath, currentResetDocFilename);
|
||||
activeBackups.delete(currentResetDocFilename);
|
||||
} catch {
|
||||
// Fallback: uncheck all tasks in the document
|
||||
try {
|
||||
const { content } = await readDocAndCountTasks(folderPath, currentResetDocFilename);
|
||||
if (content) {
|
||||
const resetContent = uncheckAllTasks(content);
|
||||
await window.maestro.autorun.writeDoc(folderPath, currentResetDocFilename + '.md', resetContent);
|
||||
}
|
||||
} catch {
|
||||
// Ignore reset errors
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No backup available - use uncheckAllTasks to reset
|
||||
try {
|
||||
const { content } = await readDocAndCountTasks(folderPath, currentResetDocFilename);
|
||||
if (content) {
|
||||
const resetContent = uncheckAllTasks(content);
|
||||
await window.maestro.autorun.writeDoc(folderPath, currentResetDocFilename + '.md', resetContent);
|
||||
}
|
||||
} catch {
|
||||
// Ignore reset errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clean up any remaining backups
|
||||
await cleanupBackups();
|
||||
};
|
||||
// Track working copies for reset-on-completion documents (original filename -> working copy path)
|
||||
// Working copies are stored in /runs/ and serve as audit logs
|
||||
const workingCopies: Map<string, string> = new Map();
|
||||
|
||||
// Helper to add final loop summary (defined here so it has access to tracking vars)
|
||||
const addFinalLoopSummary = (exitReason: string) => {
|
||||
@@ -697,14 +639,30 @@ export function useBatchProcessor({
|
||||
// Reset stall detection counter for each new document
|
||||
consecutiveNoChangeCount = 0;
|
||||
|
||||
// Create backup for reset-on-completion documents before processing
|
||||
// The actual filename to process (may be working copy for reset-on-completion docs)
|
||||
let effectiveFilename = docEntry.filename;
|
||||
|
||||
// Create working copy for reset-on-completion documents
|
||||
// Working copies are stored in /runs/ and the original is never modified
|
||||
if (docEntry.resetOnCompletion) {
|
||||
try {
|
||||
await window.maestro.autorun.createBackup(folderPath, docEntry.filename);
|
||||
activeBackups.add(docEntry.filename);
|
||||
currentResetDocFilename = docEntry.filename;
|
||||
} catch {
|
||||
// Continue without backup - will fall back to uncheckAllTasks behavior
|
||||
const { workingCopyPath } = await window.maestro.autorun.createWorkingCopy(
|
||||
folderPath,
|
||||
docEntry.filename,
|
||||
loopIteration + 1 // 1-indexed loop number
|
||||
);
|
||||
workingCopies.set(docEntry.filename, workingCopyPath);
|
||||
effectiveFilename = workingCopyPath;
|
||||
|
||||
// Re-read the working copy for task counting
|
||||
const workingCopyResult = await readDocAndCountTasks(folderPath, effectiveFilename);
|
||||
remainingTasks = workingCopyResult.taskCount;
|
||||
docContent = workingCopyResult.content;
|
||||
docCheckedCount = workingCopyResult.checkedCount;
|
||||
docTasksTotal = remainingTasks;
|
||||
} catch (err) {
|
||||
console.error(`[BatchProcessor] Failed to create working copy for ${docEntry.filename}:`, err);
|
||||
// Continue with original document as fallback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,7 +729,7 @@ export function useBatchProcessor({
|
||||
effectiveCwd,
|
||||
customPrompt: prompt,
|
||||
},
|
||||
docEntry.filename,
|
||||
effectiveFilename, // Use working copy path for reset-on-completion docs
|
||||
docCheckedCount,
|
||||
remainingTasks,
|
||||
docContent,
|
||||
@@ -947,125 +905,63 @@ export function useBatchProcessor({
|
||||
}
|
||||
}
|
||||
|
||||
// Check for stop request before doing reset (stalled documents are skipped, not stopped)
|
||||
// Check for stop request before moving to next document
|
||||
if (stopRequestedRefs.current[sessionId]) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip document reset if this document stalled (it didn't complete normally)
|
||||
// Skip document handling if this document stalled (it didn't complete normally)
|
||||
if (stalledDocuments.has(docEntry.filename)) {
|
||||
// If this was a reset doc that stalled, restore from backup
|
||||
if (docEntry.resetOnCompletion && activeBackups.has(docEntry.filename)) {
|
||||
try {
|
||||
await window.maestro.autorun.restoreBackup(folderPath, docEntry.filename);
|
||||
activeBackups.delete(docEntry.filename);
|
||||
} catch {
|
||||
// Ignore restore errors
|
||||
}
|
||||
}
|
||||
currentResetDocFilename = null;
|
||||
// Working copy approach: stalled working copy stays in /runs/ as audit log
|
||||
// Original document is untouched, so nothing to restore
|
||||
workingCopies.delete(docEntry.filename);
|
||||
// Reset consecutive no-change counter for next document
|
||||
consecutiveNoChangeCount = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (skipCurrentDocumentAfterError) {
|
||||
// If this was a reset doc that errored, restore from backup
|
||||
if (docEntry.resetOnCompletion && activeBackups.has(docEntry.filename)) {
|
||||
try {
|
||||
await window.maestro.autorun.restoreBackup(folderPath, docEntry.filename);
|
||||
activeBackups.delete(docEntry.filename);
|
||||
} catch {
|
||||
// Ignore restore errors
|
||||
}
|
||||
}
|
||||
currentResetDocFilename = null;
|
||||
// Working copy approach: errored working copy stays in /runs/ as audit log
|
||||
// Original document is untouched, so nothing to restore
|
||||
workingCopies.delete(docEntry.filename);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Document complete - handle reset-on-completion if enabled
|
||||
// Document complete - for reset-on-completion docs, original is untouched
|
||||
// Working copy in /runs/ serves as the audit log of this loop's work
|
||||
if (docEntry.resetOnCompletion && docTasksCompleted > 0) {
|
||||
|
||||
// AUTORUN LOG: Document reset
|
||||
// AUTORUN LOG: Document loop completed
|
||||
window.maestro.logger.autorun(
|
||||
`Resetting document: ${docEntry.filename}`,
|
||||
`Document loop completed: ${docEntry.filename}`,
|
||||
session.name,
|
||||
{
|
||||
document: docEntry.filename,
|
||||
workingCopy: workingCopies.get(docEntry.filename),
|
||||
tasksCompleted: docTasksCompleted,
|
||||
loopNumber: loopIteration + 1
|
||||
}
|
||||
);
|
||||
|
||||
// Restore from backup if available, otherwise fall back to uncheckAllTasks
|
||||
if (activeBackups.has(docEntry.filename)) {
|
||||
try {
|
||||
await window.maestro.autorun.restoreBackup(folderPath, docEntry.filename);
|
||||
activeBackups.delete(docEntry.filename);
|
||||
currentResetDocFilename = null;
|
||||
|
||||
// Count tasks in restored content for loop mode
|
||||
if (loopEnabled) {
|
||||
const { taskCount: resetTaskCount } = await readDocAndCountTasks(folderPath, docEntry.filename);
|
||||
updateBatchStateAndBroadcastRef.current!(sessionId, prev => ({
|
||||
...prev,
|
||||
[sessionId]: {
|
||||
...prev[sessionId],
|
||||
totalTasksAcrossAllDocs: prev[sessionId].totalTasksAcrossAllDocs + resetTaskCount,
|
||||
totalTasks: prev[sessionId].totalTasks + resetTaskCount
|
||||
}
|
||||
}));
|
||||
// For loop mode, re-count tasks in the original document for next iteration
|
||||
// (original is unchanged, so it still has all unchecked tasks)
|
||||
if (loopEnabled) {
|
||||
const { taskCount: resetTaskCount } = await readDocAndCountTasks(folderPath, docEntry.filename);
|
||||
updateBatchStateAndBroadcastRef.current!(sessionId, prev => ({
|
||||
...prev,
|
||||
[sessionId]: {
|
||||
...prev[sessionId],
|
||||
totalTasksAcrossAllDocs: prev[sessionId].totalTasksAcrossAllDocs + resetTaskCount,
|
||||
totalTasks: prev[sessionId].totalTasks + resetTaskCount
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[BatchProcessor] Failed to restore backup for ${docEntry.filename}, falling back to uncheckAllTasks:`, err);
|
||||
// Fall back to uncheckAllTasks behavior
|
||||
const { content: currentContent } = await readDocAndCountTasks(folderPath, docEntry.filename);
|
||||
const resetContent = uncheckAllTasks(currentContent);
|
||||
await window.maestro.autorun.writeDoc(folderPath, docEntry.filename + '.md', resetContent);
|
||||
activeBackups.delete(docEntry.filename);
|
||||
currentResetDocFilename = null;
|
||||
|
||||
if (loopEnabled) {
|
||||
const resetTaskCount = countUnfinishedTasks(resetContent);
|
||||
updateBatchStateAndBroadcastRef.current!(sessionId, prev => ({
|
||||
...prev,
|
||||
[sessionId]: {
|
||||
...prev[sessionId],
|
||||
totalTasksAcrossAllDocs: prev[sessionId].totalTasksAcrossAllDocs + resetTaskCount,
|
||||
totalTasks: prev[sessionId].totalTasks + resetTaskCount
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No backup available - use legacy uncheckAllTasks behavior
|
||||
const { content: currentContent } = await readDocAndCountTasks(folderPath, docEntry.filename);
|
||||
const resetContent = uncheckAllTasks(currentContent);
|
||||
await window.maestro.autorun.writeDoc(folderPath, docEntry.filename + '.md', resetContent);
|
||||
|
||||
if (loopEnabled) {
|
||||
const resetTaskCount = countUnfinishedTasks(resetContent);
|
||||
updateBatchStateAndBroadcastRef.current!(sessionId, prev => ({
|
||||
...prev,
|
||||
[sessionId]: {
|
||||
...prev[sessionId],
|
||||
totalTasksAcrossAllDocs: prev[sessionId].totalTasksAcrossAllDocs + resetTaskCount,
|
||||
totalTasks: prev[sessionId].totalTasks + resetTaskCount
|
||||
}
|
||||
}));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Clear tracking - working copy stays in /runs/ as audit log
|
||||
workingCopies.delete(docEntry.filename);
|
||||
} else if (docEntry.resetOnCompletion) {
|
||||
// Document had reset enabled but no tasks were completed - clean up backup
|
||||
if (activeBackups.has(docEntry.filename)) {
|
||||
try {
|
||||
// Delete just this backup by restoring (which deletes) or we can just delete it
|
||||
// Actually, let's leave it for now and clean up at the end
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
currentResetDocFilename = null;
|
||||
// Document had reset enabled but no tasks were completed
|
||||
// Working copy still serves as record of the attempt
|
||||
workingCopies.delete(docEntry.filename);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1206,13 +1102,10 @@ export function useBatchProcessor({
|
||||
}));
|
||||
}
|
||||
|
||||
// Handle backup cleanup - if we were stopped mid-document, restore the reset doc first
|
||||
if (stopRequestedRefs.current[sessionId]) {
|
||||
await handleInterruptionCleanup();
|
||||
} else {
|
||||
// Normal completion - just clean up any remaining backups
|
||||
await cleanupBackups();
|
||||
}
|
||||
// Working copy approach: no cleanup needed
|
||||
// - Original documents are never modified
|
||||
// - Working copies in /runs/ serve as audit logs and are kept
|
||||
// - User can delete them manually if desired
|
||||
|
||||
// Create PR if worktree was used, PR creation is enabled, and not stopped
|
||||
const wasStopped = stopRequestedRefs.current[sessionId] || false;
|
||||
|
||||
@@ -253,6 +253,10 @@ export function useMobileSessionManagement(
|
||||
const handleSelectSession = useCallback((sessionId: string) => {
|
||||
// Find the session to get its activeTabId
|
||||
const session = sessions.find(s => s.id === sessionId);
|
||||
// Update refs synchronously BEFORE state updates to avoid race conditions
|
||||
// with WebSocket messages arriving during the render cycle
|
||||
activeSessionIdRef.current = sessionId;
|
||||
activeTabIdRef.current = session?.activeTabId || null;
|
||||
setActiveSessionId(sessionId);
|
||||
setActiveTabId(session?.activeTabId || null);
|
||||
triggerHaptic(hapticTapPattern);
|
||||
@@ -266,6 +270,8 @@ export function useMobileSessionManagement(
|
||||
triggerHaptic(hapticTapPattern);
|
||||
// Notify desktop to switch to this tab
|
||||
sendRef.current?.({ type: 'select_tab', sessionId: activeSessionId, tabId });
|
||||
// Update ref synchronously to avoid race conditions with WebSocket messages
|
||||
activeTabIdRef.current = tabId;
|
||||
// Update local activeTabId state directly (triggers log fetch)
|
||||
setActiveTabId(tabId);
|
||||
// Also update sessions state for UI consistency
|
||||
@@ -324,21 +330,22 @@ export function useMobileSessionManagement(
|
||||
|
||||
setSessions(newSessions);
|
||||
// Auto-select first session if none selected, and sync activeTabId
|
||||
setActiveSessionId(prev => {
|
||||
if (!prev && newSessions.length > 0) {
|
||||
const firstSession = newSessions[0];
|
||||
setActiveTabId(firstSession.activeTabId || null);
|
||||
return firstSession.id;
|
||||
}
|
||||
// Update refs synchronously to avoid race conditions with WebSocket messages
|
||||
const currentActiveId = activeSessionIdRef.current;
|
||||
if (!currentActiveId && newSessions.length > 0) {
|
||||
const firstSession = newSessions[0];
|
||||
activeSessionIdRef.current = firstSession.id;
|
||||
activeTabIdRef.current = firstSession.activeTabId || null;
|
||||
setActiveSessionId(firstSession.id);
|
||||
setActiveTabId(firstSession.activeTabId || null);
|
||||
} else if (currentActiveId) {
|
||||
// Sync activeTabId for current session
|
||||
if (prev) {
|
||||
const currentSession = newSessions.find(s => s.id === prev);
|
||||
if (currentSession) {
|
||||
setActiveTabId(currentSession.activeTabId || null);
|
||||
}
|
||||
const currentSession = newSessions.find(s => s.id === currentActiveId);
|
||||
if (currentSession) {
|
||||
activeTabIdRef.current = currentSession.activeTabId || null;
|
||||
setActiveTabId(currentSession.activeTabId || null);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
},
|
||||
onSessionStateChange: (sessionId: string, state: string, additionalData?: Partial<Session>) => {
|
||||
// Check if this is a busy -> idle transition (AI response completed)
|
||||
@@ -383,17 +390,20 @@ export function useMobileSessionManagement(
|
||||
previousSessionStatesRef.current.delete(sessionId);
|
||||
|
||||
setSessions(prev => prev.filter(s => s.id !== sessionId));
|
||||
setActiveSessionId(prev => {
|
||||
if (prev === sessionId) {
|
||||
setActiveTabId(null);
|
||||
return null;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
// Update refs synchronously if the removed session was active
|
||||
if (activeSessionIdRef.current === sessionId) {
|
||||
activeSessionIdRef.current = null;
|
||||
activeTabIdRef.current = null;
|
||||
setActiveSessionId(null);
|
||||
setActiveTabId(null);
|
||||
}
|
||||
},
|
||||
onActiveSessionChanged: (sessionId: string) => {
|
||||
// Desktop app switched to a different session - sync with web
|
||||
webLogger.debug(`Desktop active session changed: ${sessionId}`, 'Mobile');
|
||||
// Update refs synchronously BEFORE state updates to avoid race conditions
|
||||
activeSessionIdRef.current = sessionId;
|
||||
activeTabIdRef.current = null;
|
||||
setActiveSessionId(sessionId);
|
||||
setActiveTabId(null);
|
||||
},
|
||||
@@ -532,9 +542,10 @@ export function useMobileSessionManagement(
|
||||
? { ...s, aiTabs, activeTabId: newActiveTabId }
|
||||
: s
|
||||
));
|
||||
// Also update activeTabId state if this is the current session
|
||||
// Also update activeTabId ref and state if this is the current session
|
||||
const currentSessionId = activeSessionIdRef.current;
|
||||
if (currentSessionId === sessionId) {
|
||||
activeTabIdRef.current = newActiveTabId;
|
||||
setActiveTabId(newActiveTabId);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -515,6 +515,26 @@ export function AllSessionsView({
|
||||
}
|
||||
}, [sortedGroupKeys, collapsedGroups]);
|
||||
|
||||
// Auto-expand groups that contain search results when searching
|
||||
useEffect(() => {
|
||||
if (localSearchQuery.trim() && collapsedGroups) {
|
||||
// Find groups that have matching sessions and expand them
|
||||
const groupsWithMatches = new Set(sortedGroupKeys.filter(key => sessionsByGroup[key]?.sessions.length > 0));
|
||||
|
||||
// If any groups have matches, expand them
|
||||
if (groupsWithMatches.size > 0) {
|
||||
setCollapsedGroups(prev => {
|
||||
const next = new Set(prev || []);
|
||||
// Remove groups with matches from collapsed set (expand them)
|
||||
for (const groupKey of groupsWithMatches) {
|
||||
next.delete(groupKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [localSearchQuery, sortedGroupKeys, sessionsByGroup]);
|
||||
|
||||
// Toggle group collapse
|
||||
const handleToggleCollapse = useCallback((groupId: string) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
|
||||
@@ -336,14 +336,20 @@ export function CommandInputBar({
|
||||
|
||||
/**
|
||||
* Handle key press events
|
||||
* AI mode: Enter adds newline (button to send)
|
||||
* AI mode: Enter adds newline, Cmd/Ctrl+Enter submits
|
||||
* Terminal mode: Enter submits (Shift+Enter adds newline)
|
||||
*/
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (inputMode === 'ai') {
|
||||
// AI mode: Enter always adds newline, use button to send
|
||||
// No special handling needed - default behavior adds newline
|
||||
// AI mode: Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux) submits
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
if (!isSendBlocked) {
|
||||
handleSubmit(e);
|
||||
}
|
||||
}
|
||||
// Plain Enter adds newline (default behavior)
|
||||
return;
|
||||
}
|
||||
// Terminal mode: Submit on Enter (Shift+Enter adds newline)
|
||||
@@ -352,7 +358,7 @@ export function CommandInputBar({
|
||||
handleSubmit(e);
|
||||
}
|
||||
},
|
||||
[handleSubmit, inputMode]
|
||||
[handleSubmit, inputMode, isSendBlocked]
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user