mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
- Added rich Symphony History view with totals header and per-card metrics 🧾
- Launched Symphony Stats dashboard with summary cards and achievement milestones 🏆 - Embedded new Symphony screenshots to showcase History and Stats tabs 🖼️ - Added “Available Issues” card linking Maestro-ready GitHub issue list 🔗 - Introduced “Confirm and Erase” agent deletion flow that trashes working directory 🗑️ - Added new Delete Agent confirmation modal with strong accessibility and focus behavior ♿ - Implemented secure `shell:trashItem` IPC handler with validation and existence checks 🛡️ - Exposed `maestro.shell.trashItem()` in preload and renderer typings for use 🧩 - Wizard document prompts now respect configurable Auto Run folder paths 📁 - Prompt templates now forbid extra summary/recap files—only Phase docs allowed 🚫
This commit is contained in:
BIN
docs/screenshots/symphony-history.png
Normal file
BIN
docs/screenshots/symphony-history.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
BIN
docs/screenshots/symphony-stats.png
Normal file
BIN
docs/screenshots/symphony-stats.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
@@ -92,20 +92,40 @@ Each active contribution shows:
|
||||
|
||||
### History Tab
|
||||
|
||||
Review completed contributions:
|
||||
- PR links with merge status (Open or Merged)
|
||||
- Completion date
|
||||
- Documents processed count
|
||||
- Total cost for the contribution
|
||||
- Summary statistics at the top (total PRs, merged count, tokens, cost)
|
||||
Review completed contributions with aggregate stats at a glance:
|
||||
|
||||

|
||||
|
||||
The header shows your totals: PRs created, merged count, tasks completed, tokens used, and dollar value donated. Each contribution card displays:
|
||||
- **Issue and repository** — What you worked on
|
||||
- **Merge status** — Whether the PR was merged
|
||||
- **Completion date** — When you finished
|
||||
- **PR link** — Direct link to the pull request
|
||||
- **Detailed metrics** — Documents processed, tasks completed, tokens used, and cost
|
||||
|
||||
### Stats Tab
|
||||
|
||||
Track your overall impact:
|
||||
- **Overview metrics**: Total contributions, merged PRs, tokens donated, estimated cost
|
||||
- **Repositories contributed to**: Unique project count
|
||||
- **Streak tracking**: Current and longest contribution streaks
|
||||
- **Achievement badges**: Unlock achievements like "First Contribution", "Week Warrior", "Token Titan"
|
||||
Track your overall impact and unlock achievements:
|
||||
|
||||

|
||||
|
||||
**Summary cards** show your cumulative contributions:
|
||||
- **Tokens Donated** — Total tokens contributed with dollar value
|
||||
- **Time Contributed** — Hours spent and repositories helped
|
||||
- **Streak** — Current and best contribution streaks
|
||||
|
||||
**Achievements** reward milestones in your Symphony journey:
|
||||
|
||||
| Achievement | Requirement |
|
||||
|-------------|-------------|
|
||||
| **First Steps** | Complete your first Symphony contribution |
|
||||
| **Merged Melody** | Have a contribution merged |
|
||||
| **Weekly Rhythm** | Maintain a 7-day contribution streak |
|
||||
| **Harmony Seeker** | Complete 10 contributions |
|
||||
| **Ensemble Player** | Contribute to 5 different repositories |
|
||||
| **Virtuoso** | Complete 1000 tasks across all contributions |
|
||||
| **Token Millionaire** | Donate over 10 million tokens |
|
||||
| **Early Adopter** | Join Symphony in its first month |
|
||||
|
||||
## Session Integration
|
||||
|
||||
@@ -192,6 +212,14 @@ Your project entry should include:
|
||||
|
||||
Once merged, your project will appear in the Symphony Projects tab (registry cached for 2 hours, issues cached for 5 minutes).
|
||||
|
||||
## Available Issues
|
||||
|
||||
Browse Symphony-ready issues on Maestro itself:
|
||||
|
||||
<Card title="Maestro Symphony Issues" icon="github" href="https://github.com/pedramamini/Maestro/issues?q=is%3Aissue%20label%3Arunmaestro.ai">
|
||||
View all issues labeled `runmaestro.ai` — ready for AI-assisted contribution
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
**Maestro Symphony — Advancing open source, together.**
|
||||
|
||||
@@ -206,6 +206,7 @@ describe('system IPC handlers', () => {
|
||||
// Shell handlers
|
||||
'shells:detect',
|
||||
'shell:openExternal',
|
||||
'shell:trashItem',
|
||||
// Tunnel handlers
|
||||
'tunnel:isCloudflaredInstalled',
|
||||
'tunnel:start',
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Tests for DeleteAgentConfirmModal component
|
||||
*
|
||||
* Tests the core behavior of the delete agent confirmation dialog:
|
||||
* - Rendering with agent name, working directory, and three buttons
|
||||
* - Button click handlers (Cancel, Confirm, Confirm and Erase)
|
||||
* - Focus management
|
||||
* - Layer stack integration
|
||||
* - Accessibility
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { DeleteAgentConfirmModal } from '../../../renderer/components/DeleteAgentConfirmModal';
|
||||
import { LayerStackProvider } from '../../../renderer/contexts/LayerStackContext';
|
||||
import type { Theme } from '../../../renderer/types';
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock('lucide-react', () => ({
|
||||
X: () => <svg data-testid="x-icon" />,
|
||||
AlertTriangle: () => <svg data-testid="alert-triangle-icon" />,
|
||||
Trash2: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<svg data-testid="trash2-icon" className={className} style={style} />
|
||||
),
|
||||
}));
|
||||
|
||||
// Create a test theme
|
||||
const testTheme: Theme = {
|
||||
id: 'test-theme',
|
||||
name: 'Test Theme',
|
||||
mode: 'dark',
|
||||
colors: {
|
||||
bgMain: '#1e1e1e',
|
||||
bgSidebar: '#252526',
|
||||
bgActivity: '#333333',
|
||||
textMain: '#d4d4d4',
|
||||
textDim: '#808080',
|
||||
accent: '#007acc',
|
||||
accentDim: '#007acc80',
|
||||
accentText: '#ffffff',
|
||||
accentForeground: '#ffffff',
|
||||
border: '#404040',
|
||||
error: '#f14c4c',
|
||||
warning: '#cca700',
|
||||
success: '#89d185',
|
||||
},
|
||||
};
|
||||
|
||||
// Helper to render with LayerStackProvider
|
||||
const renderWithLayerStack = (ui: React.ReactElement) => {
|
||||
return render(<LayerStackProvider>{ui}</LayerStackProvider>);
|
||||
};
|
||||
|
||||
describe('DeleteAgentConfirmModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders with agent name and three action buttons', () => {
|
||||
renderWithLayerStack(
|
||||
<DeleteAgentConfirmModal
|
||||
theme={testTheme}
|
||||
agentName="TestAgent"
|
||||
workingDirectory="/home/user/project"
|
||||
onConfirm={vi.fn()}
|
||||
onConfirmAndErase={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/TestAgent/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Confirm and Erase' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders working directory path', () => {
|
||||
renderWithLayerStack(
|
||||
<DeleteAgentConfirmModal
|
||||
theme={testTheme}
|
||||
agentName="TestAgent"
|
||||
workingDirectory="/home/user/project"
|
||||
onConfirm={vi.fn()}
|
||||
onConfirmAndErase={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('/home/user/project')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders explanatory text about Confirm and Erase', () => {
|
||||
renderWithLayerStack(
|
||||
<DeleteAgentConfirmModal
|
||||
theme={testTheme}
|
||||
agentName="TestAgent"
|
||||
workingDirectory="/home/user/project"
|
||||
onConfirm={vi.fn()}
|
||||
onConfirmAndErase={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check the explanatory text (in a <strong> tag within a <p>)
|
||||
expect(screen.getByText(/will also move the working directory to the trash/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders header with title and close button', () => {
|
||||
renderWithLayerStack(
|
||||
<DeleteAgentConfirmModal
|
||||
theme={testTheme}
|
||||
agentName="TestAgent"
|
||||
workingDirectory="/home/user/project"
|
||||
onConfirm={vi.fn()}
|
||||
onConfirmAndErase={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Confirm Delete')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('x-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has correct ARIA attributes', () => {
|
||||
renderWithLayerStack(
|
||||
<DeleteAgentConfirmModal
|
||||
theme={testTheme}
|
||||
agentName="TestAgent"
|
||||
workingDirectory="/home/user/project"
|
||||
onConfirm={vi.fn()}
|
||||
onConfirmAndErase={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
expect(dialog).toHaveAttribute('aria-label', 'Confirm Delete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('focus management', () => {
|
||||
it('focuses Confirm button on mount (not Confirm and Erase)', async () => {
|
||||
renderWithLayerStack(
|
||||
<DeleteAgentConfirmModal
|
||||
theme={testTheme}
|
||||
agentName="TestAgent"
|
||||
workingDirectory="/home/user/project"
|
||||
onConfirm={vi.fn()}
|
||||
onConfirmAndErase={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Confirm' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('button handlers', () => {
|
||||
it('calls onClose when X button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
renderWithLayerStack(
|
||||
<DeleteAgentConfirmModal
|
||||
theme={testTheme}
|
||||
agentName="TestAgent"
|
||||
workingDirectory="/home/user/project"
|
||||
onConfirm={vi.fn()}
|
||||
onConfirmAndErase={vi.fn()}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
const closeButton = screen.getByTestId('x-icon').closest('button');
|
||||
fireEvent.click(closeButton!);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
renderWithLayerStack(
|
||||
<DeleteAgentConfirmModal
|
||||
theme={testTheme}
|
||||
agentName="TestAgent"
|
||||
workingDirectory="/home/user/project"
|
||||
onConfirm={vi.fn()}
|
||||
onConfirmAndErase={vi.fn()}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onConfirm then onClose when Confirm is clicked', () => {
|
||||
const callOrder: string[] = [];
|
||||
const onClose = vi.fn(() => callOrder.push('close'));
|
||||
const onConfirm = vi.fn(() => callOrder.push('confirm'));
|
||||
const onConfirmAndErase = vi.fn();
|
||||
|
||||
renderWithLayerStack(
|
||||
<DeleteAgentConfirmModal
|
||||
theme={testTheme}
|
||||
agentName="TestAgent"
|
||||
workingDirectory="/home/user/project"
|
||||
onConfirm={onConfirm}
|
||||
onConfirmAndErase={onConfirmAndErase}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
||||
expect(callOrder).toEqual(['confirm', 'close']);
|
||||
expect(onConfirmAndErase).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onConfirmAndErase then onClose when Confirm and Erase is clicked', () => {
|
||||
const callOrder: string[] = [];
|
||||
const onClose = vi.fn(() => callOrder.push('close'));
|
||||
const onConfirm = vi.fn();
|
||||
const onConfirmAndErase = vi.fn(() => callOrder.push('confirmAndErase'));
|
||||
|
||||
renderWithLayerStack(
|
||||
<DeleteAgentConfirmModal
|
||||
theme={testTheme}
|
||||
agentName="TestAgent"
|
||||
workingDirectory="/home/user/project"
|
||||
onConfirm={onConfirm}
|
||||
onConfirmAndErase={onConfirmAndErase}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm and Erase' }));
|
||||
expect(callOrder).toEqual(['confirmAndErase', 'close']);
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Cancel does not call onConfirm or onConfirmAndErase', () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onConfirmAndErase = vi.fn();
|
||||
renderWithLayerStack(
|
||||
<DeleteAgentConfirmModal
|
||||
theme={testTheme}
|
||||
agentName="TestAgent"
|
||||
workingDirectory="/home/user/project"
|
||||
onConfirm={onConfirm}
|
||||
onConfirmAndErase={onConfirmAndErase}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(onConfirmAndErase).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard interaction', () => {
|
||||
it('stops propagation of keydown events', () => {
|
||||
const parentHandler = vi.fn();
|
||||
|
||||
render(
|
||||
<div onKeyDown={parentHandler}>
|
||||
<LayerStackProvider>
|
||||
<DeleteAgentConfirmModal
|
||||
theme={testTheme}
|
||||
agentName="TestAgent"
|
||||
workingDirectory="/home/user/project"
|
||||
onConfirm={vi.fn()}
|
||||
onConfirmAndErase={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
</LayerStackProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'a' });
|
||||
expect(parentHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('layer stack integration', () => {
|
||||
it('registers and unregisters without errors', () => {
|
||||
const { unmount } = renderWithLayerStack(
|
||||
<DeleteAgentConfirmModal
|
||||
theme={testTheme}
|
||||
agentName="TestAgent"
|
||||
workingDirectory="/home/user/project"
|
||||
onConfirm={vi.fn()}
|
||||
onConfirmAndErase={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(() => unmount()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('has tabIndex on dialog for focus', () => {
|
||||
renderWithLayerStack(
|
||||
<DeleteAgentConfirmModal
|
||||
theme={testTheme}
|
||||
agentName="TestAgent"
|
||||
workingDirectory="/home/user/project"
|
||||
onConfirm={vi.fn()}
|
||||
onConfirmAndErase={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('tabIndex', '-1');
|
||||
});
|
||||
|
||||
it('has semantic button elements', () => {
|
||||
renderWithLayerStack(
|
||||
<DeleteAgentConfirmModal
|
||||
theme={testTheme}
|
||||
agentName="TestAgent"
|
||||
workingDirectory="/home/user/project"
|
||||
onConfirm={vi.fn()}
|
||||
onConfirmAndErase={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// X, Cancel, Confirm, Confirm and Erase
|
||||
expect(screen.getAllByRole('button')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('has heading for modal title', () => {
|
||||
renderWithLayerStack(
|
||||
<DeleteAgentConfirmModal
|
||||
theme={testTheme}
|
||||
agentName="TestAgent"
|
||||
workingDirectory="/home/user/project"
|
||||
onConfirm={vi.fn()}
|
||||
onConfirmAndErase={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Confirm Delete' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
sanitizeFilename,
|
||||
generateWizardFolderBaseName,
|
||||
countTasks,
|
||||
generateDocumentPrompt,
|
||||
type DocumentGenerationConfig,
|
||||
} from '../../../renderer/services/inlineWizardDocumentGeneration';
|
||||
|
||||
describe('inlineWizardDocumentGeneration', () => {
|
||||
@@ -390,4 +392,138 @@ CONTENT:
|
||||
expect(day).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateDocumentPrompt', () => {
|
||||
// Helper to create a minimal config for testing
|
||||
const createTestConfig = (overrides: Partial<DocumentGenerationConfig> = {}): DocumentGenerationConfig => ({
|
||||
agentType: 'claude-code',
|
||||
directoryPath: '/project/root',
|
||||
projectName: 'Test Project',
|
||||
conversationHistory: [
|
||||
{ id: '1', role: 'user', content: 'Build a web app', timestamp: Date.now() },
|
||||
{ id: '2', role: 'assistant', content: 'I can help with that', timestamp: Date.now() },
|
||||
],
|
||||
mode: 'new',
|
||||
autoRunFolderPath: '/project/root/Auto Run Docs',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('should use the configured autoRunFolderPath in the prompt', () => {
|
||||
const config = createTestConfig({
|
||||
autoRunFolderPath: '/custom/autorun/path',
|
||||
});
|
||||
|
||||
const prompt = generateDocumentPrompt(config);
|
||||
|
||||
// The prompt should contain the custom path, not the default 'Auto Run Docs'
|
||||
expect(prompt).toContain('/custom/autorun/path');
|
||||
// Should NOT contain the hardcoded pattern with directoryPath + default folder
|
||||
expect(prompt).not.toContain('/project/root/Auto Run Docs');
|
||||
});
|
||||
|
||||
it('should use external autoRunFolderPath when different from directoryPath', () => {
|
||||
const config = createTestConfig({
|
||||
directoryPath: '/main/repo',
|
||||
autoRunFolderPath: '/worktrees/autorun/feature-branch',
|
||||
});
|
||||
|
||||
const prompt = generateDocumentPrompt(config);
|
||||
|
||||
// The prompt should instruct writing to the external path
|
||||
expect(prompt).toContain('/worktrees/autorun/feature-branch');
|
||||
// Read access should still reference the project directory
|
||||
expect(prompt).toContain('/main/repo');
|
||||
});
|
||||
|
||||
it('should append subfolder to autoRunFolderPath when provided', () => {
|
||||
const config = createTestConfig({
|
||||
autoRunFolderPath: '/custom/autorun',
|
||||
});
|
||||
|
||||
const prompt = generateDocumentPrompt(config, 'Wizard-2026-01-11');
|
||||
|
||||
// Should contain the full path with subfolder
|
||||
expect(prompt).toContain('/custom/autorun/Wizard-2026-01-11');
|
||||
});
|
||||
|
||||
it('should handle autoRunFolderPath that is inside directoryPath', () => {
|
||||
const config = createTestConfig({
|
||||
directoryPath: '/project/root',
|
||||
autoRunFolderPath: '/project/root/Auto Run Docs',
|
||||
});
|
||||
|
||||
const prompt = generateDocumentPrompt(config);
|
||||
|
||||
// Should still work correctly when path is inside project
|
||||
expect(prompt).toContain('/project/root/Auto Run Docs');
|
||||
});
|
||||
|
||||
it('should include project name in the prompt', () => {
|
||||
const config = createTestConfig({
|
||||
projectName: 'My Awesome Project',
|
||||
});
|
||||
|
||||
const prompt = generateDocumentPrompt(config);
|
||||
|
||||
expect(prompt).toContain('My Awesome Project');
|
||||
});
|
||||
|
||||
it('should include conversation summary in the prompt', () => {
|
||||
const config = createTestConfig({
|
||||
conversationHistory: [
|
||||
{ id: '1', role: 'user', content: 'I want to build a dashboard', timestamp: Date.now() },
|
||||
{ id: '2', role: 'assistant', content: 'What metrics should it display?', timestamp: Date.now() },
|
||||
],
|
||||
});
|
||||
|
||||
const prompt = generateDocumentPrompt(config);
|
||||
|
||||
expect(prompt).toContain('User: I want to build a dashboard');
|
||||
expect(prompt).toContain('Assistant: What metrics should it display?');
|
||||
});
|
||||
|
||||
it('should use iterate prompt template when mode is iterate', () => {
|
||||
const config = createTestConfig({
|
||||
mode: 'iterate',
|
||||
goal: 'Add authentication',
|
||||
existingDocuments: [
|
||||
{ name: 'Phase-01-Setup', filename: 'Phase-01-Setup.md', path: '/path/Phase-01-Setup.md' },
|
||||
],
|
||||
});
|
||||
|
||||
const prompt = generateDocumentPrompt(config);
|
||||
|
||||
// Iterate mode has specific markers
|
||||
expect(prompt).toContain('Add authentication');
|
||||
expect(prompt).toContain('Existing Documents');
|
||||
});
|
||||
|
||||
it('should NOT contain hardcoded Auto Run Docs when custom path is configured', () => {
|
||||
const config = createTestConfig({
|
||||
directoryPath: '/my/project',
|
||||
autoRunFolderPath: '/completely/different/path',
|
||||
});
|
||||
|
||||
const prompt = generateDocumentPrompt(config);
|
||||
|
||||
// The combined pattern should be replaced with custom path
|
||||
// Check that we don't have the default path in write instructions
|
||||
expect(prompt).not.toMatch(/\/my\/project\/Auto Run Docs/);
|
||||
expect(prompt).toContain('/completely/different/path');
|
||||
});
|
||||
|
||||
it('should preserve directoryPath for read access instructions', () => {
|
||||
const config = createTestConfig({
|
||||
directoryPath: '/project/source',
|
||||
autoRunFolderPath: '/external/autorun',
|
||||
});
|
||||
|
||||
const prompt = generateDocumentPrompt(config);
|
||||
|
||||
// Read access should reference project directory
|
||||
expect(prompt).toContain('Read any file in: `/project/source`');
|
||||
// Write access should reference autorun path
|
||||
expect(prompt).toContain('/external/autorun');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,6 +182,19 @@ export function registerSystemHandlers(deps: SystemHandlerDependencies): void {
|
||||
await shell.openExternal(url);
|
||||
});
|
||||
|
||||
// Shell operations - move item to system trash
|
||||
ipcMain.handle('shell:trashItem', async (_event, itemPath: string) => {
|
||||
if (!itemPath || typeof itemPath !== 'string') {
|
||||
throw new Error('Invalid path: path must be a non-empty string');
|
||||
}
|
||||
// Resolve to absolute path and verify it exists
|
||||
const absolutePath = path.resolve(itemPath);
|
||||
if (!fsSync.existsSync(absolutePath)) {
|
||||
throw new Error(`Path does not exist: ${absolutePath}`);
|
||||
}
|
||||
await shell.trashItem(absolutePath);
|
||||
});
|
||||
|
||||
// ============ Tunnel Handlers (Cloudflare) ============
|
||||
|
||||
ipcMain.handle('tunnel:isCloudflaredInstalled', async () => {
|
||||
|
||||
@@ -622,6 +622,7 @@ contextBridge.exposeInMainWorld('maestro', {
|
||||
// Shell API
|
||||
shell: {
|
||||
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
|
||||
trashItem: (itemPath: string) => ipcRenderer.invoke('shell:trashItem', itemPath),
|
||||
},
|
||||
|
||||
// Tunnel API (Cloudflare tunnel support)
|
||||
@@ -2004,6 +2005,7 @@ export interface MaestroAPI {
|
||||
};
|
||||
shell: {
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
trashItem: (itemPath: string) => Promise<void>;
|
||||
};
|
||||
tunnel: {
|
||||
isCloudflaredInstalled: () => Promise<boolean>;
|
||||
|
||||
@@ -267,6 +267,8 @@ File naming convention:
|
||||
|
||||
**IMPORTANT**: Write files one at a time, IN ORDER (Phase-01 first, then Phase-02, etc.). Do NOT wait until you've finished all documents to write them - save each one as soon as it's complete.
|
||||
|
||||
**DO NOT create any additional files** such as summary documents, README files, recap files, or "what I did" files. Only create the Phase-XX-[Description].md documents. The user can see your generated documents in real-time and does not need a summary.
|
||||
|
||||
## Project Discovery Conversation
|
||||
|
||||
{{CONVERSATION_SUMMARY}}
|
||||
|
||||
@@ -194,6 +194,8 @@ File paths for the Auto Run folder:
|
||||
- When updating, provide the COMPLETE updated document content, not just the additions
|
||||
- New phases should use the next available phase number
|
||||
|
||||
**DO NOT create any additional files** such as summary documents, README files, recap files, or "what I did" files. Only create the Phase-XX-[Description].md documents. The user can see your generated documents in real-time and does not need a summary.
|
||||
|
||||
## Project Discovery Conversation
|
||||
|
||||
{{CONVERSATION_SUMMARY}}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { CONDUCTOR_BADGES, getBadgeForTime } from './constants/conductorBadges';
|
||||
import { EmptyStateView } from './components/EmptyStateView';
|
||||
import { MarketplaceModal } from './components/MarketplaceModal';
|
||||
import { DocumentGraphView } from './components/DocumentGraph/DocumentGraphView';
|
||||
import { DeleteAgentConfirmModal } from './components/DeleteAgentConfirmModal';
|
||||
|
||||
// Group Chat Components
|
||||
import { GroupChatPanel } from './components/GroupChatPanel';
|
||||
@@ -472,6 +473,10 @@ function MaestroConsoleInner() {
|
||||
// File gist URL storage - maps file paths to their published gist info
|
||||
const [fileGistUrls, setFileGistUrls] = useState<Record<string, GistInfo>>({});
|
||||
|
||||
// Delete Agent Modal State
|
||||
const [deleteAgentModalOpen, setDeleteAgentModalOpen] = useState(false);
|
||||
const [deleteAgentSession, setDeleteAgentSession] = useState<Session | null>(null);
|
||||
|
||||
// Note: Git Diff State, Tour Overlay State, and Git Log Viewer State are now from ModalContext
|
||||
|
||||
// Renaming State
|
||||
@@ -501,6 +506,12 @@ function MaestroConsoleInner() {
|
||||
// Confirm modal close handler
|
||||
const handleCloseConfirmModal = useCallback(() => setConfirmModalOpen(false), []);
|
||||
|
||||
// Delete agent modal handlers
|
||||
const handleCloseDeleteAgentModal = useCallback(() => {
|
||||
setDeleteAgentModalOpen(false);
|
||||
setDeleteAgentSession(null);
|
||||
}, []);
|
||||
|
||||
// Quit confirm modal handlers
|
||||
const handleConfirmQuit = useCallback(() => {
|
||||
setQuitConfirmModalOpen(false);
|
||||
@@ -6378,9 +6389,15 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
const session = sessions.find(s => s.id === id);
|
||||
if (!session) return;
|
||||
|
||||
showConfirmation(
|
||||
`Are you sure you want to delete the agent "${session.name}"? This action cannot be undone.`,
|
||||
async () => {
|
||||
// Open the delete agent modal
|
||||
setDeleteAgentSession(session);
|
||||
setDeleteAgentModalOpen(true);
|
||||
};
|
||||
|
||||
// Internal function to perform the actual session deletion
|
||||
const performDeleteSession = useCallback(async (session: Session, eraseWorkingDirectory: boolean) => {
|
||||
const id = session.id;
|
||||
|
||||
// Record session closure for Usage Dashboard (before cleanup)
|
||||
window.maestro.stats.recordSessionClosed(id, Date.now());
|
||||
|
||||
@@ -6409,6 +6426,21 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
setRemovedWorktreePaths(prev => new Set([...prev, session.cwd]));
|
||||
}
|
||||
|
||||
// Optionally erase the working directory (move to trash)
|
||||
if (eraseWorkingDirectory && session.cwd) {
|
||||
try {
|
||||
await window.maestro.shell.trashItem(session.cwd);
|
||||
} catch (error) {
|
||||
console.error('Failed to move working directory to trash:', error);
|
||||
// Show a toast notification about the failure
|
||||
addToast({
|
||||
title: 'Failed to Erase Directory',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newSessions = sessions.filter(s => s.id !== id);
|
||||
setSessions(newSessions);
|
||||
// Flush immediately for critical operation (session deletion)
|
||||
@@ -6419,9 +6451,7 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
} else {
|
||||
setActiveSessionId('');
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
}, [sessions, setSessions, setActiveSessionId, flushSessionPersistence, setRemovedWorktreePaths, addToast]);
|
||||
|
||||
// Delete an entire worktree group and all its agents
|
||||
const deleteWorktreeGroup = (groupId: string) => {
|
||||
@@ -9931,6 +9961,18 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
|
||||
{/* NOTE: All modals are now rendered via the unified <AppModals /> component above */}
|
||||
|
||||
{/* Delete Agent Confirmation Modal */}
|
||||
{deleteAgentModalOpen && deleteAgentSession && (
|
||||
<DeleteAgentConfirmModal
|
||||
theme={theme}
|
||||
agentName={deleteAgentSession.name}
|
||||
workingDirectory={deleteAgentSession.cwd}
|
||||
onConfirm={() => performDeleteSession(deleteAgentSession, false)}
|
||||
onConfirmAndErase={() => performDeleteSession(deleteAgentSession, true)}
|
||||
onClose={handleCloseDeleteAgentModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* --- EMPTY STATE VIEW (when no sessions) --- */}
|
||||
{sessions.length === 0 && !isMobileLandscape ? (
|
||||
<EmptyStateView
|
||||
|
||||
127
src/renderer/components/DeleteAgentConfirmModal.tsx
Normal file
127
src/renderer/components/DeleteAgentConfirmModal.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||
import type { Theme } from '../types';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
import { Modal } from './ui/Modal';
|
||||
|
||||
interface DeleteAgentConfirmModalProps {
|
||||
theme: Theme;
|
||||
agentName: string;
|
||||
workingDirectory: string;
|
||||
onConfirm: () => void;
|
||||
onConfirmAndErase: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DeleteAgentConfirmModal({
|
||||
theme,
|
||||
agentName,
|
||||
workingDirectory,
|
||||
onConfirm,
|
||||
onConfirmAndErase,
|
||||
onClose,
|
||||
}: DeleteAgentConfirmModalProps) {
|
||||
const confirmButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
}, [onConfirm, onClose]);
|
||||
|
||||
const handleConfirmAndErase = useCallback(() => {
|
||||
onConfirmAndErase();
|
||||
onClose();
|
||||
}, [onConfirmAndErase, onClose]);
|
||||
|
||||
// Stop Enter key propagation to prevent parent handlers from triggering after modal closes
|
||||
const handleKeyDown = (e: React.KeyboardEvent, action: () => void) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
action();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
theme={theme}
|
||||
title="Confirm Delete"
|
||||
priority={MODAL_PRIORITIES.CONFIRM}
|
||||
onClose={onClose}
|
||||
headerIcon={<Trash2 className="w-4 h-4" style={{ color: theme.colors.error }} />}
|
||||
width={500}
|
||||
zIndex={10000}
|
||||
initialFocusRef={confirmButtonRef}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2 w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => handleKeyDown(e, onClose)}
|
||||
className="px-4 py-2 rounded border hover:bg-white/5 transition-colors outline-none focus:ring-2 focus:ring-offset-1"
|
||||
style={{
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.textMain,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
ref={confirmButtonRef}
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
onKeyDown={(e) => handleKeyDown(e, handleConfirm)}
|
||||
className="px-4 py-2 rounded transition-colors outline-none focus:ring-2 focus:ring-offset-1"
|
||||
style={{
|
||||
backgroundColor: theme.colors.error,
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmAndErase}
|
||||
onKeyDown={(e) => handleKeyDown(e, handleConfirmAndErase)}
|
||||
className="px-4 py-2 rounded transition-colors outline-none focus:ring-2 focus:ring-offset-1"
|
||||
style={{
|
||||
backgroundColor: theme.colors.error,
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
Confirm and Erase
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-full h-fit"
|
||||
style={{ backgroundColor: `${theme.colors.error}20` }}
|
||||
>
|
||||
<AlertTriangle className="w-5 h-5" style={{ color: theme.colors.error }} />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<p className="leading-relaxed" style={{ color: theme.colors.textMain }}>
|
||||
Are you sure you want to delete the agent "{agentName}"? This action cannot be undone.
|
||||
</p>
|
||||
<p
|
||||
className="text-sm leading-relaxed"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
>
|
||||
<strong>Confirm and Erase</strong> will also move the working directory to the trash:
|
||||
</p>
|
||||
<code
|
||||
className="block text-xs px-2 py-1 rounded break-all"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
color: theme.colors.textDim,
|
||||
border: `1px solid ${theme.colors.border}`,
|
||||
}}
|
||||
>
|
||||
{workingDirectory}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
1
src/renderer/global.d.ts
vendored
1
src/renderer/global.d.ts
vendored
@@ -599,6 +599,7 @@ interface MaestroAPI {
|
||||
};
|
||||
shell: {
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
trashItem: (itemPath: string) => Promise<void>;
|
||||
};
|
||||
tunnel: {
|
||||
isCloudflaredInstalled: () => Promise<boolean>;
|
||||
|
||||
@@ -288,8 +288,8 @@ function formatExistingDocsForPrompt(docs: ExistingDocument[]): string {
|
||||
* @param subfolder Optional subfolder name within Auto Run Docs
|
||||
* @returns The complete prompt for the agent
|
||||
*/
|
||||
function generateDocumentPrompt(config: DocumentGenerationConfig, subfolder?: string): string {
|
||||
const { projectName, directoryPath, conversationHistory, mode, goal, existingDocuments } = config;
|
||||
export function generateDocumentPrompt(config: DocumentGenerationConfig, subfolder?: string): string {
|
||||
const { projectName, directoryPath, conversationHistory, mode, goal, existingDocuments, autoRunFolderPath } = config;
|
||||
const projectDisplay = projectName || 'this project';
|
||||
|
||||
// Build conversation summary from the wizard conversation
|
||||
@@ -307,15 +307,22 @@ function generateDocumentPrompt(config: DocumentGenerationConfig, subfolder?: st
|
||||
: wizardDocumentGenerationPrompt;
|
||||
|
||||
// Build the full Auto Run folder path (including subfolder if specified)
|
||||
const autoRunFolderPath = subfolder
|
||||
? `${AUTO_RUN_FOLDER_NAME}/${subfolder}`
|
||||
: AUTO_RUN_FOLDER_NAME;
|
||||
// Use the user-configured autoRunFolderPath (which may be external to directoryPath)
|
||||
const fullAutoRunPath = subfolder
|
||||
? `${autoRunFolderPath}/${subfolder}`
|
||||
: autoRunFolderPath;
|
||||
|
||||
// Handle wizard-specific template variables
|
||||
// The prompt template uses {{DIRECTORY_PATH}}/{{AUTO_RUN_FOLDER_NAME}} as a combined pattern
|
||||
// for specifying where documents should be written. Since the user may have configured
|
||||
// an external Auto Run folder (not inside directoryPath), we replace the combined pattern
|
||||
// with the full absolute path.
|
||||
let prompt = basePrompt
|
||||
.replace(/\{\{PROJECT_NAME\}\}/gi, projectDisplay)
|
||||
// Replace the combined pattern first (for write access paths)
|
||||
.replace(/\{\{DIRECTORY_PATH\}\}\/\{\{AUTO_RUN_FOLDER_NAME\}\}/gi, fullAutoRunPath)
|
||||
// Then replace remaining individual placeholders (for read access, etc.)
|
||||
.replace(/\{\{DIRECTORY_PATH\}\}/gi, directoryPath)
|
||||
.replace(/\{\{AUTO_RUN_FOLDER_NAME\}\}/gi, autoRunFolderPath)
|
||||
.replace(/\{\{AUTO_RUN_FOLDER_NAME\}\}/gi, fullAutoRunPath)
|
||||
.replace(/\{\{CONVERSATION_SUMMARY\}\}/gi, conversationSummary);
|
||||
|
||||
// Handle iterate-mode specific placeholders
|
||||
|
||||
Reference in New Issue
Block a user