- 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:
Pedram Amini
2026-01-11 15:12:37 -06:00
parent 8de3dfc43a
commit f05a3f2570
14 changed files with 778 additions and 61 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

View File

@@ -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:
![Contribution History](./screenshots/symphony-history.png)
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:
![Symphony Stats](./screenshots/symphony-stats.png)
**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.**

View File

@@ -206,6 +206,7 @@ describe('system IPC handlers', () => {
// Shell handlers
'shells:detect',
'shell:openExternal',
'shell:trashItem',
// Tunnel handlers
'tunnel:isCloudflaredInstalled',
'tunnel:start',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,51 +6389,70 @@ 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 () => {
// Record session closure for Usage Dashboard (before cleanup)
window.maestro.stats.recordSessionClosed(id, Date.now());
// Kill both processes for this session
try {
await window.maestro.process.kill(`${id}-ai`);
} catch (error) {
console.error('Failed to kill AI process:', error);
}
try {
await window.maestro.process.kill(`${id}-terminal`);
} catch (error) {
console.error('Failed to kill terminal process:', error);
}
// Delete associated playbooks
try {
await window.maestro.playbooks.deleteAll(id);
} catch (error) {
console.error('Failed to delete playbooks:', error);
}
// If this is a worktree session, track its path to prevent re-discovery
if (session.worktreeParentPath && session.cwd) {
setRemovedWorktreePaths(prev => new Set([...prev, session.cwd]));
}
const newSessions = sessions.filter(s => s.id !== id);
setSessions(newSessions);
// Flush immediately for critical operation (session deletion)
// Note: flushSessionPersistence will pick up the latest state via ref
setTimeout(() => flushSessionPersistence(), 0);
if (newSessions.length > 0) {
setActiveSessionId(newSessions[0].id);
} else {
setActiveSessionId('');
}
}
);
// 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());
// Kill both processes for this session
try {
await window.maestro.process.kill(`${id}-ai`);
} catch (error) {
console.error('Failed to kill AI process:', error);
}
try {
await window.maestro.process.kill(`${id}-terminal`);
} catch (error) {
console.error('Failed to kill terminal process:', error);
}
// Delete associated playbooks
try {
await window.maestro.playbooks.deleteAll(id);
} catch (error) {
console.error('Failed to delete playbooks:', error);
}
// If this is a worktree session, track its path to prevent re-discovery
if (session.worktreeParentPath && session.cwd) {
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)
// Note: flushSessionPersistence will pick up the latest state via ref
setTimeout(() => flushSessionPersistence(), 0);
if (newSessions.length > 0) {
setActiveSessionId(newSessions[0].id);
} else {
setActiveSessionId('');
}
}, [sessions, setSessions, setActiveSessionId, flushSessionPersistence, setRemovedWorktreePaths, addToast]);
// Delete an entire worktree group and all its agents
const deleteWorktreeGroup = (groupId: string) => {
const group = groups.find(g => g.id === groupId);
@@ -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

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

View File

@@ -599,6 +599,7 @@ interface MaestroAPI {
};
shell: {
openExternal: (url: string) => Promise<void>;
trashItem: (itemPath: string) => Promise<void>;
};
tunnel: {
isCloudflaredInstalled: () => Promise<boolean>;

View File

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