mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
# 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:
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -7,4 +7,5 @@
|
||||
export { AgentSelectionScreen } from './AgentSelectionScreen';
|
||||
export { DirectorySelectionScreen } from './DirectorySelectionScreen';
|
||||
export { ConversationScreen } from './ConversationScreen';
|
||||
export { PreparingPlanScreen } from './PreparingPlanScreen';
|
||||
export { PhaseReviewScreen } from './PhaseReviewScreen';
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
1
src/renderer/global.d.ts
vendored
1
src/renderer/global.d.ts
vendored
@@ -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 }>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user