diff --git a/apps/server/src/routes/auto-mode/routes/start.ts b/apps/server/src/routes/auto-mode/routes/start.ts index 405a31b2..3ace816d 100644 --- a/apps/server/src/routes/auto-mode/routes/start.ts +++ b/apps/server/src/routes/auto-mode/routes/start.ts @@ -12,8 +12,9 @@ const logger = createLogger('AutoMode'); export function createStartHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { try { - const { projectPath, maxConcurrency } = req.body as { + const { projectPath, branchName, maxConcurrency } = req.body as { projectPath: string; + branchName?: string | null; maxConcurrency?: number; }; @@ -25,26 +26,38 @@ export function createStartHandler(autoModeService: AutoModeService) { return; } + // Normalize branchName: undefined becomes null + const normalizedBranchName = branchName ?? null; + const worktreeDesc = normalizedBranchName + ? `worktree ${normalizedBranchName}` + : 'main worktree'; + // Check if already running - if (autoModeService.isAutoLoopRunningForProject(projectPath)) { + if (autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) { res.json({ success: true, - message: 'Auto mode is already running for this project', + message: `Auto mode is already running for ${worktreeDesc}`, alreadyRunning: true, + branchName: normalizedBranchName, }); return; } - // Start the auto loop for this project - await autoModeService.startAutoLoopForProject(projectPath, maxConcurrency ?? 3); + // Start the auto loop for this project/worktree + const resolvedMaxConcurrency = await autoModeService.startAutoLoopForProject( + projectPath, + normalizedBranchName, + maxConcurrency + ); logger.info( - `Started auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency ?? 3}` + `Started auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}` ); res.json({ success: true, - message: `Auto mode started with max ${maxConcurrency ?? 3} concurrent features`, + message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`, + branchName: normalizedBranchName, }); } catch (error) { logError(error, 'Start auto mode failed'); diff --git a/apps/server/src/routes/auto-mode/routes/status.ts b/apps/server/src/routes/auto-mode/routes/status.ts index a2ccd832..73c77945 100644 --- a/apps/server/src/routes/auto-mode/routes/status.ts +++ b/apps/server/src/routes/auto-mode/routes/status.ts @@ -12,11 +12,19 @@ import { getErrorMessage, logError } from '../common.js'; export function createStatusHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { try { - const { projectPath } = req.body as { projectPath?: string }; + const { projectPath, branchName } = req.body as { + projectPath?: string; + branchName?: string | null; + }; - // If projectPath is provided, return per-project status + // If projectPath is provided, return per-project/worktree status if (projectPath) { - const projectStatus = autoModeService.getStatusForProject(projectPath); + // Normalize branchName: undefined becomes null + const normalizedBranchName = branchName ?? null; + const projectStatus = autoModeService.getStatusForProject( + projectPath, + normalizedBranchName + ); res.json({ success: true, isRunning: projectStatus.runningCount > 0, @@ -25,6 +33,7 @@ export function createStatusHandler(autoModeService: AutoModeService) { runningCount: projectStatus.runningCount, maxConcurrency: projectStatus.maxConcurrency, projectPath, + branchName: normalizedBranchName, }); return; } @@ -32,10 +41,12 @@ export function createStatusHandler(autoModeService: AutoModeService) { // Fall back to global status for backward compatibility const status = autoModeService.getStatus(); const activeProjects = autoModeService.getActiveAutoLoopProjects(); + const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees(); res.json({ success: true, ...status, activeAutoLoopProjects: activeProjects, + activeAutoLoopWorktrees: activeWorktrees, }); } catch (error) { logError(error, 'Get status failed'); diff --git a/apps/server/src/routes/auto-mode/routes/stop.ts b/apps/server/src/routes/auto-mode/routes/stop.ts index 79f074a8..b3c2fd52 100644 --- a/apps/server/src/routes/auto-mode/routes/stop.ts +++ b/apps/server/src/routes/auto-mode/routes/stop.ts @@ -12,8 +12,9 @@ const logger = createLogger('AutoMode'); export function createStopHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { try { - const { projectPath } = req.body as { + const { projectPath, branchName } = req.body as { projectPath: string; + branchName?: string | null; }; if (!projectPath) { @@ -24,27 +25,38 @@ export function createStopHandler(autoModeService: AutoModeService) { return; } + // Normalize branchName: undefined becomes null + const normalizedBranchName = branchName ?? null; + const worktreeDesc = normalizedBranchName + ? `worktree ${normalizedBranchName}` + : 'main worktree'; + // Check if running - if (!autoModeService.isAutoLoopRunningForProject(projectPath)) { + if (!autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) { res.json({ success: true, - message: 'Auto mode is not running for this project', + message: `Auto mode is not running for ${worktreeDesc}`, wasRunning: false, + branchName: normalizedBranchName, }); return; } - // Stop the auto loop for this project - const runningCount = await autoModeService.stopAutoLoopForProject(projectPath); + // Stop the auto loop for this project/worktree + const runningCount = await autoModeService.stopAutoLoopForProject( + projectPath, + normalizedBranchName + ); logger.info( - `Stopped auto loop for project: ${projectPath}, ${runningCount} features still running` + `Stopped auto loop for ${worktreeDesc} in project: ${projectPath}, ${runningCount} features still running` ); res.json({ success: true, message: 'Auto mode stopped', runningFeaturesCount: runningCount, + branchName: normalizedBranchName, }); } catch (error) { logError(error, 'Stop auto mode failed'); diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 59af7872..1b92671f 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -21,7 +21,12 @@ import type { ThinkingLevel, PlanningMode, } from '@automaker/types'; -import { DEFAULT_PHASE_MODELS, isClaudeModel, stripProviderPrefix } from '@automaker/types'; +import { + DEFAULT_PHASE_MODELS, + DEFAULT_MAX_CONCURRENCY, + isClaudeModel, + stripProviderPrefix, +} from '@automaker/types'; import { buildPromptWithImages, classifyError, @@ -233,10 +238,20 @@ interface AutoModeConfig { maxConcurrency: number; useWorktrees: boolean; projectPath: string; + branchName: string | null; // null = main worktree } /** - * Per-project autoloop state for multi-project support + * Generate a unique key for worktree-scoped auto loop state + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree + */ +function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string { + return `${projectPath}::${branchName ?? '__main__'}`; +} + +/** + * Per-worktree autoloop state for multi-project/worktree support */ interface ProjectAutoLoopState { abortController: AbortController; @@ -244,6 +259,8 @@ interface ProjectAutoLoopState { isRunning: boolean; consecutiveFailures: { timestamp: number; error: string }[]; pausedDueToFailures: boolean; + hasEmittedIdleEvent: boolean; + branchName: string | null; // null = main worktree } /** @@ -255,6 +272,7 @@ interface ExecutionState { autoLoopWasRunning: boolean; maxConcurrency: number; projectPath: string; + branchName: string | null; // null = main worktree runningFeatureIds: string[]; savedAt: string; } @@ -263,8 +281,9 @@ interface ExecutionState { const DEFAULT_EXECUTION_STATE: ExecutionState = { version: 1, autoLoopWasRunning: false, - maxConcurrency: 3, + maxConcurrency: DEFAULT_MAX_CONCURRENCY, projectPath: '', + branchName: null, runningFeatureIds: [], savedAt: '', }; @@ -289,6 +308,8 @@ export class AutoModeService { // Track consecutive failures to detect quota/API issues (legacy global, now per-project in autoLoopsByProject) private consecutiveFailures: { timestamp: number; error: string }[] = []; private pausedDueToFailures = false; + // Track if idle event has been emitted (legacy, now per-project in autoLoopsByProject) + private hasEmittedIdleEvent = false; constructor(events: EventEmitter, settingsService?: SettingsService) { this.events = events; @@ -472,24 +493,81 @@ export class AutoModeService { this.consecutiveFailures = []; } - /** - * Start the auto mode loop for a specific project (supports multiple concurrent projects) - * @param projectPath - The project to start auto mode for - * @param maxConcurrency - Maximum concurrent features (default: 3) - */ - async startAutoLoopForProject(projectPath: string, maxConcurrency = 3): Promise { - // Check if this project already has an active autoloop - const existingState = this.autoLoopsByProject.get(projectPath); - if (existingState?.isRunning) { - throw new Error(`Auto mode is already running for project: ${projectPath}`); + private async resolveMaxConcurrency( + projectPath: string, + branchName: string | null, + provided?: number + ): Promise { + if (typeof provided === 'number' && Number.isFinite(provided)) { + return provided; } - // Create new project autoloop state + if (!this.settingsService) { + return DEFAULT_MAX_CONCURRENCY; + } + + try { + const settings = await this.settingsService.getGlobalSettings(); + const globalMax = + typeof settings.maxConcurrency === 'number' + ? settings.maxConcurrency + : DEFAULT_MAX_CONCURRENCY; + const projectId = settings.projects?.find((project) => project.path === projectPath)?.id; + const autoModeByWorktree = (settings as unknown as Record) + .autoModeByWorktree; + + if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') { + const key = `${projectId}::${branchName ?? '__main__'}`; + const entry = (autoModeByWorktree as Record)[key] as + | { maxConcurrency?: number } + | undefined; + if (entry && typeof entry.maxConcurrency === 'number') { + return entry.maxConcurrency; + } + } + + return globalMax; + } catch { + return DEFAULT_MAX_CONCURRENCY; + } + } + + /** + * Start the auto mode loop for a specific project/worktree (supports multiple concurrent projects and worktrees) + * @param projectPath - The project to start auto mode for + * @param branchName - The branch name for worktree scoping, null for main worktree + * @param maxConcurrency - Maximum concurrent features (default: DEFAULT_MAX_CONCURRENCY) + */ + async startAutoLoopForProject( + projectPath: string, + branchName: string | null = null, + maxConcurrency?: number + ): Promise { + const resolvedMaxConcurrency = await this.resolveMaxConcurrency( + projectPath, + branchName, + maxConcurrency + ); + + // Use worktree-scoped key + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + + // Check if this project/worktree already has an active autoloop + const existingState = this.autoLoopsByProject.get(worktreeKey); + if (existingState?.isRunning) { + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + throw new Error( + `Auto mode is already running for ${worktreeDesc} in project: ${projectPath}` + ); + } + + // Create new project/worktree autoloop state const abortController = new AbortController(); const config: AutoModeConfig = { - maxConcurrency, + maxConcurrency: resolvedMaxConcurrency, useWorktrees: true, projectPath, + branchName, }; const projectState: ProjectAutoLoopState = { @@ -498,56 +576,68 @@ export class AutoModeService { isRunning: true, consecutiveFailures: [], pausedDueToFailures: false, + hasEmittedIdleEvent: false, + branchName, }; - this.autoLoopsByProject.set(projectPath, projectState); + this.autoLoopsByProject.set(worktreeKey, projectState); + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; logger.info( - `Starting auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency}` + `Starting auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}` ); this.emitAutoModeEvent('auto_mode_started', { - message: `Auto mode started with max ${maxConcurrency} concurrent features`, + message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`, projectPath, + branchName, }); // Save execution state for recovery after restart - await this.saveExecutionStateForProject(projectPath, maxConcurrency); + await this.saveExecutionStateForProject(projectPath, branchName, resolvedMaxConcurrency); // Run the loop in the background - this.runAutoLoopForProject(projectPath).catch((error) => { - logger.error(`Loop error for ${projectPath}:`, error); + this.runAutoLoopForProject(worktreeKey).catch((error) => { + const worktreeDescErr = branchName ? `worktree ${branchName}` : 'main worktree'; + logger.error(`Loop error for ${worktreeDescErr} in ${projectPath}:`, error); const errorInfo = classifyError(error); this.emitAutoModeEvent('auto_mode_error', { error: errorInfo.message, errorType: errorInfo.type, projectPath, + branchName, }); }); + + return resolvedMaxConcurrency; } /** - * Run the auto loop for a specific project + * Run the auto loop for a specific project/worktree + * @param worktreeKey - The worktree key (projectPath::branchName or projectPath::__main__) */ - private async runAutoLoopForProject(projectPath: string): Promise { - const projectState = this.autoLoopsByProject.get(projectPath); + private async runAutoLoopForProject(worktreeKey: string): Promise { + const projectState = this.autoLoopsByProject.get(worktreeKey); if (!projectState) { - logger.warn(`No project state found for ${projectPath}, stopping loop`); + logger.warn(`No project state found for ${worktreeKey}, stopping loop`); return; } + const { projectPath, branchName } = projectState.config; + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + logger.info( - `[AutoLoop] Starting loop for ${projectPath}, maxConcurrency: ${projectState.config.maxConcurrency}` + `[AutoLoop] Starting loop for ${worktreeDesc} in ${projectPath}, maxConcurrency: ${projectState.config.maxConcurrency}` ); let iterationCount = 0; while (projectState.isRunning && !projectState.abortController.signal.aborted) { iterationCount++; try { - // Count running features for THIS project only - const projectRunningCount = this.getRunningCountForProject(projectPath); + // Count running features for THIS project/worktree only + const projectRunningCount = this.getRunningCountForWorktree(projectPath, branchName); - // Check if we have capacity for this project + // Check if we have capacity for this project/worktree if (projectRunningCount >= projectState.config.maxConcurrency) { logger.debug( `[AutoLoop] At capacity (${projectRunningCount}/${projectState.config.maxConcurrency}), waiting...` @@ -556,19 +646,32 @@ export class AutoModeService { continue; } - // Load pending features for this project - const pendingFeatures = await this.loadPendingFeatures(projectPath); + // Load pending features for this project/worktree + const pendingFeatures = await this.loadPendingFeatures(projectPath, branchName); - logger.debug( - `[AutoLoop] Iteration ${iterationCount}: Found ${pendingFeatures.length} pending features, ${projectRunningCount} running` + logger.info( + `[AutoLoop] Iteration ${iterationCount}: Found ${pendingFeatures.length} pending features, ${projectRunningCount}/${projectState.config.maxConcurrency} running for ${worktreeDesc}` ); if (pendingFeatures.length === 0) { - this.emitAutoModeEvent('auto_mode_idle', { - message: 'No pending features - auto mode idle', - projectPath, - }); - logger.info(`[AutoLoop] No pending features, sleeping for 10s...`); + // Emit idle event only once when backlog is empty AND no features are running + if (projectRunningCount === 0 && !projectState.hasEmittedIdleEvent) { + this.emitAutoModeEvent('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath, + branchName, + }); + projectState.hasEmittedIdleEvent = true; + logger.info(`[AutoLoop] Backlog complete, auto mode now idle for ${worktreeDesc}`); + } else if (projectRunningCount > 0) { + logger.info( + `[AutoLoop] No pending features available, ${projectRunningCount} still running, waiting...` + ); + } else { + logger.warn( + `[AutoLoop] No pending features found for ${worktreeDesc} (branchName: ${branchName === null ? 'null (main)' : branchName}). Check server logs for filtering details.` + ); + } await this.sleep(10000); continue; } @@ -578,6 +681,8 @@ export class AutoModeService { if (nextFeature) { logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`); + // Reset idle event flag since we're doing work again + projectState.hasEmittedIdleEvent = false; // Start feature execution in background this.executeFeature( projectPath, @@ -619,13 +724,47 @@ export class AutoModeService { } /** - * Stop the auto mode loop for a specific project - * @param projectPath - The project to stop auto mode for + * Get count of running features for a specific worktree + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree (features without branchName or with "main") */ - async stopAutoLoopForProject(projectPath: string): Promise { - const projectState = this.autoLoopsByProject.get(projectPath); + private getRunningCountForWorktree(projectPath: string, branchName: string | null): number { + let count = 0; + for (const [, feature] of this.runningFeatures) { + // Filter by project path AND branchName to get accurate worktree-specific count + const featureBranch = feature.branchName ?? null; + if (branchName === null) { + // Main worktree: match features with branchName === null OR branchName === "main" + if ( + feature.projectPath === projectPath && + (featureBranch === null || featureBranch === 'main') + ) { + count++; + } + } else { + // Feature worktree: exact match + if (feature.projectPath === projectPath && featureBranch === branchName) { + count++; + } + } + } + return count; + } + + /** + * Stop the auto mode loop for a specific project/worktree + * @param projectPath - The project to stop auto mode for + * @param branchName - The branch name, or null for main worktree + */ + async stopAutoLoopForProject( + projectPath: string, + branchName: string | null = null + ): Promise { + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + const projectState = this.autoLoopsByProject.get(worktreeKey); if (!projectState) { - logger.warn(`No auto loop running for project: ${projectPath}`); + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + logger.warn(`No auto loop running for ${worktreeDesc} in project: ${projectPath}`); return 0; } @@ -634,43 +773,57 @@ export class AutoModeService { projectState.abortController.abort(); // Clear execution state when auto-loop is explicitly stopped - await this.clearExecutionState(projectPath); + await this.clearExecutionState(projectPath, branchName); // Emit stop event if (wasRunning) { this.emitAutoModeEvent('auto_mode_stopped', { message: 'Auto mode stopped', projectPath, + branchName, }); } // Remove from map - this.autoLoopsByProject.delete(projectPath); + this.autoLoopsByProject.delete(worktreeKey); - return this.getRunningCountForProject(projectPath); + return this.getRunningCountForWorktree(projectPath, branchName); } /** - * Check if auto mode is running for a specific project + * Check if auto mode is running for a specific project/worktree + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree */ - isAutoLoopRunningForProject(projectPath: string): boolean { - const projectState = this.autoLoopsByProject.get(projectPath); + isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean { + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + const projectState = this.autoLoopsByProject.get(worktreeKey); return projectState?.isRunning ?? false; } /** - * Get auto loop config for a specific project + * Get auto loop config for a specific project/worktree + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree */ - getAutoLoopConfigForProject(projectPath: string): AutoModeConfig | null { - const projectState = this.autoLoopsByProject.get(projectPath); + getAutoLoopConfigForProject( + projectPath: string, + branchName: string | null = null + ): AutoModeConfig | null { + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + const projectState = this.autoLoopsByProject.get(worktreeKey); return projectState?.config ?? null; } /** - * Save execution state for a specific project + * Save execution state for a specific project/worktree + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree + * @param maxConcurrency - Maximum concurrent features */ private async saveExecutionStateForProject( projectPath: string, + branchName: string | null, maxConcurrency: number ): Promise { try { @@ -685,15 +838,18 @@ export class AutoModeService { autoLoopWasRunning: true, maxConcurrency, projectPath, + branchName, runningFeatureIds, savedAt: new Date().toISOString(), }; await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8'); + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; logger.info( - `Saved execution state for ${projectPath}: ${runningFeatureIds.length} running features` + `Saved execution state for ${worktreeDesc} in ${projectPath}: ${runningFeatureIds.length} running features` ); } catch (error) { - logger.error(`Failed to save execution state for ${projectPath}:`, error); + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + logger.error(`Failed to save execution state for ${worktreeDesc} in ${projectPath}:`, error); } } @@ -701,7 +857,10 @@ export class AutoModeService { * Start the auto mode loop - continuously picks and executes pending features * @deprecated Use startAutoLoopForProject instead for multi-project support */ - async startAutoLoop(projectPath: string, maxConcurrency = 3): Promise { + async startAutoLoop( + projectPath: string, + maxConcurrency = DEFAULT_MAX_CONCURRENCY + ): Promise { // For backward compatibility, delegate to the new per-project method // But also maintain legacy state for existing code that might check it if (this.autoLoopRunning) { @@ -717,6 +876,7 @@ export class AutoModeService { maxConcurrency, useWorktrees: true, projectPath, + branchName: null, }; this.emitAutoModeEvent('auto_mode_started', { @@ -752,7 +912,7 @@ export class AutoModeService { ) { try { // Check if we have capacity - if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) { + if (this.runningFeatures.size >= (this.config?.maxConcurrency || DEFAULT_MAX_CONCURRENCY)) { await this.sleep(5000); continue; } @@ -761,10 +921,22 @@ export class AutoModeService { const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath); if (pendingFeatures.length === 0) { - this.emitAutoModeEvent('auto_mode_idle', { - message: 'No pending features - auto mode idle', - projectPath: this.config!.projectPath, - }); + // Emit idle event only once when backlog is empty AND no features are running + const runningCount = this.runningFeatures.size; + if (runningCount === 0 && !this.hasEmittedIdleEvent) { + this.emitAutoModeEvent('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: this.config!.projectPath, + }); + this.hasEmittedIdleEvent = true; + logger.info(`[AutoLoop] Backlog complete, auto mode now idle`); + } else if (runningCount > 0) { + logger.debug( + `[AutoLoop] No pending features, ${runningCount} still running, waiting...` + ); + } else { + logger.debug(`[AutoLoop] No pending features, waiting for new items...`); + } await this.sleep(10000); continue; } @@ -773,6 +945,8 @@ export class AutoModeService { const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id)); if (nextFeature) { + // Reset idle event flag since we're doing work again + this.hasEmittedIdleEvent = false; // Start feature execution in background this.executeFeature( this.config!.projectPath, @@ -862,6 +1036,9 @@ export class AutoModeService { await this.saveExecutionState(projectPath); } + // Declare feature outside try block so it's available in catch for error reporting + let feature: Awaited> | null = null; + try { // Validate that project path is allowed using centralized validation validateWorkingDirectory(projectPath); @@ -880,18 +1057,8 @@ export class AutoModeService { } } - // Emit feature start event early - this.emitAutoModeEvent('auto_mode_feature_start', { - featureId, - projectPath, - feature: { - id: featureId, - title: 'Loading...', - description: 'Feature is starting', - }, - }); // Load feature details FIRST to get branchName - const feature = await this.loadFeature(projectPath, featureId); + feature = await this.loadFeature(projectPath, featureId); if (!feature) { throw new Error(`Feature ${featureId} not found`); } @@ -924,9 +1091,22 @@ export class AutoModeService { tempRunningFeature.worktreePath = worktreePath; tempRunningFeature.branchName = branchName ?? null; - // Update feature status to in_progress + // Update feature status to in_progress BEFORE emitting event + // This ensures the frontend sees the updated status when it reloads features await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); + // Emit feature start event AFTER status update so frontend sees correct status + this.emitAutoModeEvent('auto_mode_feature_start', { + featureId, + projectPath, + branchName: feature.branchName ?? null, + feature: { + id: featureId, + title: feature.title || 'Loading...', + description: feature.description || 'Feature is starting', + }, + }); + // Load autoLoadClaudeMd setting to determine context loading strategy const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( projectPath, @@ -1070,6 +1250,8 @@ export class AutoModeService { this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, passes: true, message: `Feature completed in ${Math.round( (Date.now() - tempRunningFeature.startTime) / 1000 @@ -1084,6 +1266,8 @@ export class AutoModeService { if (errorInfo.isAbort) { this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, passes: false, message: 'Feature stopped by user', projectPath, @@ -1093,6 +1277,8 @@ export class AutoModeService { await this.updateFeatureStatus(projectPath, featureId, 'backlog'); this.emitAutoModeEvent('auto_mode_error', { featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, error: errorInfo.message, errorType: errorInfo.type, projectPath, @@ -1413,6 +1599,8 @@ Complete the pipeline step instructions above. Review the previous work and appl this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, passes: true, message: 'Pipeline step no longer exists - feature completed without remaining pipeline steps', @@ -1526,6 +1714,7 @@ Complete the pipeline step instructions above. Review the previous work and appl this.emitAutoModeEvent('auto_mode_feature_start', { featureId, projectPath, + branchName: branchName ?? null, feature: { id: featureId, title: feature.title || 'Resuming Pipeline', @@ -1535,8 +1724,9 @@ Complete the pipeline step instructions above. Review the previous work and appl this.emitAutoModeEvent('auto_mode_progress', { featureId, - content: `Resuming from pipeline step ${startFromStepIndex + 1}/${sortedSteps.length}`, projectPath, + branchName: branchName ?? null, + content: `Resuming from pipeline step ${startFromStepIndex + 1}/${sortedSteps.length}`, }); // Load autoLoadClaudeMd setting @@ -1565,6 +1755,8 @@ Complete the pipeline step instructions above. Review the previous work and appl this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, passes: true, message: 'Pipeline resumed and completed successfully', projectPath, @@ -1575,6 +1767,8 @@ Complete the pipeline step instructions above. Review the previous work and appl if (errorInfo.isAbort) { this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, passes: false, message: 'Pipeline resume stopped by user', projectPath, @@ -1584,6 +1778,8 @@ Complete the pipeline step instructions above. Review the previous work and appl await this.updateFeatureStatus(projectPath, featureId, 'backlog'); this.emitAutoModeEvent('auto_mode_error', { featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, error: errorInfo.message, errorType: errorInfo.type, projectPath, @@ -1705,22 +1901,25 @@ Address the follow-up instructions above. Review the previous work and make the provider, }); - this.emitAutoModeEvent('auto_mode_feature_start', { - featureId, - projectPath, - feature: feature || { - id: featureId, - title: 'Follow-up', - description: prompt.substring(0, 100), - }, - model, - provider, - }); - try { - // Update feature status to in_progress + // Update feature status to in_progress BEFORE emitting event + // This ensures the frontend sees the updated status when it reloads features await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); + // Emit feature start event AFTER status update so frontend sees correct status + this.emitAutoModeEvent('auto_mode_feature_start', { + featureId, + projectPath, + branchName, + feature: feature || { + id: featureId, + title: 'Follow-up', + description: prompt.substring(0, 100), + }, + model, + provider, + }); + // Copy follow-up images to feature folder const copiedImagePaths: string[] = []; if (imagePaths && imagePaths.length > 0) { @@ -1814,6 +2013,8 @@ Address the follow-up instructions above. Review the previous work and make the this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, + featureName: feature?.title, + branchName: branchName ?? null, passes: true, message: `Follow-up completed successfully${finalStatus === 'verified' ? ' - auto-verified' : ''}`, projectPath, @@ -1825,6 +2026,8 @@ Address the follow-up instructions above. Review the previous work and make the if (!errorInfo.isCancellation) { this.emitAutoModeEvent('auto_mode_error', { featureId, + featureName: feature?.title, + branchName: branchName ?? null, error: errorInfo.message, errorType: errorInfo.type, projectPath, @@ -1852,6 +2055,9 @@ Address the follow-up instructions above. Review the previous work and make the * Verify a feature's implementation */ async verifyFeature(projectPath: string, featureId: string): Promise { + // Load feature to get the name for event reporting + const feature = await this.loadFeature(projectPath, featureId); + // Worktrees are in project dir const worktreePath = path.join(projectPath, '.worktrees', featureId); let workDir = projectPath; @@ -1898,6 +2104,8 @@ Address the follow-up instructions above. Review the previous work and make the this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, passes: allPassed, message: allPassed ? 'All verification checks passed' @@ -1974,6 +2182,8 @@ Address the follow-up instructions above. Review the previous work and make the this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, passes: true, message: `Changes committed: ${hash.trim().substring(0, 8)}`, projectPath, @@ -2012,6 +2222,7 @@ Address the follow-up instructions above. Review the previous work and make the this.emitAutoModeEvent('auto_mode_feature_start', { featureId: analysisFeatureId, projectPath, + branchName: null, // Project analysis is not worktree-specific feature: { id: analysisFeatureId, title: 'Project Analysis', @@ -2096,6 +2307,8 @@ Format your response as a structured markdown document.`; this.emitAutoModeEvent('auto_mode_feature_complete', { featureId: analysisFeatureId, + featureName: 'Project Analysis', + branchName: null, // Project analysis is not worktree-specific passes: true, message: 'Project analysis completed', projectPath, @@ -2104,6 +2317,8 @@ Format your response as a structured markdown document.`; const errorInfo = classifyError(error); this.emitAutoModeEvent('auto_mode_error', { featureId: analysisFeatureId, + featureName: 'Project Analysis', + branchName: null, // Project analysis is not worktree-specific error: errorInfo.message, errorType: errorInfo.type, projectPath, @@ -2127,20 +2342,27 @@ Format your response as a structured markdown document.`; } /** - * Get status for a specific project - * @param projectPath - The project to get status for + * Get status for a specific project/worktree + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree */ - getStatusForProject(projectPath: string): { + getStatusForProject( + projectPath: string, + branchName: string | null = null + ): { isAutoLoopRunning: boolean; runningFeatures: string[]; runningCount: number; maxConcurrency: number; + branchName: string | null; } { - const projectState = this.autoLoopsByProject.get(projectPath); + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + const projectState = this.autoLoopsByProject.get(worktreeKey); const runningFeatures: string[] = []; for (const [featureId, feature] of this.runningFeatures) { - if (feature.projectPath === projectPath) { + // Filter by project path AND branchName to get worktree-specific features + if (feature.projectPath === projectPath && feature.branchName === branchName) { runningFeatures.push(featureId); } } @@ -2149,21 +2371,39 @@ Format your response as a structured markdown document.`; isAutoLoopRunning: projectState?.isRunning ?? false, runningFeatures, runningCount: runningFeatures.length, - maxConcurrency: projectState?.config.maxConcurrency ?? 3, + maxConcurrency: projectState?.config.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + branchName, }; } /** - * Get all projects that have auto mode running + * Get all active auto loop worktrees with their project paths and branch names */ - getActiveAutoLoopProjects(): string[] { - const activeProjects: string[] = []; - for (const [projectPath, state] of this.autoLoopsByProject) { + getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> { + const activeWorktrees: Array<{ projectPath: string; branchName: string | null }> = []; + for (const [, state] of this.autoLoopsByProject) { if (state.isRunning) { - activeProjects.push(projectPath); + activeWorktrees.push({ + projectPath: state.config.projectPath, + branchName: state.branchName, + }); } } - return activeProjects; + return activeWorktrees; + } + + /** + * Get all projects that have auto mode running (legacy, returns unique project paths) + * @deprecated Use getActiveAutoLoopWorktrees instead for full worktree information + */ + getActiveAutoLoopProjects(): string[] { + const activeProjects = new Set(); + for (const [, state] of this.autoLoopsByProject) { + if (state.isRunning) { + activeProjects.add(state.config.projectPath); + } + } + return Array.from(activeProjects); } /** @@ -2600,7 +2840,15 @@ Format your response as a structured markdown document.`; } } - private async loadPendingFeatures(projectPath: string): Promise { + /** + * Load pending features for a specific project/worktree + * @param projectPath - The project path + * @param branchName - The branch name to filter by, or null for main worktree (features without branchName) + */ + private async loadPendingFeatures( + projectPath: string, + branchName: string | null = null + ): Promise { // Features are stored in .automaker directory const featuresDir = getFeaturesDir(projectPath); @@ -2632,21 +2880,60 @@ Format your response as a structured markdown document.`; allFeatures.push(feature); - // Track pending features separately + // Track pending features separately, filtered by worktree/branch if ( feature.status === 'pending' || feature.status === 'ready' || feature.status === 'backlog' ) { - pendingFeatures.push(feature); + // Filter by branchName: + // - If branchName is null (main worktree), include features with branchName === null OR branchName === "main" + // - If branchName is set, only include features with matching branchName + const featureBranch = feature.branchName ?? null; + if (branchName === null) { + // Main worktree: include features without branchName OR with branchName === "main" + // This handles both correct (null) and legacy ("main") cases + if (featureBranch === null || featureBranch === 'main') { + pendingFeatures.push(feature); + } else { + logger.debug( + `[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}) for main worktree` + ); + } + } else { + // Feature worktree: include features with matching branchName + if (featureBranch === branchName) { + pendingFeatures.push(feature); + } else { + logger.debug( + `[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}, expected: ${branchName}) for worktree ${branchName}` + ); + } + } } } } - logger.debug( - `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} with backlog/pending/ready status` + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + logger.info( + `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} with backlog/pending/ready status for ${worktreeDesc}` ); + if (pendingFeatures.length === 0) { + logger.warn( + `[loadPendingFeatures] No pending features found for ${worktreeDesc}. Check branchName matching - looking for branchName: ${branchName === null ? 'null (main)' : branchName}` + ); + // Log all backlog features to help debug branchName matching + const allBacklogFeatures = allFeatures.filter( + (f) => f.status === 'backlog' || f.status === 'pending' || f.status === 'ready' + ); + if (allBacklogFeatures.length > 0) { + logger.info( + `[loadPendingFeatures] Found ${allBacklogFeatures.length} backlog features with branchNames: ${allBacklogFeatures.map((f) => `${f.id}(${f.branchName ?? 'null'})`).join(', ')}` + ); + } + } + // Apply dependency-aware ordering const { orderedFeatures } = resolveDependencies(pendingFeatures); @@ -2655,11 +2942,41 @@ Format your response as a structured markdown document.`; const skipVerification = settings?.skipVerificationInAutoMode ?? false; // Filter to only features with satisfied dependencies - const readyFeatures = orderedFeatures.filter((feature: Feature) => - areDependenciesSatisfied(feature, allFeatures, { skipVerification }) - ); + const readyFeatures: Feature[] = []; + const blockedFeatures: Array<{ feature: Feature; reason: string }> = []; - logger.debug( + for (const feature of orderedFeatures) { + const isSatisfied = areDependenciesSatisfied(feature, allFeatures, { skipVerification }); + if (isSatisfied) { + readyFeatures.push(feature); + } else { + // Find which dependencies are blocking + const blockingDeps = + feature.dependencies?.filter((depId) => { + const dep = allFeatures.find((f) => f.id === depId); + if (!dep) return true; // Missing dependency + if (skipVerification) { + return dep.status === 'running'; + } + return dep.status !== 'completed' && dep.status !== 'verified'; + }) || []; + blockedFeatures.push({ + feature, + reason: + blockingDeps.length > 0 + ? `Blocked by dependencies: ${blockingDeps.join(', ')}` + : 'Unknown dependency issue', + }); + } + } + + if (blockedFeatures.length > 0) { + logger.info( + `[loadPendingFeatures] ${blockedFeatures.length} features blocked by dependencies: ${blockedFeatures.map((b) => `${b.feature.id} (${b.reason})`).join('; ')}` + ); + } + + logger.info( `[loadPendingFeatures] After dependency filtering: ${readyFeatures.length} ready features (skipVerification=${skipVerification})` ); @@ -3818,8 +4135,9 @@ After generating the revised spec, output: const state: ExecutionState = { version: 1, autoLoopWasRunning: this.autoLoopRunning, - maxConcurrency: this.config?.maxConcurrency ?? 3, + maxConcurrency: this.config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, projectPath, + branchName: null, // Legacy global auto mode uses main worktree runningFeatureIds: Array.from(this.runningFeatures.keys()), savedAt: new Date().toISOString(), }; @@ -3850,11 +4168,15 @@ After generating the revised spec, output: /** * Clear execution state (called on successful shutdown or when auto-loop stops) */ - private async clearExecutionState(projectPath: string): Promise { + private async clearExecutionState( + projectPath: string, + branchName: string | null = null + ): Promise { try { const statePath = getExecutionStatePath(projectPath); await secureFs.unlink(statePath); - logger.info('Cleared execution state'); + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + logger.info(`Cleared execution state for ${worktreeDesc}`); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { logger.error('Failed to clear execution state:', error); diff --git a/apps/server/src/services/event-hook-service.ts b/apps/server/src/services/event-hook-service.ts index 08da71dd..74070b78 100644 --- a/apps/server/src/services/event-hook-service.ts +++ b/apps/server/src/services/event-hook-service.ts @@ -57,6 +57,7 @@ interface HookContext { interface AutoModeEventPayload { type?: string; featureId?: string; + featureName?: string; passes?: boolean; message?: string; error?: string; @@ -152,6 +153,7 @@ export class EventHookService { // Build context for variable substitution const context: HookContext = { featureId: payload.featureId, + featureName: payload.featureName, projectPath: payload.projectPath, projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined, error: payload.error || payload.message, diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 5b9f81cb..c6a061ea 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -41,7 +41,12 @@ import { CREDENTIALS_VERSION, PROJECT_SETTINGS_VERSION, } from '../types/settings.js'; -import { migrateModelId, migrateCursorModelIds, migrateOpencodeModelIds } from '@automaker/types'; +import { + DEFAULT_MAX_CONCURRENCY, + migrateModelId, + migrateCursorModelIds, + migrateOpencodeModelIds, +} from '@automaker/types'; const logger = createLogger('SettingsService'); @@ -682,7 +687,7 @@ export class SettingsService { theme: (appState.theme as GlobalSettings['theme']) || 'dark', sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true, chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false, - maxConcurrency: (appState.maxConcurrency as number) || 3, + maxConcurrency: (appState.maxConcurrency as number) || DEFAULT_MAX_CONCURRENCY, defaultSkipTests: appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true, enableDependencyBlocking: diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 0aa80462..17d44d2b 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -88,8 +88,8 @@ const logger = createLogger('Board'); export function BoardView() { const { currentProject, - maxConcurrency, - setMaxConcurrency, + maxConcurrency: legacyMaxConcurrency, + setMaxConcurrency: legacySetMaxConcurrency, defaultSkipTests, specCreatingForProject, setSpecCreatingForProject, @@ -261,11 +261,6 @@ export function BoardView() { loadPipelineConfig(); }, [currentProject?.path, setPipelineConfig]); - // Auto mode hook - const autoMode = useAutoMode(); - // Get runningTasks from the hook (scoped to current project) - const runningAutoTasks = autoMode.runningTasks; - // Window state hook for compact dialog mode const { isMaximized } = useWindowState(); @@ -374,14 +369,6 @@ export function BoardView() { [hookFeatures, updateFeature, persistFeatureUpdate] ); - // Get in-progress features for keyboard shortcuts (needed before actions hook) - const inProgressFeaturesForShortcuts = useMemo(() => { - return hookFeatures.filter((f) => { - const isRunning = runningAutoTasks.includes(f.id); - return isRunning || f.status === 'in_progress'; - }); - }, [hookFeatures, runningAutoTasks]); - // Get current worktree info (path) for filtering features // This needs to be before useBoardActions so we can pass currentWorktreeBranch const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null; @@ -407,6 +394,16 @@ export function BoardView() { } }, [worktrees, currentWorktreePath]); + // Auto mode hook - pass current worktree to get worktree-specific state + // Must be after selectedWorktree is defined + const autoMode = useAutoMode(selectedWorktree ?? undefined); + // Get runningTasks from the hook (scoped to current project/worktree) + const runningAutoTasks = autoMode.runningTasks; + // Get worktree-specific maxConcurrency from the hook + const maxConcurrency = autoMode.maxConcurrency; + // Get worktree-specific setter + const setMaxConcurrencyForWorktree = useAppStore((state) => state.setMaxConcurrencyForWorktree); + // Get the current branch from the selected worktree (not from store which may be stale) const currentWorktreeBranch = selectedWorktree?.branch ?? null; @@ -415,6 +412,15 @@ export function BoardView() { const selectedWorktreeBranch = currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main'; + // Get in-progress features for keyboard shortcuts (needed before actions hook) + // Must be after runningAutoTasks is defined + const inProgressFeaturesForShortcuts = useMemo(() => { + return hookFeatures.filter((f) => { + const isRunning = runningAutoTasks.includes(f.id); + return isRunning || f.status === 'in_progress'; + }); + }, [hookFeatures, runningAutoTasks]); + // Calculate unarchived card counts per branch const branchCardCounts = useMemo(() => { // Use primary worktree branch as default for features without branchName @@ -512,14 +518,14 @@ export function BoardView() { try { // Determine final branch name based on work mode: - // - 'current': Empty string to clear branch assignment (work on main/current branch) + // - 'current': Use selected worktree branch if available, otherwise undefined (work on main) // - 'auto': Auto-generate branch name based on current branch // - 'custom': Use the provided branch name let finalBranchName: string | undefined; if (workMode === 'current') { - // Empty string clears the branch assignment, moving features to main/current branch - finalBranchName = ''; + // If a worktree is selected, use its branch; otherwise work on main (undefined = no branch assignment) + finalBranchName = currentWorktreeBranch || undefined; } else if (workMode === 'auto') { // Auto-generate a branch name based on primary branch (main/master) and timestamp // Always use primary branch to avoid nested feature/feature/... paths @@ -605,6 +611,7 @@ export function BoardView() { exitSelectionMode, getPrimaryWorktreeBranch, addAndSelectWorktree, + currentWorktreeBranch, setWorktreeRefreshKey, ] ); @@ -1127,7 +1134,21 @@ export function BoardView() { projectPath={currentProject.path} maxConcurrency={maxConcurrency} runningAgentsCount={runningAutoTasks.length} - onConcurrencyChange={setMaxConcurrency} + onConcurrencyChange={(newMaxConcurrency) => { + if (currentProject && selectedWorktree) { + const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch; + setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency); + // Also update backend if auto mode is running + if (autoMode.isRunning) { + // Restart auto mode with new concurrency (backend will handle this) + autoMode.stop().then(() => { + autoMode.start().catch((error) => { + logger.error('[AutoMode] Failed to restart with new concurrency:', error); + }); + }); + } + } + }} isAutoModeRunning={autoMode.isRunning} onAutoModeToggle={(enabled) => { if (enabled) { diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index b5684a08..00e36af2 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -182,6 +182,13 @@ export function BoardHeader({ > Auto Mode + + {maxConcurrency} + Auto Mode + + {maxConcurrency} +
{ + // Check if event is for the current project by matching projectPath + const eventProjectPath = ('projectPath' in event && event.projectPath) as string | undefined; + if (eventProjectPath && eventProjectPath !== projectPath) { + // Event is for a different project, ignore it + logger.debug( + `Ignoring auto mode event for different project: ${eventProjectPath} (current: ${projectPath})` + ); + return; + } + // Use event's projectPath or projectId if available, otherwise use current project // Board view only reacts to events for the currently selected project const eventProjectId = ('projectId' in event && event.projectId) || projectId; - if (event.type === 'auto_mode_feature_complete') { + if (event.type === 'auto_mode_feature_start') { + // Reload features when a feature starts to ensure status update (backlog -> in_progress) is reflected + logger.info( + `[BoardFeatures] Feature ${event.featureId} started for project ${projectPath}, reloading features to update status...` + ); + loadFeatures(); + } else if (event.type === 'auto_mode_feature_complete') { // Reload features when a feature is completed logger.info('Feature completed, reloading features...'); loadFeatures(); diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index 41041315..97c6ecc5 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -29,6 +29,7 @@ import { Terminal, SquarePlus, SplitSquareHorizontal, + Zap, } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; @@ -56,6 +57,8 @@ interface WorktreeActionsDropdownProps { gitRepoStatus: GitRepoStatus; /** When true, renders as a standalone button (not attached to another element) */ standalone?: boolean; + /** Whether auto mode is running for this worktree */ + isAutoModeRunning?: boolean; onOpenChange: (open: boolean) => void; onPull: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void; @@ -73,6 +76,7 @@ interface WorktreeActionsDropdownProps { onOpenDevServerUrl: (worktree: WorktreeInfo) => void; onViewDevServerLogs: (worktree: WorktreeInfo) => void; onRunInitScript: (worktree: WorktreeInfo) => void; + onToggleAutoMode?: (worktree: WorktreeInfo) => void; hasInitScript: boolean; } @@ -88,6 +92,7 @@ export function WorktreeActionsDropdown({ devServerInfo, gitRepoStatus, standalone = false, + isAutoModeRunning = false, onOpenChange, onPull, onPush, @@ -105,6 +110,7 @@ export function WorktreeActionsDropdown({ onOpenDevServerUrl, onViewDevServerLogs, onRunInitScript, + onToggleAutoMode, hasInitScript, }: WorktreeActionsDropdownProps) { // Get available editors for the "Open In" submenu @@ -214,6 +220,26 @@ export function WorktreeActionsDropdown({ )} + {/* Auto Mode toggle */} + {onToggleAutoMode && ( + <> + {isAutoModeRunning ? ( + onToggleAutoMode(worktree)} className="text-xs"> + + + + + Stop Auto Mode + + ) : ( + onToggleAutoMode(worktree)} className="text-xs"> + + Start Auto Mode + + )} + + + )} canPerformGitOps && onPull(worktree)} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index 56478385..accc5799 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -29,6 +29,8 @@ interface WorktreeTabProps { aheadCount: number; behindCount: number; gitRepoStatus: GitRepoStatus; + /** Whether auto mode is running for this worktree */ + isAutoModeRunning?: boolean; onSelectWorktree: (worktree: WorktreeInfo) => void; onBranchDropdownOpenChange: (open: boolean) => void; onActionsDropdownOpenChange: (open: boolean) => void; @@ -51,6 +53,7 @@ interface WorktreeTabProps { onOpenDevServerUrl: (worktree: WorktreeInfo) => void; onViewDevServerLogs: (worktree: WorktreeInfo) => void; onRunInitScript: (worktree: WorktreeInfo) => void; + onToggleAutoMode?: (worktree: WorktreeInfo) => void; hasInitScript: boolean; } @@ -75,6 +78,7 @@ export function WorktreeTab({ aheadCount, behindCount, gitRepoStatus, + isAutoModeRunning = false, onSelectWorktree, onBranchDropdownOpenChange, onActionsDropdownOpenChange, @@ -97,6 +101,7 @@ export function WorktreeTab({ onOpenDevServerUrl, onViewDevServerLogs, onRunInitScript, + onToggleAutoMode, hasInitScript, }: WorktreeTabProps) { let prBadge: JSX.Element | null = null; @@ -332,6 +337,26 @@ export function WorktreeTab({ )} + {isAutoModeRunning && ( + + + + + + + + +

Auto Mode Running

+
+
+
+ )} +
diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 1c05eb7b..a79bf621 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { Button } from '@/components/ui/button'; import { GitBranch, Plus, RefreshCw } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; -import { cn, pathsEqual } from '@/lib/utils'; +import { pathsEqual } from '@/lib/utils'; import { toast } from 'sonner'; import { getHttpApiClient } from '@/lib/http-api-client'; import { useIsMobile } from '@/hooks/use-media-query'; @@ -21,6 +21,7 @@ import { WorktreeActionsDropdown, BranchSwitchDropdown, } from './components'; +import { useAppStore } from '@/store/app-store'; export function WorktreePanel({ projectPath, @@ -50,7 +51,6 @@ export function WorktreePanel({ const { isStartingDevServer, - getWorktreeKey, isDevServerRunning, getDevServerInfo, handleStartDevServer, @@ -92,6 +92,67 @@ export function WorktreePanel({ features, }); + // Auto-mode state management using the store + // Use separate selectors to avoid creating new object references on each render + const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree); + const currentProject = useAppStore((state) => state.currentProject); + + // Helper to generate worktree key for auto-mode (inlined to avoid selector issues) + const getAutoModeWorktreeKey = useCallback( + (projectId: string, branchName: string | null): string => { + return `${projectId}::${branchName ?? '__main__'}`; + }, + [] + ); + + // Helper to check if auto-mode is running for a specific worktree + const isAutoModeRunningForWorktree = useCallback( + (worktree: WorktreeInfo): boolean => { + if (!currentProject) return false; + const branchName = worktree.isMain ? null : worktree.branch; + const key = getAutoModeWorktreeKey(currentProject.id, branchName); + return autoModeByWorktree[key]?.isRunning ?? false; + }, + [currentProject, autoModeByWorktree, getAutoModeWorktreeKey] + ); + + // Handler to toggle auto-mode for a worktree + const handleToggleAutoMode = useCallback( + async (worktree: WorktreeInfo) => { + if (!currentProject) return; + + // Import the useAutoMode to get start/stop functions + // Since useAutoMode is a hook, we'll use the API client directly + const api = getHttpApiClient(); + const branchName = worktree.isMain ? null : worktree.branch; + const isRunning = isAutoModeRunningForWorktree(worktree); + + try { + if (isRunning) { + const result = await api.autoMode.stop(projectPath, branchName); + if (result.success) { + const desc = branchName ? `worktree ${branchName}` : 'main branch'; + toast.success(`Auto Mode stopped for ${desc}`); + } else { + toast.error(result.error || 'Failed to stop Auto Mode'); + } + } else { + const result = await api.autoMode.start(projectPath, branchName); + if (result.success) { + const desc = branchName ? `worktree ${branchName}` : 'main branch'; + toast.success(`Auto Mode started for ${desc}`); + } else { + toast.error(result.error || 'Failed to start Auto Mode'); + } + } + } catch (error) { + toast.error('Error toggling Auto Mode'); + console.error('Auto mode toggle error:', error); + } + }, + [currentProject, projectPath, isAutoModeRunningForWorktree] + ); + // Track whether init script exists for the project const [hasInitScript, setHasInitScript] = useState(false); @@ -244,6 +305,7 @@ export function WorktreePanel({ isDevServerRunning={isDevServerRunning(selectedWorktree)} devServerInfo={getDevServerInfo(selectedWorktree)} gitRepoStatus={gitRepoStatus} + isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)} onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)} onPull={handlePull} onPush={handlePush} @@ -261,6 +323,7 @@ export function WorktreePanel({ onOpenDevServerUrl={handleOpenDevServerUrl} onViewDevServerLogs={handleViewDevServerLogs} onRunInitScript={handleRunInitScript} + onToggleAutoMode={handleToggleAutoMode} hasInitScript={hasInitScript} /> )} @@ -328,6 +391,7 @@ export function WorktreePanel({ aheadCount={aheadCount} behindCount={behindCount} gitRepoStatus={gitRepoStatus} + isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)} onSelectWorktree={handleSelectWorktree} onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)} onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)} @@ -350,6 +414,7 @@ export function WorktreePanel({ onOpenDevServerUrl={handleOpenDevServerUrl} onViewDevServerLogs={handleViewDevServerLogs} onRunInitScript={handleRunInitScript} + onToggleAutoMode={handleToggleAutoMode} hasInitScript={hasInitScript} /> )} @@ -388,6 +453,7 @@ export function WorktreePanel({ aheadCount={aheadCount} behindCount={behindCount} gitRepoStatus={gitRepoStatus} + isAutoModeRunning={isAutoModeRunningForWorktree(worktree)} onSelectWorktree={handleSelectWorktree} onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)} onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)} @@ -410,6 +476,7 @@ export function WorktreePanel({ onOpenDevServerUrl={handleOpenDevServerUrl} onViewDevServerLogs={handleViewDevServerLogs} onRunInitScript={handleRunInitScript} + onToggleAutoMode={handleToggleAutoMode} hasInitScript={hasInitScript} /> ); diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts index 8175b16a..b62f6fa4 100644 --- a/apps/ui/src/hooks/use-auto-mode.ts +++ b/apps/ui/src/hooks/use-auto-mode.ts @@ -1,13 +1,24 @@ import { useEffect, useCallback, useMemo } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { createLogger } from '@automaker/utils/logger'; +import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types'; import { useAppStore } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; import type { AutoModeEvent } from '@/types/electron'; +import type { WorktreeInfo } from '@/components/views/board-view/worktree-panel/types'; const logger = createLogger('AutoMode'); -const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByProjectPath'; +const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByWorktreeKey'; + +/** + * Generate a worktree key for session storage + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree + */ +function getWorktreeSessionKey(projectPath: string, branchName: string | null): string { + return `${projectPath}::${branchName ?? '__main__'}`; +} function readAutoModeSession(): Record { try { @@ -31,9 +42,14 @@ function writeAutoModeSession(next: Record): void { } } -function setAutoModeSessionForProjectPath(projectPath: string, running: boolean): void { +function setAutoModeSessionForWorktree( + projectPath: string, + branchName: string | null, + running: boolean +): void { + const worktreeKey = getWorktreeSessionKey(projectPath, branchName); const current = readAutoModeSession(); - const next = { ...current, [projectPath]: running }; + const next = { ...current, [worktreeKey]: running }; writeAutoModeSession(next); } @@ -45,33 +61,44 @@ function isPlanApprovalEvent( } /** - * Hook for managing auto mode (scoped per project) + * Hook for managing auto mode (scoped per worktree) + * @param worktree - Optional worktree info. If not provided, uses main worktree (branchName = null) */ -export function useAutoMode() { +export function useAutoMode(worktree?: WorktreeInfo) { const { - autoModeByProject, + autoModeByWorktree, setAutoModeRunning, addRunningTask, removeRunningTask, currentProject, addAutoModeActivity, - maxConcurrency, projects, setPendingPlanApproval, + getWorktreeKey, + getMaxConcurrencyForWorktree, + setMaxConcurrencyForWorktree, } = useAppStore( useShallow((state) => ({ - autoModeByProject: state.autoModeByProject, + autoModeByWorktree: state.autoModeByWorktree, setAutoModeRunning: state.setAutoModeRunning, addRunningTask: state.addRunningTask, removeRunningTask: state.removeRunningTask, currentProject: state.currentProject, addAutoModeActivity: state.addAutoModeActivity, - maxConcurrency: state.maxConcurrency, projects: state.projects, setPendingPlanApproval: state.setPendingPlanApproval, + getWorktreeKey: state.getWorktreeKey, + getMaxConcurrencyForWorktree: state.getMaxConcurrencyForWorktree, + setMaxConcurrencyForWorktree: state.setMaxConcurrencyForWorktree, })) ); + // Derive branchName from worktree: main worktree uses null, feature worktrees use their branch + const branchName = useMemo(() => { + if (!worktree) return null; + return worktree.isMain ? null : worktree.branch; + }, [worktree]); + // Helper to look up project ID from path const getProjectIdFromPath = useCallback( (path: string): string | undefined => { @@ -81,15 +108,30 @@ export function useAutoMode() { [projects] ); - // Get project-specific auto mode state + // Get worktree-specific auto mode state const projectId = currentProject?.id; - const projectAutoModeState = useMemo(() => { - if (!projectId) return { isRunning: false, runningTasks: [] }; - return autoModeByProject[projectId] || { isRunning: false, runningTasks: [] }; - }, [autoModeByProject, projectId]); + const worktreeAutoModeState = useMemo(() => { + if (!projectId) + return { + isRunning: false, + runningTasks: [], + branchName: null, + maxConcurrency: DEFAULT_MAX_CONCURRENCY, + }; + const key = getWorktreeKey(projectId, branchName); + return ( + autoModeByWorktree[key] || { + isRunning: false, + runningTasks: [], + branchName, + maxConcurrency: DEFAULT_MAX_CONCURRENCY, + } + ); + }, [autoModeByWorktree, projectId, branchName, getWorktreeKey]); - const isAutoModeRunning = projectAutoModeState.isRunning; - const runningAutoTasks = projectAutoModeState.runningTasks; + const isAutoModeRunning = worktreeAutoModeState.isRunning; + const runningAutoTasks = worktreeAutoModeState.runningTasks; + const maxConcurrency = worktreeAutoModeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY; // Check if we can start a new task based on concurrency limit const canStartNewTask = runningAutoTasks.length < maxConcurrency; @@ -104,15 +146,17 @@ export function useAutoMode() { const api = getElectronAPI(); if (!api?.autoMode?.status) return; - const result = await api.autoMode.status(currentProject.path); + const result = await api.autoMode.status(currentProject.path, branchName); if (result.success && result.isAutoLoopRunning !== undefined) { const backendIsRunning = result.isAutoLoopRunning; + if (backendIsRunning !== isAutoModeRunning) { + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; logger.info( - `[AutoMode] Syncing UI state with backend for ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}` + `[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}` ); - setAutoModeRunning(currentProject.id, backendIsRunning); - setAutoModeSessionForProjectPath(currentProject.path, backendIsRunning); + setAutoModeRunning(currentProject.id, branchName, backendIsRunning); + setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning); } } } catch (error) { @@ -121,9 +165,9 @@ export function useAutoMode() { }; syncWithBackend(); - }, [currentProject, isAutoModeRunning, setAutoModeRunning]); + }, [currentProject, branchName, isAutoModeRunning, setAutoModeRunning]); - // Handle auto mode events - listen globally for all projects + // Handle auto mode events - listen globally for all projects/worktrees useEffect(() => { const api = getElectronAPI(); if (!api?.autoMode) return; @@ -131,8 +175,8 @@ export function useAutoMode() { const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => { logger.info('Event:', event); - // Events include projectPath from backend - use it to look up project ID - // Fall back to current projectId if not provided in event + // Events include projectPath and branchName from backend + // Use them to look up project ID and determine the worktree let eventProjectId: string | undefined; if ('projectPath' in event && event.projectPath) { eventProjectId = getProjectIdFromPath(event.projectPath); @@ -144,6 +188,10 @@ export function useAutoMode() { eventProjectId = projectId; } + // Extract branchName from event, defaulting to null (main worktree) + const eventBranchName: string | null = + 'branchName' in event && event.branchName !== undefined ? event.branchName : null; + // Skip event if we couldn't determine the project if (!eventProjectId) { logger.warn('Could not determine project for event:', event); @@ -153,23 +201,34 @@ export function useAutoMode() { switch (event.type) { case 'auto_mode_started': // Backend started auto loop - update UI state - logger.info('[AutoMode] Backend started auto loop for project'); - if (eventProjectId) { - setAutoModeRunning(eventProjectId, true); + { + const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree'; + logger.info(`[AutoMode] Backend started auto loop for ${worktreeDesc}`); + if (eventProjectId) { + // Extract maxConcurrency from event if available, otherwise use current or default + const eventMaxConcurrency = + 'maxConcurrency' in event && typeof event.maxConcurrency === 'number' + ? event.maxConcurrency + : getMaxConcurrencyForWorktree(eventProjectId, eventBranchName); + setAutoModeRunning(eventProjectId, eventBranchName, true, eventMaxConcurrency); + } } break; case 'auto_mode_stopped': // Backend stopped auto loop - update UI state - logger.info('[AutoMode] Backend stopped auto loop for project'); - if (eventProjectId) { - setAutoModeRunning(eventProjectId, false); + { + const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree'; + logger.info(`[AutoMode] Backend stopped auto loop for ${worktreeDesc}`); + if (eventProjectId) { + setAutoModeRunning(eventProjectId, eventBranchName, false); + } } break; case 'auto_mode_feature_start': if (event.featureId) { - addRunningTask(eventProjectId, event.featureId); + addRunningTask(eventProjectId, eventBranchName, event.featureId); addAutoModeActivity({ featureId: event.featureId, type: 'start', @@ -182,7 +241,7 @@ export function useAutoMode() { // Feature completed - remove from running tasks and UI will reload features on its own if (event.featureId) { logger.info('Feature completed:', event.featureId, 'passes:', event.passes); - removeRunningTask(eventProjectId, event.featureId); + removeRunningTask(eventProjectId, eventBranchName, event.featureId); addAutoModeActivity({ featureId: event.featureId, type: 'complete', @@ -202,7 +261,7 @@ export function useAutoMode() { logger.info('Feature cancelled/aborted:', event.error); // Remove from running tasks if (eventProjectId) { - removeRunningTask(eventProjectId, event.featureId); + removeRunningTask(eventProjectId, eventBranchName, event.featureId); } break; } @@ -229,7 +288,7 @@ export function useAutoMode() { // Remove the task from running since it failed if (eventProjectId) { - removeRunningTask(eventProjectId, event.featureId); + removeRunningTask(eventProjectId, eventBranchName, event.featureId); } } break; @@ -404,9 +463,11 @@ export function useAutoMode() { setPendingPlanApproval, setAutoModeRunning, currentProject?.path, + getMaxConcurrencyForWorktree, + setMaxConcurrencyForWorktree, ]); - // Start auto mode - calls backend to start the auto loop + // Start auto mode - calls backend to start the auto loop for this worktree const start = useCallback(async () => { if (!currentProject) { logger.error('No project selected'); @@ -419,36 +480,35 @@ export function useAutoMode() { throw new Error('Start auto mode API not available'); } - logger.info( - `[AutoMode] Starting auto loop for ${currentProject.path} with maxConcurrency: ${maxConcurrency}` - ); + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + logger.info(`[AutoMode] Starting auto loop for ${worktreeDesc} in ${currentProject.path}`); // Optimistically update UI state (backend will confirm via event) - setAutoModeSessionForProjectPath(currentProject.path, true); - setAutoModeRunning(currentProject.id, true); + setAutoModeSessionForWorktree(currentProject.path, branchName, true); + setAutoModeRunning(currentProject.id, branchName, true); - // Call backend to start the auto loop - const result = await api.autoMode.start(currentProject.path, maxConcurrency); + // Call backend to start the auto loop (backend uses stored concurrency) + const result = await api.autoMode.start(currentProject.path, branchName); if (!result.success) { // Revert UI state on failure - setAutoModeSessionForProjectPath(currentProject.path, false); - setAutoModeRunning(currentProject.id, false); + setAutoModeSessionForWorktree(currentProject.path, branchName, false); + setAutoModeRunning(currentProject.id, branchName, false); logger.error('Failed to start auto mode:', result.error); throw new Error(result.error || 'Failed to start auto mode'); } - logger.debug(`[AutoMode] Started successfully`); + logger.debug(`[AutoMode] Started successfully for ${worktreeDesc}`); } catch (error) { // Revert UI state on error - setAutoModeSessionForProjectPath(currentProject.path, false); - setAutoModeRunning(currentProject.id, false); + setAutoModeSessionForWorktree(currentProject.path, branchName, false); + setAutoModeRunning(currentProject.id, branchName, false); logger.error('Error starting auto mode:', error); throw error; } - }, [currentProject, setAutoModeRunning, maxConcurrency]); + }, [currentProject, branchName, setAutoModeRunning]); - // Stop auto mode - calls backend to stop the auto loop + // Stop auto mode - calls backend to stop the auto loop for this worktree const stop = useCallback(async () => { if (!currentProject) { logger.error('No project selected'); @@ -461,34 +521,35 @@ export function useAutoMode() { throw new Error('Stop auto mode API not available'); } - logger.info(`[AutoMode] Stopping auto loop for ${currentProject.path}`); + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + logger.info(`[AutoMode] Stopping auto loop for ${worktreeDesc} in ${currentProject.path}`); // Optimistically update UI state (backend will confirm via event) - setAutoModeSessionForProjectPath(currentProject.path, false); - setAutoModeRunning(currentProject.id, false); + setAutoModeSessionForWorktree(currentProject.path, branchName, false); + setAutoModeRunning(currentProject.id, branchName, false); // Call backend to stop the auto loop - const result = await api.autoMode.stop(currentProject.path); + const result = await api.autoMode.stop(currentProject.path, branchName); if (!result.success) { // Revert UI state on failure - setAutoModeSessionForProjectPath(currentProject.path, true); - setAutoModeRunning(currentProject.id, true); + setAutoModeSessionForWorktree(currentProject.path, branchName, true); + setAutoModeRunning(currentProject.id, branchName, true); logger.error('Failed to stop auto mode:', result.error); throw new Error(result.error || 'Failed to stop auto mode'); } // NOTE: Running tasks will continue until natural completion. // The backend stops picking up new features but doesn't abort running ones. - logger.info('Stopped - running tasks will continue'); + logger.info(`Stopped ${worktreeDesc} - running tasks will continue`); } catch (error) { // Revert UI state on error - setAutoModeSessionForProjectPath(currentProject.path, true); - setAutoModeRunning(currentProject.id, true); + setAutoModeSessionForWorktree(currentProject.path, branchName, true); + setAutoModeRunning(currentProject.id, branchName, true); logger.error('Error stopping auto mode:', error); throw error; } - }, [currentProject, setAutoModeRunning]); + }, [currentProject, branchName, setAutoModeRunning]); // Stop a specific feature const stopFeature = useCallback( @@ -507,7 +568,7 @@ export function useAutoMode() { const result = await api.autoMode.stopFeature(featureId); if (result.success) { - removeRunningTask(currentProject.id, featureId); + removeRunningTask(currentProject.id, branchName, featureId); logger.info('Feature stopped successfully:', featureId); addAutoModeActivity({ featureId, @@ -524,7 +585,7 @@ export function useAutoMode() { throw error; } }, - [currentProject, removeRunningTask, addAutoModeActivity] + [currentProject, branchName, removeRunningTask, addAutoModeActivity] ); return { @@ -532,6 +593,7 @@ export function useAutoMode() { runningTasks: runningAutoTasks, maxConcurrency, canStartNewTask, + branchName, start, stop, stopFeature, diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 58b3ec2d..c679a859 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -30,6 +30,7 @@ import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { DEFAULT_OPENCODE_MODEL, + DEFAULT_MAX_CONCURRENCY, getAllOpencodeModelIds, getAllCursorModelIds, migrateCursorModelIds, @@ -194,6 +195,7 @@ export function parseLocalStorageSettings(): Partial | null { keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'], mcpServers: state.mcpServers as GlobalSettings['mcpServers'], promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'], + eventHooks: state.eventHooks as GlobalSettings['eventHooks'], projects: state.projects as GlobalSettings['projects'], trashedProjects: state.trashedProjects as GlobalSettings['trashedProjects'], currentProjectId: (state.currentProject as { id?: string } | null)?.id ?? null, @@ -635,13 +637,39 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { setItem(THEME_STORAGE_KEY, storedTheme); } + // Restore autoModeByWorktree settings (only maxConcurrency is persisted, runtime state is reset) + const restoredAutoModeByWorktree: Record< + string, + { + isRunning: boolean; + runningTasks: string[]; + branchName: string | null; + maxConcurrency: number; + } + > = {}; + if ((settings as Record).autoModeByWorktree) { + const persistedSettings = (settings as Record).autoModeByWorktree as Record< + string, + { maxConcurrency?: number; branchName?: string | null } + >; + for (const [key, value] of Object.entries(persistedSettings)) { + restoredAutoModeByWorktree[key] = { + isRunning: false, // Always start with auto mode off + runningTasks: [], // No running tasks on startup + branchName: value.branchName ?? null, + maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + }; + } + } + useAppStore.setState({ theme: settings.theme as unknown as import('@/store/app-store').ThemeMode, fontFamilySans: settings.fontFamilySans ?? null, fontFamilyMono: settings.fontFamilyMono ?? null, sidebarOpen: settings.sidebarOpen ?? true, chatHistoryOpen: settings.chatHistoryOpen ?? false, - maxConcurrency: settings.maxConcurrency ?? 3, + maxConcurrency: settings.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + autoModeByWorktree: restoredAutoModeByWorktree, defaultSkipTests: settings.defaultSkipTests ?? true, enableDependencyBlocking: settings.enableDependencyBlocking ?? true, skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false, @@ -671,6 +699,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { }, mcpServers: settings.mcpServers ?? [], promptCustomization: settings.promptCustomization ?? {}, + eventHooks: settings.eventHooks ?? [], projects, currentProject, trashedProjects: settings.trashedProjects ?? [], @@ -705,6 +734,19 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { function buildSettingsUpdateFromStore(): Record { const state = useAppStore.getState(); const setupState = useSetupStore.getState(); + + // Only persist settings (maxConcurrency), not runtime state (isRunning, runningTasks) + const persistedAutoModeByWorktree: Record< + string, + { maxConcurrency: number; branchName: string | null } + > = {}; + for (const [key, value] of Object.entries(state.autoModeByWorktree)) { + persistedAutoModeByWorktree[key] = { + maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + branchName: value.branchName, + }; + } + return { setupComplete: setupState.setupComplete, isFirstRun: setupState.isFirstRun, @@ -713,6 +755,7 @@ function buildSettingsUpdateFromStore(): Record { sidebarOpen: state.sidebarOpen, chatHistoryOpen: state.chatHistoryOpen, maxConcurrency: state.maxConcurrency, + autoModeByWorktree: persistedAutoModeByWorktree, defaultSkipTests: state.defaultSkipTests, enableDependencyBlocking: state.enableDependencyBlocking, skipVerificationInAutoMode: state.skipVerificationInAutoMode, @@ -732,6 +775,7 @@ function buildSettingsUpdateFromStore(): Record { keyboardShortcuts: state.keyboardShortcuts, mcpServers: state.mcpServers, promptCustomization: state.promptCustomization, + eventHooks: state.eventHooks, projects: state.projects, trashedProjects: state.trashedProjects, currentProjectId: state.currentProject?.id ?? null, diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index c978b6a7..7e9f837f 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -21,6 +21,7 @@ import { useAuthStore } from '@/store/auth-store'; import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration'; import { DEFAULT_OPENCODE_MODEL, + DEFAULT_MAX_CONCURRENCY, getAllOpencodeModelIds, getAllCursorModelIds, migrateCursorModelIds, @@ -46,6 +47,7 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'sidebarOpen', 'chatHistoryOpen', 'maxConcurrency', + 'autoModeByWorktree', // Per-worktree auto mode settings (only maxConcurrency is persisted) 'defaultSkipTests', 'enableDependencyBlocking', 'skipVerificationInAutoMode', @@ -112,6 +114,19 @@ function getSettingsFieldValue( if (field === 'openTerminalMode') { return appState.terminalState.openTerminalMode; } + if (field === 'autoModeByWorktree') { + // Only persist settings (maxConcurrency), not runtime state (isRunning, runningTasks) + const autoModeByWorktree = appState.autoModeByWorktree; + const persistedSettings: Record = + {}; + for (const [key, value] of Object.entries(autoModeByWorktree)) { + persistedSettings[key] = { + maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + branchName: value.branchName, + }; + } + return persistedSettings; + } return appState[field as keyof typeof appState]; } @@ -591,11 +606,37 @@ export async function refreshSettingsFromServer(): Promise { setItem(THEME_STORAGE_KEY, serverSettings.theme); } + // Restore autoModeByWorktree settings (only maxConcurrency is persisted, runtime state is reset) + const restoredAutoModeByWorktree: Record< + string, + { + isRunning: boolean; + runningTasks: string[]; + branchName: string | null; + maxConcurrency: number; + } + > = {}; + if (serverSettings.autoModeByWorktree) { + const persistedSettings = serverSettings.autoModeByWorktree as Record< + string, + { maxConcurrency?: number; branchName?: string | null } + >; + for (const [key, value] of Object.entries(persistedSettings)) { + restoredAutoModeByWorktree[key] = { + isRunning: false, // Always start with auto mode off + runningTasks: [], // No running tasks on startup + branchName: value.branchName ?? null, + maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + }; + } + } + useAppStore.setState({ theme: serverSettings.theme as unknown as ThemeMode, sidebarOpen: serverSettings.sidebarOpen, chatHistoryOpen: serverSettings.chatHistoryOpen, maxConcurrency: serverSettings.maxConcurrency, + autoModeByWorktree: restoredAutoModeByWorktree, defaultSkipTests: serverSettings.defaultSkipTests, enableDependencyBlocking: serverSettings.enableDependencyBlocking, skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode, diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 9c834955..fd833d79 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -28,6 +28,7 @@ import type { UpdateIdeaInput, ConvertToFeatureOptions, } from '@automaker/types'; +import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types'; import { getJSON, setJSON, removeItem } from './storage'; // Re-export issue validation types for use in components @@ -486,13 +487,18 @@ export interface FeaturesAPI { export interface AutoModeAPI { start: ( projectPath: string, + branchName?: string | null, maxConcurrency?: number ) => Promise<{ success: boolean; error?: string }>; stop: ( - projectPath: string + projectPath: string, + branchName?: string | null ) => Promise<{ success: boolean; error?: string; runningFeatures?: number }>; stopFeature: (featureId: string) => Promise<{ success: boolean; error?: string }>; - status: (projectPath?: string) => Promise<{ + status: ( + projectPath?: string, + branchName?: string | null + ) => Promise<{ success: boolean; isRunning?: boolean; isAutoLoopRunning?: boolean; @@ -2060,7 +2066,9 @@ function createMockAutoModeAPI(): AutoModeAPI { } mockAutoModeRunning = true; - console.log(`[Mock] Auto mode started with maxConcurrency: ${maxConcurrency || 3}`); + console.log( + `[Mock] Auto mode started with maxConcurrency: ${maxConcurrency || DEFAULT_MAX_CONCURRENCY}` + ); const featureId = 'auto-mode-0'; mockRunningFeatures.add(featureId); diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index ba2b8dd3..cb2d5ca2 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1667,11 +1667,13 @@ export class HttpApiClient implements ElectronAPI { // Auto Mode API autoMode: AutoModeAPI = { - start: (projectPath: string, maxConcurrency?: number) => - this.post('/api/auto-mode/start', { projectPath, maxConcurrency }), - stop: (projectPath: string) => this.post('/api/auto-mode/stop', { projectPath }), + start: (projectPath: string, branchName?: string | null, maxConcurrency?: number) => + this.post('/api/auto-mode/start', { projectPath, branchName, maxConcurrency }), + stop: (projectPath: string, branchName?: string | null) => + this.post('/api/auto-mode/stop', { projectPath, branchName }), stopFeature: (featureId: string) => this.post('/api/auto-mode/stop-feature', { featureId }), - status: (projectPath?: string) => this.post('/api/auto-mode/status', { projectPath }), + status: (projectPath?: string, branchName?: string | null) => + this.post('/api/auto-mode/status', { projectPath, branchName }), runFeature: ( projectPath: string, featureId: string, diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 6030033d..5bb12729 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -38,6 +38,7 @@ import { getAllOpencodeModelIds, DEFAULT_PHASE_MODELS, DEFAULT_OPENCODE_MODEL, + DEFAULT_MAX_CONCURRENCY, } from '@automaker/types'; const logger = createLogger('AppStore'); @@ -626,16 +627,18 @@ export interface AppState { currentChatSession: ChatSession | null; chatHistoryOpen: boolean; - // Auto Mode (per-project state, keyed by project ID) - autoModeByProject: Record< + // Auto Mode (per-worktree state, keyed by "${projectId}::${branchName ?? '__main__'}") + autoModeByWorktree: Record< string, { isRunning: boolean; runningTasks: string[]; // Feature IDs being worked on + branchName: string | null; // null = main worktree + maxConcurrency?: number; // Maximum concurrent features for this worktree (defaults to 3) } >; autoModeActivityLog: AutoModeActivity[]; - maxConcurrency: number; // Maximum number of concurrent agent tasks + maxConcurrency: number; // Legacy: Maximum number of concurrent agent tasks (deprecated, use per-worktree maxConcurrency) // Kanban Card Display Settings boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view @@ -1057,18 +1060,36 @@ export interface AppActions { setChatHistoryOpen: (open: boolean) => void; toggleChatHistory: () => void; - // Auto Mode actions (per-project) - setAutoModeRunning: (projectId: string, running: boolean) => void; - addRunningTask: (projectId: string, taskId: string) => void; - removeRunningTask: (projectId: string, taskId: string) => void; - clearRunningTasks: (projectId: string) => void; - getAutoModeState: (projectId: string) => { + // Auto Mode actions (per-worktree) + setAutoModeRunning: ( + projectId: string, + branchName: string | null, + running: boolean, + maxConcurrency?: number + ) => void; + addRunningTask: (projectId: string, branchName: string | null, taskId: string) => void; + removeRunningTask: (projectId: string, branchName: string | null, taskId: string) => void; + clearRunningTasks: (projectId: string, branchName: string | null) => void; + getAutoModeState: ( + projectId: string, + branchName: string | null + ) => { isRunning: boolean; runningTasks: string[]; + branchName: string | null; + maxConcurrency?: number; }; + /** Helper to generate worktree key from projectId and branchName */ + getWorktreeKey: (projectId: string, branchName: string | null) => string; addAutoModeActivity: (activity: Omit) => void; clearAutoModeActivity: () => void; - setMaxConcurrency: (max: number) => void; + setMaxConcurrency: (max: number) => void; // Legacy: kept for backward compatibility + getMaxConcurrencyForWorktree: (projectId: string, branchName: string | null) => number; + setMaxConcurrencyForWorktree: ( + projectId: string, + branchName: string | null, + maxConcurrency: number + ) => void; // Kanban Card Settings actions setBoardViewMode: (mode: BoardViewMode) => void; @@ -1387,9 +1408,9 @@ const initialState: AppState = { chatSessions: [], currentChatSession: null, chatHistoryOpen: false, - autoModeByProject: {}, + autoModeByWorktree: {}, autoModeActivityLog: [], - maxConcurrency: 3, // Default to 3 concurrent agents + maxConcurrency: DEFAULT_MAX_CONCURRENCY, // Default concurrent agents boardViewMode: 'kanban', // Default to kanban view defaultSkipTests: true, // Default to manual verification (tests disabled) enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI) @@ -2073,74 +2094,125 @@ export const useAppStore = create()((set, get) => ({ toggleChatHistory: () => set({ chatHistoryOpen: !get().chatHistoryOpen }), - // Auto Mode actions (per-project) - setAutoModeRunning: (projectId, running) => { - const current = get().autoModeByProject; - const projectState = current[projectId] || { + // Auto Mode actions (per-worktree) + getWorktreeKey: (projectId, branchName) => { + return `${projectId}::${branchName ?? '__main__'}`; + }, + + setAutoModeRunning: (projectId, branchName, running, maxConcurrency?: number) => { + const worktreeKey = get().getWorktreeKey(projectId, branchName); + const current = get().autoModeByWorktree; + const worktreeState = current[worktreeKey] || { isRunning: false, runningTasks: [], + branchName, + maxConcurrency: maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, }; set({ - autoModeByProject: { + autoModeByWorktree: { ...current, - [projectId]: { ...projectState, isRunning: running }, + [worktreeKey]: { + ...worktreeState, + isRunning: running, + branchName, + maxConcurrency: maxConcurrency ?? worktreeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + }, }, }); }, - addRunningTask: (projectId, taskId) => { - const current = get().autoModeByProject; - const projectState = current[projectId] || { + addRunningTask: (projectId, branchName, taskId) => { + const worktreeKey = get().getWorktreeKey(projectId, branchName); + const current = get().autoModeByWorktree; + const worktreeState = current[worktreeKey] || { isRunning: false, runningTasks: [], + branchName, }; - if (!projectState.runningTasks.includes(taskId)) { + if (!worktreeState.runningTasks.includes(taskId)) { set({ - autoModeByProject: { + autoModeByWorktree: { ...current, - [projectId]: { - ...projectState, - runningTasks: [...projectState.runningTasks, taskId], + [worktreeKey]: { + ...worktreeState, + runningTasks: [...worktreeState.runningTasks, taskId], + branchName, }, }, }); } }, - removeRunningTask: (projectId, taskId) => { - const current = get().autoModeByProject; - const projectState = current[projectId] || { + removeRunningTask: (projectId, branchName, taskId) => { + const worktreeKey = get().getWorktreeKey(projectId, branchName); + const current = get().autoModeByWorktree; + const worktreeState = current[worktreeKey] || { isRunning: false, runningTasks: [], + branchName, }; set({ - autoModeByProject: { + autoModeByWorktree: { ...current, - [projectId]: { - ...projectState, - runningTasks: projectState.runningTasks.filter((id) => id !== taskId), + [worktreeKey]: { + ...worktreeState, + runningTasks: worktreeState.runningTasks.filter((id) => id !== taskId), + branchName, }, }, }); }, - clearRunningTasks: (projectId) => { - const current = get().autoModeByProject; - const projectState = current[projectId] || { + clearRunningTasks: (projectId, branchName) => { + const worktreeKey = get().getWorktreeKey(projectId, branchName); + const current = get().autoModeByWorktree; + const worktreeState = current[worktreeKey] || { isRunning: false, runningTasks: [], + branchName, }; set({ - autoModeByProject: { + autoModeByWorktree: { ...current, - [projectId]: { ...projectState, runningTasks: [] }, + [worktreeKey]: { ...worktreeState, runningTasks: [], branchName }, }, }); }, - getAutoModeState: (projectId) => { - const projectState = get().autoModeByProject[projectId]; - return projectState || { isRunning: false, runningTasks: [] }; + getAutoModeState: (projectId, branchName) => { + const worktreeKey = get().getWorktreeKey(projectId, branchName); + const worktreeState = get().autoModeByWorktree[worktreeKey]; + return ( + worktreeState || { + isRunning: false, + runningTasks: [], + branchName, + maxConcurrency: DEFAULT_MAX_CONCURRENCY, + } + ); + }, + + getMaxConcurrencyForWorktree: (projectId, branchName) => { + const worktreeKey = get().getWorktreeKey(projectId, branchName); + const worktreeState = get().autoModeByWorktree[worktreeKey]; + return worktreeState?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY; + }, + + setMaxConcurrencyForWorktree: (projectId, branchName, maxConcurrency) => { + const worktreeKey = get().getWorktreeKey(projectId, branchName); + const current = get().autoModeByWorktree; + const worktreeState = current[worktreeKey] || { + isRunning: false, + runningTasks: [], + branchName, + maxConcurrency: DEFAULT_MAX_CONCURRENCY, + }; + set({ + autoModeByWorktree: { + ...current, + [worktreeKey]: { ...worktreeState, maxConcurrency, branchName }, + }, + }); }, addAutoModeActivity: (activity) => { diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index a8e7c347..ebaf5f59 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -163,11 +163,30 @@ export interface SessionsAPI { } export type AutoModeEvent = + | { + type: 'auto_mode_started'; + message: string; + projectPath?: string; + branchName?: string | null; + } + | { + type: 'auto_mode_stopped'; + message: string; + projectPath?: string; + branchName?: string | null; + } + | { + type: 'auto_mode_idle'; + message: string; + projectPath?: string; + branchName?: string | null; + } | { type: 'auto_mode_feature_start'; featureId: string; projectId?: string; projectPath?: string; + branchName?: string | null; feature: unknown; } | { @@ -175,6 +194,7 @@ export type AutoModeEvent = featureId: string; projectId?: string; projectPath?: string; + branchName?: string | null; content: string; } | { @@ -182,6 +202,7 @@ export type AutoModeEvent = featureId: string; projectId?: string; projectPath?: string; + branchName?: string | null; tool: string; input: unknown; } @@ -190,6 +211,7 @@ export type AutoModeEvent = featureId: string; projectId?: string; projectPath?: string; + branchName?: string | null; passes: boolean; message: string; } @@ -218,6 +240,7 @@ export type AutoModeEvent = featureId?: string; projectId?: string; projectPath?: string; + branchName?: string | null; } | { type: 'auto_mode_phase'; @@ -389,18 +412,48 @@ export interface SpecRegenerationAPI { } export interface AutoModeAPI { + start: ( + projectPath: string, + branchName?: string | null, + maxConcurrency?: number + ) => Promise<{ + success: boolean; + message?: string; + alreadyRunning?: boolean; + branchName?: string | null; + error?: string; + }>; + + stop: ( + projectPath: string, + branchName?: string | null + ) => Promise<{ + success: boolean; + message?: string; + wasRunning?: boolean; + runningFeaturesCount?: number; + branchName?: string | null; + error?: string; + }>; + stopFeature: (featureId: string) => Promise<{ success: boolean; error?: string; }>; - status: (projectPath?: string) => Promise<{ + status: ( + projectPath?: string, + branchName?: string | null + ) => Promise<{ success: boolean; isRunning?: boolean; + isAutoLoopRunning?: boolean; currentFeatureId?: string | null; runningFeatures?: string[]; runningProjects?: string[]; runningCount?: number; + maxConcurrency?: number; + branchName?: string | null; error?: string; }>; diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 21985230..c5e8f153 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -168,6 +168,7 @@ export { DEFAULT_GLOBAL_SETTINGS, DEFAULT_CREDENTIALS, DEFAULT_PROJECT_SETTINGS, + DEFAULT_MAX_CONCURRENCY, SETTINGS_VERSION, CREDENTIALS_VERSION, PROJECT_SETTINGS_VERSION, diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 64c3df41..61074f96 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -833,6 +833,9 @@ export const CREDENTIALS_VERSION = 1; /** Current version of the project settings schema */ export const PROJECT_SETTINGS_VERSION = 1; +/** Default maximum concurrent agents for auto mode */ +export const DEFAULT_MAX_CONCURRENCY = 1; + /** Default keyboard shortcut bindings */ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { board: 'K', @@ -866,7 +869,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { theme: 'dark', sidebarOpen: true, chatHistoryOpen: false, - maxConcurrency: 3, + maxConcurrency: DEFAULT_MAX_CONCURRENCY, defaultSkipTests: true, enableDependencyBlocking: true, skipVerificationInAutoMode: false, diff --git a/worktrees/automode-api/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/worktrees/automode-api/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx new file mode 100644 index 00000000..69392afa --- /dev/null +++ b/worktrees/automode-api/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -0,0 +1,1582 @@ +import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; +import { cn } from '@/lib/utils'; +import { useAppStore } from '@/store/app-store'; +import { useIsMobile } from '@/hooks/use-media-query'; +import type { + ModelAlias, + CursorModelId, + CodexModelId, + OpencodeModelId, + GroupedModel, + PhaseModelEntry, +} from '@automaker/types'; +import { + stripProviderPrefix, + STANDALONE_CURSOR_MODELS, + getModelGroup, + isGroupSelected, + getSelectedVariant, + codexModelHasThinking, +} from '@automaker/types'; +import { + CLAUDE_MODELS, + CURSOR_MODELS, + OPENCODE_MODELS, + THINKING_LEVELS, + THINKING_LEVEL_LABELS, + REASONING_EFFORT_LEVELS, + REASONING_EFFORT_LABELS, + type ModelOption, +} from '@/components/views/board-view/shared/model-constants'; +import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react'; +import { + AnthropicIcon, + CursorIcon, + OpenAIIcon, + getProviderIconForModel, +} from '@/components/ui/provider-icon'; +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; + +const OPENCODE_CLI_GROUP_LABEL = 'OpenCode CLI'; +const OPENCODE_PROVIDER_FALLBACK = 'opencode'; +const OPENCODE_PROVIDER_WORD_SEPARATOR = '-'; +const OPENCODE_MODEL_ID_SEPARATOR = '/'; +const OPENCODE_SECTION_GROUP_PADDING = 'pt-2'; + +const OPENCODE_STATIC_PROVIDER_LABELS: Record = { + [OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)', +}; + +const OPENCODE_DYNAMIC_PROVIDER_LABELS: Record = { + 'github-copilot': 'GitHub Copilot', + 'zai-coding-plan': 'Z.AI Coding Plan', + google: 'Google AI', + openai: 'OpenAI', + openrouter: 'OpenRouter', + anthropic: 'Anthropic', + xai: 'xAI', + deepseek: 'DeepSeek', + ollama: 'Ollama (Local)', + lmstudio: 'LM Studio (Local)', + azure: 'Azure OpenAI', + [OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)', +}; + +const OPENCODE_DYNAMIC_PROVIDER_ORDER = [ + 'github-copilot', + 'google', + 'openai', + 'openrouter', + 'anthropic', + 'xai', + 'deepseek', + 'ollama', + 'lmstudio', + 'azure', + 'zai-coding-plan', +]; + +const OPENCODE_SECTION_ORDER = ['free', 'dynamic'] as const; + +const OPENCODE_SECTION_LABELS: Record<(typeof OPENCODE_SECTION_ORDER)[number], string> = { + free: 'Free Tier', + dynamic: 'Connected Providers', +}; + +const OPENCODE_STATIC_PROVIDER_BY_ID = new Map( + OPENCODE_MODELS.map((model) => [model.id, model.provider]) +); + +function formatProviderLabel(providerKey: string): string { + return providerKey + .split(OPENCODE_PROVIDER_WORD_SEPARATOR) + .map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word)) + .join(' '); +} + +function getOpencodeSectionKey(providerKey: string): (typeof OPENCODE_SECTION_ORDER)[number] { + if (providerKey === OPENCODE_PROVIDER_FALLBACK) { + return 'free'; + } + return 'dynamic'; +} + +function getOpencodeGroupLabel( + providerKey: string, + sectionKey: (typeof OPENCODE_SECTION_ORDER)[number] +): string { + if (sectionKey === 'free') { + return OPENCODE_STATIC_PROVIDER_LABELS[providerKey] || 'OpenCode Free Tier'; + } + return OPENCODE_DYNAMIC_PROVIDER_LABELS[providerKey] || formatProviderLabel(providerKey); +} + +interface PhaseModelSelectorProps { + /** Label shown in full mode */ + label?: string; + /** Description shown in full mode */ + description?: string; + /** Current model selection */ + value: PhaseModelEntry; + /** Callback when model is selected */ + onChange: (entry: PhaseModelEntry) => void; + /** Compact mode - just shows the button trigger without label/description wrapper */ + compact?: boolean; + /** Custom trigger class name */ + triggerClassName?: string; + /** Popover alignment */ + align?: 'start' | 'end'; + /** Disabled state */ + disabled?: boolean; +} + +export function PhaseModelSelector({ + label, + description, + value, + onChange, + compact = false, + triggerClassName, + align = 'end', + disabled = false, +}: PhaseModelSelectorProps) { + const [open, setOpen] = useState(false); + const [expandedGroup, setExpandedGroup] = useState(null); + const [expandedClaudeModel, setExpandedClaudeModel] = useState(null); + const [expandedCodexModel, setExpandedCodexModel] = useState(null); + const commandListRef = useRef(null); + const expandedTriggerRef = useRef(null); + const expandedClaudeTriggerRef = useRef(null); + const expandedCodexTriggerRef = useRef(null); + const { + enabledCursorModels, + favoriteModels, + toggleFavoriteModel, + codexModels, + codexModelsLoading, + fetchCodexModels, + dynamicOpencodeModels, + enabledDynamicModelIds, + opencodeModelsLoading, + fetchOpencodeModels, + disabledProviders, + } = useAppStore(); + + // Detect mobile devices to use inline expansion instead of nested popovers + const isMobile = useIsMobile(); + + // Extract model and thinking/reasoning levels from value + const selectedModel = value.model; + const selectedThinkingLevel = value.thinkingLevel || 'none'; + const selectedReasoningEffort = value.reasoningEffort || 'none'; + + // Fetch Codex models on mount + useEffect(() => { + if (codexModels.length === 0 && !codexModelsLoading) { + fetchCodexModels().catch(() => { + // Silently fail - user will see empty Codex section + }); + } + }, [codexModels.length, codexModelsLoading, fetchCodexModels]); + + // Fetch OpenCode models on mount + useEffect(() => { + if (dynamicOpencodeModels.length === 0 && !opencodeModelsLoading) { + fetchOpencodeModels().catch(() => { + // Silently fail - user will see only static OpenCode models + }); + } + }, [dynamicOpencodeModels.length, opencodeModelsLoading, fetchOpencodeModels]); + + // Close expanded group when trigger scrolls out of view + useEffect(() => { + const triggerElement = expandedTriggerRef.current; + const listElement = commandListRef.current; + if (!triggerElement || !listElement || !expandedGroup) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry.isIntersecting) { + setExpandedGroup(null); + } + }, + { + root: listElement, + threshold: 0.1, // Close when less than 10% visible + } + ); + + observer.observe(triggerElement); + return () => observer.disconnect(); + }, [expandedGroup]); + + // Close expanded Claude model popover when trigger scrolls out of view + useEffect(() => { + const triggerElement = expandedClaudeTriggerRef.current; + const listElement = commandListRef.current; + if (!triggerElement || !listElement || !expandedClaudeModel) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry.isIntersecting) { + setExpandedClaudeModel(null); + } + }, + { + root: listElement, + threshold: 0.1, + } + ); + + observer.observe(triggerElement); + return () => observer.disconnect(); + }, [expandedClaudeModel]); + + // Close expanded Codex model popover when trigger scrolls out of view + useEffect(() => { + const triggerElement = expandedCodexTriggerRef.current; + const listElement = commandListRef.current; + if (!triggerElement || !listElement || !expandedCodexModel) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry.isIntersecting) { + setExpandedCodexModel(null); + } + }, + { + root: listElement, + threshold: 0.1, + } + ); + + observer.observe(triggerElement); + return () => observer.disconnect(); + }, [expandedCodexModel]); + + // Transform dynamic Codex models from store to component format + const transformedCodexModels = useMemo(() => { + return codexModels.map((model) => ({ + id: model.id, + label: model.label, + description: model.description, + provider: 'codex' as const, + badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Speed' : undefined, + })); + }, [codexModels]); + + // Filter Cursor models to only show enabled ones + // With canonical IDs, both CURSOR_MODELS and enabledCursorModels use prefixed format + const availableCursorModels = CURSOR_MODELS.filter((model) => { + return enabledCursorModels.includes(model.id as CursorModelId); + }); + + // Helper to find current selected model details + const currentModel = useMemo(() => { + const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel); + if (claudeModel) { + // Add thinking level to label if not 'none' + const thinkingLabel = + selectedThinkingLevel !== 'none' + ? ` (${THINKING_LEVEL_LABELS[selectedThinkingLevel]} Thinking)` + : ''; + return { + ...claudeModel, + label: `${claudeModel.label}${thinkingLabel}`, + icon: AnthropicIcon, + }; + } + + // With canonical IDs, direct comparison works + const cursorModel = availableCursorModels.find((m) => m.id === selectedModel); + if (cursorModel) return { ...cursorModel, icon: CursorIcon }; + + // Check if selectedModel is part of a grouped model + const group = getModelGroup(selectedModel as CursorModelId); + if (group) { + const variant = getSelectedVariant(group, selectedModel as CursorModelId); + return { + id: selectedModel, + label: `${group.label} (${variant?.label || 'Unknown'})`, + description: group.description, + provider: 'cursor' as const, + icon: CursorIcon, + }; + } + + // Check Codex models + const codexModel = transformedCodexModels.find((m) => m.id === selectedModel); + if (codexModel) return { ...codexModel, icon: OpenAIIcon }; + + // Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons + const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel); + if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) }; + + // Check dynamic OpenCode models - use dynamic icon resolution for provider-specific icons + const dynamicModel = dynamicOpencodeModels.find((m) => m.id === selectedModel); + if (dynamicModel) { + return { + id: dynamicModel.id, + label: dynamicModel.name, + description: dynamicModel.description, + provider: 'opencode' as const, + icon: getProviderIconForModel(dynamicModel.id), + }; + } + + return null; + }, [ + selectedModel, + selectedThinkingLevel, + availableCursorModels, + transformedCodexModels, + dynamicOpencodeModels, + ]); + + // Compute grouped vs standalone Cursor models + const { groupedModels, standaloneCursorModels } = useMemo(() => { + const grouped: GroupedModel[] = []; + const standalone: typeof CURSOR_MODELS = []; + const seenGroups = new Set(); + + availableCursorModels.forEach((model) => { + const cursorId = model.id as CursorModelId; + + // Check if this model is standalone + if (STANDALONE_CURSOR_MODELS.includes(cursorId)) { + standalone.push(model); + return; + } + + // Check if this model belongs to a group + const group = getModelGroup(cursorId); + if (group && !seenGroups.has(group.baseId)) { + // Filter variants to only include enabled models + const enabledVariants = group.variants.filter((v) => enabledCursorModels.includes(v.id)); + if (enabledVariants.length > 0) { + grouped.push({ + ...group, + variants: enabledVariants, + }); + seenGroups.add(group.baseId); + } + } + }); + + return { groupedModels: grouped, standaloneCursorModels: standalone }; + }, [availableCursorModels, enabledCursorModels]); + + // Combine static and dynamic OpenCode models + const allOpencodeModels: ModelOption[] = useMemo(() => { + // Start with static models + const staticModels = [...OPENCODE_MODELS]; + + // Add dynamic models (convert ModelDefinition to ModelOption) + // Only include dynamic models that are enabled by the user + const dynamicModelOptions: ModelOption[] = dynamicOpencodeModels + .filter((model) => enabledDynamicModelIds.includes(model.id)) + .map((model) => ({ + id: model.id, + label: model.name, + description: model.description, + badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Free' : undefined, + provider: 'opencode' as const, + })); + + // Merge, avoiding duplicates (static models take precedence for same ID) + // In practice, static and dynamic IDs don't overlap + const staticIds = new Set(staticModels.map((m) => m.id)); + const uniqueDynamic = dynamicModelOptions.filter((m) => !staticIds.has(m.id)); + + return [...staticModels, ...uniqueDynamic]; + }, [dynamicOpencodeModels, enabledDynamicModelIds]); + + // Group models (filtering out disabled providers) + const { favorites, claude, cursor, codex, opencode } = useMemo(() => { + const favs: typeof CLAUDE_MODELS = []; + const cModels: typeof CLAUDE_MODELS = []; + const curModels: typeof CURSOR_MODELS = []; + const codModels: typeof transformedCodexModels = []; + const ocModels: ModelOption[] = []; + + const isClaudeDisabled = disabledProviders.includes('claude'); + const isCursorDisabled = disabledProviders.includes('cursor'); + const isCodexDisabled = disabledProviders.includes('codex'); + const isOpencodeDisabled = disabledProviders.includes('opencode'); + + // Process Claude Models (skip if provider is disabled) + if (!isClaudeDisabled) { + CLAUDE_MODELS.forEach((model) => { + if (favoriteModels.includes(model.id)) { + favs.push(model); + } else { + cModels.push(model); + } + }); + } + + // Process Cursor Models (skip if provider is disabled) + if (!isCursorDisabled) { + availableCursorModels.forEach((model) => { + if (favoriteModels.includes(model.id)) { + favs.push(model); + } else { + curModels.push(model); + } + }); + } + + // Process Codex Models (skip if provider is disabled) + if (!isCodexDisabled) { + transformedCodexModels.forEach((model) => { + if (favoriteModels.includes(model.id)) { + favs.push(model); + } else { + codModels.push(model); + } + }); + } + + // Process OpenCode Models (skip if provider is disabled) + if (!isOpencodeDisabled) { + allOpencodeModels.forEach((model) => { + if (favoriteModels.includes(model.id)) { + favs.push(model); + } else { + ocModels.push(model); + } + }); + } + + return { + favorites: favs, + claude: cModels, + cursor: curModels, + codex: codModels, + opencode: ocModels, + }; + }, [ + favoriteModels, + availableCursorModels, + transformedCodexModels, + allOpencodeModels, + disabledProviders, + ]); + + // Group OpenCode models by model type for better organization + const opencodeSections = useMemo(() => { + type OpencodeSectionKey = (typeof OPENCODE_SECTION_ORDER)[number]; + type OpencodeGroup = { key: string; label: string; models: ModelOption[] }; + type OpencodeSection = { + key: OpencodeSectionKey; + label: string; + showGroupLabels: boolean; + groups: OpencodeGroup[]; + }; + + const sections: Record> = { + free: {}, + dynamic: {}, + }; + const dynamicProviderById = new Map( + dynamicOpencodeModels.map((model) => [model.id, model.provider]) + ); + + const resolveProviderKey = (modelId: string): string => { + const staticProvider = OPENCODE_STATIC_PROVIDER_BY_ID.get(modelId); + if (staticProvider) return staticProvider; + + const dynamicProvider = dynamicProviderById.get(modelId); + if (dynamicProvider) return dynamicProvider; + + return modelId.includes(OPENCODE_MODEL_ID_SEPARATOR) + ? modelId.split(OPENCODE_MODEL_ID_SEPARATOR)[0] + : OPENCODE_PROVIDER_FALLBACK; + }; + + const addModelToGroup = ( + sectionKey: OpencodeSectionKey, + providerKey: string, + model: ModelOption + ) => { + if (!sections[sectionKey][providerKey]) { + sections[sectionKey][providerKey] = { + key: providerKey, + label: getOpencodeGroupLabel(providerKey, sectionKey), + models: [], + }; + } + sections[sectionKey][providerKey].models.push(model); + }; + + opencode.forEach((model) => { + const providerKey = resolveProviderKey(model.id); + const sectionKey = getOpencodeSectionKey(providerKey); + addModelToGroup(sectionKey, providerKey, model); + }); + + const buildGroupList = (sectionKey: OpencodeSectionKey): OpencodeGroup[] => { + const groupMap = sections[sectionKey]; + const priorityOrder = sectionKey === 'dynamic' ? OPENCODE_DYNAMIC_PROVIDER_ORDER : []; + const priorityMap = new Map(priorityOrder.map((provider, index) => [provider, index])); + + return Object.keys(groupMap) + .sort((a, b) => { + const aPriority = priorityMap.get(a); + const bPriority = priorityMap.get(b); + + if (aPriority !== undefined && bPriority !== undefined) { + return aPriority - bPriority; + } + if (aPriority !== undefined) return -1; + if (bPriority !== undefined) return 1; + + return groupMap[a].label.localeCompare(groupMap[b].label); + }) + .map((key) => groupMap[key]); + }; + + const builtSections = OPENCODE_SECTION_ORDER.map((sectionKey) => { + const groups = buildGroupList(sectionKey); + if (groups.length === 0) return null; + + return { + key: sectionKey, + label: OPENCODE_SECTION_LABELS[sectionKey], + showGroupLabels: sectionKey !== 'free', + groups, + }; + }).filter(Boolean) as OpencodeSection[]; + + return builtSections; + }, [opencode, dynamicOpencodeModels]); + + // Render Codex model item with secondary popover for reasoning effort (only for models that support it) + const renderCodexModelItem = (model: (typeof transformedCodexModels)[0]) => { + const isSelected = selectedModel === model.id; + const isFavorite = favoriteModels.includes(model.id); + const hasReasoning = codexModelHasThinking(model.id as CodexModelId); + const isExpanded = expandedCodexModel === model.id; + const currentReasoning = isSelected ? selectedReasoningEffort : 'none'; + + // If model doesn't support reasoning, render as simple selector (like Cursor models) + if (!hasReasoning) { + return ( + { + onChange({ model: model.id as CodexModelId }); + setOpen(false); + }} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + {model.description} +
+
+ +
+ + {isSelected && } +
+
+ ); + } + + // Model supports reasoning - show popover with reasoning effort options + // On mobile, render inline expansion instead of nested popover + if (isMobile) { + return ( +
+ setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + + {isSelected && currentReasoning !== 'none' + ? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}` + : model.description} + +
+
+ +
+ + {isSelected && !isExpanded && } + +
+
+ + {/* Inline reasoning effort options on mobile */} + {isExpanded && ( +
+
+ Reasoning Effort +
+ {REASONING_EFFORT_LEVELS.map((effort) => ( + + ))} +
+ )} +
+ ); + } + + // Desktop: Use nested popover + return ( + setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))} + className="p-0 data-[selected=true]:bg-transparent" + > + { + if (!isOpen) { + setExpandedCodexModel(null); + } + }} + > + +
+
+ +
+ + {model.label} + + + {isSelected && currentReasoning !== 'none' + ? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}` + : model.description} + +
+
+ +
+ + {isSelected && } + +
+
+
+ e.preventDefault()} + > +
+
+ Reasoning Effort +
+ {REASONING_EFFORT_LEVELS.map((effort) => ( + + ))} +
+
+
+
+ ); + }; + + // Render OpenCode model item (simple selector, no thinking/reasoning options) + const renderOpencodeModelItem = (model: (typeof OPENCODE_MODELS)[0]) => { + const isSelected = selectedModel === model.id; + const isFavorite = favoriteModels.includes(model.id); + + // Get the appropriate icon based on the specific model ID + const ProviderIcon = getProviderIconForModel(model.id); + + return ( + { + onChange({ model: model.id as OpencodeModelId }); + setOpen(false); + }} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + {model.description} +
+
+ +
+ {model.badge && ( + + {model.badge} + + )} + + {isSelected && } +
+
+ ); + }; + + // Render Cursor model item (no thinking level needed) + const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => { + // With canonical IDs, store the full prefixed ID + const isSelected = selectedModel === model.id; + const isFavorite = favoriteModels.includes(model.id); + + return ( + { + onChange({ model: model.id as CursorModelId }); + setOpen(false); + }} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + {model.description} +
+
+ +
+ + {isSelected && } +
+
+ ); + }; + + // Render Claude model item with secondary popover for thinking level + const renderClaudeModelItem = (model: (typeof CLAUDE_MODELS)[0]) => { + const isSelected = selectedModel === model.id; + const isFavorite = favoriteModels.includes(model.id); + const isExpanded = expandedClaudeModel === model.id; + const currentThinking = isSelected ? selectedThinkingLevel : 'none'; + + // On mobile, render inline expansion instead of nested popover + if (isMobile) { + return ( +
+ setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + + {isSelected && currentThinking !== 'none' + ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}` + : model.description} + +
+
+ +
+ + {isSelected && !isExpanded && } + +
+
+ + {/* Inline thinking level options on mobile */} + {isExpanded && ( +
+
+ Thinking Level +
+ {THINKING_LEVELS.map((level) => ( + + ))} +
+ )} +
+ ); + } + + // Desktop: Use nested popover + return ( + setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))} + className="p-0 data-[selected=true]:bg-transparent" + > + { + if (!isOpen) { + setExpandedClaudeModel(null); + } + }} + > + +
+
+ +
+ + {model.label} + + + {isSelected && currentThinking !== 'none' + ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}` + : model.description} + +
+
+ +
+ + {isSelected && } + +
+
+
+ e.preventDefault()} + > +
+
+ Thinking Level +
+ {THINKING_LEVELS.map((level) => ( + + ))} +
+
+
+
+ ); + }; + + // Render a grouped model with secondary popover for variant selection + const renderGroupedModelItem = (group: GroupedModel) => { + const groupIsSelected = isGroupSelected(group, selectedModel as CursorModelId); + const selectedVariant = getSelectedVariant(group, selectedModel as CursorModelId); + const isExpanded = expandedGroup === group.baseId; + + const variantTypeLabel = + group.variantType === 'compute' + ? 'Compute Level' + : group.variantType === 'thinking' + ? 'Reasoning Mode' + : 'Capacity Options'; + + // On mobile, render inline expansion instead of nested popover + if (isMobile) { + return ( +
+ setExpandedGroup(isExpanded ? null : group.baseId)} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {group.label} + + + {selectedVariant ? `Selected: ${selectedVariant.label}` : group.description} + +
+
+ +
+ {groupIsSelected && !isExpanded && ( + + )} + +
+
+ + {/* Inline variant options on mobile */} + {isExpanded && ( +
+
+ {variantTypeLabel} +
+ {group.variants.map((variant) => ( + + ))} +
+ )} +
+ ); + } + + // Desktop: Use nested popover + return ( + setExpandedGroup(isExpanded ? null : group.baseId)} + className="p-0 data-[selected=true]:bg-transparent" + > + { + if (!isOpen) { + setExpandedGroup(null); + } + }} + > + +
+
+ +
+ + {group.label} + + + {selectedVariant ? `Selected: ${selectedVariant.label}` : group.description} + +
+
+ +
+ {groupIsSelected && } + +
+
+
+ e.preventDefault()} + > +
+
+ {variantTypeLabel} +
+ {group.variants.map((variant) => ( + + ))} +
+
+
+
+ ); + }; + + // Compact trigger button (for agent view etc.) + const compactTrigger = ( + + ); + + // Full trigger button (for settings view) + const fullTrigger = ( + + ); + + // The popover content (shared between both modes) + const popoverContent = ( + e.stopPropagation()} + onTouchMove={(e) => e.stopPropagation()} + onPointerDownOutside={(e) => { + // Only prevent close if clicking inside a nested popover (thinking level panel) + const target = e.target as HTMLElement; + if (target.closest('[data-slot="popover-content"]')) { + e.preventDefault(); + } + }} + > + + + + No model found. + + {favorites.length > 0 && ( + <> + + {(() => { + const renderedGroups = new Set(); + return favorites.map((model) => { + // Check if this favorite is part of a grouped model + if (model.provider === 'cursor') { + const cursorId = model.id as CursorModelId; + const group = getModelGroup(cursorId); + if (group) { + // Skip if we already rendered this group + if (renderedGroups.has(group.baseId)) { + return null; + } + renderedGroups.add(group.baseId); + // Find the group in groupedModels (which has filtered variants) + const filteredGroup = groupedModels.find((g) => g.baseId === group.baseId); + if (filteredGroup) { + return renderGroupedModelItem(filteredGroup); + } + } + // Standalone Cursor model + return renderCursorModelItem(model); + } + // Codex model + if (model.provider === 'codex') { + return renderCodexModelItem(model as (typeof transformedCodexModels)[0]); + } + // OpenCode model + if (model.provider === 'opencode') { + return renderOpencodeModelItem(model); + } + // Claude model + return renderClaudeModelItem(model); + }); + })()} + + + + )} + + {claude.length > 0 && ( + + {claude.map((model) => renderClaudeModelItem(model))} + + )} + + {(groupedModels.length > 0 || standaloneCursorModels.length > 0) && ( + + {/* Grouped models with secondary popover */} + {groupedModels.map((group) => renderGroupedModelItem(group))} + {/* Standalone models */} + {standaloneCursorModels.map((model) => renderCursorModelItem(model))} + + )} + + {codex.length > 0 && ( + + {codex.map((model) => renderCodexModelItem(model))} + + )} + + {opencodeSections.length > 0 && ( + + {opencodeSections.map((section, sectionIndex) => ( + +
+ {section.label} +
+
+ {section.groups.map((group) => ( +
+ {section.showGroupLabels && ( +
+ {group.label} +
+ )} + {group.models.map((model) => renderOpencodeModelItem(model))} +
+ ))} +
+
+ ))} +
+ )} +
+
+
+ ); + + // Compact mode - just the popover with compact trigger + if (compact) { + return ( + + {compactTrigger} + {popoverContent} + + ); + } + + // Full mode - with label and description wrapper + return ( +
+ {/* Label and Description */} +
+

{label}

+

{description}

+
+ + {/* Model Selection Popover */} + + {fullTrigger} + {popoverContent} + +
+ ); +}