# CHANGES

- Added drag-and-drop reordering support for execution queue items 🎯
- Created new PreparingPlanScreen step in wizard workflow 🚀
- Enhanced document selector dropdown for multi-document support 📄
- Improved process monitor UI with two-line layout design 💅
- Fixed queue processing after interrupting running commands 
- Added lightweight session timestamp fetching for activity graphs 📊
- Separated document generation from review in wizard flow 🔄
- Enhanced file creation tracking with retry logic 🔁
- Added visual feedback for drag operations with shimmer effect 
- Fixed tab header layout in right panel interface 🎨
This commit is contained in:
Pedram Amini
2025-12-11 03:54:13 -06:00
parent 6f0cbe039d
commit e947df56ae
23 changed files with 1432 additions and 897 deletions

View File

@@ -1564,4 +1564,312 @@ describe('ExecutionQueueBrowser', () => {
expect(initialOnClose).not.toHaveBeenCalled();
});
});
describe('drag and drop reordering', () => {
let mockOnReorderItems: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockOnReorderItems = vi.fn();
});
it('should not enable drag when onReorderItems is not provided', () => {
const session = createSession({
id: 'active-session',
executionQueue: [
createQueuedItem({ id: 'item-1' }),
createQueuedItem({ id: 'item-2' })
]
});
const { container } = render(
<ExecutionQueueBrowser
isOpen={true}
onClose={mockOnClose}
sessions={[session]}
activeSessionId="active-session"
theme={theme}
onRemoveItem={mockOnRemoveItem}
onSwitchSession={mockOnSwitchSession}
/>
);
// Items should not have grab cursor when onReorderItems is not provided
const itemRows = container.querySelectorAll('.group.select-none');
itemRows.forEach(row => {
expect(row).not.toHaveStyle({ cursor: 'grab' });
});
});
it('should not enable drag when session has only one item', () => {
const session = createSession({
id: 'active-session',
executionQueue: [createQueuedItem({ id: 'item-1' })]
});
const { container } = render(
<ExecutionQueueBrowser
isOpen={true}
onClose={mockOnClose}
sessions={[session]}
activeSessionId="active-session"
theme={theme}
onRemoveItem={mockOnRemoveItem}
onSwitchSession={mockOnSwitchSession}
onReorderItems={mockOnReorderItems}
/>
);
// Single item should not have grab cursor
const itemRow = container.querySelector('.group.select-none');
expect(itemRow).not.toHaveStyle({ cursor: 'grab' });
});
it('should enable drag when onReorderItems is provided and session has multiple items', () => {
const session = createSession({
id: 'active-session',
executionQueue: [
createQueuedItem({ id: 'item-1' }),
createQueuedItem({ id: 'item-2' })
]
});
const { container } = render(
<ExecutionQueueBrowser
isOpen={true}
onClose={mockOnClose}
sessions={[session]}
activeSessionId="active-session"
theme={theme}
onRemoveItem={mockOnRemoveItem}
onSwitchSession={mockOnSwitchSession}
onReorderItems={mockOnReorderItems}
/>
);
// Items should have grab cursor
const itemRows = container.querySelectorAll('.group.select-none');
itemRows.forEach(row => {
expect(row).toHaveStyle({ cursor: 'grab' });
});
});
it('should show drag handle indicator when draggable', () => {
const session = createSession({
id: 'active-session',
executionQueue: [
createQueuedItem({ id: 'item-1' }),
createQueuedItem({ id: 'item-2' })
]
});
const { container } = render(
<ExecutionQueueBrowser
isOpen={true}
onClose={mockOnClose}
sessions={[session]}
activeSessionId="active-session"
theme={theme}
onRemoveItem={mockOnRemoveItem}
onSwitchSession={mockOnSwitchSession}
onReorderItems={mockOnReorderItems}
/>
);
// Find the first item row
const itemRow = container.querySelector('.group.select-none');
expect(itemRow).not.toBeNull();
// Drag handle should exist (it's just hidden until hover)
const dragHandle = container.querySelector('.absolute.left-1');
expect(dragHandle).toBeInTheDocument();
});
it('should render drop zones between items when draggable', () => {
const session = createSession({
id: 'active-session',
executionQueue: [
createQueuedItem({ id: 'item-1' }),
createQueuedItem({ id: 'item-2' }),
createQueuedItem({ id: 'item-3' })
]
});
const { container } = render(
<ExecutionQueueBrowser
isOpen={true}
onClose={mockOnClose}
sessions={[session]}
activeSessionId="active-session"
theme={theme}
onRemoveItem={mockOnRemoveItem}
onSwitchSession={mockOnSwitchSession}
onReorderItems={mockOnReorderItems}
/>
);
// Should have drop zones: before item 1, before item 2, before item 3, and after item 3
const dropZones = container.querySelectorAll('.relative.h-1');
expect(dropZones.length).toBe(4); // n+1 drop zones for n items
});
it('should not initiate drag when clicking on remove button', () => {
const session = createSession({
id: 'active-session',
executionQueue: [
createQueuedItem({ id: 'item-1' }),
createQueuedItem({ id: 'item-2' })
]
});
render(
<ExecutionQueueBrowser
isOpen={true}
onClose={mockOnClose}
sessions={[session]}
activeSessionId="active-session"
theme={theme}
onRemoveItem={mockOnRemoveItem}
onSwitchSession={mockOnSwitchSession}
onReorderItems={mockOnReorderItems}
/>
);
// Get all remove buttons (there should be 2)
const removeButtons = screen.getAllByTitle('Remove from queue');
expect(removeButtons.length).toBe(2);
// Click the first remove button
fireEvent.click(removeButtons[0]);
// onRemoveItem should be called, not onReorderItems
expect(mockOnRemoveItem).toHaveBeenCalledWith('active-session', 'item-1');
expect(mockOnReorderItems).not.toHaveBeenCalled();
});
it('should enable drag for sessions in global view', () => {
const session1 = createSession({
id: 'session-1',
name: 'Project One',
executionQueue: [
createQueuedItem({ id: 'item-1' }),
createQueuedItem({ id: 'item-2' })
]
});
const session2 = createSession({
id: 'session-2',
name: 'Project Two',
executionQueue: [
createQueuedItem({ id: 'item-3' }),
createQueuedItem({ id: 'item-4' })
]
});
const { container } = render(
<ExecutionQueueBrowser
isOpen={true}
onClose={mockOnClose}
sessions={[session1, session2]}
activeSessionId="session-1"
theme={theme}
onRemoveItem={mockOnRemoveItem}
onSwitchSession={mockOnSwitchSession}
onReorderItems={mockOnReorderItems}
/>
);
// Switch to global view
const allButton = screen.getByText('All Projects').closest('button');
fireEvent.click(allButton!);
// All items should have grab cursor
const itemRows = container.querySelectorAll('.group.select-none');
expect(itemRows.length).toBe(4);
itemRows.forEach(row => {
expect(row).toHaveStyle({ cursor: 'grab' });
});
});
it('should have correct number of drop zones in global view', () => {
const session1 = createSession({
id: 'session-1',
name: 'Project One',
executionQueue: [
createQueuedItem({ id: 'item-1' }),
createQueuedItem({ id: 'item-2' })
]
});
const session2 = createSession({
id: 'session-2',
name: 'Project Two',
executionQueue: [
createQueuedItem({ id: 'item-3' }),
createQueuedItem({ id: 'item-4' })
]
});
const { container } = render(
<ExecutionQueueBrowser
isOpen={true}
onClose={mockOnClose}
sessions={[session1, session2]}
activeSessionId="session-1"
theme={theme}
onRemoveItem={mockOnRemoveItem}
onSwitchSession={mockOnSwitchSession}
onReorderItems={mockOnReorderItems}
/>
);
// Switch to global view
const allButton = screen.getByText('All Projects').closest('button');
fireEvent.click(allButton!);
// Should have drop zones for each session: 3 for session1 (2 items + 1 after) + 3 for session2
const dropZones = container.querySelectorAll('.relative.h-1');
expect(dropZones.length).toBe(6);
});
it('should not show drag handle when session has only one item', () => {
const session = createSession({
id: 'active-session',
executionQueue: [createQueuedItem({ id: 'item-1' })]
});
const { container } = render(
<ExecutionQueueBrowser
isOpen={true}
onClose={mockOnClose}
sessions={[session]}
activeSessionId="active-session"
theme={theme}
onRemoveItem={mockOnRemoveItem}
onSwitchSession={mockOnSwitchSession}
onReorderItems={mockOnReorderItems}
/>
);
// Drag handle should not exist for single item
const dragHandle = container.querySelector('.absolute.left-1');
expect(dragHandle).not.toBeInTheDocument();
});
it('should show visual feedback on mousedown', () => {
const session = createSession({
id: 'active-session',
executionQueue: [
createQueuedItem({ id: 'item-1' }),
createQueuedItem({ id: 'item-2' })
]
});
const { container } = render(
<ExecutionQueueBrowser
isOpen={true}
onClose={mockOnClose}
sessions={[session]}
activeSessionId="active-session"
theme={theme}
onRemoveItem={mockOnRemoveItem}
onSwitchSession={mockOnSwitchSession}
onReorderItems={mockOnReorderItems}
/>
);
const itemRow = container.querySelector('.group.select-none');
expect(itemRow).not.toBeNull();
// Verify item has grab cursor before interaction
expect(itemRow).toHaveStyle({ cursor: 'grab' });
});
});
});

View File

@@ -81,7 +81,7 @@ function createMockDocument(
describe('WizardContext', () => {
describe('Constants', () => {
it('has correct total steps', () => {
expect(WIZARD_TOTAL_STEPS).toBe(4);
expect(WIZARD_TOTAL_STEPS).toBe(5);
});
it('has correct step index mapping', () => {
@@ -89,7 +89,8 @@ describe('WizardContext', () => {
'agent-selection': 1,
'directory-selection': 2,
'conversation': 3,
'phase-review': 4,
'preparing-plan': 4,
'phase-review': 5,
});
});
@@ -98,7 +99,8 @@ describe('WizardContext', () => {
1: 'agent-selection',
2: 'directory-selection',
3: 'conversation',
4: 'phase-review',
4: 'preparing-plan',
5: 'phase-review',
});
});
@@ -327,6 +329,11 @@ describe('WizardContext', () => {
});
expect(result.current.state.currentStep).toBe('conversation');
act(() => {
result.current.nextStep();
});
expect(result.current.state.currentStep).toBe('preparing-plan');
act(() => {
result.current.nextStep();
});
@@ -370,6 +377,11 @@ describe('WizardContext', () => {
result.current.goToStep('phase-review');
});
act(() => {
result.current.previousStep();
});
expect(result.current.state.currentStep).toBe('preparing-plan');
act(() => {
result.current.previousStep();
});
@@ -416,9 +428,14 @@ describe('WizardContext', () => {
expect(result.current.getCurrentStepNumber()).toBe(3);
act(() => {
result.current.goToStep('phase-review');
result.current.goToStep('preparing-plan');
});
expect(result.current.getCurrentStepNumber()).toBe(4);
act(() => {
result.current.goToStep('phase-review');
});
expect(result.current.getCurrentStepNumber()).toBe(5);
});
});
});

View File

@@ -310,7 +310,7 @@ describe('Wizard Integration Tests', () => {
await waitFor(() => {
expect(screen.getByText('Create a Maestro Agent')).toBeInTheDocument();
expect(screen.getByText('Step 1 of 4')).toBeInTheDocument();
expect(screen.getByText('Step 1 of 5')).toBeInTheDocument();
});
});
@@ -362,15 +362,19 @@ describe('Wizard Integration Tests', () => {
it('should track step progress through navigation', async () => {
function TestWrapper() {
const { openWizard, state, goToStep, setSelectedAgent, setDirectoryPath } = useWizard();
const { openWizard, state, goToStep, setSelectedAgent, setDirectoryPath, setGeneratedDocuments } = useWizard();
React.useEffect(() => {
if (!state.isOpen) {
setSelectedAgent('claude-code');
setDirectoryPath('/test/path');
// Set generated documents so PhaseReviewScreen doesn't redirect back
setGeneratedDocuments([
{ filename: 'Phase-01-Test.md', content: '# Test\n- [ ] Task 1', taskCount: 1 }
]);
openWizard();
}
}, [openWizard, state.isOpen, setSelectedAgent, setDirectoryPath]);
}, [openWizard, state.isOpen, setSelectedAgent, setDirectoryPath, setGeneratedDocuments]);
return (
<>
@@ -391,14 +395,14 @@ describe('Wizard Integration Tests', () => {
renderWithProviders(<TestWrapper />);
await waitFor(() => {
expect(screen.getByText('Step 1 of 4')).toBeInTheDocument();
expect(screen.getByText('Step 1 of 5')).toBeInTheDocument();
});
// Navigate to step 2
fireEvent.click(screen.getByTestId('go-step-2'));
await waitFor(() => {
expect(screen.getByText('Step 2 of 4')).toBeInTheDocument();
expect(screen.getByText('Step 2 of 5')).toBeInTheDocument();
expect(screen.getByText('Choose Project Directory')).toBeInTheDocument();
});
@@ -406,16 +410,16 @@ describe('Wizard Integration Tests', () => {
fireEvent.click(screen.getByTestId('go-step-3'));
await waitFor(() => {
expect(screen.getByText('Step 3 of 4')).toBeInTheDocument();
expect(screen.getByText('Step 3 of 5')).toBeInTheDocument();
expect(screen.getByText('Project Discovery')).toBeInTheDocument();
});
// Navigate to step 4
// Navigate to step 5 (phase-review is now step 5)
fireEvent.click(screen.getByTestId('go-step-4'));
await waitFor(() => {
expect(screen.getByText('Step 4 of 4')).toBeInTheDocument();
expect(screen.getByText('Review Your Action Plan')).toBeInTheDocument();
expect(screen.getByText('Step 5 of 5')).toBeInTheDocument();
expect(screen.getByText('Review Your Action Plans')).toBeInTheDocument();
});
});
@@ -443,13 +447,14 @@ describe('Wizard Integration Tests', () => {
await waitFor(() => {
const progressDots = screen.getAllByLabelText(/step \d+/i);
expect(progressDots).toHaveLength(4);
expect(progressDots).toHaveLength(5);
// Steps 1 and 2 should be completed, step 3 should be current
expect(progressDots[0]).toHaveAttribute('aria-label', 'Step 1 (completed - click to go back)');
expect(progressDots[1]).toHaveAttribute('aria-label', 'Step 2 (completed - click to go back)');
expect(progressDots[2]).toHaveAttribute('aria-label', 'Step 3 (current)');
expect(progressDots[3]).toHaveAttribute('aria-label', 'Step 4');
expect(progressDots[4]).toHaveAttribute('aria-label', 'Step 5');
});
});
});
@@ -728,7 +733,7 @@ describe('Wizard Integration Tests', () => {
// Should show saved state info
await waitFor(() => {
expect(screen.getByText('Resume Setup?')).toBeInTheDocument();
expect(screen.getByText('Step 3 of 4')).toBeInTheDocument();
expect(screen.getByText('Step 3 of 5')).toBeInTheDocument();
expect(screen.getByText('Test Project')).toBeInTheDocument();
expect(screen.getByText('/saved/project/path')).toBeInTheDocument();
// The modal shows "X messages exchanged (Y% confidence)"
@@ -1266,7 +1271,7 @@ describe('Wizard Integration Tests', () => {
renderWithProviders(<TestWrapper />);
await waitFor(() => {
expect(screen.getByText('Review Your Action Plan')).toBeInTheDocument();
expect(screen.getByText('Review Your Action Plans')).toBeInTheDocument();
});
});

View File

@@ -669,12 +669,12 @@ describe('Wizard Keyboard Navigation', () => {
renderWithProviders(<TestWrapper />);
await waitFor(() => {
expect(screen.getByText('Step 2 of 4')).toBeInTheDocument();
expect(screen.getByText('Step 2 of 5')).toBeInTheDocument();
});
// Should show 4 progress dots
// Should show 5 progress dots
const progressDots = screen.getAllByLabelText(/step \d+/i);
expect(progressDots).toHaveLength(4);
expect(progressDots).toHaveLength(5);
// Step 1 should be completed, step 2 should be current
expect(progressDots[0]).toHaveAttribute('aria-label', 'Step 1 (completed - click to go back)');

View File

@@ -2908,6 +2908,65 @@ function setupIpcHandlers() {
}
});
// Get all session timestamps for activity graph (lightweight, from cache or quick scan)
ipcMain.handle('claude:getSessionTimestamps', async (_event, projectPath: string) => {
try {
// First try to get from cache
const cache = await loadStatsCache(projectPath);
if (cache && Object.keys(cache.sessions).length > 0) {
// Return timestamps from cache
const timestamps = Object.values(cache.sessions)
.map(s => s.oldestTimestamp)
.filter((t): t is string => t !== null);
return { timestamps };
}
// Fall back to quick scan of session files (just read first line for timestamp)
const homeDir = os.homedir();
const claudeProjectsDir = path.join(homeDir, '.claude', 'projects');
const encodedPath = encodeClaudeProjectPath(projectPath);
const projectDir = path.join(claudeProjectsDir, encodedPath);
try {
await fs.access(projectDir);
} catch {
return { timestamps: [] };
}
const files = await fs.readdir(projectDir);
const sessionFiles = files.filter(f => f.endsWith('.jsonl'));
const timestamps: string[] = [];
await Promise.all(
sessionFiles.map(async (filename) => {
const filePath = path.join(projectDir, filename);
try {
// Read only first few KB to get the timestamp
const handle = await fs.open(filePath, 'r');
const buffer = Buffer.alloc(1024);
await handle.read(buffer, 0, 1024, 0);
await handle.close();
const firstLine = buffer.toString('utf-8').split('\n')[0];
if (firstLine) {
const entry = JSON.parse(firstLine);
if (entry.timestamp) {
timestamps.push(entry.timestamp);
}
}
} catch {
// Skip files that can't be read
}
})
);
return { timestamps };
} catch (error) {
logger.error('Error getting session timestamps', 'ClaudeSessions', error);
return { timestamps: [] };
}
});
// Get global stats across ALL Claude projects (uses cache for speed)
// Only recalculates stats for new or modified session files
ipcMain.handle('claude:getGlobalStats', async () => {

View File

@@ -412,6 +412,9 @@ contextBridge.exposeInMainWorld('maestro', {
// Get aggregate stats for all sessions in a project (streams progressive updates)
getProjectStats: (projectPath: string) =>
ipcRenderer.invoke('claude:getProjectStats', projectPath),
// Get all session timestamps for activity graph (lightweight)
getSessionTimestamps: (projectPath: string) =>
ipcRenderer.invoke('claude:getSessionTimestamps', projectPath) as Promise<{ timestamps: string[] }>,
onProjectStatsUpdate: (callback: (stats: {
projectPath: string;
totalSessions: number;

View File

@@ -19,7 +19,7 @@ import { MainPanel } from './components/MainPanel';
import { ProcessMonitor } from './components/ProcessMonitor';
import { GitDiffViewer } from './components/GitDiffViewer';
import { GitLogViewer } from './components/GitLogViewer';
import { BatchRunnerModal } from './components/BatchRunnerModal';
import { BatchRunnerModal, DEFAULT_BATCH_PROMPT } from './components/BatchRunnerModal';
import { TabSwitcherModal } from './components/TabSwitcherModal';
import { PromptComposerModal } from './components/PromptComposerModal';
import { ExecutionQueueBrowser } from './components/ExecutionQueueBrowser';
@@ -134,6 +134,7 @@ export default function MaestroConsole() {
clearResumeState,
completeWizard,
closeWizard: closeWizardModal,
goToStep: wizardGoToStep,
} = useWizard();
// --- SETTINGS (from useSettings hook) ---
@@ -4255,6 +4256,29 @@ export default function MaestroConsole() {
// Focus input
setActiveFocus('main');
setTimeout(() => inputRef.current?.focus(), 100);
// Auto-start the batch run with the first document that has tasks
// This is the core purpose of the onboarding wizard - get the user's first Auto Run going
const firstDocWithTasks = generatedDocuments.find(doc => doc.taskCount > 0);
if (firstDocWithTasks && autoRunFolderPath) {
// Create batch config for single document run
const batchConfig: BatchRunConfig = {
documents: [{
id: generateId(),
filename: firstDocWithTasks.filename.replace(/\.md$/, ''),
resetOnCompletion: false,
isDuplicate: false,
}],
prompt: DEFAULT_BATCH_PROMPT,
loopEnabled: false,
};
// Small delay to ensure session state is fully propagated before starting batch
setTimeout(() => {
console.log('[Wizard] Auto-starting batch run with first document:', firstDocWithTasks.filename);
startBatchRun(newId, batchConfig, autoRunFolderPath);
}, 500);
}
}, [
wizardState,
defaultSaveToHistory,
@@ -4266,6 +4290,7 @@ export default function MaestroConsole() {
setActiveRightTab,
setTourOpen,
setActiveFocus,
startBatchRun,
]);
const toggleInputMode = () => {
@@ -5641,9 +5666,79 @@ export default function MaestroConsole() {
// Send interrupt signal (Ctrl+C)
await window.maestro.process.interrupt(targetSessionId);
// Set state to idle with full cleanup
// Check if there are queued items to process after interrupt
const currentSession = sessionsRef.current.find(s => s.id === activeSession.id);
let queuedItemToProcess: { sessionId: string; item: QueuedItem } | null = null;
if (currentSession && currentSession.executionQueue.length > 0) {
queuedItemToProcess = {
sessionId: activeSession.id,
item: currentSession.executionQueue[0]
};
}
// Set state to idle with full cleanup, or process next queued item
setSessions(prev => prev.map(s => {
if (s.id !== activeSession.id) return s;
// If there are queued items, start processing the next one
if (s.executionQueue.length > 0) {
const [nextItem, ...remainingQueue] = s.executionQueue;
const targetTab = s.aiTabs.find(tab => tab.id === nextItem.tabId) || getActiveTab(s);
if (!targetTab) {
return {
...s,
state: 'busy' as SessionState,
busySource: 'ai',
executionQueue: remainingQueue,
thinkingStartTime: Date.now(),
currentCycleTokens: 0,
currentCycleBytes: 0
};
}
// Set the interrupted tab to idle, and the target tab for queued item to busy
let updatedAiTabs = s.aiTabs.map(tab => {
if (tab.id === targetTab.id) {
return { ...tab, state: 'busy' as const, thinkingStartTime: Date.now() };
}
// Set any other busy tabs to idle (they were interrupted)
if (tab.state === 'busy') {
return { ...tab, state: 'idle' as const, thinkingStartTime: undefined };
}
return tab;
});
// For message items, add a log entry to the target tab
if (nextItem.type === 'message' && nextItem.text) {
const logEntry: LogEntry = {
id: generateId(),
timestamp: Date.now(),
source: 'user',
text: nextItem.text,
images: nextItem.images
};
updatedAiTabs = updatedAiTabs.map(tab =>
tab.id === targetTab.id
? { ...tab, logs: [...tab.logs, logEntry] }
: tab
);
}
return {
...s,
state: 'busy' as SessionState,
busySource: 'ai',
aiTabs: updatedAiTabs,
executionQueue: remainingQueue,
thinkingStartTime: Date.now(),
currentCycleTokens: 0,
currentCycleBytes: 0
};
}
// No queued items, just go to idle
return {
...s,
state: 'idle',
@@ -5651,6 +5746,13 @@ export default function MaestroConsole() {
thinkingStartTime: undefined
};
}));
// Process the queued item after state update
if (queuedItemToProcess) {
setTimeout(() => {
processQueuedItem(queuedItemToProcess!.sessionId, queuedItemToProcess!.item);
}, 0);
}
} catch (error) {
console.error('Failed to interrupt process:', error);
@@ -5670,23 +5772,113 @@ export default function MaestroConsole() {
source: 'system',
text: 'Process forcefully terminated'
};
// Check if there are queued items to process after kill
const currentSessionForKill = sessionsRef.current.find(s => s.id === activeSession.id);
let queuedItemAfterKill: { sessionId: string; item: QueuedItem } | null = null;
if (currentSessionForKill && currentSessionForKill.executionQueue.length > 0) {
queuedItemAfterKill = {
sessionId: activeSession.id,
item: currentSessionForKill.executionQueue[0]
};
}
setSessions(prev => prev.map(s => {
if (s.id !== activeSession.id) return s;
// Add kill log to the appropriate place
let updatedSession = { ...s };
if (currentMode === 'ai') {
const tab = getActiveTab(s);
if (!tab) return { ...s, state: 'idle', busySource: undefined, thinkingStartTime: undefined };
if (tab) {
updatedSession.aiTabs = s.aiTabs.map(t =>
t.id === tab.id ? { ...t, logs: [...t.logs, killLog] } : t
);
}
} else {
updatedSession.shellLogs = [...s.shellLogs, killLog];
}
// If there are queued items, start processing the next one
if (s.executionQueue.length > 0) {
const [nextItem, ...remainingQueue] = s.executionQueue;
const targetTab = s.aiTabs.find(tab => tab.id === nextItem.tabId) || getActiveTab(s);
if (!targetTab) {
return {
...updatedSession,
state: 'busy' as SessionState,
busySource: 'ai',
executionQueue: remainingQueue,
thinkingStartTime: Date.now(),
currentCycleTokens: 0,
currentCycleBytes: 0
};
}
// Set tabs appropriately
let updatedAiTabs = updatedSession.aiTabs.map(tab => {
if (tab.id === targetTab.id) {
return { ...tab, state: 'busy' as const, thinkingStartTime: Date.now() };
}
if (tab.state === 'busy') {
return { ...tab, state: 'idle' as const, thinkingStartTime: undefined };
}
return tab;
});
// For message items, add a log entry to the target tab
if (nextItem.type === 'message' && nextItem.text) {
const logEntry: LogEntry = {
id: generateId(),
timestamp: Date.now(),
source: 'user',
text: nextItem.text,
images: nextItem.images
};
updatedAiTabs = updatedAiTabs.map(tab =>
tab.id === targetTab.id
? { ...tab, logs: [...tab.logs, logEntry] }
: tab
);
}
return {
...s,
...updatedSession,
state: 'busy' as SessionState,
busySource: 'ai',
aiTabs: updatedAiTabs,
executionQueue: remainingQueue,
thinkingStartTime: Date.now(),
currentCycleTokens: 0,
currentCycleBytes: 0
};
}
// No queued items, just go to idle
if (currentMode === 'ai') {
const tab = getActiveTab(s);
if (!tab) return { ...updatedSession, state: 'idle', busySource: undefined, thinkingStartTime: undefined };
return {
...updatedSession,
state: 'idle',
busySource: undefined,
thinkingStartTime: undefined,
aiTabs: s.aiTabs.map(t =>
t.id === tab.id ? { ...t, state: 'idle' as const, thinkingStartTime: undefined, logs: [...t.logs, killLog] } : t
aiTabs: updatedSession.aiTabs.map(t =>
t.id === tab.id ? { ...t, state: 'idle' as const, thinkingStartTime: undefined } : t
)
};
}
return { ...s, shellLogs: [...s.shellLogs, killLog], state: 'idle', busySource: undefined, thinkingStartTime: undefined };
return { ...updatedSession, state: 'idle', busySource: undefined, thinkingStartTime: undefined };
}));
// Process the queued item after state update
if (queuedItemAfterKill) {
setTimeout(() => {
processQueuedItem(queuedItemAfterKill!.sessionId, queuedItemAfterKill!.item);
}, 0);
}
} catch (killError) {
console.error('Failed to kill process:', killError);
const errorLog: LogEntry = {
@@ -6455,6 +6647,7 @@ export default function MaestroConsole() {
onToggleMarkdownRawMode={() => setMarkdownRawMode(!markdownRawMode)}
setUpdateCheckModalOpen={setUpdateCheckModalOpen}
openWizard={openWizardModal}
wizardGoToStep={wizardGoToStep}
/>
)}
{lightboxImage && (
@@ -7336,6 +7529,15 @@ export default function MaestroConsole() {
onSwitchSession={(sessionId) => {
setActiveSessionId(sessionId);
}}
onReorderItems={(sessionId, fromIndex, toIndex) => {
setSessions(prev => prev.map(s => {
if (s.id !== sessionId) return s;
const queue = [...s.executionQueue];
const [removed] = queue.splice(fromIndex, 1);
queue.splice(toIndex, 0, removed);
return { ...s, executionQueue: queue };
}));
}}
/>
{/* Old settings modal removed - using new SettingsModal component below */}

View File

@@ -731,22 +731,11 @@ export function AgentSessionsBrowser({
return date.toLocaleDateString();
};
// Stable activity entries for the graph - only updates when switching to graph view
// This prevents the graph from re-rendering during pagination
const [activityEntries, setActivityEntries] = useState<ActivityEntry[]>([]);
const prevShowSearchPanelRef = useRef(showSearchPanel);
useEffect(() => {
const switchingToGraph = prevShowSearchPanelRef.current && !showSearchPanel;
prevShowSearchPanelRef.current = showSearchPanel;
// Update entries when: switching TO graph view, OR initial load completes while graph is shown
if ((switchingToGraph || (!loading && activityEntries.length === 0)) && sessions.length > 0) {
setActivityEntries(sessions.map(s => ({
timestamp: s.modifiedAt,
})));
}
}, [showSearchPanel, loading, sessions, activityEntries.length]);
// Activity entries for the graph - derived from filteredSessions so filters apply
// Since we auto-load all sessions in background, this will eventually include all data
const activityEntries = useMemo<ActivityEntry[]>(() => {
return filteredSessions.map(s => ({ timestamp: s.modifiedAt }));
}, [filteredSessions]);
// Handle activity graph bar click - scroll to first session in that time range
const handleGraphBarClick = useCallback((bucketStart: number, bucketEnd: number) => {

View File

@@ -12,6 +12,18 @@ interface ExecutionQueueBrowserProps {
theme: Theme;
onRemoveItem: (sessionId: string, itemId: string) => void;
onSwitchSession: (sessionId: string) => void;
onReorderItems?: (sessionId: string, fromIndex: number, toIndex: number) => void;
}
interface DragState {
sessionId: string;
itemId: string;
fromIndex: number;
}
interface DropIndicator {
sessionId: string;
index: number;
}
/**
@@ -25,13 +37,49 @@ export function ExecutionQueueBrowser({
activeSessionId,
theme,
onRemoveItem,
onSwitchSession
onSwitchSession,
onReorderItems
}: ExecutionQueueBrowserProps) {
const [viewMode, setViewMode] = useState<'current' | 'global'>('current');
const [dragState, setDragState] = useState<DragState | null>(null);
const [dropIndicator, setDropIndicator] = useState<DropIndicator | null>(null);
const { registerLayer, unregisterLayer } = useLayerStack();
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// Drag handlers
const handleDragStart = (sessionId: string, itemId: string, index: number) => {
setDragState({ sessionId, itemId, fromIndex: index });
};
const handleDragOver = (sessionId: string, index: number) => {
// Allow dropping within the same session only (cross-session would require moving items)
if (dragState && dragState.sessionId === sessionId) {
setDropIndicator({ sessionId, index });
}
};
const handleDragEnd = () => {
if (dragState && dropIndicator && onReorderItems) {
const { sessionId, fromIndex } = dragState;
const toIndex = dropIndicator.index;
// Only reorder if indices differ
if (fromIndex !== toIndex && fromIndex !== toIndex - 1) {
// Adjust toIndex if dropping after the dragged item
const adjustedToIndex = toIndex > fromIndex ? toIndex - 1 : toIndex;
onReorderItems(sessionId, fromIndex, adjustedToIndex);
}
}
setDragState(null);
setDropIndicator(null);
};
const handleDragCancel = () => {
setDragState(null);
setDropIndicator(null);
};
// Register with layer stack for proper escape handling
useEffect(() => {
if (isOpen) {
@@ -182,20 +230,45 @@ export function ExecutionQueueBrowser({
)}
{/* Queue Items */}
<div className="space-y-1.5">
<div className="space-y-0">
{session.executionQueue?.map((item, index) => (
<QueueItemRow
key={item.id}
item={item}
index={index}
theme={theme}
onRemove={() => onRemoveItem(session.id, item.id)}
onSwitchToSession={() => {
onSwitchSession(session.id);
onClose();
}}
/>
<React.Fragment key={item.id}>
{/* Drop indicator before this item */}
<DropZone
theme={theme}
isActive={
dropIndicator?.sessionId === session.id &&
dropIndicator?.index === index
}
onDragOver={() => handleDragOver(session.id, index)}
/>
<QueueItemRow
item={item}
index={index}
theme={theme}
onRemove={() => onRemoveItem(session.id, item.id)}
onSwitchToSession={() => {
onSwitchSession(session.id);
onClose();
}}
isDragging={dragState?.itemId === item.id}
canDrag={!!onReorderItems && (session.executionQueue?.length || 0) > 1}
isAnyDragging={!!dragState}
onDragStart={() => handleDragStart(session.id, item.id, index)}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
/>
</React.Fragment>
))}
{/* Final drop zone after all items */}
<DropZone
theme={theme}
isActive={
dropIndicator?.sessionId === session.id &&
dropIndicator?.index === (session.executionQueue?.length || 0)
}
onDragOver={() => handleDragOver(session.id, session.executionQueue?.length || 0)}
/>
</div>
</div>
))
@@ -214,15 +287,62 @@ export function ExecutionQueueBrowser({
);
}
interface DropZoneProps {
theme: Theme;
isActive: boolean;
onDragOver: () => void;
}
function DropZone({ theme, isActive, onDragOver }: DropZoneProps) {
return (
<div
className="relative h-1 -my-0.5 z-10"
onMouseEnter={onDragOver}
>
<div
className="absolute inset-x-3 top-1/2 -translate-y-1/2 h-0.5 rounded-full transition-all duration-200"
style={{
backgroundColor: isActive ? theme.colors.accent : 'transparent',
boxShadow: isActive ? `0 0 8px ${theme.colors.accent}` : 'none',
transform: `translateY(-50%) scaleX(${isActive ? 1 : 0})`,
}}
/>
</div>
);
}
interface QueueItemRowProps {
item: QueuedItem;
index: number;
theme: Theme;
onRemove: () => void;
onSwitchToSession: () => void;
isDragging?: boolean;
canDrag?: boolean;
isAnyDragging?: boolean;
onDragStart?: () => void;
onDragEnd?: () => void;
onDragCancel?: () => void;
}
function QueueItemRow({ item, index, theme, onRemove, onSwitchToSession }: QueueItemRowProps) {
function QueueItemRow({
item,
index,
theme,
onRemove,
onSwitchToSession,
isDragging,
canDrag,
isAnyDragging,
onDragStart,
onDragEnd,
onDragCancel
}: QueueItemRowProps) {
const [isPressed, setIsPressed] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const pressTimerRef = useRef<NodeJS.Timeout | null>(null);
const isDraggingRef = useRef(false);
const isCommand = item.type === 'command';
const displayText = isCommand
? item.command
@@ -234,88 +354,249 @@ function QueueItemRow({ item, index, theme, onRemove, onSwitchToSession }: Queue
const minutes = Math.floor(timeSinceQueued / 60000);
const timeDisplay = minutes < 1 ? 'Just now' : `${minutes}m ago`;
// Handle mouse down for drag initiation
const handleMouseDown = (e: React.MouseEvent) => {
if (!canDrag || e.button !== 0) return;
// Don't start drag if clicking on buttons
if ((e.target as HTMLElement).closest('button')) return;
setIsPressed(true);
// Small delay before initiating drag to allow for click detection
pressTimerRef.current = setTimeout(() => {
isDraggingRef.current = true;
onDragStart?.();
}, 150);
};
const handleMouseUp = () => {
if (pressTimerRef.current) {
clearTimeout(pressTimerRef.current);
pressTimerRef.current = null;
}
if (isDraggingRef.current) {
onDragEnd?.();
isDraggingRef.current = false;
}
setIsPressed(false);
};
const handleMouseLeave = () => {
setIsHovered(false);
if (pressTimerRef.current) {
clearTimeout(pressTimerRef.current);
pressTimerRef.current = null;
}
// Don't cancel drag on leave - let mouse up handle it
if (!isDraggingRef.current) {
setIsPressed(false);
}
};
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (pressTimerRef.current) {
clearTimeout(pressTimerRef.current);
}
};
}, []);
// Handle escape key to cancel drag
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isDragging) {
onDragCancel?.();
isDraggingRef.current = false;
setIsPressed(false);
}
};
if (isDragging) {
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('mouseup', handleMouseUp);
};
}
}, [isDragging, onDragCancel]);
// Visual states
const showDragReady = canDrag && isHovered && !isDragging && !isAnyDragging;
const showGrabbed = isPressed || isDragging;
const isDimmed = isAnyDragging && !isDragging;
return (
<div
className="flex items-start gap-3 px-3 py-2.5 rounded-lg border group"
className="relative my-1"
style={{
backgroundColor: theme.colors.bgSidebar,
borderColor: theme.colors.border
zIndex: isDragging ? 50 : 1,
}}
>
{/* Position indicator */}
<span
className="text-xs font-mono mt-0.5 w-5 text-center"
style={{ color: theme.colors.textDim }}
<div
className="flex items-start gap-3 px-3 py-2.5 rounded-lg border group select-none"
style={{
backgroundColor: isDragging
? theme.colors.bgMain
: theme.colors.bgSidebar,
borderColor: isDragging
? theme.colors.accent
: showGrabbed
? theme.colors.accent + '80'
: theme.colors.border,
cursor: canDrag
? isDragging
? 'grabbing'
: 'grab'
: 'default',
transform: isDragging
? 'scale(1.02) rotate(1deg)'
: showGrabbed
? 'scale(1.01)'
: 'scale(1)',
boxShadow: isDragging
? `0 8px 32px ${theme.colors.accent}40, 0 4px 16px rgba(0,0,0,0.3)`
: showGrabbed
? `0 4px 16px ${theme.colors.accent}20`
: 'none',
transition: isDragging ? 'none' : 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
opacity: isDragging ? 0.95 : isDimmed ? 0.5 : 1,
}}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={handleMouseLeave}
>
#{index + 1}
</span>
{/* Type icon */}
<div className="mt-0.5">
{isCommand ? (
<Command className="w-4 h-4" style={{ color: theme.colors.warning }} />
) : (
<MessageSquare className="w-4 h-4" style={{ color: theme.colors.accent }} />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{item.tabName && (
<button
onClick={onSwitchToSession}
className="text-xs px-1.5 py-0.5 rounded font-mono hover:opacity-80 transition-opacity cursor-pointer"
style={{
backgroundColor: theme.colors.accent + '25',
color: theme.colors.textMain
}}
title="Jump to this session"
>
{item.tabName}
</button>
)}
<span
className="text-xs flex items-center gap-1"
style={{ color: theme.colors.textDim }}
{/* Drag handle indicator */}
{canDrag && (
<div
className="absolute left-1 top-1/2 -translate-y-1/2 flex flex-col gap-0.5 transition-opacity duration-200"
style={{
opacity: showDragReady || showGrabbed ? 0.6 : 0,
}}
>
<Clock className="w-3 h-3" />
{timeDisplay}
</span>
</div>
<div
className={`mt-1 text-sm ${isCommand ? 'font-mono' : ''}`}
style={{ color: theme.colors.textMain }}
<div className="flex gap-0.5">
<div className="w-1 h-1 rounded-full" style={{ backgroundColor: theme.colors.textDim }} />
<div className="w-1 h-1 rounded-full" style={{ backgroundColor: theme.colors.textDim }} />
</div>
<div className="flex gap-0.5">
<div className="w-1 h-1 rounded-full" style={{ backgroundColor: theme.colors.textDim }} />
<div className="w-1 h-1 rounded-full" style={{ backgroundColor: theme.colors.textDim }} />
</div>
<div className="flex gap-0.5">
<div className="w-1 h-1 rounded-full" style={{ backgroundColor: theme.colors.textDim }} />
<div className="w-1 h-1 rounded-full" style={{ backgroundColor: theme.colors.textDim }} />
</div>
</div>
)}
{/* Position indicator */}
<span
className="text-xs font-mono mt-0.5 w-5 text-center transition-all duration-200"
style={{
color: theme.colors.textDim,
transform: showGrabbed ? 'scale(1.1)' : 'scale(1)',
fontWeight: showGrabbed ? 600 : 400,
}}
>
{displayText}
#{index + 1}
</span>
{/* Type icon */}
<div
className="mt-0.5 transition-transform duration-200"
style={{
transform: showGrabbed ? 'scale(1.1)' : 'scale(1)',
}}
>
{isCommand ? (
<Command className="w-4 h-4" style={{ color: theme.colors.warning }} />
) : (
<MessageSquare className="w-4 h-4" style={{ color: theme.colors.accent }} />
)}
</div>
{isCommand && item.commandDescription && (
<div
className="text-xs mt-0.5"
style={{ color: theme.colors.textDim }}
>
{item.commandDescription}
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{item.tabName && (
<button
onClick={(e) => {
e.stopPropagation();
onSwitchToSession();
}}
className="text-xs px-1.5 py-0.5 rounded font-mono hover:opacity-80 transition-opacity cursor-pointer"
style={{
backgroundColor: theme.colors.accent + '25',
color: theme.colors.textMain
}}
title="Jump to this session"
>
{item.tabName}
</button>
)}
<span
className="text-xs flex items-center gap-1"
style={{ color: theme.colors.textDim }}
>
<Clock className="w-3 h-3" />
{timeDisplay}
</span>
</div>
)}
{item.images && item.images.length > 0 && (
<div
className="text-xs mt-1 flex items-center gap-1"
style={{ color: theme.colors.textDim }}
className={`mt-1 text-sm ${isCommand ? 'font-mono' : ''}`}
style={{ color: theme.colors.textMain }}
>
+ {item.images.length} image{item.images.length > 1 ? 's' : ''}
{displayText}
</div>
)}
{isCommand && item.commandDescription && (
<div
className="text-xs mt-0.5"
style={{ color: theme.colors.textDim }}
>
{item.commandDescription}
</div>
)}
{item.images && item.images.length > 0 && (
<div
className="text-xs mt-1 flex items-center gap-1"
style={{ color: theme.colors.textDim }}
>
+ {item.images.length} image{item.images.length > 1 ? 's' : ''}
</div>
)}
</div>
{/* Remove button */}
<button
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="p-1.5 rounded opacity-0 group-hover:opacity-100 hover:bg-red-500/20 transition-all"
style={{ color: theme.colors.error }}
title="Remove from queue"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Remove button */}
<button
onClick={onRemove}
className="p-1.5 rounded opacity-0 group-hover:opacity-100 hover:bg-red-500/20 transition-all"
style={{ color: theme.colors.error }}
title="Remove from queue"
>
<Trash2 className="w-4 h-4" />
</button>
{/* Shimmer effect when grabbed */}
{showGrabbed && (
<div
className="absolute inset-0 rounded-lg pointer-events-none overflow-hidden"
style={{
background: `linear-gradient(90deg, transparent, ${theme.colors.accent}10, transparent)`,
animation: 'shimmer 1.5s infinite',
}}
/>
)}
</div>
);
}

View File

@@ -620,7 +620,7 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
ref={isSelected ? selectedNodeRef as React.RefObject<HTMLDivElement> : null}
key={node.id}
tabIndex={0}
className="px-4 py-2 flex items-center gap-2 cursor-default group"
className="px-4 py-1.5 cursor-default group"
style={{
paddingLeft: `${paddingLeft}px`,
color: theme.colors.textMain,
@@ -632,75 +632,81 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = `${theme.colors.accent}15`; }}
onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<div className="w-4 h-4 flex-shrink-0" />
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: theme.colors.success }}
/>
<span className="text-sm flex-1 truncate">{node.label}</span>
{node.isAutoRun && (
{/* First line: status dot, label, AUTO badge, kill button */}
<div className="flex items-center gap-2">
<div className="w-4 h-4 flex-shrink-0" />
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: theme.colors.success }}
/>
<span className="text-sm flex-1 truncate">{node.label}</span>
{node.isAutoRun && (
<span
className="text-xs font-semibold px-1.5 py-0.5 rounded flex-shrink-0"
style={{
backgroundColor: theme.colors.accent + '20',
color: theme.colors.accent
}}
>
AUTO
</span>
)}
{/* Kill button */}
{node.processSessionId && (
<button
className="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-opacity-20 transition-opacity"
style={{ color: theme.colors.error }}
onClick={(e) => {
e.stopPropagation();
setKillConfirmProcessId(node.processSessionId!);
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = `${theme.colors.error}20`}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
title="Kill process"
>
<XCircle className="w-4 h-4" />
</button>
)}
</div>
{/* Second line: Claude session ID, PID, runtime, status - indented */}
<div className="flex items-center gap-3 mt-1" style={{ paddingLeft: '24px' }}>
{node.claudeSessionId && node.sessionId && onNavigateToSession && (
<button
className="text-xs font-mono hover:underline cursor-pointer"
style={{ color: theme.colors.accent }}
onClick={(e) => {
e.stopPropagation();
onNavigateToSession(node.sessionId!, node.tabId);
onClose();
}}
title="Click to navigate to this session"
>
{node.claudeSessionId.substring(0, 8)}...
</button>
)}
{node.claudeSessionId && (!node.sessionId || !onNavigateToSession) && (
<span className="text-xs font-mono" style={{ color: theme.colors.accent }}>
{node.claudeSessionId.substring(0, 8)}...
</span>
)}
<span className="text-xs font-mono" style={{ color: theme.colors.textDim }}>
PID: {node.pid}
</span>
{node.startTime && (
<span className="text-xs font-mono" style={{ color: theme.colors.textDim }}>
{formatRuntime(node.startTime)}
</span>
)}
<span
className="text-xs font-semibold px-1.5 py-0.5 rounded flex-shrink-0"
className="text-xs px-2 py-0.5 rounded"
style={{
backgroundColor: theme.colors.accent + '20',
color: theme.colors.accent
backgroundColor: `${theme.colors.success}20`,
color: theme.colors.success
}}
>
AUTO
Running
</span>
)}
{node.claudeSessionId && node.sessionId && onNavigateToSession && (
<button
className="text-xs font-mono flex-shrink-0 hover:underline cursor-pointer"
style={{ color: theme.colors.accent }}
onClick={(e) => {
e.stopPropagation();
onNavigateToSession(node.sessionId!, node.tabId);
onClose();
}}
title="Click to navigate to this session"
>
{node.claudeSessionId.substring(0, 8)}...
</button>
)}
{node.claudeSessionId && (!node.sessionId || !onNavigateToSession) && (
<span className="text-xs font-mono flex-shrink-0" style={{ color: theme.colors.accent }}>
{node.claudeSessionId.substring(0, 8)}...
</span>
)}
<span className="text-xs font-mono flex-shrink-0" style={{ color: theme.colors.textDim }}>
PID: {node.pid}
</span>
{node.startTime && (
<span className="text-xs font-mono flex-shrink-0" style={{ color: theme.colors.textDim }}>
{formatRuntime(node.startTime)}
</span>
)}
<span
className="text-xs px-2 py-0.5 rounded"
style={{
backgroundColor: `${theme.colors.success}20`,
color: theme.colors.success
}}
>
Running
</span>
{/* Kill button */}
{node.processSessionId && (
<button
className="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-opacity-20 transition-opacity"
style={{ color: theme.colors.error }}
onClick={(e) => {
e.stopPropagation();
setKillConfirmProcessId(node.processSessionId!);
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = `${theme.colors.error}20`}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
title="Kill process"
>
<XCircle className="w-4 h-4" />
</button>
)}
</div>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
import { gitService } from '../services/git';
import { formatShortcutKeys } from '../utils/shortcutFormatter';
import type { WizardStep } from './Wizard/WizardContext';
interface QuickAction {
id: string;
@@ -62,6 +63,7 @@ interface QuickActionsModalProps {
onToggleMarkdownRawMode?: () => void;
setUpdateCheckModalOpen?: (open: boolean) => void;
openWizard?: () => void;
wizardGoToStep?: (step: WizardStep) => void;
}
export function QuickActionsModal(props: QuickActionsModalProps) {
@@ -76,7 +78,7 @@ export function QuickActionsModal(props: QuickActionsModalProps) {
setShortcutsHelpOpen, setAboutModalOpen, setLogViewerOpen, setProcessMonitorOpen,
setAgentSessionsOpen, setActiveClaudeSessionId, setGitDiffPreview, setGitLogOpen,
onRenameTab, onToggleReadOnlyMode, onOpenTabSwitcher, tabShortcuts, isAiMode, setPlaygroundOpen, onRefreshGitFileState,
onDebugReleaseQueuedItem, markdownRawMode, onToggleMarkdownRawMode, setUpdateCheckModalOpen, openWizard
onDebugReleaseQueuedItem, markdownRawMode, onToggleMarkdownRawMode, setUpdateCheckModalOpen, openWizard, wizardGoToStep
} = props;
const [search, setSearch] = useState('');
@@ -319,6 +321,17 @@ export function QuickActionsModal(props: QuickActionsModalProps) {
setQuickActionOpen(false);
}
}] : []),
...(openWizard && wizardGoToStep ? [{
id: 'debugWizardPhaseReview',
label: 'Debug: Wizard → Review Action Plans',
subtext: 'Jump directly to Phase Review step (requires existing Auto Run docs)',
action: () => {
openWizard();
// Small delay to ensure wizard is open before navigating
setTimeout(() => wizardGoToStep('phase-review'), 50);
setQuickActionOpen(false);
}
}] : []),
];
const groupActions: QuickAction[] = [

View File

@@ -213,14 +213,6 @@ export const RightPanel = forwardRef<RightPanelHandle, RightPanelProps>(function
{/* Tab Header */}
<div className="flex border-b h-16" style={{ borderColor: theme.colors.border }}>
<button
onClick={() => setRightPanelOpen(!rightPanelOpen)}
className="flex items-center justify-center p-2 rounded hover:bg-white/5 transition-colors w-12 shrink-0"
title={`${rightPanelOpen ? "Collapse" : "Expand"} Right Panel (${formatShortcutKeys(shortcuts.toggleRightPanel.keys)})`}
>
{rightPanelOpen ? <PanelRightClose className="w-4 h-4 opacity-50" /> : <PanelRightOpen className="w-4 h-4 opacity-50" />}
</button>
{['files', 'history', 'autorun'].map(tab => (
<button
key={tab}
@@ -235,6 +227,14 @@ export const RightPanel = forwardRef<RightPanelHandle, RightPanelProps>(function
{tab === 'autorun' ? 'Auto Run' : tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
<button
onClick={() => setRightPanelOpen(!rightPanelOpen)}
className="flex items-center justify-center p-2 rounded hover:bg-white/5 transition-colors w-12 shrink-0"
title={`${rightPanelOpen ? "Collapse" : "Expand"} Right Panel (${formatShortcutKeys(shortcuts.toggleRightPanel.keys)})`}
>
{rightPanelOpen ? <PanelRightClose className="w-4 h-4 opacity-50" /> : <PanelRightOpen className="w-4 h-4 opacity-50" />}
</button>
</div>
{/* Tab Content */}

View File

@@ -25,6 +25,7 @@ import {
AgentSelectionScreen,
DirectorySelectionScreen,
ConversationScreen,
PreparingPlanScreen,
PhaseReviewScreen,
} from './screens';
@@ -58,8 +59,10 @@ function getStepTitle(step: WizardStep): string {
return 'Choose Project Directory';
case 'conversation':
return 'Project Discovery';
case 'preparing-plan':
return 'Preparing Action Plans';
case 'phase-review':
return 'Review Your Action Plan';
return 'Review Your Action Plans';
default:
return 'Setup Wizard';
}
@@ -99,8 +102,6 @@ export function MaestroWizard({
const wizardStartTimeRef = useRef<number>(0);
// Track if wizard start has been recorded for this open session
const wizardStartedRef = useRef(false);
// Track if we resumed directly into phase-review (needs special handling)
const [resumedAtPhaseReview, setResumedAtPhaseReview] = useState(false);
// State for screen transition animations
// displayedStep is the step actually being rendered (lags behind currentStep during transitions)
@@ -223,14 +224,10 @@ export function MaestroWizard({
// Determine if this is a fresh start or resume based on current step
// If we're on step 1, it's a fresh start. Otherwise, it's a resume.
if (getCurrentStepNumber() === 1) {
setResumedAtPhaseReview(false);
if (onWizardStart) {
onWizardStart();
}
} else {
// Track if we resumed directly into phase-review
// This needs special handling to re-run the agent with resume context
setResumedAtPhaseReview(state.currentStep === 'phase-review');
if (onWizardResume) {
onWizardResume();
}
@@ -238,9 +235,8 @@ export function MaestroWizard({
} else if (!state.isOpen) {
// Reset when wizard closes
wizardStartedRef.current = false;
setResumedAtPhaseReview(false);
}
}, [state.isOpen, state.currentStep, getCurrentStepNumber, onWizardStart, onWizardResume]);
}, [state.isOpen, getCurrentStepNumber, onWizardStart, onWizardResume]);
// Announce step changes to screen readers
useEffect(() => {
@@ -283,6 +279,8 @@ export function MaestroWizard({
return <DirectorySelectionScreen theme={theme} />;
case 'conversation':
return <ConversationScreen theme={theme} />;
case 'preparing-plan':
return <PreparingPlanScreen theme={theme} />;
case 'phase-review':
return (
<PhaseReviewScreen
@@ -290,13 +288,12 @@ export function MaestroWizard({
onLaunchSession={onLaunchSession || (async () => {})}
onWizardComplete={onWizardComplete}
wizardStartTime={wizardStartTimeRef.current}
isResuming={resumedAtPhaseReview}
/>
);
default:
return null;
}
}, [displayedStep, theme, onLaunchSession, onWizardComplete, resumedAtPhaseReview]);
}, [displayedStep, theme, onLaunchSession, onWizardComplete]);
// Don't render if wizard is not open
if (!state.isOpen) {

View File

@@ -25,12 +25,13 @@ export type WizardStep =
| 'agent-selection'
| 'directory-selection'
| 'conversation'
| 'preparing-plan'
| 'phase-review';
/**
* Total number of steps in the wizard
*/
export const WIZARD_TOTAL_STEPS = 4;
export const WIZARD_TOTAL_STEPS = 5;
/**
* Map step names to their numeric index (1-based for display)
@@ -39,7 +40,8 @@ export const STEP_INDEX: Record<WizardStep, number> = {
'agent-selection': 1,
'directory-selection': 2,
'conversation': 3,
'phase-review': 4,
'preparing-plan': 4,
'phase-review': 5,
};
/**
@@ -49,7 +51,8 @@ export const INDEX_TO_STEP: Record<number, WizardStep> = {
1: 'agent-selection',
2: 'directory-selection',
3: 'conversation',
4: 'phase-review',
4: 'preparing-plan',
5: 'phase-review',
};
/**

View File

@@ -11,7 +11,7 @@ import type { Theme, AgentConfig } from '../../types';
import { useLayerStack } from '../../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../../constants/modalPriorities';
import type { SerializableWizardState, WizardStep } from './WizardContext';
import { STEP_INDEX } from './WizardContext';
import { STEP_INDEX, WIZARD_TOTAL_STEPS } from './WizardContext';
interface WizardResumeModalProps {
theme: Theme;
@@ -32,6 +32,8 @@ function getStepDescription(step: WizardStep): string {
return 'Directory Selection';
case 'conversation':
return 'Project Discovery';
case 'preparing-plan':
return 'Preparing Action Plans';
case 'phase-review':
return 'Phase Review';
default:
@@ -43,7 +45,7 @@ function getStepDescription(step: WizardStep): string {
* Get progress percentage based on step
*/
function getProgressPercentage(step: WizardStep): number {
return ((STEP_INDEX[step] - 1) / 3) * 100;
return ((STEP_INDEX[step] - 1) / (WIZARD_TOTAL_STEPS - 1)) * 100;
}
export function WizardResumeModal({
@@ -212,7 +214,7 @@ export function WizardResumeModal({
Progress
</span>
<span className="text-xs font-medium" style={{ color: theme.colors.accent }}>
Step {STEP_INDEX[resumeState.currentStep]} of 4
Step {STEP_INDEX[resumeState.currentStep]} of {WIZARD_TOTAL_STEPS}
</span>
</div>
<div

View File

@@ -510,7 +510,7 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
return (
<div
ref={containerRef}
className="flex flex-col flex-1 min-h-0 px-8 py-6 overflow-y-auto"
className="flex flex-col flex-1 min-h-0 px-8 py-6 overflow-y-auto justify-between"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
@@ -527,7 +527,7 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
className="text-2xl font-semibold mb-2"
style={{ color: theme.colors.textMain }}
>
Choose Your AI Assistant
Choose Your Provider
</h3>
<p
className="text-sm"
@@ -537,9 +537,6 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
</p>
</div>
{/* Spacer */}
<div className="h-8" />
{/* Section 2: Agent Grid */}
<div className="flex justify-center">
<div className="grid grid-cols-3 gap-4 max-w-3xl">
@@ -658,14 +655,11 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
</div>
</div>
{/* Spacer */}
<div className="h-10" />
{/* Section 3: Name Your Agent - Prominent */}
<div className="flex flex-col items-center">
<label
htmlFor="project-name"
className="text-lg font-medium mb-3"
className="text-2xl font-semibold mb-4"
style={{ color: theme.colors.textMain }}
>
Name Your Agent
@@ -704,9 +698,6 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
</div>
</div>
{/* Flexible spacer to push footer down */}
<div className="flex-1 min-h-8" />
{/* Section 4: Keyboard hints (footer) */}
<div className="flex justify-center gap-6">
<span

View File

@@ -358,6 +358,8 @@ export function ConversationScreen({ theme }: ConversationScreenProps): JSX.Elem
const [showInitialQuestion, setShowInitialQuestion] = useState(
state.conversationHistory.length === 0
);
// Store initial question once to prevent it changing on re-renders
const [initialQuestion] = useState(() => getInitialQuestion());
const [errorRetryCount, setErrorRetryCount] = useState(0);
const [streamingText, setStreamingText] = useState('');
const [fillerPhrase, setFillerPhrase] = useState('');
@@ -536,7 +538,7 @@ export function ConversationScreen({ theme }: ConversationScreenProps): JSX.Elem
initialQuestionAddedRef.current = true;
addMessage({
role: 'assistant',
content: getInitialQuestion(),
content: initialQuestion,
});
// Hide the direct JSX render immediately - the message is now in history
setShowInitialQuestion(false);
@@ -780,7 +782,7 @@ export function ConversationScreen({ theme }: ConversationScreenProps): JSX.Elem
{formatAgentName(state.agentName || '')}
</div>
<div className="text-sm" style={{ color: theme.colors.textMain }}>
{getInitialQuestion()}
{initialQuestion}
</div>
</div>
</div>
@@ -866,7 +868,7 @@ export function ConversationScreen({ theme }: ConversationScreenProps): JSX.Elem
className="px-6 py-2.5 rounded-lg text-sm font-bold transition-all hover:scale-105"
style={{
backgroundColor: theme.colors.success,
color: 'white',
color: theme.colors.bgMain,
boxShadow: `0 4px 12px ${theme.colors.success}40`,
}}
>
@@ -929,7 +931,7 @@ export function ConversationScreen({ theme }: ConversationScreenProps): JSX.Elem
<button
onClick={handleSendMessage}
disabled={!inputValue.trim() || state.isConversationLoading}
className="px-4 py-2.5 rounded-lg font-medium transition-all flex items-center gap-2"
className="px-4 py-2.5 rounded-lg font-medium transition-all flex items-center gap-2 shrink-0"
style={{
backgroundColor:
inputValue.trim() && !state.isConversationLoading

View File

@@ -232,14 +232,29 @@ export function DirectorySelectionScreen({ theme }: DirectorySelectionScreenProp
/**
* Attempt to proceed to next step
* Note: We no longer show a modal for existing docs - the agent will discover
* them during phase generation and make intelligent decisions about whether
* to continue, modify, or replace them.
* Blocks if Auto Run Docs folder exists and is not empty
*/
const attemptNextStep = useCallback(() => {
const attemptNextStep = useCallback(async () => {
if (!canProceedToNext()) return;
// Check if Auto Run Docs folder exists and has files
try {
const autoRunPath = `${state.directoryPath}/${AUTO_RUN_FOLDER_NAME}`;
const docs = await window.maestro.autorun.listDocuments(autoRunPath);
if (docs && docs.length > 0) {
setDirectoryError(
`This project already has ${docs.length} Auto Run document${docs.length > 1 ? 's' : ''}. ` +
`Please manually delete the "${AUTO_RUN_FOLDER_NAME}" folder if you want to start fresh.`
);
return;
}
} catch {
// Folder doesn't exist or can't be read - that's fine, proceed
}
nextStep();
}, [canProceedToNext, nextStep]);
}, [canProceedToNext, nextStep, state.directoryPath, setDirectoryError]);
/**
* Handle keyboard navigation

File diff suppressed because it is too large Load Diff

View File

@@ -7,4 +7,5 @@
export { AgentSelectionScreen } from './AgentSelectionScreen';
export { DirectorySelectionScreen } from './DirectorySelectionScreen';
export { ConversationScreen } from './ConversationScreen';
export { PreparingPlanScreen } from './PreparingPlanScreen';
export { PhaseReviewScreen } from './PhaseReviewScreen';

View File

@@ -21,8 +21,6 @@ export interface GenerationConfig {
projectName: string;
/** Full conversation history from project discovery */
conversationHistory: WizardMessage[];
/** Whether this is a resumed session (interrupted during document generation) */
isResuming?: boolean;
}
/**
@@ -49,6 +47,59 @@ export interface CreatedFileInfo {
size: number;
path: string;
timestamp: number;
/** Brief description extracted from file content (first paragraph after title) */
description?: string;
/** Number of tasks (unchecked checkboxes) in the document */
taskCount?: number;
}
/**
* Extract a brief description from markdown content
* Looks for the first paragraph after the title heading
*/
function extractDescription(content: string): string | undefined {
// Split into lines and find content after the first heading
const lines = content.split('\n');
let foundHeading = false;
let descriptionLines: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines before we find the heading
if (!foundHeading) {
if (trimmed.startsWith('# ')) {
foundHeading = true;
}
continue;
}
// Skip empty lines after heading
if (trimmed === '' && descriptionLines.length === 0) {
continue;
}
// Stop at next heading or task section
if (trimmed.startsWith('#') || trimmed.startsWith('- [')) {
break;
}
// Collect description lines (stop at empty line if we have content)
if (trimmed === '' && descriptionLines.length > 0) {
break;
}
descriptionLines.push(trimmed);
}
const description = descriptionLines.join(' ').trim();
// Truncate if too long
if (description.length > 150) {
return description.substring(0, 147) + '...';
}
return description || undefined;
}
/**
@@ -117,7 +168,6 @@ const GENERATION_TIMEOUT = 300000;
* Generate the system prompt for document generation
*
* This prompt instructs the agent to:
* - First check for and intelligently handle any existing Auto Run documents
* - Create multiple Auto Run documents
* - Make Phase 1 achievable without user input
* - Make Phase 1 deliver a working prototype
@@ -139,21 +189,6 @@ export function generateDocumentGenerationPrompt(config: GenerationConfig): stri
return `You are an expert project planner creating actionable task documents for "${projectDisplay}".
## FIRST: Check for Existing Documents
Before creating any new documents, check \`${directoryPath}/${AUTO_RUN_FOLDER_NAME}/\` for existing Phase-XX-*.md files.
If existing documents are found:
1. **Read and analyze them** to understand what planning has already been done
2. **Assess their quality and completeness** - are they well-formed? Do they follow the format below?
3. **Decide how to proceed**:
- If they are high-quality and complete, you can keep them as-is or refine them based on the conversation
- If they are incomplete or low-quality, improve or replace them
- If the conversation reveals the user wants something different, replace them entirely
- Add any missing phases that the existing docs don't cover
You have full autonomy to modify, delete, or keep existing documents based on your assessment and the project requirements from the conversation.
## Your Task
Based on the project discovery conversation below, create a series of Auto Run documents that will guide an AI coding assistant through building this project step by step.
@@ -688,22 +723,51 @@ class PhaseGenerator {
resetTimeout();
callbacks?.onActivity?.();
// If a .md file was created/changed, notify about it
if (data.filename?.endsWith('.md') && (data.eventType === 'rename' || data.eventType === 'change')) {
// Read the file to get its size for display
const fullPath = `${autoRunPath}/${data.filename}`;
window.maestro.fs.readFile(fullPath).then((content) => {
if (content && typeof content === 'string') {
callbacks?.onFileCreated?.({
filename: data.filename,
size: new Blob([content]).size,
path: fullPath,
timestamp: Date.now(),
});
// If a file was created/changed, notify about it
// Note: Main process already filters for .md files but strips the extension
// when sending the event, so we check for any filename here
if (data.filename && (data.eventType === 'rename' || data.eventType === 'change')) {
// Re-add the .md extension since main process strips it
const filenameWithExt = data.filename.endsWith('.md') ? data.filename : `${data.filename}.md`;
const fullPath = `${autoRunPath}/${filenameWithExt}`;
// Use retry logic since file might still be being written
const readWithRetry = async (retries = 3, delayMs = 200): Promise<void> => {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const content = await window.maestro.fs.readFile(fullPath);
if (content && typeof content === 'string' && content.length > 0) {
console.log('[PhaseGenerator] File read successful:', filenameWithExt, 'size:', content.length);
callbacks?.onFileCreated?.({
filename: filenameWithExt,
size: new Blob([content]).size,
path: fullPath,
timestamp: Date.now(),
description: extractDescription(content),
taskCount: countTasks(content),
});
return;
}
} catch (err) {
console.log(`[PhaseGenerator] File read attempt ${attempt}/${retries} failed for ${filenameWithExt}:`, err);
}
if (attempt < retries) {
await new Promise(r => setTimeout(r, delayMs));
}
}
}).catch(() => {
// File might still be being written, ignore errors
});
// Even if we couldn't read content, still notify that file exists
// This provides feedback to user that files are being created
console.log('[PhaseGenerator] Notifying file creation (without size):', filenameWithExt);
callbacks?.onFileCreated?.({
filename: filenameWithExt,
size: 0, // Unknown size
path: fullPath,
timestamp: Date.now(),
});
};
readWithRetry();
}
}
});
@@ -856,6 +920,8 @@ class PhaseGenerator {
size: new Blob([doc.content]).size,
path: fullPath,
timestamp: Date.now(),
description: extractDescription(doc.content),
taskCount: countTasks(doc.content),
});
}

View File

@@ -320,6 +320,7 @@ interface MaestroAPI {
lastActivityAt?: number;
}>>;
deleteMessagePair: (projectPath: string, sessionId: string, userMessageUuid: string, fallbackContent?: string) => Promise<{ success: boolean; linesRemoved?: number; error?: string }>;
getSessionTimestamps: (projectPath: string) => Promise<{ timestamps: string[] }>;
};
tempfile: {
write: (content: string, filename?: string) => Promise<{ success: boolean; path?: string; error?: string }>;

View File

@@ -81,6 +81,16 @@ body {
animation: fade-in 0.2s ease-in;
}
/* Shimmer effect for drag-and-drop */
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
/* Restore list styling for markdown preview prose containers */
.prose ul {
list-style-type: disc !important;