- 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:
Pedram Amini
2026-01-08 19:31:01 -06:00
parent a7f5ebf824
commit 4e562947cf
6 changed files with 1121 additions and 249 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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