From 82e22b43629e03118a6d2e34bbce2e1421265947 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Mon, 19 Jan 2026 17:17:40 -0500 Subject: [PATCH] feat: enhance auto mode functionality with worktree support - Updated auto mode handlers to support branch-specific operations, allowing for better management of features across different worktrees. - Introduced normalization of branch names to handle undefined values gracefully. - Enhanced status and response messages to reflect the current worktree context. - Updated the auto mode service to manage state and concurrency settings per worktree, improving user experience and flexibility. - Added UI elements to display current max concurrency for auto mode in both board and mobile views. This update aims to streamline the auto mode experience, making it more intuitive for users working with multiple branches and worktrees. --- .../src/routes/auto-mode/routes/start.ts | 27 +- .../src/routes/auto-mode/routes/status.ts | 17 +- .../src/routes/auto-mode/routes/stop.ts | 24 +- apps/server/src/services/auto-mode-service.ts | 544 ++++-- .../server/src/services/event-hook-service.ts | 2 + apps/server/src/services/settings-service.ts | 9 +- apps/ui/src/components/views/board-view.tsx | 59 +- .../views/board-view/board-header.tsx | 7 + .../views/board-view/header-mobile-menu.tsx | 7 + .../board-view/hooks/use-board-actions.ts | 11 +- .../hooks/use-board-column-features.ts | 19 +- .../board-view/hooks/use-board-features.ts | 19 +- .../components/worktree-actions-dropdown.tsx | 26 + .../components/worktree-tab.tsx | 27 + .../worktree-panel/worktree-panel.tsx | 71 +- apps/ui/src/hooks/use-auto-mode.ts | 184 +- apps/ui/src/hooks/use-settings-migration.ts | 46 +- apps/ui/src/hooks/use-settings-sync.ts | 41 + apps/ui/src/lib/electron.ts | 14 +- apps/ui/src/lib/http-api-client.ts | 10 +- apps/ui/src/store/app-store.ts | 154 +- apps/ui/src/types/electron.d.ts | 55 +- libs/types/src/index.ts | 1 + libs/types/src/settings.ts | 5 +- .../model-defaults/phase-model-selector.tsx | 1582 +++++++++++++++++ 25 files changed, 2693 insertions(+), 268 deletions(-) create mode 100644 worktrees/automode-api/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx 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} + +
+ ); +}