mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
- Launched **Maestro Symphony** to browse curated OSS issues and contribute fast 🎶
- Switched to **deferred draft PR creation**, triggered on first real commit 🧷 - Added PR auto-finalization: **mark ready + summarize contribution stats** 📣 - Posting rich PR comments with tokens, cost, time, docs, tasks breakdown 🧾 - External issue documents now download to **cache**, keeping repos clean 🗄️ - Contribution metadata persisted to `metadata.json` for reliable tracking 🧬 - New IPC + preload APIs for clone, start, createDraftPR, complete workflows 🔌 - App now listens for `symphony:prCreated` to backfill session PR metadata 📡 - Upgraded Symphony session creation to auto-start **Auto Run batch processing** 🚀 - Agent creation dialog revamped: batch-only agents, expandable config, folder picker 🧰
This commit is contained in:
@@ -548,24 +548,31 @@ async function createDraftPR(
|
||||
return { success: false, error: authCheck.error };
|
||||
}
|
||||
|
||||
// Get current branch name
|
||||
const branchResult = await execFileNoThrow('git', ['rev-parse', '--abbrev-ref', 'HEAD'], repoPath);
|
||||
const branchName = branchResult.stdout.trim();
|
||||
if (!branchName || branchResult.exitCode !== 0) {
|
||||
return { success: false, error: 'Failed to determine current branch' };
|
||||
}
|
||||
|
||||
// First push the branch
|
||||
const pushResult = await execFileNoThrow('git', ['push', '-u', 'origin', 'HEAD'], repoPath);
|
||||
const pushResult = await execFileNoThrow('git', ['push', '-u', 'origin', branchName], repoPath);
|
||||
|
||||
if (pushResult.exitCode !== 0) {
|
||||
return { success: false, error: `Failed to push: ${pushResult.stderr}` };
|
||||
}
|
||||
|
||||
// Create draft PR using gh CLI
|
||||
// Create draft PR using gh CLI (use --head to explicitly specify the branch)
|
||||
const prResult = await execFileNoThrow(
|
||||
'gh',
|
||||
['pr', 'create', '--draft', '--base', baseBranch, '--title', title, '--body', body],
|
||||
['pr', 'create', '--draft', '--base', baseBranch, '--head', branchName, '--title', title, '--body', body],
|
||||
repoPath
|
||||
);
|
||||
|
||||
if (prResult.exitCode !== 0) {
|
||||
// If PR creation failed after push, try to delete the remote branch
|
||||
logger.warn('PR creation failed, attempting to clean up remote branch', LOG_CONTEXT);
|
||||
await execFileNoThrow('git', ['push', 'origin', '--delete', 'HEAD'], repoPath);
|
||||
await execFileNoThrow('git', ['push', 'origin', '--delete', branchName], repoPath);
|
||||
return { success: false, error: `Failed to create PR: ${prResult.stderr}` };
|
||||
}
|
||||
|
||||
@@ -597,6 +604,66 @@ async function markPRReady(
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a comment to a PR with Symphony contribution stats.
|
||||
*/
|
||||
async function postPRComment(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
stats: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
estimatedCost: number;
|
||||
timeSpentMs: number;
|
||||
documentsProcessed: number;
|
||||
tasksCompleted: number;
|
||||
}
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Format time spent
|
||||
const hours = Math.floor(stats.timeSpentMs / 3600000);
|
||||
const minutes = Math.floor((stats.timeSpentMs % 3600000) / 60000);
|
||||
const seconds = Math.floor((stats.timeSpentMs % 60000) / 1000);
|
||||
const timeStr = hours > 0
|
||||
? `${hours}h ${minutes}m ${seconds}s`
|
||||
: minutes > 0
|
||||
? `${minutes}m ${seconds}s`
|
||||
: `${seconds}s`;
|
||||
|
||||
// Format token counts with commas
|
||||
const formatNumber = (n: number) => n.toLocaleString('en-US');
|
||||
|
||||
// Build the comment body
|
||||
const commentBody = `## Symphony Contribution Summary
|
||||
|
||||
This pull request was created using [Maestro Symphony](https://runmaestro.ai/symphony) - connecting AI-powered contributors with open source projects.
|
||||
|
||||
### Contribution Stats
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Input Tokens | ${formatNumber(stats.inputTokens)} |
|
||||
| Output Tokens | ${formatNumber(stats.outputTokens)} |
|
||||
| Total Tokens | ${formatNumber(stats.inputTokens + stats.outputTokens)} |
|
||||
| Estimated Cost | $${stats.estimatedCost.toFixed(2)} |
|
||||
| Time Spent | ${timeStr} |
|
||||
| Documents Processed | ${stats.documentsProcessed} |
|
||||
| Tasks Completed | ${stats.tasksCompleted} |
|
||||
|
||||
---
|
||||
*Powered by [Maestro](https://runmaestro.ai) • [Learn about Symphony](https://docs.runmaestro.ai/symphony)*`;
|
||||
|
||||
const result = await execFileNoThrow(
|
||||
'gh',
|
||||
['pr', 'comment', String(prNumber), '--body', commentBody],
|
||||
repoPath
|
||||
);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
return { success: false, error: result.stderr };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Real-time Updates
|
||||
// ============================================================================
|
||||
@@ -982,6 +1049,7 @@ This PR will be updated automatically when the Auto Run completes.`;
|
||||
|
||||
/**
|
||||
* Complete a contribution (mark PR as ready).
|
||||
* Accepts optional stats from the frontend which override stored values.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'symphony:complete',
|
||||
@@ -990,8 +1058,16 @@ This PR will be updated automatically when the Auto Run completes.`;
|
||||
async (params: {
|
||||
contributionId: string;
|
||||
prBody?: string;
|
||||
stats?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
estimatedCost: number;
|
||||
timeSpentMs: number;
|
||||
documentsProcessed: number;
|
||||
tasksCompleted: number;
|
||||
};
|
||||
}): Promise<Omit<CompleteContributionResponse, 'success'>> => {
|
||||
const { contributionId } = params;
|
||||
const { contributionId, stats } = params;
|
||||
const state = await readState(app);
|
||||
const contributionIndex = state.active.findIndex(c => c.id === contributionId);
|
||||
|
||||
@@ -1012,6 +1088,38 @@ This PR will be updated automatically when the Auto Run completes.`;
|
||||
return { error: readyResult.error };
|
||||
}
|
||||
|
||||
// Post PR comment with stats (use provided stats or fall back to stored values)
|
||||
const commentStats = stats || {
|
||||
inputTokens: contribution.tokenUsage.inputTokens,
|
||||
outputTokens: contribution.tokenUsage.outputTokens,
|
||||
estimatedCost: contribution.tokenUsage.estimatedCost,
|
||||
timeSpentMs: contribution.timeSpent,
|
||||
documentsProcessed: contribution.progress.completedDocuments,
|
||||
tasksCompleted: contribution.progress.completedTasks,
|
||||
};
|
||||
|
||||
const commentResult = await postPRComment(
|
||||
contribution.localPath,
|
||||
contribution.draftPrNumber,
|
||||
commentStats
|
||||
);
|
||||
|
||||
if (!commentResult.success) {
|
||||
// Log but don't fail - the PR is already ready, comment is just bonus
|
||||
logger.warn('Failed to post PR comment', LOG_CONTEXT, {
|
||||
contributionId,
|
||||
error: commentResult.error,
|
||||
});
|
||||
}
|
||||
|
||||
// Use provided stats for the completed record if available
|
||||
const finalInputTokens = stats?.inputTokens ?? contribution.tokenUsage.inputTokens;
|
||||
const finalOutputTokens = stats?.outputTokens ?? contribution.tokenUsage.outputTokens;
|
||||
const finalCost = stats?.estimatedCost ?? contribution.tokenUsage.estimatedCost;
|
||||
const finalTimeSpent = stats?.timeSpentMs ?? contribution.timeSpent;
|
||||
const finalDocsProcessed = stats?.documentsProcessed ?? contribution.progress.completedDocuments;
|
||||
const finalTasksCompleted = stats?.tasksCompleted ?? contribution.progress.completedTasks;
|
||||
|
||||
// Move to completed
|
||||
const completed: CompletedContribution = {
|
||||
id: contribution.id,
|
||||
@@ -1024,13 +1132,13 @@ This PR will be updated automatically when the Auto Run completes.`;
|
||||
prUrl: contribution.draftPrUrl,
|
||||
prNumber: contribution.draftPrNumber,
|
||||
tokenUsage: {
|
||||
inputTokens: contribution.tokenUsage.inputTokens,
|
||||
outputTokens: contribution.tokenUsage.outputTokens,
|
||||
totalCost: contribution.tokenUsage.estimatedCost,
|
||||
inputTokens: finalInputTokens,
|
||||
outputTokens: finalOutputTokens,
|
||||
totalCost: finalCost,
|
||||
},
|
||||
timeSpent: contribution.timeSpent,
|
||||
documentsProcessed: contribution.progress.completedDocuments,
|
||||
tasksCompleted: contribution.progress.completedTasks,
|
||||
timeSpent: finalTimeSpent,
|
||||
documentsProcessed: finalDocsProcessed,
|
||||
tasksCompleted: finalTasksCompleted,
|
||||
};
|
||||
|
||||
// Update state
|
||||
@@ -1185,7 +1293,8 @@ This PR will be updated automatically when the Auto Run completes.`;
|
||||
|
||||
/**
|
||||
* Start the contribution workflow after session is created.
|
||||
* Creates branch, empty commit, pushes, and creates draft PR.
|
||||
* Creates branch and sets up Auto Run documents.
|
||||
* Draft PR will be created on first real commit (deferred to avoid "no commits" error).
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'symphony:startContribution',
|
||||
@@ -1226,14 +1335,14 @@ This PR will be updated automatically when the Auto Run completes.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check gh CLI authentication
|
||||
// Check gh CLI authentication (needed later for PR creation)
|
||||
const authCheck = await checkGhAuthentication();
|
||||
if (!authCheck.authenticated) {
|
||||
return { success: false, error: authCheck.error };
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Create branch
|
||||
// 1. Create branch and checkout
|
||||
const branchName = generateBranchName(issueNumber);
|
||||
const branchResult = await createBranch(localPath, branchName);
|
||||
if (!branchResult.success) {
|
||||
@@ -1241,52 +1350,19 @@ This PR will be updated automatically when the Auto Run completes.`;
|
||||
return { success: false, error: `Failed to create branch: ${branchResult.error}` };
|
||||
}
|
||||
|
||||
// 2. Empty commit to enable push
|
||||
const commitMessage = `[Symphony] Start contribution for #${issueNumber}`;
|
||||
const commitResult = await execFileNoThrow('git', ['commit', '--allow-empty', '-m', commitMessage], localPath);
|
||||
if (commitResult.exitCode !== 0) {
|
||||
logger.error('Failed to create empty commit', LOG_CONTEXT, { localPath, error: commitResult.stderr });
|
||||
}
|
||||
// 2. Set up Auto Run documents directory
|
||||
// External docs (GitHub attachments) go to cache dir to avoid polluting the repo
|
||||
// Repo-internal docs are referenced in place
|
||||
const symphonyDocsDir = path.join(getSymphonyDir(app), 'contributions', contributionId, 'docs');
|
||||
await fs.mkdir(symphonyDocsDir, { recursive: true });
|
||||
|
||||
// 3. Push branch
|
||||
const pushResult = await execFileNoThrow('git', ['push', '-u', 'origin', branchName], localPath);
|
||||
if (pushResult.exitCode !== 0) {
|
||||
logger.error('Failed to push branch', LOG_CONTEXT, { localPath, branchName, error: pushResult.stderr });
|
||||
return { success: false, error: `Failed to push branch: ${pushResult.stderr}` };
|
||||
}
|
||||
|
||||
// 4. Detect default branch for PR base
|
||||
const baseBranch = await getDefaultBranch(localPath);
|
||||
|
||||
// 5. Create draft PR
|
||||
const prTitle = `[WIP] Symphony: ${issueTitle}`;
|
||||
const prBody = `## Symphony Contribution\n\nCloses #${issueNumber}\n\n*Work in progress via Maestro Symphony*`;
|
||||
const prResult = await execFileNoThrow(
|
||||
'gh',
|
||||
['pr', 'create', '--draft', '--base', baseBranch, '--title', prTitle, '--body', prBody],
|
||||
localPath
|
||||
);
|
||||
if (prResult.exitCode !== 0) {
|
||||
// Attempt to clean up the remote branch
|
||||
logger.warn('PR creation failed, cleaning up remote branch', LOG_CONTEXT, { branchName });
|
||||
await execFileNoThrow('git', ['push', 'origin', '--delete', branchName], localPath);
|
||||
logger.error('Failed to create draft PR', LOG_CONTEXT, { localPath, error: prResult.stderr });
|
||||
return { success: false, error: `Failed to create draft PR: ${prResult.stderr}` };
|
||||
}
|
||||
|
||||
const prUrl = prResult.stdout.trim();
|
||||
const prNumberMatch = prUrl.match(/\/pull\/(\d+)/);
|
||||
const prNumber = prNumberMatch ? parseInt(prNumberMatch[1], 10) : 0;
|
||||
|
||||
// 6. Copy Auto Run documents to local folder
|
||||
const autoRunDir = path.join(localPath, 'Auto Run Docs');
|
||||
await fs.mkdir(autoRunDir, { recursive: true });
|
||||
// Track resolved document paths for Auto Run
|
||||
const resolvedDocs: { name: string; path: string; isExternal: boolean }[] = [];
|
||||
|
||||
for (const doc of documentPaths) {
|
||||
const destPath = path.join(autoRunDir, doc.name);
|
||||
|
||||
if (doc.isExternal) {
|
||||
// Download external file (GitHub attachment)
|
||||
// Download external file (GitHub attachment) to cache directory
|
||||
const destPath = path.join(symphonyDocsDir, doc.name);
|
||||
try {
|
||||
logger.info('Downloading external document', LOG_CONTEXT, { name: doc.name, url: doc.path });
|
||||
const response = await fetch(doc.path);
|
||||
@@ -1296,52 +1372,72 @@ This PR will be updated automatically when the Auto Run completes.`;
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
await fs.writeFile(destPath, Buffer.from(buffer));
|
||||
logger.info('Downloaded document', LOG_CONTEXT, { name: doc.name, to: destPath });
|
||||
logger.info('Downloaded document to cache', LOG_CONTEXT, { name: doc.name, to: destPath });
|
||||
resolvedDocs.push({ name: doc.name, path: destPath, isExternal: true });
|
||||
} catch (e) {
|
||||
logger.warn('Failed to download document', LOG_CONTEXT, { name: doc.name, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
} else {
|
||||
// Copy from repo - ensure the path doesn't escape the localPath
|
||||
// Repo-internal doc - verify it exists and reference in place
|
||||
const resolvedSource = path.resolve(localPath, doc.path);
|
||||
if (!resolvedSource.startsWith(localPath)) {
|
||||
logger.error('Attempted path traversal in document copy', LOG_CONTEXT, { docPath: doc.path });
|
||||
logger.error('Attempted path traversal in document path', LOG_CONTEXT, { docPath: doc.path });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await fs.copyFile(resolvedSource, destPath);
|
||||
logger.info('Copied document', LOG_CONTEXT, { from: resolvedSource, to: destPath });
|
||||
await fs.access(resolvedSource);
|
||||
logger.info('Using repo document', LOG_CONTEXT, { name: doc.name, path: resolvedSource });
|
||||
resolvedDocs.push({ name: doc.name, path: resolvedSource, isExternal: false });
|
||||
} catch (e) {
|
||||
logger.warn('Failed to copy document', LOG_CONTEXT, { docPath: doc.path, error: e instanceof Error ? e.message : String(e) });
|
||||
logger.warn('Document not found in repo', LOG_CONTEXT, { docPath: doc.path, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Broadcast status update
|
||||
// 3. Write contribution metadata for later PR creation
|
||||
const metadataPath = path.join(symphonyDocsDir, '..', 'metadata.json');
|
||||
await fs.writeFile(metadataPath, JSON.stringify({
|
||||
contributionId,
|
||||
sessionId,
|
||||
repoSlug,
|
||||
issueNumber,
|
||||
issueTitle,
|
||||
branchName,
|
||||
localPath,
|
||||
resolvedDocs,
|
||||
startedAt: new Date().toISOString(),
|
||||
prCreated: false,
|
||||
}, null, 2));
|
||||
|
||||
// 4. Determine Auto Run path (use cache dir if we have external docs, otherwise repo path)
|
||||
const hasExternalDocs = resolvedDocs.some(d => d.isExternal);
|
||||
const autoRunPath = hasExternalDocs ? symphonyDocsDir : (resolvedDocs[0]?.path ? path.dirname(resolvedDocs[0].path) : localPath);
|
||||
|
||||
// 5. Broadcast status update (no PR yet - will be created on first commit)
|
||||
const mainWindow = getMainWindow?.();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('symphony:contributionStarted', {
|
||||
contributionId,
|
||||
sessionId,
|
||||
branchName,
|
||||
draftPrNumber: prNumber,
|
||||
draftPrUrl: prUrl,
|
||||
autoRunPath: autoRunDir,
|
||||
autoRunPath,
|
||||
// No PR yet - will be created on first commit
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('Symphony contribution started', LOG_CONTEXT, {
|
||||
contributionId,
|
||||
sessionId,
|
||||
prNumber,
|
||||
documentCount: documentPaths.length,
|
||||
branchName,
|
||||
documentCount: resolvedDocs.length,
|
||||
hasExternalDocs,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
branchName,
|
||||
draftPrNumber: prNumber,
|
||||
draftPrUrl: prUrl,
|
||||
autoRunPath: autoRunDir,
|
||||
autoRunPath,
|
||||
// draftPrNumber and draftPrUrl will be set when PR is created on first commit
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Symphony contribution failed', LOG_CONTEXT, { error });
|
||||
@@ -1354,6 +1450,138 @@ This PR will be updated automatically when the Auto Run completes.`;
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Create draft PR for a contribution (called on first commit).
|
||||
* Reads metadata from the contribution folder, pushes branch, and creates draft PR.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'symphony:createDraftPR',
|
||||
createIpcHandler(
|
||||
handlerOpts('createDraftPR'),
|
||||
async (params: {
|
||||
contributionId: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
draftPrNumber?: number;
|
||||
draftPrUrl?: string;
|
||||
error?: string;
|
||||
}> => {
|
||||
const { contributionId } = params;
|
||||
|
||||
// Read contribution metadata
|
||||
const metadataPath = path.join(getSymphonyDir(app), 'contributions', contributionId, 'metadata.json');
|
||||
let metadata: {
|
||||
contributionId: string;
|
||||
sessionId: string;
|
||||
repoSlug: string;
|
||||
issueNumber: number;
|
||||
issueTitle: string;
|
||||
branchName: string;
|
||||
localPath: string;
|
||||
prCreated: boolean;
|
||||
draftPrNumber?: number;
|
||||
draftPrUrl?: string;
|
||||
};
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(metadataPath, 'utf-8');
|
||||
metadata = JSON.parse(content);
|
||||
} catch (e) {
|
||||
logger.error('Failed to read contribution metadata', LOG_CONTEXT, { contributionId, error: e });
|
||||
return { success: false, error: 'Contribution metadata not found' };
|
||||
}
|
||||
|
||||
// Check if PR already created
|
||||
if (metadata.prCreated && metadata.draftPrUrl) {
|
||||
logger.info('Draft PR already exists', LOG_CONTEXT, { contributionId, prUrl: metadata.draftPrUrl });
|
||||
return {
|
||||
success: true,
|
||||
draftPrNumber: metadata.draftPrNumber,
|
||||
draftPrUrl: metadata.draftPrUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// Check gh CLI authentication
|
||||
const authCheck = await checkGhAuthentication();
|
||||
if (!authCheck.authenticated) {
|
||||
return { success: false, error: authCheck.error };
|
||||
}
|
||||
|
||||
const { localPath, issueNumber, issueTitle, sessionId } = metadata;
|
||||
|
||||
// Check if there are any commits on this branch
|
||||
// Use rev-list to count commits not in the default branch
|
||||
const baseBranch = await getDefaultBranch(localPath);
|
||||
const commitCheckResult = await execFileNoThrow(
|
||||
'git',
|
||||
['rev-list', '--count', `${baseBranch}..HEAD`],
|
||||
localPath
|
||||
);
|
||||
|
||||
const commitCount = parseInt(commitCheckResult.stdout.trim(), 10) || 0;
|
||||
if (commitCount === 0) {
|
||||
// No commits yet - return success but indicate no PR created
|
||||
logger.info('No commits yet, skipping PR creation', LOG_CONTEXT, { contributionId });
|
||||
return {
|
||||
success: true,
|
||||
// No PR fields - caller should know PR wasn't created yet
|
||||
};
|
||||
}
|
||||
|
||||
logger.info('Found commits, creating draft PR', LOG_CONTEXT, { contributionId, commitCount });
|
||||
|
||||
// Create PR title and body
|
||||
const prTitle = `[WIP] Symphony: ${issueTitle} (#${issueNumber})`;
|
||||
const prBody = `## Maestro Symphony Contribution
|
||||
|
||||
Working on #${issueNumber} via [Maestro Symphony](https://runmaestro.ai).
|
||||
|
||||
**Status:** In Progress
|
||||
**Started:** ${new Date().toISOString()}
|
||||
|
||||
---
|
||||
|
||||
This PR will be updated automatically when the Auto Run completes.`;
|
||||
|
||||
// Create draft PR (this also pushes the branch)
|
||||
const prResult = await createDraftPR(localPath, baseBranch, prTitle, prBody);
|
||||
if (!prResult.success) {
|
||||
logger.error('Failed to create draft PR', LOG_CONTEXT, { contributionId, error: prResult.error });
|
||||
return { success: false, error: prResult.error };
|
||||
}
|
||||
|
||||
// Update metadata with PR info
|
||||
metadata.prCreated = true;
|
||||
metadata.draftPrNumber = prResult.prNumber;
|
||||
metadata.draftPrUrl = prResult.prUrl;
|
||||
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
|
||||
// Broadcast PR creation event
|
||||
const mainWindow = getMainWindow?.();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('symphony:prCreated', {
|
||||
contributionId,
|
||||
sessionId,
|
||||
draftPrNumber: prResult.prNumber,
|
||||
draftPrUrl: prResult.prUrl,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('Draft PR created for Symphony contribution', LOG_CONTEXT, {
|
||||
contributionId,
|
||||
prNumber: prResult.prNumber,
|
||||
prUrl: prResult.prUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
draftPrNumber: prResult.prNumber,
|
||||
draftPrUrl: prResult.prUrl,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Handler for fetching document content (from main process to avoid CORS)
|
||||
ipcMain.handle(
|
||||
'symphony:fetchDocumentContent',
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
*
|
||||
* Dialog for selecting an AI provider and creating a dedicated agent session
|
||||
* for a Symphony contribution. Shown when user clicks "Start Symphony" on an issue.
|
||||
*
|
||||
* Features:
|
||||
* - Filters to agents that support batch mode (required for Symphony)
|
||||
* - Accordion-style expandable agent config (Custom Path, Arguments, Env Vars)
|
||||
* - Folder browser for working directory
|
||||
* - Uses shared AgentSelector and AgentConfigPanel components
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
@@ -14,29 +20,19 @@ import {
|
||||
Bot,
|
||||
Settings,
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import type { Theme } from '../types';
|
||||
import type { Theme, AgentConfig } from '../types';
|
||||
import type { RegisteredRepository, SymphonyIssue } from '../../shared/symphony-types';
|
||||
import { useLayerStack } from '../contexts/LayerStackContext';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
import { AgentConfigPanel } from './shared/AgentConfigPanel';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Minimal agent info needed for the agent selection card.
|
||||
* Compatible with the IPC API response (global.d.ts AgentConfig).
|
||||
*/
|
||||
interface AgentInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
available: boolean;
|
||||
path?: string;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentCreationDialogProps {
|
||||
theme: Theme;
|
||||
isOpen: boolean;
|
||||
@@ -51,57 +47,20 @@ export interface AgentCreationConfig {
|
||||
agentType: string;
|
||||
/** Session name (pre-filled, editable) */
|
||||
sessionName: string;
|
||||
/** Working directory (pre-filled, usually not editable) */
|
||||
/** Working directory (pre-filled, editable) */
|
||||
workingDirectory: string;
|
||||
/** Repository being contributed to */
|
||||
repo: RegisteredRepository;
|
||||
/** Issue being worked on */
|
||||
issue: SymphonyIssue;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent Selection Card
|
||||
// ============================================================================
|
||||
|
||||
function AgentCard({
|
||||
agent,
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
agent: AgentInfo;
|
||||
theme: Theme;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className={`w-full p-4 rounded-lg border text-left transition-all hover:bg-white/5 ${
|
||||
isSelected ? 'ring-2' : ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isSelected ? theme.colors.bgActivity : 'transparent',
|
||||
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
|
||||
...(isSelected && { boxShadow: `0 0 0 2px ${theme.colors.accent}` }),
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Bot className="w-6 h-6" style={{ color: isSelected ? theme.colors.accent : theme.colors.textDim }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium" style={{ color: theme.colors.textMain }}>
|
||||
{agent.name}
|
||||
</h4>
|
||||
<p className="text-xs truncate" style={{ color: theme.colors.textDim }}>
|
||||
{agent.path ?? agent.command}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: theme.colors.accent }} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
/** Custom path override for the agent */
|
||||
customPath?: string;
|
||||
/** Custom arguments for the agent */
|
||||
customArgs?: string;
|
||||
/** Custom environment variables */
|
||||
customEnvVars?: Record<string, string>;
|
||||
/** Agent-specific configuration options */
|
||||
agentConfig?: Record<string, any>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -121,23 +80,51 @@ export function AgentCreationDialog({
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
// State
|
||||
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
||||
const [agents, setAgents] = useState<AgentConfig[]>([]);
|
||||
const [isLoadingAgents, setIsLoadingAgents] = useState(true);
|
||||
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
|
||||
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
||||
const [sessionName, setSessionName] = useState('');
|
||||
const [workingDirectory, setWorkingDirectory] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshingAgent, setRefreshingAgent] = useState<string | null>(null);
|
||||
|
||||
// Generate default session name
|
||||
// Per-agent customization state
|
||||
const [customAgentPaths, setCustomAgentPaths] = useState<Record<string, string>>({});
|
||||
const [customAgentArgs, setCustomAgentArgs] = useState<Record<string, string>>({});
|
||||
const [customAgentEnvVars, setCustomAgentEnvVars] = useState<Record<string, Record<string, string>>>({});
|
||||
const [agentConfigs, setAgentConfigs] = useState<Record<string, Record<string, any>>>({});
|
||||
const [availableModels, setAvailableModels] = useState<Record<string, string[]>>({});
|
||||
const [loadingModels, setLoadingModels] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Get currently selected agent object
|
||||
const selectedAgentObj = agents.find(a => a.id === selectedAgent);
|
||||
|
||||
// Filter function: only agents that support batch mode (required for Symphony)
|
||||
const symphonyAgentFilter = useCallback((agent: AgentConfig) => {
|
||||
return (
|
||||
agent.id !== 'terminal' &&
|
||||
agent.available &&
|
||||
!agent.hidden &&
|
||||
agent.capabilities?.supportsBatchMode === true
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Reset all state when dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen && repo && issue) {
|
||||
setSessionName(`Symphony: ${repo.slug} #${issue.number}`);
|
||||
// Default working directory: ~/Maestro-Symphony/{owner}-{repo}/
|
||||
const [owner, repoName] = repo.slug.split('/');
|
||||
// Note: getHomePath may not be available in all contexts
|
||||
const homeDir = '~';
|
||||
setWorkingDirectory(`${homeDir}/Maestro-Symphony/${owner}-${repoName}`);
|
||||
if (isOpen) {
|
||||
// Reset error state
|
||||
setError(null);
|
||||
setIsCreating(false);
|
||||
|
||||
// Generate default values for this repo/issue
|
||||
if (repo && issue) {
|
||||
setSessionName(`Symphony: ${repo.slug} #${issue.number}`);
|
||||
const [owner, repoName] = repo.slug.split('/');
|
||||
const homeDir = '~';
|
||||
setWorkingDirectory(`${homeDir}/Maestro-Symphony/${owner}-${repoName}`);
|
||||
}
|
||||
}
|
||||
}, [isOpen, repo, issue]);
|
||||
|
||||
@@ -145,15 +132,11 @@ export function AgentCreationDialog({
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsLoadingAgents(true);
|
||||
setError(null);
|
||||
window.maestro.agents.detect()
|
||||
.then((detectedAgents: AgentInfo[]) => {
|
||||
// Filter to only agents that are available and not hidden (like terminal)
|
||||
const compatibleAgents = detectedAgents.filter((a: AgentInfo) =>
|
||||
a.id !== 'terminal' && a.available && !a.hidden
|
||||
);
|
||||
setAgents(compatibleAgents);
|
||||
// Auto-select first agent (usually Claude Code)
|
||||
.then((detectedAgents: AgentConfig[]) => {
|
||||
setAgents(detectedAgents);
|
||||
// Auto-select first compatible agent
|
||||
const compatibleAgents = detectedAgents.filter(symphonyAgentFilter);
|
||||
if (compatibleAgents.length > 0 && !selectedAgent) {
|
||||
setSelectedAgent(compatibleAgents[0].id);
|
||||
}
|
||||
@@ -166,7 +149,38 @@ export function AgentCreationDialog({
|
||||
setIsLoadingAgents(false);
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen, symphonyAgentFilter]);
|
||||
|
||||
// Load models for an agent
|
||||
const loadModelsForAgent = useCallback(async (agentId: string, force = false) => {
|
||||
if (!force && availableModels[agentId]) return;
|
||||
|
||||
setLoadingModels(prev => ({ ...prev, [agentId]: true }));
|
||||
try {
|
||||
const models = await window.maestro.agents.getModels(agentId, force);
|
||||
setAvailableModels(prev => ({ ...prev, [agentId]: models || [] }));
|
||||
} catch (err) {
|
||||
console.error('Failed to load models for', agentId, err);
|
||||
} finally {
|
||||
setLoadingModels(prev => ({ ...prev, [agentId]: false }));
|
||||
}
|
||||
}, [availableModels]);
|
||||
|
||||
// Refresh single agent detection
|
||||
const handleRefreshAgent = useCallback(async (agentId: string) => {
|
||||
setRefreshingAgent(agentId);
|
||||
try {
|
||||
const result = await window.maestro.agents.refresh(agentId);
|
||||
if (result?.agents) {
|
||||
// Update all agents with the refreshed data
|
||||
setAgents(result.agents);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh agent:', err);
|
||||
} finally {
|
||||
setRefreshingAgent(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Layer stack registration
|
||||
useEffect(() => {
|
||||
@@ -184,6 +198,26 @@ export function AgentCreationDialog({
|
||||
}
|
||||
}, [isOpen, registerLayer, unregisterLayer]);
|
||||
|
||||
// Handle folder selection
|
||||
const handleSelectFolder = useCallback(async () => {
|
||||
const folder = await window.maestro.dialog.selectFolder();
|
||||
if (folder) {
|
||||
setWorkingDirectory(folder);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle agent selection (also expands it)
|
||||
const handleSelectAgent = useCallback((agentId: string) => {
|
||||
setSelectedAgent(agentId);
|
||||
setExpandedAgent(prev => prev === agentId ? null : agentId);
|
||||
|
||||
// Load models if agent supports model selection
|
||||
const agent = agents.find(a => a.id === agentId);
|
||||
if (agent?.capabilities?.supportsModelSelection) {
|
||||
loadModelsForAgent(agentId);
|
||||
}
|
||||
}, [agents, loadModelsForAgent]);
|
||||
|
||||
// Handle create
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!selectedAgent || !sessionName.trim()) return;
|
||||
@@ -198,6 +232,10 @@ export function AgentCreationDialog({
|
||||
workingDirectory,
|
||||
repo,
|
||||
issue,
|
||||
customPath: customAgentPaths[selectedAgent] || undefined,
|
||||
customArgs: customAgentArgs[selectedAgent] || undefined,
|
||||
customEnvVars: customAgentEnvVars[selectedAgent] || undefined,
|
||||
agentConfig: agentConfigs[selectedAgent] || undefined,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
@@ -209,10 +247,13 @@ export function AgentCreationDialog({
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [selectedAgent, sessionName, workingDirectory, repo, issue, onCreateAgent]);
|
||||
}, [selectedAgent, sessionName, workingDirectory, repo, issue, customAgentPaths, customAgentArgs, customAgentEnvVars, agentConfigs, onCreateAgent]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Get filtered agents
|
||||
const filteredAgents = agents.filter(symphonyAgentFilter);
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 modal-overlay flex items-center justify-center z-[9999] animate-in fade-in duration-100"
|
||||
@@ -223,11 +264,11 @@ export function AgentCreationDialog({
|
||||
aria-modal="true"
|
||||
aria-labelledby="agent-creation-dialog-title"
|
||||
tabIndex={-1}
|
||||
className="w-[500px] max-w-[95vw] rounded-xl shadow-2xl border overflow-hidden flex flex-col outline-none"
|
||||
className="w-[550px] max-w-[95vw] max-h-[90vh] rounded-xl shadow-2xl border overflow-hidden flex flex-col outline-none"
|
||||
style={{ backgroundColor: theme.colors.bgActivity, borderColor: theme.colors.border }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: theme.colors.border }}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0" style={{ borderColor: theme.colors.border }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Music className="w-5 h-5" style={{ color: theme.colors.accent }} />
|
||||
<h2 id="agent-creation-dialog-title" className="text-lg font-semibold" style={{ color: theme.colors.textMain }}>
|
||||
@@ -239,8 +280,8 @@ export function AgentCreationDialog({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Content - scrollable */}
|
||||
<div className="p-4 space-y-4 overflow-y-auto flex-1">
|
||||
{/* Issue info */}
|
||||
<div className="p-3 rounded-lg" style={{ backgroundColor: theme.colors.bgMain }}>
|
||||
<p className="text-xs mb-1" style={{ color: theme.colors.textDim }}>Contributing to</p>
|
||||
@@ -253,31 +294,189 @@ export function AgentCreationDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Agent selection */}
|
||||
{/* Agent selection with accordion */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: theme.colors.textMain }}>
|
||||
<Bot className="w-4 h-4 inline mr-1" />
|
||||
Select AI Provider
|
||||
</label>
|
||||
|
||||
{isLoadingAgents ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin" style={{ color: theme.colors.accent }} />
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
) : filteredAgents.length === 0 ? (
|
||||
<div className="text-center py-4" style={{ color: theme.colors.textDim }}>
|
||||
No AI agents detected. Please install Claude Code or another supported agent.
|
||||
<p>No compatible AI agents detected.</p>
|
||||
<p className="text-xs mt-1">Symphony requires an agent with batch mode support (Claude Code, Codex, or OpenCode).</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{agents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
theme={theme}
|
||||
isSelected={selectedAgent === agent.id}
|
||||
onSelect={() => setSelectedAgent(agent.id)}
|
||||
/>
|
||||
))}
|
||||
{filteredAgents.map((agent) => {
|
||||
const isSelected = selectedAgent === agent.id;
|
||||
const isExpanded = expandedAgent === agent.id;
|
||||
const isBetaAgent = agent.id === 'codex' || agent.id === 'opencode';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="rounded-lg border transition-all"
|
||||
style={{
|
||||
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
|
||||
...(isSelected && { boxShadow: `0 0 0 2px ${theme.colors.accent}` }),
|
||||
}}
|
||||
>
|
||||
{/* Agent header row */}
|
||||
<div
|
||||
onClick={() => handleSelectAgent(agent.id)}
|
||||
className="w-full text-left px-3 py-2 flex items-center justify-between hover:bg-white/5 cursor-pointer"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronRight
|
||||
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
style={{ color: theme.colors.textDim }}
|
||||
/>
|
||||
<span className="font-medium">{agent.name}</span>
|
||||
{isBetaAgent && (
|
||||
<span
|
||||
className="text-[9px] px-1.5 py-0.5 rounded font-bold uppercase"
|
||||
style={{
|
||||
backgroundColor: theme.colors.warning + '30',
|
||||
color: theme.colors.warning,
|
||||
}}
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded"
|
||||
style={{ backgroundColor: theme.colors.success + '20', color: theme.colors.success }}
|
||||
>
|
||||
Available
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRefreshAgent(agent.id);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors"
|
||||
title="Refresh detection"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${refreshingAgent === agent.id ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded config panel */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 pt-2 border-t" style={{ borderColor: theme.colors.border }}>
|
||||
<AgentConfigPanel
|
||||
theme={theme}
|
||||
agent={agent}
|
||||
customPath={customAgentPaths[agent.id] || ''}
|
||||
onCustomPathChange={(value) => {
|
||||
setCustomAgentPaths(prev => ({ ...prev, [agent.id]: value }));
|
||||
}}
|
||||
onCustomPathBlur={() => {}}
|
||||
onCustomPathClear={() => {
|
||||
setCustomAgentPaths(prev => {
|
||||
const newPaths = { ...prev };
|
||||
delete newPaths[agent.id];
|
||||
return newPaths;
|
||||
});
|
||||
}}
|
||||
customArgs={customAgentArgs[agent.id] || ''}
|
||||
onCustomArgsChange={(value) => {
|
||||
setCustomAgentArgs(prev => ({ ...prev, [agent.id]: value }));
|
||||
}}
|
||||
onCustomArgsBlur={() => {}}
|
||||
onCustomArgsClear={() => {
|
||||
setCustomAgentArgs(prev => {
|
||||
const newArgs = { ...prev };
|
||||
delete newArgs[agent.id];
|
||||
return newArgs;
|
||||
});
|
||||
}}
|
||||
customEnvVars={customAgentEnvVars[agent.id] || {}}
|
||||
onEnvVarKeyChange={(oldKey, newKey, value) => {
|
||||
const currentVars = { ...customAgentEnvVars[agent.id] };
|
||||
delete currentVars[oldKey];
|
||||
currentVars[newKey] = value;
|
||||
setCustomAgentEnvVars(prev => ({
|
||||
...prev,
|
||||
[agent.id]: currentVars
|
||||
}));
|
||||
}}
|
||||
onEnvVarValueChange={(key, value) => {
|
||||
setCustomAgentEnvVars(prev => ({
|
||||
...prev,
|
||||
[agent.id]: {
|
||||
...prev[agent.id],
|
||||
[key]: value
|
||||
}
|
||||
}));
|
||||
}}
|
||||
onEnvVarRemove={(key) => {
|
||||
const currentVars = { ...customAgentEnvVars[agent.id] };
|
||||
delete currentVars[key];
|
||||
if (Object.keys(currentVars).length > 0) {
|
||||
setCustomAgentEnvVars(prev => ({
|
||||
...prev,
|
||||
[agent.id]: currentVars
|
||||
}));
|
||||
} else {
|
||||
setCustomAgentEnvVars(prev => {
|
||||
const newVars = { ...prev };
|
||||
delete newVars[agent.id];
|
||||
return newVars;
|
||||
});
|
||||
}
|
||||
}}
|
||||
onEnvVarAdd={() => {
|
||||
const currentVars = customAgentEnvVars[agent.id] || {};
|
||||
let newKey = 'NEW_VAR';
|
||||
let counter = 1;
|
||||
while (currentVars[newKey]) {
|
||||
newKey = `NEW_VAR_${counter}`;
|
||||
counter++;
|
||||
}
|
||||
setCustomAgentEnvVars(prev => ({
|
||||
...prev,
|
||||
[agent.id]: {
|
||||
...prev[agent.id],
|
||||
[newKey]: ''
|
||||
}
|
||||
}));
|
||||
}}
|
||||
onEnvVarsBlur={() => {}}
|
||||
agentConfig={agentConfigs[agent.id] || {}}
|
||||
onConfigChange={(key, value) => {
|
||||
setAgentConfigs(prev => ({
|
||||
...prev,
|
||||
[agent.id]: {
|
||||
...prev[agent.id],
|
||||
[key]: value
|
||||
}
|
||||
}));
|
||||
}}
|
||||
onConfigBlur={() => {}}
|
||||
availableModels={availableModels[agent.id] || []}
|
||||
loadingModels={loadingModels[agent.id] || false}
|
||||
onRefreshModels={() => loadModelsForAgent(agent.id, true)}
|
||||
onRefreshAgent={() => handleRefreshAgent(agent.id)}
|
||||
refreshingAgent={refreshingAgent === agent.id}
|
||||
compact
|
||||
showBuiltInEnvVars
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -298,18 +497,29 @@ export function AgentCreationDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Working directory (read-only display) */}
|
||||
{/* Working directory (editable with folder browser) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: theme.colors.textMain }}>
|
||||
<FolderOpen className="w-4 h-4 inline mr-1" />
|
||||
Working Directory
|
||||
</label>
|
||||
<div
|
||||
className="px-3 py-2 rounded border text-sm truncate"
|
||||
style={{ backgroundColor: theme.colors.bgMain, borderColor: theme.colors.border, color: theme.colors.textDim }}
|
||||
title={workingDirectory}
|
||||
>
|
||||
{workingDirectory}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={workingDirectory}
|
||||
onChange={(e) => setWorkingDirectory(e.target.value)}
|
||||
className="flex-1 px-3 py-2 rounded border bg-transparent outline-none text-sm focus:ring-1"
|
||||
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
||||
placeholder="~/Maestro-Symphony/owner-repo"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSelectFolder}
|
||||
className="px-3 py-2 rounded border hover:bg-white/10 transition-colors"
|
||||
style={{ borderColor: theme.colors.border, color: theme.colors.textDim }}
|
||||
title="Browse for folder"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs mt-1" style={{ color: theme.colors.textDim }}>
|
||||
Repository will be cloned here
|
||||
@@ -325,7 +535,7 @@ export function AgentCreationDialog({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-4 py-3 border-t" style={{ borderColor: theme.colors.border }}>
|
||||
<div className="flex items-center justify-end gap-3 px-4 py-3 border-t shrink-0" style={{ borderColor: theme.colors.border }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded text-sm hover:bg-white/10 transition-colors"
|
||||
@@ -335,7 +545,7 @@ export function AgentCreationDialog({
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!selectedAgent || !sessionName.trim() || isCreating || agents.length === 0}
|
||||
disabled={!selectedAgent || !sessionName.trim() || isCreating || filteredAgents.length === 0}
|
||||
className="px-4 py-2 rounded font-semibold text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
style={{ backgroundColor: theme.colors.accent, color: theme.colors.accentForeground }}
|
||||
>
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
FileText,
|
||||
Hash,
|
||||
ChevronDown,
|
||||
HelpCircle,
|
||||
Github,
|
||||
} from 'lucide-react';
|
||||
import type { Theme } from '../types';
|
||||
import type {
|
||||
@@ -59,11 +61,25 @@ import { generateProseStyles, createMarkdownComponents } from '../utils/markdown
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface SymphonyContributionData {
|
||||
contributionId: string;
|
||||
localPath: string;
|
||||
autoRunPath?: string;
|
||||
branchName?: string;
|
||||
agentType: string;
|
||||
sessionName: string;
|
||||
repo: RegisteredRepository;
|
||||
issue: SymphonyIssue;
|
||||
customPath?: string;
|
||||
customArgs?: string;
|
||||
customEnvVars?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SymphonyModalProps {
|
||||
theme: Theme;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onStartContribution: (contributionId: string, localPath: string) => void;
|
||||
onStartContribution: (data: SymphonyContributionData) => void;
|
||||
}
|
||||
|
||||
type ModalTab = 'projects' | 'active' | 'history' | 'stats';
|
||||
@@ -424,39 +440,42 @@ function RepositoryDetailView({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 px-4 py-3 border-b shrink-0" style={{ borderColor: theme.colors.border }}>
|
||||
<button onClick={onBack} className="p-1.5 rounded hover:bg-white/10 transition-colors" title="Back (Esc)">
|
||||
<ArrowLeft className="w-5 h-5" style={{ color: theme.colors.textDim }} />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-xs flex items-center gap-1"
|
||||
style={{ backgroundColor: `${theme.colors.accent}20`, color: theme.colors.accent }}
|
||||
>
|
||||
<span>{categoryInfo.emoji}</span>
|
||||
<span>{categoryInfo.label}</span>
|
||||
</span>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0" style={{ borderColor: theme.colors.border }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onBack} className="p-1.5 rounded hover:bg-white/10 transition-colors" title="Back (Esc)">
|
||||
<ArrowLeft className="w-5 h-5" style={{ color: theme.colors.textDim }} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Music className="w-5 h-5" style={{ color: theme.colors.accent }} />
|
||||
<h2 className="text-lg font-semibold" style={{ color: theme.colors.textMain }}>
|
||||
Maestro Symphony: {repo.name}
|
||||
</h2>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold truncate" style={{ color: theme.colors.textMain }}>
|
||||
{repo.name}
|
||||
</h2>
|
||||
</div>
|
||||
<a
|
||||
href={repo.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1.5 rounded hover:bg-white/10 transition-colors"
|
||||
title="View on GitHub"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleOpenExternal(repo.url);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" style={{ color: theme.colors.textDim }} />
|
||||
</a>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-xs flex items-center gap-1"
|
||||
style={{ backgroundColor: `${theme.colors.accent}20`, color: theme.colors.accent }}
|
||||
>
|
||||
<span>{categoryInfo.emoji}</span>
|
||||
<span>{categoryInfo.label}</span>
|
||||
</span>
|
||||
<a
|
||||
href={repo.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1.5 rounded hover:bg-white/10 transition-colors"
|
||||
title="View repository on GitHub"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleOpenExternal(repo.url);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" style={{ color: theme.colors.textDim }} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -555,9 +574,26 @@ function RepositoryDetailView({
|
||||
{selectedIssue ? (
|
||||
<>
|
||||
<div className="px-4 py-3 border-b shrink-0" style={{ borderColor: theme.colors.border }}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm" style={{ color: theme.colors.textDim }}>#{selectedIssue.number}</span>
|
||||
<h3 className="font-semibold" style={{ color: theme.colors.textMain }}>{selectedIssue.title}</h3>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm" style={{ color: theme.colors.textDim }}>#{selectedIssue.number}</span>
|
||||
<h3 className="font-semibold" style={{ color: theme.colors.textMain }}>{selectedIssue.title}</h3>
|
||||
</div>
|
||||
<a
|
||||
href={selectedIssue.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs hover:underline flex items-center gap-1"
|
||||
style={{ color: theme.colors.accent }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleOpenExternal(selectedIssue.htmlUrl);
|
||||
}}
|
||||
title="View issue on GitHub"
|
||||
>
|
||||
View Issue
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs" style={{ color: theme.colors.textDim }}>
|
||||
<FileText className="w-3 h-3" />
|
||||
@@ -997,9 +1033,14 @@ export function SymphonyModal({
|
||||
const [isLoadingDocument, setIsLoadingDocument] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [showAgentDialog, setShowAgentDialog] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const tileGridRef = useRef<HTMLDivElement>(null);
|
||||
const helpButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const showDetailViewRef = useRef(showDetailView);
|
||||
const showHelpRef = useRef(showHelp);
|
||||
showHelpRef.current = showHelp;
|
||||
showDetailViewRef.current = showDetailView;
|
||||
|
||||
// Reset on filter change
|
||||
@@ -1029,7 +1070,9 @@ export function SymphonyModal({
|
||||
focusTrap: 'strict',
|
||||
ariaLabel: 'Maestro Symphony',
|
||||
onEscape: () => {
|
||||
if (showDetailViewRef.current) {
|
||||
if (showHelpRef.current) {
|
||||
setShowHelp(false);
|
||||
} else if (showDetailViewRef.current) {
|
||||
handleBackRef.current();
|
||||
} else {
|
||||
onCloseRef.current();
|
||||
@@ -1040,13 +1083,13 @@ export function SymphonyModal({
|
||||
}
|
||||
}, [isOpen, registerLayer, unregisterLayer]);
|
||||
|
||||
// Focus search
|
||||
// Focus tile grid for keyboard navigation (keyboard-first design)
|
||||
useEffect(() => {
|
||||
if (isOpen && activeTab === 'projects') {
|
||||
const timer = setTimeout(() => searchInputRef.current?.focus(), 50);
|
||||
if (isOpen && activeTab === 'projects' && !showDetailView) {
|
||||
const timer = setTimeout(() => tileGridRef.current?.focus(), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isOpen, activeTab]);
|
||||
}, [isOpen, activeTab, showDetailView]);
|
||||
|
||||
// Select repo
|
||||
const handleSelectRepo = useCallback(async (repo: RegisteredRepository) => {
|
||||
@@ -1106,7 +1149,8 @@ export function SymphonyModal({
|
||||
config.repo,
|
||||
config.issue,
|
||||
config.agentType,
|
||||
'' // session ID will be generated by the backend
|
||||
'', // session ID will be generated by the backend
|
||||
config.workingDirectory // Pass the working directory for cloning
|
||||
);
|
||||
setIsStarting(false);
|
||||
|
||||
@@ -1116,8 +1160,20 @@ export function SymphonyModal({
|
||||
// Switch to Active tab
|
||||
setActiveTab('active');
|
||||
handleBack();
|
||||
// Notify parent with contribution ID and working directory
|
||||
onStartContribution(result.contributionId, config.workingDirectory);
|
||||
// Notify parent with all data needed to create the session
|
||||
onStartContribution({
|
||||
contributionId: result.contributionId,
|
||||
localPath: config.workingDirectory,
|
||||
autoRunPath: result.autoRunPath,
|
||||
branchName: result.branchName,
|
||||
agentType: config.agentType,
|
||||
sessionName: config.sessionName,
|
||||
repo: config.repo,
|
||||
issue: config.issue,
|
||||
customPath: config.customPath,
|
||||
customArgs: config.customArgs,
|
||||
customEnvVars: config.customEnvVars,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -1146,6 +1202,21 @@ export function SymphonyModal({
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (activeTab !== 'projects' || showDetailView) return;
|
||||
|
||||
// "/" to focus search (vim-style)
|
||||
if (e.key === '/' && !(e.target instanceof HTMLInputElement)) {
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape from search returns focus to grid
|
||||
if (e.key === 'Escape' && e.target instanceof HTMLInputElement) {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
tileGridRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const total = filteredRepositories.length;
|
||||
if (total === 0) return;
|
||||
if (e.target instanceof HTMLInputElement && !['ArrowDown', 'ArrowUp'].includes(e.key)) return;
|
||||
@@ -1163,6 +1234,10 @@ export function SymphonyModal({
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedTileIndex((i) => Math.min(total - 1, i + gridColumns));
|
||||
// If we're in the search box, move focus to grid
|
||||
if (e.target instanceof HTMLInputElement) {
|
||||
tileGridRef.current?.focus();
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
@@ -1223,6 +1298,84 @@ export function SymphonyModal({
|
||||
<h2 id="symphony-modal-title" className="text-lg font-semibold" style={{ color: theme.colors.textMain }}>
|
||||
Maestro Symphony
|
||||
</h2>
|
||||
{/* Help button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={helpButtonRef}
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors"
|
||||
title="About Maestro Symphony"
|
||||
aria-label="Help"
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" style={{ color: theme.colors.textDim }} />
|
||||
</button>
|
||||
{showHelp && (
|
||||
<div
|
||||
className="absolute top-full left-0 mt-2 w-80 p-4 rounded-lg shadow-xl z-50"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgSidebar,
|
||||
border: `1px solid ${theme.colors.border}`,
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
className="text-sm font-semibold mb-2"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
>
|
||||
About Maestro Symphony
|
||||
</h3>
|
||||
<p className="text-xs mb-3" style={{ color: theme.colors.textDim }}>
|
||||
Symphony connects Maestro users with open source projects seeking AI-assisted
|
||||
contributions. Browse projects, find issues labeled with <code className="px-1 py-0.5 rounded text-xs" style={{ backgroundColor: theme.colors.bgActivity }}>runmaestro.ai</code>,
|
||||
and contribute by running Auto Run documents that maintainers have prepared.
|
||||
</p>
|
||||
<h4
|
||||
className="text-xs font-semibold mb-1"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
>
|
||||
Register Your Project
|
||||
</h4>
|
||||
<p className="text-xs mb-2" style={{ color: theme.colors.textDim }}>
|
||||
Want to receive Symphony contributions for your open source project?
|
||||
Add your repository to the registry:
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
window.maestro.shell.openExternal(
|
||||
'https://docs.runmaestro.ai/symphony'
|
||||
);
|
||||
setShowHelp(false);
|
||||
}}
|
||||
className="text-xs hover:opacity-80 transition-colors"
|
||||
style={{ color: theme.colors.accent }}
|
||||
>
|
||||
docs.runmaestro.ai/symphony
|
||||
</button>
|
||||
<div className="mt-3 pt-3 border-t" style={{ borderColor: theme.colors.border }}>
|
||||
<button
|
||||
onClick={() => setShowHelp(false)}
|
||||
className="text-xs px-2 py-1 rounded hover:bg-white/10 transition-colors"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Register Project link */}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.maestro.shell.openExternal(
|
||||
'https://docs.runmaestro.ai/symphony'
|
||||
);
|
||||
}}
|
||||
className="px-2 py-1 rounded hover:bg-white/10 transition-colors flex items-center gap-1.5 text-xs"
|
||||
title="Register your project for Symphony contributions"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
>
|
||||
<Github className="w-3.5 h-3.5" />
|
||||
<span>Register Your Project</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{activeTab === 'projects' && (
|
||||
@@ -1354,7 +1507,13 @@ export function SymphonyModal({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div
|
||||
ref={tileGridRef}
|
||||
tabIndex={0}
|
||||
className="grid grid-cols-3 gap-4 outline-none"
|
||||
role="grid"
|
||||
aria-label="Repository tiles"
|
||||
>
|
||||
{filteredRepositories.map((repo, index) => (
|
||||
<RepositoryTile
|
||||
key={repo.slug}
|
||||
@@ -1374,7 +1533,7 @@ export function SymphonyModal({
|
||||
style={{ borderColor: theme.colors.border, color: theme.colors.textDim }}
|
||||
>
|
||||
<span>{filteredRepositories.length} repositories • Contribute to open source with AI</span>
|
||||
<span>Arrow keys to navigate • Enter to select</span>
|
||||
<span>↑↓←→ navigate • Enter select • / search</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
257
src/renderer/components/shared/AgentSelector.tsx
Normal file
257
src/renderer/components/shared/AgentSelector.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* AgentSelector.tsx
|
||||
*
|
||||
* Shared component for selecting AI agents across the application.
|
||||
* Used by:
|
||||
* - AgentCreationDialog (Symphony)
|
||||
* - NewInstanceModal (new session creation)
|
||||
* - Wizard AgentSelectionScreen
|
||||
* - Group chat modals
|
||||
*
|
||||
* Features:
|
||||
* - Renders agent cards with selection state
|
||||
* - Shows availability status (Available/Not Found/Coming Soon)
|
||||
* - Optional filtering by capabilities (e.g., supportsBatchMode)
|
||||
* - Optional expandable config panel integration
|
||||
* - Keyboard navigation support
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Bot, RefreshCw } from 'lucide-react';
|
||||
import type { Theme, AgentConfig } from '../../types';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface AgentSelectorProps {
|
||||
theme: Theme;
|
||||
/** List of agents to display */
|
||||
agents: AgentConfig[];
|
||||
/** Currently selected agent ID */
|
||||
selectedAgentId: string | null;
|
||||
/** Called when an agent is selected */
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
/** Optional: Show loading state */
|
||||
isLoading?: boolean;
|
||||
/** Optional: Filter function to determine which agents to show */
|
||||
filterFn?: (agent: AgentConfig) => boolean;
|
||||
/** Optional: Custom empty state message */
|
||||
emptyMessage?: React.ReactNode;
|
||||
/** Optional: Show refresh button and handler */
|
||||
onRefreshAgent?: (agentId: string) => void;
|
||||
/** Optional: Agent currently being refreshed */
|
||||
refreshingAgentId?: string | null;
|
||||
/** Optional: Render custom content below agent info (e.g., config panel) */
|
||||
renderExpandedContent?: (agent: AgentConfig) => React.ReactNode;
|
||||
/** Optional: Control which agent is expanded (for expandable mode) */
|
||||
expandedAgentId?: string | null;
|
||||
/** Optional: Compact mode (less padding) */
|
||||
compact?: boolean;
|
||||
/** Optional: Show "Beta" badge for certain agents */
|
||||
showBetaBadge?: boolean;
|
||||
/** Optional: Show "Coming Soon" for unsupported agents */
|
||||
showComingSoon?: boolean;
|
||||
/** Optional: List of supported agent IDs (others shown as "Coming Soon") */
|
||||
supportedAgentIds?: string[];
|
||||
}
|
||||
|
||||
export interface AgentCardProps {
|
||||
agent: AgentConfig;
|
||||
theme: Theme;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
compact?: boolean;
|
||||
showBetaBadge?: boolean;
|
||||
isSupported?: boolean;
|
||||
showComingSoon?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AgentCard Component
|
||||
// ============================================================================
|
||||
|
||||
export function AgentCard({
|
||||
agent,
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
compact,
|
||||
showBetaBadge,
|
||||
isSupported = true,
|
||||
showComingSoon,
|
||||
}: AgentCardProps) {
|
||||
const isBetaAgent = agent.id === 'codex' || agent.id === 'opencode';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={isSupported ? onSelect : undefined}
|
||||
disabled={!isSupported}
|
||||
className={`w-full rounded-lg border text-left transition-all ${
|
||||
compact ? 'p-3' : 'p-4'
|
||||
} ${
|
||||
isSelected ? 'ring-2' : ''
|
||||
} ${
|
||||
!isSupported ? 'opacity-50 cursor-not-allowed' : 'hover:bg-white/5'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isSelected ? theme.colors.bgActivity : 'transparent',
|
||||
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
|
||||
...(isSelected && { boxShadow: `0 0 0 2px ${theme.colors.accent}` }),
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Bot
|
||||
className={compact ? 'w-5 h-5' : 'w-6 h-6'}
|
||||
style={{ color: isSelected ? theme.colors.accent : theme.colors.textDim }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium" style={{ color: theme.colors.textMain }}>
|
||||
{agent.name}
|
||||
</h4>
|
||||
{showBetaBadge && isBetaAgent && (
|
||||
<span
|
||||
className="text-[9px] px-1.5 py-0.5 rounded font-bold uppercase"
|
||||
style={{
|
||||
backgroundColor: theme.colors.warning + '30',
|
||||
color: theme.colors.warning,
|
||||
}}
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs truncate" style={{ color: theme.colors.textDim }}>
|
||||
{agent.path ?? agent.command}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSupported ? (
|
||||
<>
|
||||
{agent.available ? (
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded"
|
||||
style={{ backgroundColor: theme.colors.success + '20', color: theme.colors.success }}
|
||||
>
|
||||
Available
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded"
|
||||
style={{ backgroundColor: theme.colors.error + '20', color: theme.colors.error }}
|
||||
>
|
||||
Not Found
|
||||
</span>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRefresh();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors"
|
||||
title="Refresh detection"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
{isSelected && (
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: theme.colors.accent }} />
|
||||
)}
|
||||
</>
|
||||
) : showComingSoon ? (
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded"
|
||||
style={{ backgroundColor: theme.colors.warning + '20', color: theme.colors.warning }}
|
||||
>
|
||||
Coming Soon
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AgentSelector Component
|
||||
// ============================================================================
|
||||
|
||||
export function AgentSelector({
|
||||
theme,
|
||||
agents,
|
||||
selectedAgentId,
|
||||
onSelectAgent,
|
||||
isLoading,
|
||||
filterFn,
|
||||
emptyMessage,
|
||||
onRefreshAgent,
|
||||
refreshingAgentId,
|
||||
renderExpandedContent,
|
||||
expandedAgentId,
|
||||
compact,
|
||||
showBetaBadge,
|
||||
showComingSoon,
|
||||
supportedAgentIds,
|
||||
}: AgentSelectorProps) {
|
||||
// Apply filter if provided
|
||||
const filteredAgents = filterFn ? agents.filter(filterFn) : agents;
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="w-6 h-6 animate-spin" style={{ color: theme.colors.accent }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (filteredAgents.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-4" style={{ color: theme.colors.textDim }}>
|
||||
{emptyMessage || 'No AI agents detected. Please install Claude Code or another supported agent.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filteredAgents.map((agent) => {
|
||||
const isSupported = supportedAgentIds ? supportedAgentIds.includes(agent.id) : true;
|
||||
const isExpanded = expandedAgentId === agent.id;
|
||||
|
||||
return (
|
||||
<div key={agent.id}>
|
||||
<AgentCard
|
||||
agent={agent}
|
||||
theme={theme}
|
||||
isSelected={selectedAgentId === agent.id}
|
||||
onSelect={() => onSelectAgent(agent.id)}
|
||||
onRefresh={onRefreshAgent ? () => onRefreshAgent(agent.id) : undefined}
|
||||
isRefreshing={refreshingAgentId === agent.id}
|
||||
compact={compact}
|
||||
showBetaBadge={showBetaBadge}
|
||||
isSupported={isSupported}
|
||||
showComingSoon={showComingSoon}
|
||||
/>
|
||||
{/* Expanded content (e.g., AgentConfigPanel) */}
|
||||
{isExpanded && renderExpandedContent && (
|
||||
<div className="mt-2 ml-8">
|
||||
{renderExpandedContent(agent)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentSelector;
|
||||
@@ -54,10 +54,11 @@ export interface UseSymphonyReturn {
|
||||
|
||||
// Actions
|
||||
refresh: (force?: boolean) => Promise<void>;
|
||||
startContribution: (repo: RegisteredRepository, issue: SymphonyIssue, agentType: string, sessionId: string) => Promise<{
|
||||
startContribution: (repo: RegisteredRepository, issue: SymphonyIssue, agentType: string, sessionId: string, workingDirectory?: string) => Promise<{
|
||||
success: boolean;
|
||||
contributionId?: string;
|
||||
draftPrUrl?: string;
|
||||
branchName?: string;
|
||||
autoRunPath?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
cancelContribution: (contributionId: string, cleanup?: boolean) => Promise<{ success: boolean }>;
|
||||
@@ -243,39 +244,56 @@ export function useSymphony(): UseSymphonyReturn {
|
||||
repo: RegisteredRepository,
|
||||
issue: SymphonyIssue,
|
||||
agentType: string,
|
||||
sessionId: string
|
||||
): Promise<{ success: boolean; contributionId?: string; draftPrUrl?: string; error?: string }> => {
|
||||
sessionId: string,
|
||||
workingDirectory?: string
|
||||
): Promise<{ success: boolean; contributionId?: string; branchName?: string; autoRunPath?: string; error?: string }> => {
|
||||
try {
|
||||
// This single action will:
|
||||
// 1. Clone the repository
|
||||
// 2. Create a branch (symphony/issue-{number}-{timestamp})
|
||||
// 3. Create an empty commit
|
||||
// 4. Push the branch
|
||||
// 5. Open a draft PR (claims the issue)
|
||||
// 6. Set up Auto Run with the document paths from the issue
|
||||
const result = await window.maestro.symphony.start({
|
||||
repoSlug: repo.slug,
|
||||
// Generate contribution ID
|
||||
const timestamp = Date.now().toString(36);
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
const contributionId = `contrib_${timestamp}_${random}`;
|
||||
|
||||
// Determine local path for the clone
|
||||
const localPath = workingDirectory || `/tmp/symphony/${repo.name}-${contributionId}`;
|
||||
|
||||
// Step 1: Clone the repository
|
||||
const cloneResult = await window.maestro.symphony.cloneRepo({
|
||||
repoUrl: repo.url,
|
||||
repoName: repo.name,
|
||||
issueNumber: issue.number,
|
||||
issueTitle: issue.title,
|
||||
documentPaths: issue.documentPaths,
|
||||
agentType,
|
||||
sessionId,
|
||||
localPath,
|
||||
});
|
||||
|
||||
if (result.contributionId) {
|
||||
await fetchSymphonyState();
|
||||
if (!cloneResult.success) {
|
||||
return {
|
||||
success: true,
|
||||
contributionId: result.contributionId,
|
||||
draftPrUrl: result.draftPrUrl,
|
||||
success: false,
|
||||
error: cloneResult.error ?? 'Failed to clone repository',
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Start contribution (creates branch, sets up docs, NO PR yet)
|
||||
// Draft PR will be created on first real commit via symphony:createDraftPR
|
||||
const startResult = await window.maestro.symphony.startContribution({
|
||||
contributionId,
|
||||
sessionId,
|
||||
repoSlug: repo.slug,
|
||||
issueNumber: issue.number,
|
||||
issueTitle: issue.title,
|
||||
localPath,
|
||||
documentPaths: issue.documentPaths,
|
||||
});
|
||||
|
||||
if (!startResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: startResult.error ?? 'Failed to start contribution',
|
||||
};
|
||||
}
|
||||
|
||||
await fetchSymphonyState();
|
||||
return {
|
||||
success: false,
|
||||
error: result.error ?? 'Unknown error',
|
||||
success: true,
|
||||
contributionId,
|
||||
branchName: startResult.branchName,
|
||||
autoRunPath: startResult.autoRunPath,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
|
||||
@@ -198,10 +198,10 @@ export interface SymphonySessionMetadata {
|
||||
issueNumber: number;
|
||||
/** Issue title for display */
|
||||
issueTitle: string;
|
||||
/** Draft PR number (created immediately to claim) */
|
||||
draftPrNumber: number;
|
||||
/** Draft PR URL */
|
||||
draftPrUrl: string;
|
||||
/** Draft PR number (set after first commit) */
|
||||
draftPrNumber?: number;
|
||||
/** Draft PR URL (set after first commit) */
|
||||
draftPrUrl?: string;
|
||||
/** Auto Run document paths from the issue */
|
||||
documentPaths: string[];
|
||||
/** Contribution status */
|
||||
|
||||
Reference in New Issue
Block a user