## 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:
Pedram Amini
2025-12-26 06:46:08 -06:00
parent b78f9523c4
commit f1712dc9bb
17 changed files with 496 additions and 446 deletions

View File

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

View File

@@ -135,6 +135,7 @@ describe('autorun IPC handlers', () => {
'autorun:createBackup',
'autorun:restoreBackup',
'autorun:deleteBackups',
'autorun:createWorkingCopy',
];
for (const channel of expectedChannels) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
}}
/>
);

View File

@@ -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' :

View File

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

View File

@@ -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 */}

View File

@@ -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: {

View File

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

View File

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

View File

@@ -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) => {

View File

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