From d97c4b7b57da42c212151d323af6bf75c689c658 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Mon, 19 Jan 2026 20:36:58 +0100 Subject: [PATCH 01/11] feat: unified Claude API key and profile system with z.AI, MiniMax, OpenRouter support (#600) * feat: add Claude API provider profiles for alternative endpoints Add support for managing multiple Claude-compatible API endpoints (z.AI GLM, AWS Bedrock, etc.) through provider profiles in settings. Features: - New ClaudeApiProfile type with base URL, API key, model mappings - Pre-configured z.AI GLM template with correct model names - Profile selector in Settings > Claude > API Profiles - Clean switching between profiles and direct Anthropic API - Immediate persistence to prevent data loss on restart Profile support added to all execution paths: - Agent service (chat) - Ideation service - Auto-mode service (feature agents, enhancements) - Simple query service (title generation, descriptions, etc.) - Backlog planning, commit messages, spec generation - GitHub issue validation, suggestions Environment variables set when profile is active: - ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN/API_KEY - ANTHROPIC_DEFAULT_HAIKU/SONNET/OPUS_MODEL - API_TIMEOUT_MS, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC --- apps/server/src/lib/settings-helpers.ts | 84 ++- apps/server/src/providers/claude-provider.ts | 129 +++- .../src/providers/simple-query-service.ts | 10 + .../app-spec/generate-features-from-spec.ts | 15 +- .../src/routes/app-spec/generate-spec.ts | 15 +- apps/server/src/routes/app-spec/sync-spec.ts | 14 +- .../src/routes/backlog-plan/generate-plan.ts | 15 +- .../routes/context/routes/describe-file.ts | 10 + .../routes/context/routes/describe-image.ts | 10 + .../routes/enhance-prompt/routes/enhance.ts | 19 +- .../routes/features/routes/generate-title.ts | 18 +- .../routes/github/routes/validate-issue.ts | 16 +- .../suggestions/generate-suggestions.ts | 15 +- .../routes/generate-commit-message.ts | 108 ++- apps/server/src/services/agent-service.ts | 10 + apps/server/src/services/auto-mode-service.ts | 25 + apps/server/src/services/ideation-service.ts | 20 +- apps/server/src/services/settings-service.ts | 47 ++ .../components/project-context-menu.tsx | 182 ++++- .../project-selector-with-options.tsx | 30 +- .../components/layout/sidebar/constants.ts | 31 + .../sidebar/hooks/use-project-creation.ts | 4 +- .../board-view/hooks/use-board-actions.ts | 3 +- .../shared/enhancement/enhance-with-ai.tsx | 7 +- .../config/navigation.ts | 3 +- .../hooks/use-project-settings-view.ts | 2 +- .../project-claude-section.tsx | 153 +++++ .../project-settings-view.tsx | 3 + .../api-keys/api-keys-section.tsx | 35 +- .../providers/claude-settings-tab.tsx | 5 + .../api-profiles-section.tsx | 638 ++++++++++++++++++ .../src/hooks/use-project-settings-loader.ts | 23 + apps/ui/src/hooks/use-settings-migration.ts | 22 + apps/ui/src/hooks/use-settings-sync.ts | 4 + apps/ui/src/lib/electron.ts | 15 +- apps/ui/src/lib/http-api-client.ts | 8 +- apps/ui/src/store/app-store.ts | 135 ++++ docs/UNIFIED_API_KEY_PROFILES.md | 448 ++++++++++++ libs/types/src/index.ts | 6 + libs/types/src/provider.ts | 13 +- libs/types/src/settings.ts | 157 ++++- package-lock.json | 15 +- package.json | 4 +- start-automaker.mjs | 201 ++++++ start-automaker.sh | 80 ++- 45 files changed, 2661 insertions(+), 146 deletions(-) create mode 100644 apps/ui/src/components/views/project-settings-view/project-claude-section.tsx create mode 100644 apps/ui/src/components/views/settings-view/providers/claude-settings-tab/api-profiles-section.tsx create mode 100644 docs/UNIFIED_API_KEY_PROFILES.md create mode 100644 start-automaker.mjs diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index a1bdc4e5..64f3caee 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -5,7 +5,12 @@ import type { SettingsService } from '../services/settings-service.js'; import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils'; import { createLogger } from '@automaker/utils'; -import type { MCPServerConfig, McpServerConfig, PromptCustomization } from '@automaker/types'; +import type { + MCPServerConfig, + McpServerConfig, + PromptCustomization, + ClaudeApiProfile, +} from '@automaker/types'; import { mergeAutoModePrompts, mergeAgentPrompts, @@ -345,3 +350,80 @@ export async function getCustomSubagents( return Object.keys(merged).length > 0 ? merged : undefined; } + +/** Result from getActiveClaudeApiProfile */ +export interface ActiveClaudeApiProfileResult { + /** The active profile, or undefined if using direct Anthropic API */ + profile: ClaudeApiProfile | undefined; + /** Credentials for resolving 'credentials' apiKeySource */ + credentials: import('@automaker/types').Credentials | undefined; +} + +/** + * Get the active Claude API profile and credentials from settings. + * Checks project settings first for per-project overrides, then falls back to global settings. + * Returns both the profile and credentials for resolving 'credentials' apiKeySource. + * + * @param settingsService - Optional settings service instance + * @param logPrefix - Prefix for log messages (e.g., '[AgentService]') + * @param projectPath - Optional project path for per-project override + * @returns Promise resolving to object with profile and credentials + */ +export async function getActiveClaudeApiProfile( + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]', + projectPath?: string +): Promise { + if (!settingsService) { + return { profile: undefined, credentials: undefined }; + } + + try { + const globalSettings = await settingsService.getGlobalSettings(); + const credentials = await settingsService.getCredentials(); + const profiles = globalSettings.claudeApiProfiles || []; + + // Check for project-level override first + let activeProfileId: string | null | undefined; + let isProjectOverride = false; + + if (projectPath) { + const projectSettings = await settingsService.getProjectSettings(projectPath); + // undefined = use global, null = explicit no profile, string = specific profile + if (projectSettings.activeClaudeApiProfileId !== undefined) { + activeProfileId = projectSettings.activeClaudeApiProfileId; + isProjectOverride = true; + } + } + + // Fall back to global if project doesn't specify + if (activeProfileId === undefined && !isProjectOverride) { + activeProfileId = globalSettings.activeClaudeApiProfileId; + } + + // No active profile selected - use direct Anthropic API + if (!activeProfileId) { + if (isProjectOverride && activeProfileId === null) { + logger.info(`${logPrefix} Project explicitly using Direct Anthropic API`); + } + return { profile: undefined, credentials }; + } + + // Find the active profile by ID + const activeProfile = profiles.find((p) => p.id === activeProfileId); + + if (activeProfile) { + const overrideSuffix = isProjectOverride ? ' (project override)' : ''; + logger.info(`${logPrefix} Using Claude API profile: ${activeProfile.name}${overrideSuffix}`); + return { profile: activeProfile, credentials }; + } else { + logger.warn( + `${logPrefix} Active profile ID "${activeProfileId}" not found, falling back to direct Anthropic API` + ); + return { profile: undefined, credentials }; + } + } catch (error) { + logger.error(`${logPrefix} Failed to load Claude API profile:`, error); + return { profile: undefined, credentials: undefined }; + } +} diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index f8a31d81..e4c8ad79 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -10,7 +10,12 @@ import { BaseProvider } from './base-provider.js'; import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils'; const logger = createLogger('ClaudeProvider'); -import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types'; +import { + getThinkingTokenBudget, + validateBareModelId, + type ClaudeApiProfile, + type Credentials, +} from '@automaker/types'; import type { ExecuteOptions, ProviderMessage, @@ -21,9 +26,19 @@ import type { // Explicit allowlist of environment variables to pass to the SDK. // Only these vars are passed - nothing else from process.env leaks through. const ALLOWED_ENV_VARS = [ + // Authentication 'ANTHROPIC_API_KEY', - 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', + // Endpoint configuration + 'ANTHROPIC_BASE_URL', + 'API_TIMEOUT_MS', + // Model mappings + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + // Traffic control + 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', + // System vars (always from process.env) 'PATH', 'HOME', 'SHELL', @@ -33,16 +48,114 @@ const ALLOWED_ENV_VARS = [ 'LC_ALL', ]; +// System vars are always passed from process.env regardless of profile +const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL']; + /** - * Build environment for the SDK with only explicitly allowed variables + * Build environment for the SDK with only explicitly allowed variables. + * When a profile is provided, uses profile configuration (clean switch - don't inherit from process.env). + * When no profile is provided, uses direct Anthropic API settings from process.env. + * + * @param profile - Optional Claude API profile for alternative endpoint configuration + * @param credentials - Optional credentials object for resolving 'credentials' apiKeySource */ -function buildEnv(): Record { +function buildEnv( + profile?: ClaudeApiProfile, + credentials?: Credentials +): Record { const env: Record = {}; - for (const key of ALLOWED_ENV_VARS) { + + if (profile) { + // Use profile configuration (clean switch - don't inherit non-system vars from process.env) + logger.debug('Building environment from Claude API profile:', { + name: profile.name, + apiKeySource: profile.apiKeySource ?? 'inline', + }); + + // Resolve API key based on source strategy + let apiKey: string | undefined; + const source = profile.apiKeySource ?? 'inline'; // Default to inline for backwards compat + + switch (source) { + case 'inline': + apiKey = profile.apiKey; + break; + case 'env': + apiKey = process.env.ANTHROPIC_API_KEY; + break; + case 'credentials': + apiKey = credentials?.apiKeys?.anthropic; + break; + } + + // Warn if no API key found + if (!apiKey) { + logger.warn(`No API key found for profile "${profile.name}" with source "${source}"`); + } + + // Authentication + if (profile.useAuthToken) { + env['ANTHROPIC_AUTH_TOKEN'] = apiKey; + } else { + env['ANTHROPIC_API_KEY'] = apiKey; + } + + // Endpoint configuration + env['ANTHROPIC_BASE_URL'] = profile.baseUrl; + + if (profile.timeoutMs) { + env['API_TIMEOUT_MS'] = String(profile.timeoutMs); + } + + // Model mappings + if (profile.modelMappings?.haiku) { + env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = profile.modelMappings.haiku; + } + if (profile.modelMappings?.sonnet) { + env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = profile.modelMappings.sonnet; + } + if (profile.modelMappings?.opus) { + env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = profile.modelMappings.opus; + } + + // Traffic control + if (profile.disableNonessentialTraffic) { + env['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'] = '1'; + } + } else { + // Use direct Anthropic API - pass through credentials or environment variables + // This supports: + // 1. API Key mode: ANTHROPIC_API_KEY from credentials (UI settings) or env + // 2. Claude Max plan: Uses CLI OAuth auth (SDK handles this automatically) + // 3. Custom endpoints via ANTHROPIC_BASE_URL env var (backward compatibility) + // + // Priority: credentials file (UI settings) -> environment variable + // Note: Only auth and endpoint vars are passed. Model mappings and traffic + // control are NOT passed (those require a profile for explicit configuration). + if (credentials?.apiKeys?.anthropic) { + env['ANTHROPIC_API_KEY'] = credentials.apiKeys.anthropic; + } else if (process.env.ANTHROPIC_API_KEY) { + env['ANTHROPIC_API_KEY'] = process.env.ANTHROPIC_API_KEY; + } + // If using Claude Max plan via CLI auth, the SDK handles auth automatically + // when no API key is provided. We don't set ANTHROPIC_AUTH_TOKEN here + // unless it was explicitly set in process.env (rare edge case). + if (process.env.ANTHROPIC_AUTH_TOKEN) { + env['ANTHROPIC_AUTH_TOKEN'] = process.env.ANTHROPIC_AUTH_TOKEN; + } + // Pass through ANTHROPIC_BASE_URL if set in environment (backward compatibility) + if (process.env.ANTHROPIC_BASE_URL) { + env['ANTHROPIC_BASE_URL'] = process.env.ANTHROPIC_BASE_URL; + } + } + + // Always add system vars from process.env + for (const key of SYSTEM_ENV_VARS) { if (process.env[key]) { env[key] = process.env[key]; } } + return env; } @@ -70,6 +183,8 @@ export class ClaudeProvider extends BaseProvider { conversationHistory, sdkSessionId, thinkingLevel, + claudeApiProfile, + credentials, } = options; // Convert thinking level to token budget @@ -82,7 +197,9 @@ export class ClaudeProvider extends BaseProvider { maxTurns, cwd, // Pass only explicitly allowed environment variables to SDK - env: buildEnv(), + // When a profile is active, uses profile settings (clean switch) + // When no profile, uses direct Anthropic API (from process.env or CLI OAuth) + env: buildEnv(claudeApiProfile, credentials), // Pass through allowedTools if provided by caller (decided by sdk-options.ts) ...(allowedTools && { allowedTools }), // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation diff --git a/apps/server/src/providers/simple-query-service.ts b/apps/server/src/providers/simple-query-service.ts index 5882b96f..6ffbed0f 100644 --- a/apps/server/src/providers/simple-query-service.ts +++ b/apps/server/src/providers/simple-query-service.ts @@ -20,6 +20,8 @@ import type { ContentBlock, ThinkingLevel, ReasoningEffort, + ClaudeApiProfile, + Credentials, } from '@automaker/types'; import { stripProviderPrefix } from '@automaker/types'; @@ -54,6 +56,10 @@ export interface SimpleQueryOptions { readOnly?: boolean; /** Setting sources for CLAUDE.md loading */ settingSources?: Array<'user' | 'project' | 'local'>; + /** Active Claude API profile for alternative endpoint configuration */ + claudeApiProfile?: ClaudeApiProfile; + /** Credentials for resolving 'credentials' apiKeySource in Claude API profiles */ + credentials?: Credentials; } /** @@ -125,6 +131,8 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise { logger.debug(`Feature text block received (${text.length} chars)`); events.emit('spec-regeneration:event', { diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts index 4fa3d11a..0de21cf5 100644 --- a/apps/server/src/routes/app-spec/generate-spec.ts +++ b/apps/server/src/routes/app-spec/generate-spec.ts @@ -16,7 +16,11 @@ import { streamingQuery } from '../../providers/simple-query-service.js'; import { generateFeaturesFromSpec } from './generate-features-from-spec.js'; import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform'; import type { SettingsService } from '../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js'; +import { + getAutoLoadClaudeMdSetting, + getPromptCustomization, + getActiveClaudeApiProfile, +} from '../../lib/settings-helpers.js'; const logger = createLogger('SpecRegeneration'); @@ -100,6 +104,13 @@ ${prompts.appSpec.structuredSpecInstructions}`; logger.info('Using model:', model); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[SpecRegeneration]', + projectPath + ); + let responseText = ''; let structuredOutput: SpecOutput | null = null; @@ -132,6 +143,8 @@ Your entire response should be valid JSON starting with { and ending with }. No thinkingLevel, readOnly: true, // Spec generation only reads code, we write the spec ourselves settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource outputFormat: useStructuredOutput ? { type: 'json_schema', diff --git a/apps/server/src/routes/app-spec/sync-spec.ts b/apps/server/src/routes/app-spec/sync-spec.ts index 98352855..aabeebf2 100644 --- a/apps/server/src/routes/app-spec/sync-spec.ts +++ b/apps/server/src/routes/app-spec/sync-spec.ts @@ -15,7 +15,10 @@ import { resolvePhaseModel } from '@automaker/model-resolver'; import { streamingQuery } from '../../providers/simple-query-service.js'; import { getAppSpecPath } from '@automaker/platform'; import type { SettingsService } from '../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js'; +import { + getAutoLoadClaudeMdSetting, + getActiveClaudeApiProfile, +} from '../../lib/settings-helpers.js'; import { FeatureLoader } from '../../services/feature-loader.js'; import { extractImplementedFeatures, @@ -157,6 +160,13 @@ export async function syncSpec( settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel; const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[SpecSync]', + projectPath + ); + // Use AI to analyze tech stack const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack. @@ -185,6 +195,8 @@ Return ONLY this JSON format, no other text: thinkingLevel, readOnly: true, settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource onText: (text) => { logger.debug(`Tech analysis text: ${text.substring(0, 100)}`); }, diff --git a/apps/server/src/routes/backlog-plan/generate-plan.ts b/apps/server/src/routes/backlog-plan/generate-plan.ts index e96ce8ea..04dc3b57 100644 --- a/apps/server/src/routes/backlog-plan/generate-plan.ts +++ b/apps/server/src/routes/backlog-plan/generate-plan.ts @@ -25,7 +25,11 @@ import { saveBacklogPlan, } from './common.js'; import type { SettingsService } from '../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js'; +import { + getAutoLoadClaudeMdSetting, + getPromptCustomization, + getActiveClaudeApiProfile, +} from '../../lib/settings-helpers.js'; const featureLoader = new FeatureLoader(); @@ -161,6 +165,13 @@ ${userPrompt}`; finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt } + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[BacklogPlan]', + projectPath + ); + // Execute the query const stream = provider.executeQuery({ prompt: finalPrompt, @@ -173,6 +184,8 @@ ${userPrompt}`; settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined, readOnly: true, // Plan generation only generates text, doesn't write files thinkingLevel, // Pass thinking level for extended thinking + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); let responseText = ''; diff --git a/apps/server/src/routes/context/routes/describe-file.ts b/apps/server/src/routes/context/routes/describe-file.ts index 5b1fc6ca..0fd3c349 100644 --- a/apps/server/src/routes/context/routes/describe-file.ts +++ b/apps/server/src/routes/context/routes/describe-file.ts @@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization, + getActiveClaudeApiProfile, } from '../../../lib/settings-helpers.js'; const logger = createLogger('DescribeFile'); @@ -165,6 +166,13 @@ ${contentToAnalyze}`; logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[DescribeFile]', + cwd + ); + // Use simpleQuery - provider abstraction handles routing to correct provider const result = await simpleQuery({ prompt, @@ -175,6 +183,8 @@ ${contentToAnalyze}`; thinkingLevel, readOnly: true, // File description only reads, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); const description = result.text; diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts index 70f9f7dc..0c05bc2a 100644 --- a/apps/server/src/routes/context/routes/describe-image.ts +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization, + getActiveClaudeApiProfile, } from '../../../lib/settings-helpers.js'; const logger = createLogger('DescribeImage'); @@ -284,6 +285,13 @@ export function createDescribeImageHandler( // Get customized prompts from settings const prompts = await getPromptCustomization(settingsService, '[DescribeImage]'); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[DescribeImage]', + cwd + ); + // Build the instruction text from centralized prompts const instructionText = prompts.contextDescription.describeImagePrompt; @@ -325,6 +333,8 @@ export function createDescribeImageHandler( thinkingLevel, readOnly: true, // Image description only reads, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`); diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index 5861b418..2fe0f669 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -12,7 +12,10 @@ import { resolveModelString } from '@automaker/model-resolver'; import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types'; import { simpleQuery } from '../../../providers/simple-query-service.js'; import type { SettingsService } from '../../../services/settings-service.js'; -import { getPromptCustomization } from '../../../lib/settings-helpers.js'; +import { + getPromptCustomization, + getActiveClaudeApiProfile, +} from '../../../lib/settings-helpers.js'; import { buildUserPrompt, isValidEnhancementMode, @@ -33,6 +36,8 @@ interface EnhanceRequestBody { model?: string; /** Optional thinking level for Claude models */ thinkingLevel?: ThinkingLevel; + /** Optional project path for per-project Claude API profile */ + projectPath?: string; } /** @@ -62,7 +67,7 @@ export function createEnhanceHandler( ): (req: Request, res: Response) => Promise { return async (req: Request, res: Response): Promise => { try { - const { originalText, enhancementMode, model, thinkingLevel } = + const { originalText, enhancementMode, model, thinkingLevel, projectPath } = req.body as EnhanceRequestBody; // Validate required fields @@ -126,6 +131,14 @@ export function createEnhanceHandler( logger.debug(`Using model: ${resolvedModel}`); + // Get active Claude API profile for alternative endpoint configuration + // Uses project-specific profile if projectPath provided, otherwise global + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[EnhancePrompt]', + projectPath + ); + // Use simpleQuery - provider abstraction handles routing to correct provider // The system prompt is combined with user prompt since some providers // don't have a separate system prompt concept @@ -137,6 +150,8 @@ export function createEnhanceHandler( allowedTools: [], thinkingLevel, readOnly: true, // Prompt enhancement only generates text, doesn't write files + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); const enhancedText = result.text; diff --git a/apps/server/src/routes/features/routes/generate-title.ts b/apps/server/src/routes/features/routes/generate-title.ts index e7603eb8..d6519940 100644 --- a/apps/server/src/routes/features/routes/generate-title.ts +++ b/apps/server/src/routes/features/routes/generate-title.ts @@ -10,12 +10,16 @@ import { createLogger } from '@automaker/utils'; import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver'; import { simpleQuery } from '../../../providers/simple-query-service.js'; import type { SettingsService } from '../../../services/settings-service.js'; -import { getPromptCustomization } from '../../../lib/settings-helpers.js'; +import { + getPromptCustomization, + getActiveClaudeApiProfile, +} from '../../../lib/settings-helpers.js'; const logger = createLogger('GenerateTitle'); interface GenerateTitleRequestBody { description: string; + projectPath?: string; } interface GenerateTitleSuccessResponse { @@ -33,7 +37,7 @@ export function createGenerateTitleHandler( ): (req: Request, res: Response) => Promise { return async (req: Request, res: Response): Promise => { try { - const { description } = req.body as GenerateTitleRequestBody; + const { description, projectPath } = req.body as GenerateTitleRequestBody; if (!description || typeof description !== 'string') { const response: GenerateTitleErrorResponse = { @@ -60,6 +64,14 @@ export function createGenerateTitleHandler( const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]'); const systemPrompt = prompts.titleGeneration.systemPrompt; + // Get active Claude API profile for alternative endpoint configuration + // Uses project-specific profile if projectPath provided, otherwise global + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[GenerateTitle]', + projectPath + ); + const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`; // Use simpleQuery - provider abstraction handles all the streaming/extraction @@ -69,6 +81,8 @@ export function createGenerateTitleHandler( cwd: process.cwd(), maxTurns: 1, allowedTools: [], + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); const title = result.text; diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index e7d83d99..699b7e46 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -34,7 +34,11 @@ import { ValidationComment, ValidationLinkedPR, } from './validation-schema.js'; -import { getPromptCustomization } from '../../../lib/settings-helpers.js'; +import { + getPromptCustomization, + getAutoLoadClaudeMdSetting, + getActiveClaudeApiProfile, +} from '../../../lib/settings-helpers.js'; import { trySetValidationRunning, clearValidationStatus, @@ -43,7 +47,6 @@ import { logger, } from './validation-common.js'; import type { SettingsService } from '../../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js'; /** * Request body for issue validation @@ -166,6 +169,13 @@ ${basePrompt}`; logger.info(`Using model: ${model}`); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[IssueValidation]', + projectPath + ); + // Use streamingQuery with event callbacks const result = await streamingQuery({ prompt: finalPrompt, @@ -177,6 +187,8 @@ ${basePrompt}`; reasoningEffort: effectiveReasoningEffort, readOnly: true, // Issue validation only reads code, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource outputFormat: useStructuredOutput ? { type: 'json_schema', diff --git a/apps/server/src/routes/suggestions/generate-suggestions.ts b/apps/server/src/routes/suggestions/generate-suggestions.ts index 08a3628b..7a21af6f 100644 --- a/apps/server/src/routes/suggestions/generate-suggestions.ts +++ b/apps/server/src/routes/suggestions/generate-suggestions.ts @@ -15,7 +15,11 @@ import { FeatureLoader } from '../../services/feature-loader.js'; import { getAppSpecPath } from '@automaker/platform'; import * as secureFs from '../../lib/secure-fs.js'; import type { SettingsService } from '../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js'; +import { + getAutoLoadClaudeMdSetting, + getPromptCustomization, + getActiveClaudeApiProfile, +} from '../../lib/settings-helpers.js'; const logger = createLogger('Suggestions'); @@ -192,6 +196,13 @@ ${prompts.suggestions.baseTemplate}`; logger.info('[Suggestions] Using model:', model); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[Suggestions]', + projectPath + ); + let responseText = ''; // Determine if we should use structured output (Claude supports it, Cursor doesn't) @@ -223,6 +234,8 @@ Your entire response should be valid JSON starting with { and ending with }. No thinkingLevel, readOnly: true, // Suggestions only reads code, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource outputFormat: useStructuredOutput ? { type: 'json_schema', diff --git a/apps/server/src/routes/worktree/routes/generate-commit-message.ts b/apps/server/src/routes/worktree/routes/generate-commit-message.ts index a450659f..1b504f6f 100644 --- a/apps/server/src/routes/worktree/routes/generate-commit-message.ts +++ b/apps/server/src/routes/worktree/routes/generate-commit-message.ts @@ -10,7 +10,6 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import { existsSync } from 'fs'; import { join } from 'path'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '@automaker/utils'; import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types'; import { resolvePhaseModel } from '@automaker/model-resolver'; @@ -18,6 +17,7 @@ import { mergeCommitMessagePrompts } from '@automaker/prompts'; import { ProviderFactory } from '../../../providers/provider-factory.js'; import type { SettingsService } from '../../../services/settings-service.js'; import { getErrorMessage, logError } from '../common.js'; +import { getActiveClaudeApiProfile } from '../../../lib/settings-helpers.js'; const logger = createLogger('GenerateCommitMessage'); const execAsync = promisify(exec); @@ -74,33 +74,6 @@ interface GenerateCommitMessageErrorResponse { error: string; } -async function extractTextFromStream( - stream: AsyncIterable<{ - type: string; - subtype?: string; - result?: string; - message?: { - content?: Array<{ type: string; text?: string }>; - }; - }> -): Promise { - let responseText = ''; - - for await (const msg of stream) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; - } - } - } else if (msg.type === 'result' && msg.subtype === 'success') { - responseText = msg.result || responseText; - } - } - - return responseText; -} - export function createGenerateCommitMessageHandler( settingsService?: SettingsService ): (req: Request, res: Response) => Promise { @@ -195,57 +168,54 @@ export function createGenerateCommitMessageHandler( // Get the effective system prompt (custom or default) const systemPrompt = await getSystemPrompt(settingsService); - let message: string; + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[GenerateCommitMessage]', + worktreePath + ); - // Route to appropriate provider based on model type - if (isCursorModel(model)) { - // Use Cursor provider for Cursor models - logger.info(`Using Cursor provider for model: ${model}`); + // Get provider for the model type + const provider = ProviderFactory.getProviderForModel(model); + const bareModel = stripProviderPrefix(model); - const provider = ProviderFactory.getProviderForModel(model); - const bareModel = stripProviderPrefix(model); + // For Cursor models, combine prompts since Cursor doesn't support systemPrompt separation + const effectivePrompt = isCursorModel(model) + ? `${systemPrompt}\n\n${userPrompt}` + : userPrompt; + const effectiveSystemPrompt = isCursorModel(model) ? undefined : systemPrompt; - const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`; + logger.info(`Using ${provider.getName()} provider for model: ${model}`); - let responseText = ''; - const cursorStream = provider.executeQuery({ - prompt: cursorPrompt, - model: bareModel, - cwd: worktreePath, - maxTurns: 1, - allowedTools: [], - readOnly: true, - }); + let responseText = ''; + const stream = provider.executeQuery({ + prompt: effectivePrompt, + model: bareModel, + cwd: worktreePath, + systemPrompt: effectiveSystemPrompt, + maxTurns: 1, + allowedTools: [], + readOnly: true, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource + }); - // Wrap with timeout to prevent indefinite hangs - for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text' && block.text) { - responseText += block.text; - } + // Wrap with timeout to prevent indefinite hangs + for await (const msg of withTimeout(stream, AI_TIMEOUT_MS)) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; } } + } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { + // Use result if available (some providers return final text here) + responseText = msg.result; } - - message = responseText.trim(); - } else { - // Use Claude SDK for Claude models - const stream = query({ - prompt: userPrompt, - options: { - model, - systemPrompt, - maxTurns: 1, - allowedTools: [], - permissionMode: 'default', - }, - }); - - // Wrap with timeout to prevent indefinite hangs - message = await extractTextFromStream(withTimeout(stream, AI_TIMEOUT_MS)); } + const message = responseText.trim(); + if (!message || message.trim().length === 0) { logger.warn('Received empty response from model'); const response: GenerateCommitMessageErrorResponse = { diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 359719d3..0b89a2bd 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -29,6 +29,7 @@ import { getSkillsConfiguration, getSubagentsConfiguration, getCustomSubagents, + getActiveClaudeApiProfile, } from '../lib/settings-helpers.js'; interface Message { @@ -274,6 +275,13 @@ export class AgentService { ? await getCustomSubagents(this.settingsService, effectiveWorkDir) : undefined; + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + this.settingsService, + '[AgentService]', + effectiveWorkDir + ); + // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files // Use the user's message as task context for smart memory selection const contextResult = await loadContextFiles({ @@ -378,6 +386,8 @@ export class AgentService { agents: customSubagents, // Pass custom subagents for task delegation thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; // Build prompt content with images diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 59af7872..bf19aa9e 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -63,6 +63,7 @@ import { filterClaudeMdFromContext, getMCPServersFromSettings, getPromptCustomization, + getActiveClaudeApiProfile, } from '../lib/settings-helpers.js'; import { getNotificationService } from './notification-service.js'; @@ -2057,6 +2058,13 @@ Format your response as a structured markdown document.`; thinkingLevel: analysisThinkingLevel, }); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + this.settingsService, + '[AutoMode]', + projectPath + ); + const options: ExecuteOptions = { prompt, model: sdkOptions.model ?? analysisModel, @@ -2066,6 +2074,8 @@ Format your response as a structured markdown document.`; abortController, settingSources: sdkOptions.settingSources, thinkingLevel: analysisThinkingLevel, // Pass thinking level + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; const stream = provider.executeQuery(options); @@ -2934,6 +2944,13 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. ); } + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + this.settingsService, + '[AutoMode]', + finalProjectPath + ); + const executeOptions: ExecuteOptions = { prompt: promptContent, model: bareModel, @@ -2945,6 +2962,8 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. settingSources: sdkOptions.settingSources, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; // Execute via provider @@ -3247,6 +3266,8 @@ After generating the revised spec, output: allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); let revisionText = ''; @@ -3392,6 +3413,8 @@ After generating the revised spec, output: allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); let taskOutput = ''; @@ -3486,6 +3509,8 @@ After generating the revised spec, output: allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); for await (const msg of continuationStream) { diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index 4ef3d8a8..bcb469b1 100644 --- a/apps/server/src/services/ideation-service.ts +++ b/apps/server/src/services/ideation-service.ts @@ -41,7 +41,7 @@ import type { FeatureLoader } from './feature-loader.js'; import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; import { resolveModelString } from '@automaker/model-resolver'; import { stripProviderPrefix } from '@automaker/types'; -import { getPromptCustomization } from '../lib/settings-helpers.js'; +import { getPromptCustomization, getActiveClaudeApiProfile } from '../lib/settings-helpers.js'; const logger = createLogger('IdeationService'); @@ -223,6 +223,13 @@ export class IdeationService { // Strip provider prefix - providers need bare model IDs const bareModel = stripProviderPrefix(modelId); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + this.settingsService, + '[IdeationService]', + projectPath + ); + const executeOptions: ExecuteOptions = { prompt: message, model: bareModel, @@ -232,6 +239,8 @@ export class IdeationService { maxTurns: 1, // Single turn for ideation abortController: activeSession.abortController!, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; const stream = provider.executeQuery(executeOptions); @@ -678,6 +687,13 @@ export class IdeationService { // Strip provider prefix - providers need bare model IDs const bareModel = stripProviderPrefix(modelId); + // Get active Claude API profile for alternative endpoint configuration + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + this.settingsService, + '[IdeationService]', + projectPath + ); + const executeOptions: ExecuteOptions = { prompt: prompt.prompt, model: bareModel, @@ -688,6 +704,8 @@ export class IdeationService { // Disable all tools - we just want text generation, not codebase analysis allowedTools: [], abortController: new AbortController(), + claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; const stream = provider.executeQuery(executeOptions); diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 5b9f81cb..61afa057 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -166,6 +166,41 @@ export class SettingsService { needsSave = true; } + // Migration v4 -> v5: Auto-create "Direct Anthropic" profile for existing users + // If user has an Anthropic API key in credentials but no profiles, create a + // "Direct Anthropic" profile that references the credentials and set it as active. + if (storedVersion < 5) { + try { + const credentials = await this.getCredentials(); + const hasAnthropicKey = !!credentials.apiKeys?.anthropic; + const hasNoProfiles = !result.claudeApiProfiles || result.claudeApiProfiles.length === 0; + const hasNoActiveProfile = !result.activeClaudeApiProfileId; + + if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) { + const directAnthropicProfile = { + id: `profile-${Date.now()}-direct-anthropic`, + name: 'Direct Anthropic', + baseUrl: 'https://api.anthropic.com', + apiKeySource: 'credentials' as const, + useAuthToken: false, + }; + + result.claudeApiProfiles = [directAnthropicProfile]; + result.activeClaudeApiProfileId = directAnthropicProfile.id; + + logger.info( + 'Migration v4->v5: Created "Direct Anthropic" profile using existing credentials' + ); + } + } catch (error) { + logger.warn( + 'Migration v4->v5: Could not check credentials for auto-profile creation:', + error + ); + } + needsSave = true; + } + // Update version if any migration occurred if (needsSave) { result.version = SETTINGS_VERSION; @@ -372,6 +407,7 @@ export class SettingsService { ignoreEmptyArrayOverwrite('recentFolders'); ignoreEmptyArrayOverwrite('mcpServers'); ignoreEmptyArrayOverwrite('enabledCursorModels'); + ignoreEmptyArrayOverwrite('claudeApiProfiles'); // Empty object overwrite guard if ( @@ -597,6 +633,17 @@ export class SettingsService { }; } + // Handle activeClaudeApiProfileId special cases: + // - "__USE_GLOBAL__" marker means delete the key (use global setting) + // - null means explicit "Direct Anthropic API" + // - string means specific profile ID + if ( + 'activeClaudeApiProfileId' in updates && + updates.activeClaudeApiProfileId === '__USE_GLOBAL__' + ) { + delete updated.activeClaudeApiProfileId; + } + await writeSettingsJson(settingsPath, updated); logger.info(`Project settings updated for ${projectPath}`); diff --git a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx index af63af32..0df4ab8c 100644 --- a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, memo, useCallback } from 'react'; +import { useEffect, useRef, useState, memo, useCallback, useMemo } from 'react'; import type { LucideIcon } from 'lucide-react'; import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react'; import { toast } from 'sonner'; @@ -6,35 +6,67 @@ import { cn } from '@/lib/utils'; import { type ThemeMode, useAppStore } from '@/store/app-store'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import type { Project } from '@/lib/electron'; -import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants'; +import { + PROJECT_DARK_THEMES, + PROJECT_LIGHT_THEMES, + THEME_SUBMENU_CONSTANTS, +} from '@/components/layout/sidebar/constants'; import { useThemePreview } from '@/components/layout/sidebar/hooks'; -// Constant for "use global theme" option +/** + * Constant representing the "use global theme" option. + * An empty string is used to indicate that no project-specific theme is set. + */ const USE_GLOBAL_THEME = '' as const; -// Constants for z-index values +/** + * Z-index values for context menu layering. + * Ensures proper stacking order when menus overlap. + */ const Z_INDEX = { + /** Base z-index for the main context menu */ CONTEXT_MENU: 100, + /** Higher z-index for theme submenu to appear above parent menu */ THEME_SUBMENU: 101, } as const; -// Theme option type - using ThemeMode for type safety +/** + * Represents a selectable theme option in the theme submenu. + * Uses ThemeMode from app-store for type safety. + */ interface ThemeOption { + /** The theme mode value (e.g., 'dark', 'light', 'dracula') */ value: ThemeMode; + /** Display label for the theme option */ label: string; + /** Lucide icon component to display alongside the label */ icon: LucideIcon; + /** CSS color value for the icon */ color: string; } -// Reusable theme button component to avoid duplication (DRY principle) +/** + * Props for the ThemeButton component. + * Defines the interface for rendering individual theme selection buttons. + */ interface ThemeButtonProps { + /** The theme option data to display */ option: ThemeOption; + /** Whether this theme is currently selected */ isSelected: boolean; + /** Handler for pointer enter events (used for preview) */ onPointerEnter: () => void; + /** Handler for pointer leave events (used to clear preview) */ onPointerLeave: (e: React.PointerEvent) => void; + /** Handler for click events (used to select theme) */ onClick: () => void; } +/** + * A reusable button component for individual theme options. + * Implements hover preview and selection functionality. + * Memoized to prevent unnecessary re-renders when parent state changes. + */ const ThemeButton = memo(function ThemeButton({ option, isSelected, @@ -63,17 +95,33 @@ const ThemeButton = memo(function ThemeButton({ ); }); -// Reusable theme column component +/** + * Props for the ThemeColumn component. + * Defines the interface for rendering a column of related theme options (e.g., dark or light themes). + */ interface ThemeColumnProps { + /** Column header title (e.g., "Dark", "Light") */ title: string; + /** Icon to display in the column header */ icon: LucideIcon; + /** Array of theme options to display in this column */ themes: ThemeOption[]; + /** Currently selected theme value, or null if using global theme */ selectedTheme: ThemeMode | null; + /** Handler called when user hovers over a theme option for preview */ onPreviewEnter: (value: ThemeMode) => void; + /** Handler called when user stops hovering over a theme option */ onPreviewLeave: (e: React.PointerEvent) => void; + /** Handler called when user clicks to select a theme */ onSelect: (value: ThemeMode) => void; } +/** + * A reusable column component for displaying themed options. + * Renders a group of related themes (e.g., all dark themes or all light themes) + * with a header and scrollable list of ThemeButton components. + * Memoized to prevent unnecessary re-renders. + */ const ThemeColumn = memo(function ThemeColumn({ title, icon: Icon, @@ -105,13 +153,36 @@ const ThemeColumn = memo(function ThemeColumn({ ); }); +/** + * Props for the ProjectContextMenu component. + * Defines the interface for the project right-click context menu. + */ interface ProjectContextMenuProps { + /** The project this context menu is for */ project: Project; + /** Screen coordinates where the context menu should appear */ position: { x: number; y: number }; + /** Callback to close the context menu */ onClose: () => void; + /** Callback when user selects "Edit Name & Icon" option */ onEdit: (project: Project) => void; } +/** + * A context menu component for project-specific actions. + * + * Provides options for: + * - Editing project name and icon + * - Setting project-specific theme (with live preview on hover) + * - Removing project from the workspace + * + * Features viewport-aware positioning for the theme submenu to prevent + * overflow, and implements delayed hover handling to improve UX when + * navigating between the trigger button and submenu. + * + * @param props - Component props + * @returns The rendered context menu or null if not visible + */ export function ProjectContextMenu({ project, position, @@ -130,9 +201,82 @@ export function ProjectContextMenu({ const [showThemeSubmenu, setShowThemeSubmenu] = useState(false); const [removeConfirmed, setRemoveConfirmed] = useState(false); const themeSubmenuRef = useRef(null); + const closeTimeoutRef = useRef | null>(null); const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); + // Handler to open theme submenu and cancel any pending close + const handleThemeMenuEnter = useCallback(() => { + // Cancel any pending close timeout + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + setShowThemeSubmenu(true); + }, []); + + // Handler to close theme submenu with a small delay + // This prevents the submenu from closing when mouse crosses the gap between trigger and submenu + const handleThemeMenuLeave = useCallback(() => { + // Add a small delay before closing to allow mouse to reach submenu + closeTimeoutRef.current = setTimeout(() => { + setShowThemeSubmenu(false); + setPreviewTheme(null); + }, 100); // 100ms delay is enough to cross the gap + }, [setPreviewTheme]); + + /** + * Calculates theme submenu position to prevent viewport overflow. + * + * This memoized calculation determines the optimal vertical position and maximum + * height for the theme submenu based on the current viewport dimensions and + * the trigger button's position. + * + * @returns Object containing: + * - top: Vertical offset from default position (negative values shift submenu up) + * - maxHeight: Maximum height constraint to prevent overflow with scrolling + */ + const submenuPosition = useMemo(() => { + const { ESTIMATED_SUBMENU_HEIGHT, COLLISION_PADDING, THEME_BUTTON_OFFSET } = + THEME_SUBMENU_CONSTANTS; + + const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800; + + // Calculate where the submenu's bottom edge would be if positioned normally + const submenuBottomY = position.y + THEME_BUTTON_OFFSET + ESTIMATED_SUBMENU_HEIGHT; + + // Check if submenu would overflow bottom of viewport + const wouldOverflowBottom = submenuBottomY > viewportHeight - COLLISION_PADDING; + + // If it would overflow, calculate how much to shift it up + if (wouldOverflowBottom) { + // Calculate the offset needed to align submenu bottom with viewport bottom minus padding + const overflowAmount = submenuBottomY - (viewportHeight - COLLISION_PADDING); + return { + top: -overflowAmount, + maxHeight: Math.min(ESTIMATED_SUBMENU_HEIGHT, viewportHeight - COLLISION_PADDING * 2), + }; + } + + // Default: submenu opens at top of parent (aligned with the theme button) + return { + top: 0, + maxHeight: Math.min( + ESTIMATED_SUBMENU_HEIGHT, + viewportHeight - position.y - THEME_BUTTON_OFFSET - COLLISION_PADDING + ), + }; + }, [position.y]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + }; + }, []); + useEffect(() => { const handleClickOutside = (event: globalThis.MouseEvent) => { // Don't close if a confirmation dialog is open (dialog is in a portal) @@ -242,11 +386,8 @@ export function ProjectContextMenu({ {/* Theme Submenu Trigger */}
setShowThemeSubmenu(true)} - onMouseLeave={() => { - setShowThemeSubmenu(false); - setPreviewTheme(null); - }} + onMouseEnter={handleThemeMenuEnter} + onMouseLeave={handleThemeMenuLeave} > + + + handleOpenAddDialog()}> + + Custom Profile + + + {CLAUDE_API_PROFILE_TEMPLATES.map((template) => ( + handleOpenAddDialog(template.name)} + > + + {template.name} + + ))} + + +
+ + {/* Content */} +
+ {/* Active Profile Selector */} +
+ + +

+ {activeClaudeApiProfileId + ? 'Using custom API endpoint' + : 'Using direct Anthropic API (API key or Claude Max plan)'} +

+
+ + {/* Profile List */} + {claudeApiProfiles.length === 0 ? ( +
+ +

No API profiles configured

+

+ Add a profile to use alternative Claude-compatible endpoints +

+
+ ) : ( +
+ {claudeApiProfiles.map((profile) => ( + handleOpenEditDialog(profile)} + onDelete={() => setDeleteConfirmId(profile.id)} + onSetActive={() => setActiveClaudeApiProfile(profile.id)} + /> + ))} +
+ )} +
+ + {/* Add/Edit Dialog */} + + + + {editingProfileId ? 'Edit API Profile' : 'Add API Profile'} + + Configure a Claude-compatible API endpoint. API keys are stored locally. + + + +
+ {/* Name */} +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., z.AI GLM" + className={isDuplicateName ? 'border-destructive' : ''} + /> + {isDuplicateName && ( +

A profile with this name already exists

+ )} +
+ + {/* Base URL */} +
+ + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder="https://api.example.com/v1" + /> +
+ + {/* API Key Source */} +
+ + + {formData.apiKeySource === 'credentials' && ( +

+ Will use the Anthropic key from Settings → API Keys +

+ )} + {formData.apiKeySource === 'env' && ( +

+ Will use ANTHROPIC_API_KEY environment variable +

+ )} +
+ + {/* API Key (only shown for inline source) */} + {formData.apiKeySource === 'inline' && ( +
+ +
+ setFormData({ ...formData, apiKey: e.target.value })} + placeholder="Enter API key" + className="pr-10" + /> + +
+ {currentTemplate?.apiKeyUrl && ( + + Get API Key from {currentTemplate.name} + + )} +
+ )} + + {/* Use Auth Token */} +
+
+ +

+ Use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY +

+
+ setFormData({ ...formData, useAuthToken: checked })} + /> +
+ + {/* Timeout */} +
+ + setFormData({ ...formData, timeoutMs: e.target.value })} + placeholder="Optional, e.g., 3000000" + /> +
+ + {/* Model Mappings */} +
+ +

+ Map Claude model aliases to provider-specific model names +

+
+
+ + + setFormData({ + ...formData, + modelMappings: { ...formData.modelMappings, haiku: e.target.value }, + }) + } + placeholder="e.g., GLM-4.5-Flash" + className="text-xs" + /> +
+
+ + + setFormData({ + ...formData, + modelMappings: { ...formData.modelMappings, sonnet: e.target.value }, + }) + } + placeholder="e.g., glm-4.7" + className="text-xs" + /> +
+
+ + + setFormData({ + ...formData, + modelMappings: { ...formData.modelMappings, opus: e.target.value }, + }) + } + placeholder="e.g., glm-4.7" + className="text-xs" + /> +
+
+
+ + {/* Disable Non-essential Traffic */} +
+
+ +

+ Sets CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +

+
+ + setFormData({ ...formData, disableNonessentialTraffic: checked }) + } + /> +
+
+ + + + + +
+
+ + {/* Delete Confirmation Dialog */} + !open && setDeleteConfirmId(null)}> + + + Delete Profile? + + This will permanently delete the API profile. If this profile is currently active, you + will be switched to direct Anthropic API. + + + + + + + + + + ); +} + +interface ProfileCardProps { + profile: ClaudeApiProfile; + isActive: boolean; + onEdit: () => void; + onDelete: () => void; + onSetActive: () => void; +} + +function ProfileCard({ profile, isActive, onEdit, onDelete, onSetActive }: ProfileCardProps) { + return ( +
+
+
+
+

{profile.name}

+ {isActive && ( + + Active + + )} +
+

{profile.baseUrl}

+
+ Key: {maskApiKey(profile.apiKey)} + {profile.useAuthToken && Auth Token} + {profile.timeoutMs && Timeout: {(profile.timeoutMs / 1000).toFixed(0)}s} +
+
+ + + + + + + {!isActive && ( + + + Set Active + + )} + + + Edit + + + + + Delete + + + +
+
+ ); +} diff --git a/apps/ui/src/hooks/use-project-settings-loader.ts b/apps/ui/src/hooks/use-project-settings-loader.ts index da0ef594..53c906e1 100644 --- a/apps/ui/src/hooks/use-project-settings-loader.ts +++ b/apps/ui/src/hooks/use-project-settings-loader.ts @@ -23,6 +23,7 @@ export function useProjectSettingsLoader() { const setAutoDismissInitScriptIndicator = useAppStore( (state) => state.setAutoDismissInitScriptIndicator ); + const setCurrentProject = useAppStore((state) => state.setCurrentProject); const loadingRef = useRef(null); const currentProjectRef = useRef(null); @@ -107,6 +108,28 @@ export function useProjectSettingsLoader() { result.settings.autoDismissInitScriptIndicator ); } + + // Apply activeClaudeApiProfileId if present + // This is stored directly on the project, so we need to update the currentProject + // Type assertion needed because API returns Record + const settingsWithProfile = result.settings as Record; + const activeClaudeApiProfileId = settingsWithProfile.activeClaudeApiProfileId as + | string + | null + | undefined; + if (activeClaudeApiProfileId !== undefined) { + const updatedProject = useAppStore.getState().currentProject; + if ( + updatedProject && + updatedProject.path === requestedProjectPath && + updatedProject.activeClaudeApiProfileId !== activeClaudeApiProfileId + ) { + setCurrentProject({ + ...updatedProject, + activeClaudeApiProfileId, + }); + } + } } } catch (error) { console.error('Failed to load project settings:', error); diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 58b3ec2d..e4001c05 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -206,6 +206,10 @@ export function parseLocalStorageSettings(): Partial | null { worktreePanelCollapsed === 'true' || (state.worktreePanelCollapsed as boolean), lastProjectDir: lastProjectDir || (state.lastProjectDir as string), recentFolders: recentFolders ? JSON.parse(recentFolders) : (state.recentFolders as string[]), + // Claude API Profiles + claudeApiProfiles: (state.claudeApiProfiles as GlobalSettings['claudeApiProfiles']) ?? [], + activeClaudeApiProfileId: + (state.activeClaudeApiProfileId as GlobalSettings['activeClaudeApiProfileId']) ?? null, }; } catch (error) { logger.error('Failed to parse localStorage settings:', error); @@ -326,6 +330,20 @@ export function mergeSettings( merged.currentProjectId = localSettings.currentProjectId; } + // Claude API Profiles - preserve from localStorage if server is empty + if ( + (!serverSettings.claudeApiProfiles || serverSettings.claudeApiProfiles.length === 0) && + localSettings.claudeApiProfiles && + localSettings.claudeApiProfiles.length > 0 + ) { + merged.claudeApiProfiles = localSettings.claudeApiProfiles; + } + + // Active Claude API Profile ID - preserve from localStorage if server doesn't have one + if (!serverSettings.activeClaudeApiProfileId && localSettings.activeClaudeApiProfileId) { + merged.activeClaudeApiProfileId = localSettings.activeClaudeApiProfileId; + } + return merged; } @@ -671,6 +689,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { }, mcpServers: settings.mcpServers ?? [], promptCustomization: settings.promptCustomization ?? {}, + claudeApiProfiles: settings.claudeApiProfiles ?? [], + activeClaudeApiProfileId: settings.activeClaudeApiProfileId ?? null, projects, currentProject, trashedProjects: settings.trashedProjects ?? [], @@ -732,6 +752,8 @@ function buildSettingsUpdateFromStore(): Record { keyboardShortcuts: state.keyboardShortcuts, mcpServers: state.mcpServers, promptCustomization: state.promptCustomization, + claudeApiProfiles: state.claudeApiProfiles, + activeClaudeApiProfileId: state.activeClaudeApiProfileId, 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..c2b48ae7 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -72,6 +72,8 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'defaultTerminalId', 'promptCustomization', 'eventHooks', + 'claudeApiProfiles', + 'activeClaudeApiProfileId', 'projects', 'trashedProjects', 'currentProjectId', // ID of currently open project @@ -628,6 +630,8 @@ export async function refreshSettingsFromServer(): Promise { defaultEditorCommand: serverSettings.defaultEditorCommand ?? null, defaultTerminalId: serverSettings.defaultTerminalId ?? null, promptCustomization: serverSettings.promptCustomization ?? {}, + claudeApiProfiles: serverSettings.claudeApiProfiles ?? [], + activeClaudeApiProfileId: serverSettings.activeClaudeApiProfileId ?? null, projects: serverSettings.projects, trashedProjects: serverSettings.trashedProjects, projectHistory: serverSettings.projectHistory, diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 9c834955..f3ae5914 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -479,7 +479,8 @@ export interface FeaturesAPI { featureId: string ) => Promise<{ success: boolean; content?: string | null; error?: string }>; generateTitle: ( - description: string + description: string, + projectPath?: string ) => Promise<{ success: boolean; title?: string; error?: string }>; } @@ -706,7 +707,8 @@ export interface ElectronAPI { originalText: string, enhancementMode: string, model?: string, - thinkingLevel?: string + thinkingLevel?: string, + projectPath?: string ) => Promise<{ success: boolean; enhancedText?: string; @@ -3173,7 +3175,7 @@ function createMockFeaturesAPI(): FeaturesAPI { return { success: true, content: content || null }; }, - generateTitle: async (description: string) => { + generateTitle: async (description: string, _projectPath?: string) => { console.log('[Mock] Generating title for:', description.substring(0, 50)); // Mock title generation - just take first few words const words = description.split(/\s+/).slice(0, 6).join(' '); @@ -3349,6 +3351,13 @@ export interface Project { isFavorite?: boolean; // Pin project to top of dashboard icon?: string; // Lucide icon name for project identification customIconPath?: string; // Path to custom uploaded icon image in .automaker/images/ + /** + * Override the active Claude API profile for this project. + * - undefined: Use global setting (activeClaudeApiProfileId) + * - null: Explicitly use Direct Anthropic API (no profile) + * - string: Use specific profile by ID + */ + activeClaudeApiProfileId?: string | null; } export interface TrashedProject extends Project { diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index ba2b8dd3..d70273d9 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1657,8 +1657,8 @@ export class HttpApiClient implements ElectronAPI { this.post('/api/features/delete', { projectPath, featureId }), getAgentOutput: (projectPath: string, featureId: string) => this.post('/api/features/agent-output', { projectPath, featureId }), - generateTitle: (description: string) => - this.post('/api/features/generate-title', { description }), + generateTitle: (description: string, projectPath?: string) => + this.post('/api/features/generate-title', { description, projectPath }), bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial) => this.post('/api/features/bulk-update', { projectPath, featureIds, updates }), bulkDelete: (projectPath: string, featureIds: string[]) => @@ -1743,13 +1743,15 @@ export class HttpApiClient implements ElectronAPI { originalText: string, enhancementMode: string, model?: string, - thinkingLevel?: string + thinkingLevel?: string, + projectPath?: string ): Promise => this.post('/api/enhance-prompt', { originalText, enhancementMode, model, thinkingLevel, + projectPath, }), }; diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 6030033d..a172ffe4 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; // Note: persist middleware removed - settings now sync via API (use-settings-sync.ts) import type { Project, TrashedProject } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron'; +import { getHttpApiClient } from '@/lib/http-api-client'; import { createLogger } from '@automaker/utils/logger'; import { setItem, getItem } from '@/lib/storage'; import { @@ -31,6 +32,7 @@ import type { ModelDefinition, ServerLogLevel, EventHook, + ClaudeApiProfile, } from '@automaker/types'; import { getAllCursorModelIds, @@ -747,6 +749,10 @@ export interface AppState { // Event Hooks eventHooks: EventHook[]; // Event hooks for custom commands or webhooks + // Claude API Profiles + claudeApiProfiles: ClaudeApiProfile[]; // Claude-compatible API endpoint profiles + activeClaudeApiProfileId: string | null; // Active profile ID (null = use direct Anthropic API) + // Project Analysis projectAnalysis: ProjectAnalysis | null; isAnalyzing: boolean; @@ -1030,6 +1036,9 @@ export interface AppActions { getEffectiveFontSans: () => string | null; // Get effective UI font (project override -> global -> null for default) getEffectiveFontMono: () => string | null; // Get effective code font (project override -> global -> null for default) + // Claude API Profile actions (per-project override) + setProjectClaudeApiProfile: (projectId: string, profileId: string | null | undefined) => void; // Set per-project Claude API profile (undefined = use global, null = direct API, string = specific profile) + // Feature actions setFeatures: (features: Feature[]) => void; updateFeature: (id: string, updates: Partial) => void; @@ -1180,6 +1189,13 @@ export interface AppActions { // Event Hook actions setEventHooks: (hooks: EventHook[]) => void; + // Claude API Profile actions + addClaudeApiProfile: (profile: ClaudeApiProfile) => Promise; + updateClaudeApiProfile: (id: string, updates: Partial) => Promise; + deleteClaudeApiProfile: (id: string) => Promise; + setActiveClaudeApiProfile: (id: string | null) => Promise; + setClaudeApiProfiles: (profiles: ClaudeApiProfile[]) => Promise; + // MCP Server actions addMCPServer: (server: Omit) => void; updateMCPServer: (id: string, updates: Partial) => void; @@ -1438,6 +1454,8 @@ const initialState: AppState = { subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default promptCustomization: {}, // Empty by default - all prompts use built-in defaults eventHooks: [], // No event hooks configured by default + claudeApiProfiles: [], // No Claude API profiles configured by default + activeClaudeApiProfileId: null, // Use direct Anthropic API by default projectAnalysis: null, isAnalyzing: false, boardBackgroundByProject: {}, @@ -1936,6 +1954,47 @@ export const useAppStore = create()((set, get) => ({ return getEffectiveFont(currentProject?.fontFamilyMono, fontFamilyMono, UI_MONO_FONT_OPTIONS); }, + // Claude API Profile actions (per-project override) + setProjectClaudeApiProfile: (projectId, profileId) => { + // Find the project to get its path for server sync + const project = get().projects.find((p) => p.id === projectId); + if (!project) { + console.error('Cannot set Claude API profile: project not found'); + return; + } + + // Update the project's activeClaudeApiProfileId property + // undefined means "use global", null means "explicit direct API", string means specific profile + const projects = get().projects.map((p) => + p.id === projectId ? { ...p, activeClaudeApiProfileId: profileId } : p + ); + set({ projects }); + + // Also update currentProject if it's the same project + const currentProject = get().currentProject; + if (currentProject?.id === projectId) { + set({ + currentProject: { + ...currentProject, + activeClaudeApiProfileId: profileId, + }, + }); + } + + // Persist to server + // Note: undefined means "use global" but JSON doesn't serialize undefined, + // so we use a special marker string "__USE_GLOBAL__" to signal deletion + const httpClient = getHttpApiClient(); + const serverValue = profileId === undefined ? '__USE_GLOBAL__' : profileId; + httpClient.settings + .updateProject(project.path, { + activeClaudeApiProfileId: serverValue, + }) + .catch((error) => { + console.error('Failed to persist activeClaudeApiProfileId:', error); + }); + }, + // Feature actions setFeatures: (features) => set({ features }), @@ -2459,6 +2518,82 @@ export const useAppStore = create()((set, get) => ({ // Event Hook actions setEventHooks: (hooks) => set({ eventHooks: hooks }), + // Claude API Profile actions + addClaudeApiProfile: async (profile) => { + set({ claudeApiProfiles: [...get().claudeApiProfiles, profile] }); + // Sync immediately to persist profile + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + updateClaudeApiProfile: async (id, updates) => { + set({ + claudeApiProfiles: get().claudeApiProfiles.map((p) => + p.id === id ? { ...p, ...updates } : p + ), + }); + // Sync immediately to persist changes + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + deleteClaudeApiProfile: async (id) => { + const currentActiveId = get().activeClaudeApiProfileId; + const projects = get().projects; + + // Find projects that have per-project override referencing the deleted profile + const affectedProjects = projects.filter((p) => p.activeClaudeApiProfileId === id); + + // Update state: remove profile and clear references + set({ + claudeApiProfiles: get().claudeApiProfiles.filter((p) => p.id !== id), + // Clear global active if the deleted profile was active + activeClaudeApiProfileId: currentActiveId === id ? null : currentActiveId, + // Clear per-project overrides that reference the deleted profile + projects: projects.map((p) => + p.activeClaudeApiProfileId === id ? { ...p, activeClaudeApiProfileId: undefined } : p + ), + }); + + // Also update currentProject if it was using the deleted profile + const currentProject = get().currentProject; + if (currentProject?.activeClaudeApiProfileId === id) { + set({ + currentProject: { ...currentProject, activeClaudeApiProfileId: undefined }, + }); + } + + // Persist per-project changes to server (use __USE_GLOBAL__ marker) + const httpClient = getHttpApiClient(); + await Promise.all( + affectedProjects.map((project) => + httpClient.settings + .updateProject(project.path, { activeClaudeApiProfileId: '__USE_GLOBAL__' }) + .catch((error) => { + console.error(`Failed to clear profile override for project ${project.name}:`, error); + }) + ) + ); + + // Sync global settings to persist deletion + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + setActiveClaudeApiProfile: async (id) => { + set({ activeClaudeApiProfileId: id }); + // Sync immediately to persist active profile change + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + setClaudeApiProfiles: async (profiles) => { + set({ claudeApiProfiles: profiles }); + // Sync immediately to persist profiles + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + // MCP Server actions addMCPServer: (server) => { const id = `mcp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; diff --git a/docs/UNIFIED_API_KEY_PROFILES.md b/docs/UNIFIED_API_KEY_PROFILES.md new file mode 100644 index 00000000..4bb8e936 --- /dev/null +++ b/docs/UNIFIED_API_KEY_PROFILES.md @@ -0,0 +1,448 @@ +# Unified Claude API Key and Profile System + +This document describes the implementation of a unified API key sourcing system for Claude API profiles, allowing flexible configuration of how API keys are resolved. + +## Problem Statement + +Previously, Automaker had two separate systems for configuring Claude API access: + +1. **API Keys section** (`credentials.json`): Stored Anthropic API key, used when no profile was active +2. **API Profiles section** (`settings.json`): Stored alternative endpoint configs (e.g., z.AI GLM) with their own inline API keys + +This created several issues: + +- Users configured Anthropic key in one place, but alternative endpoints in another +- No way to create a "Direct Anthropic" profile that reused the stored credentials +- Environment variable detection didn't integrate with the profile system +- Duplicated API key entry when users wanted the same key for multiple configurations + +## Solution Overview + +The solution introduces a flexible `apiKeySource` field on Claude API profiles that determines where the API key is resolved from: + +| Source | Description | +| ------------- | ----------------------------------------------------------------- | +| `inline` | API key stored directly in the profile (legacy behavior, default) | +| `env` | Uses `ANTHROPIC_API_KEY` environment variable | +| `credentials` | Uses the Anthropic key from Settings → API Keys | + +This allows: + +- A single API key to be shared across multiple profile configurations +- "Direct Anthropic" profile that references saved credentials +- Environment variable support for CI/CD and containerized deployments +- Backwards compatibility with existing inline key profiles + +## Implementation Details + +### Type Changes + +#### New Type: `ApiKeySource` + +```typescript +// libs/types/src/settings.ts +export type ApiKeySource = 'inline' | 'env' | 'credentials'; +``` + +#### Updated Interface: `ClaudeApiProfile` + +```typescript +export interface ClaudeApiProfile { + id: string; + name: string; + baseUrl: string; + + // NEW: API key sourcing strategy (default: 'inline' for backwards compat) + apiKeySource?: ApiKeySource; + + // Now optional - only required when apiKeySource = 'inline' + apiKey?: string; + + // Existing fields unchanged... + useAuthToken?: boolean; + timeoutMs?: number; + modelMappings?: { haiku?: string; sonnet?: string; opus?: string }; + disableNonessentialTraffic?: boolean; +} +``` + +#### Updated Interface: `ClaudeApiProfileTemplate` + +```typescript +export interface ClaudeApiProfileTemplate { + name: string; + baseUrl: string; + defaultApiKeySource?: ApiKeySource; // NEW: Suggested source for this template + useAuthToken: boolean; + // ... other fields +} +``` + +### Provider Templates + +The following provider templates are available: + +#### Direct Anthropic + +```typescript +{ + name: 'Direct Anthropic', + baseUrl: 'https://api.anthropic.com', + defaultApiKeySource: 'credentials', + useAuthToken: false, + description: 'Standard Anthropic API with your API key', + apiKeyUrl: 'https://console.anthropic.com/settings/keys', +} +``` + +#### OpenRouter + +Access Claude and 300+ other models through OpenRouter's unified API. + +```typescript +{ + name: 'OpenRouter', + baseUrl: 'https://openrouter.ai/api', + defaultApiKeySource: 'inline', + useAuthToken: true, + description: 'Access Claude and 300+ models via OpenRouter', + apiKeyUrl: 'https://openrouter.ai/keys', +} +``` + +**Notes:** + +- Uses `ANTHROPIC_AUTH_TOKEN` with your OpenRouter API key +- No model mappings by default - OpenRouter auto-maps Anthropic models +- Can customize model mappings to use any OpenRouter-supported model (e.g., `openai/gpt-5.1-codex-max`) + +#### z.AI GLM + +```typescript +{ + name: 'z.AI GLM', + baseUrl: 'https://api.z.ai/api/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'GLM-4.5-Air', + sonnet: 'GLM-4.7', + opus: 'GLM-4.7', + }, + disableNonessentialTraffic: true, + description: '3× usage at fraction of cost via GLM Coding Plan', + apiKeyUrl: 'https://z.ai/manage-apikey/apikey-list', +} +``` + +#### MiniMax + +MiniMax M2.1 coding model with extended context support. + +```typescript +{ + name: 'MiniMax', + baseUrl: 'https://api.minimax.io/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'MiniMax-M2.1', + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + }, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 coding model with extended context', + apiKeyUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key', +} +``` + +#### MiniMax (China) + +Same as MiniMax but using the China-region endpoint. + +```typescript +{ + name: 'MiniMax (China)', + baseUrl: 'https://api.minimaxi.com/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'MiniMax-M2.1', + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + }, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 for users in China', + apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', +} +``` + +### Server-Side Changes + +#### 1. Environment Building (`claude-provider.ts`) + +The `buildEnv()` function now resolves API keys based on the `apiKeySource`: + +```typescript +function buildEnv( + profile?: ClaudeApiProfile, + credentials?: Credentials // NEW parameter +): Record { + if (profile) { + // Resolve API key based on source strategy + let apiKey: string | undefined; + const source = profile.apiKeySource ?? 'inline'; + + switch (source) { + case 'inline': + apiKey = profile.apiKey; + break; + case 'env': + apiKey = process.env.ANTHROPIC_API_KEY; + break; + case 'credentials': + apiKey = credentials?.apiKeys?.anthropic; + break; + } + + // ... rest of profile-based env building + } + // ... no-profile fallback +} +``` + +#### 2. Settings Helper (`settings-helpers.ts`) + +The `getActiveClaudeApiProfile()` function now returns both profile and credentials: + +```typescript +export interface ActiveClaudeApiProfileResult { + profile: ClaudeApiProfile | undefined; + credentials: Credentials | undefined; +} + +export async function getActiveClaudeApiProfile( + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]' +): Promise { + // Returns both profile and credentials for API key resolution +} +``` + +#### 3. Auto-Migration (`settings-service.ts`) + +A v4→v5 migration automatically creates a "Direct Anthropic" profile for existing users: + +```typescript +// Migration v4 -> v5: Auto-create "Direct Anthropic" profile +if (storedVersion < 5) { + const credentials = await this.getCredentials(); + const hasAnthropicKey = !!credentials.apiKeys?.anthropic; + const hasNoProfiles = !result.claudeApiProfiles?.length; + const hasNoActiveProfile = !result.activeClaudeApiProfileId; + + if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) { + // Create "Direct Anthropic" profile with apiKeySource: 'credentials' + // and set it as active + } +} +``` + +#### 4. Updated Call Sites + +All files that call `getActiveClaudeApiProfile()` were updated to: + +1. Destructure both `profile` and `credentials` from the result +2. Pass `credentials` to the provider via `ExecuteOptions` + +**Files updated:** + +- `apps/server/src/services/agent-service.ts` +- `apps/server/src/services/auto-mode-service.ts` (2 locations) +- `apps/server/src/services/ideation-service.ts` (2 locations) +- `apps/server/src/providers/simple-query-service.ts` +- `apps/server/src/routes/enhance-prompt/routes/enhance.ts` +- `apps/server/src/routes/context/routes/describe-file.ts` +- `apps/server/src/routes/context/routes/describe-image.ts` +- `apps/server/src/routes/github/routes/validate-issue.ts` +- `apps/server/src/routes/worktree/routes/generate-commit-message.ts` +- `apps/server/src/routes/features/routes/generate-title.ts` +- `apps/server/src/routes/backlog-plan/generate-plan.ts` +- `apps/server/src/routes/app-spec/sync-spec.ts` +- `apps/server/src/routes/app-spec/generate-features-from-spec.ts` +- `apps/server/src/routes/app-spec/generate-spec.ts` +- `apps/server/src/routes/suggestions/generate-suggestions.ts` + +### UI Changes + +#### 1. Profile Form (`api-profiles-section.tsx`) + +Added an API Key Source selector dropdown: + +```tsx + +``` + +The API Key input field is now conditionally rendered only when `apiKeySource === 'inline'`. + +#### 2. API Keys Section (`api-keys-section.tsx`) + +Added an informational note: + +> API Keys saved here can be used by API Profiles with "credentials" as the API key source. This lets you share a single key across multiple profile configurations without re-entering it. + +## User Flows + +### New User Flow + +1. Go to Settings → API Keys +2. Enter Anthropic API key and save +3. Go to Settings → Providers → Claude +4. Create new profile from "Direct Anthropic" template +5. API Key Source defaults to "credentials" - no need to re-enter key +6. Save profile and set as active + +### Existing User Migration + +When an existing user with an Anthropic API key (but no profiles) loads settings: + +1. System detects v4→v5 migration needed +2. Automatically creates "Direct Anthropic" profile with `apiKeySource: 'credentials'` +3. Sets new profile as active +4. User's existing workflow continues to work seamlessly + +### Environment Variable Flow + +For CI/CD or containerized deployments: + +1. Set `ANTHROPIC_API_KEY` in environment +2. Create profile with `apiKeySource: 'env'` +3. Profile will use the environment variable at runtime + +## Backwards Compatibility + +- Profiles without `apiKeySource` field default to `'inline'` +- Existing profiles with inline `apiKey` continue to work unchanged +- No changes to the credentials file format +- Settings version bumped from 4 to 5 (migration is additive) + +## Files Changed + +| File | Changes | +| --------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `libs/types/src/settings.ts` | Added `ApiKeySource` type, updated `ClaudeApiProfile`, added Direct Anthropic template | +| `libs/types/src/provider.ts` | Added `credentials` field to `ExecuteOptions` | +| `libs/types/src/index.ts` | Exported `ApiKeySource` type | +| `apps/server/src/providers/claude-provider.ts` | Updated `buildEnv()` to resolve keys from different sources | +| `apps/server/src/lib/settings-helpers.ts` | Updated return type to include credentials | +| `apps/server/src/services/settings-service.ts` | Added v4→v5 auto-migration | +| `apps/server/src/providers/simple-query-service.ts` | Added credentials passthrough | +| `apps/server/src/services/*.ts` | Updated to pass credentials | +| `apps/server/src/routes/**/*.ts` | Updated to pass credentials (15 files) | +| `apps/ui/src/.../api-profiles-section.tsx` | Added API Key Source selector | +| `apps/ui/src/.../api-keys-section.tsx` | Added profile usage note | + +## Testing + +To verify the implementation: + +1. **New user flow**: Create "Direct Anthropic" profile, select `credentials` source, enter key in API Keys section → verify it works +2. **Existing user migration**: User with credentials.json key sees auto-created "Direct Anthropic" profile +3. **Env var support**: Create profile with `env` source, set ANTHROPIC_API_KEY → verify it works +4. **z.AI GLM unchanged**: Existing profiles with inline keys continue working +5. **Backwards compat**: Profiles without `apiKeySource` field default to `inline` + +```bash +# Build and run +npm run build:packages +npm run dev:web + +# Run server tests +npm run test:server +``` + +## Per-Project Profile Override + +Projects can override the global Claude API profile selection, allowing different projects to use different endpoints or configurations. + +### Configuration + +In **Project Settings → Claude**, users can select: + +| Option | Behavior | +| ------------------------ | ------------------------------------------------------------------ | +| **Use Global Setting** | Inherits the active profile from global settings (default) | +| **Direct Anthropic API** | Explicitly uses direct Anthropic API, bypassing any global profile | +| **\** | Uses that specific profile for this project only | + +### Storage + +The per-project setting is stored in `.automaker/settings.json`: + +```json +{ + "activeClaudeApiProfileId": "profile-id-here" +} +``` + +- `undefined` (or key absent): Use global setting +- `null`: Explicitly use Direct Anthropic API +- `""`: Use specific profile by ID + +### Implementation + +The `getActiveClaudeApiProfile()` function accepts an optional `projectPath` parameter: + +```typescript +export async function getActiveClaudeApiProfile( + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]', + projectPath?: string // Optional: check project settings first +): Promise; +``` + +When `projectPath` is provided: + +1. Project settings are checked first for `activeClaudeApiProfileId` +2. If project has a value (including `null`), that takes precedence +3. If project has no override (`undefined`), falls back to global setting + +### Scope + +**Important:** Per-project profiles only affect Claude model calls. When other providers are used (Codex, OpenCode, Cursor), the Claude API profile setting has no effect—those providers use their own configuration. + +Affected operations when using Claude models: + +- Agent chat and feature implementation +- Code analysis and suggestions +- Commit message generation +- Spec generation and sync +- Issue validation +- Backlog planning + +### Use Cases + +1. **Experimentation**: Test z.AI GLM or MiniMax on a side project while keeping production projects on Direct Anthropic +2. **Cost optimization**: Use cheaper endpoints for hobby projects, premium for work projects +3. **Regional compliance**: Use China endpoints for projects with data residency requirements + +## Future Enhancements + +Potential future improvements: + +1. **UI indicators**: Show whether credentials/env key is configured when selecting those sources +2. **Validation**: Warn if selected source has no key configured +3. **Per-provider credentials**: Support different credential keys for different providers +4. **Key rotation**: Support for rotating keys without updating profiles diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 21985230..123dbeda 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -161,6 +161,10 @@ export type { EventHookHttpAction, EventHookAction, EventHook, + // Claude API profile types + ApiKeySource, + ClaudeApiProfile, + ClaudeApiProfileTemplate, } from './settings.js'; export { DEFAULT_KEYBOARD_SHORTCUTS, @@ -175,6 +179,8 @@ export { getThinkingTokenBudget, // Event hook constants EVENT_HOOK_TRIGGER_LABELS, + // Claude API profile constants + CLAUDE_API_PROFILE_TEMPLATES, } from './settings.js'; // Model display constants diff --git a/libs/types/src/provider.ts b/libs/types/src/provider.ts index e934e999..6fddb460 100644 --- a/libs/types/src/provider.ts +++ b/libs/types/src/provider.ts @@ -2,7 +2,7 @@ * Shared types for AI model providers */ -import type { ThinkingLevel } from './settings.js'; +import type { ThinkingLevel, ClaudeApiProfile, Credentials } from './settings.js'; import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js'; /** @@ -209,6 +209,17 @@ export interface ExecuteOptions { type: 'json_schema'; schema: Record; }; + /** + * Active Claude API profile for alternative endpoint configuration. + * When set, uses profile's settings (base URL, auth, model mappings) instead of direct Anthropic API. + * When undefined, uses direct Anthropic API (via API key or Claude Max CLI OAuth). + */ + claudeApiProfile?: ClaudeApiProfile; + /** + * Credentials for resolving 'credentials' apiKeySource in Claude API profiles. + * When a profile has apiKeySource='credentials', the Anthropic key from this object is used. + */ + credentials?: Credentials; } /** diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 64c3df41..21ce10a8 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -101,6 +101,137 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number /** ModelProvider - AI model provider for credentials and API key management */ export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode'; +// ============================================================================ +// Claude API Profiles - Configuration for Claude-compatible API endpoints +// ============================================================================ + +/** + * ApiKeySource - Strategy for sourcing API keys + * + * - 'inline': API key stored directly in the profile (legacy/default behavior) + * - 'env': Use ANTHROPIC_API_KEY environment variable + * - 'credentials': Use the Anthropic key from Settings → API Keys (credentials.json) + */ +export type ApiKeySource = 'inline' | 'env' | 'credentials'; + +/** + * ClaudeApiProfile - Configuration for a Claude-compatible API endpoint + * + * Allows using alternative providers like z.AI GLM, AWS Bedrock, etc. + */ +export interface ClaudeApiProfile { + /** Unique identifier (uuid) */ + id: string; + /** Display name (e.g., "z.AI GLM", "AWS Bedrock") */ + name: string; + /** ANTHROPIC_BASE_URL - custom API endpoint */ + baseUrl: string; + /** + * API key sourcing strategy (default: 'inline' for backwards compatibility) + * - 'inline': Use apiKey field value + * - 'env': Use ANTHROPIC_API_KEY environment variable + * - 'credentials': Use the Anthropic key from credentials.json + */ + apiKeySource?: ApiKeySource; + /** API key value (only required when apiKeySource = 'inline' or undefined) */ + apiKey?: string; + /** If true, use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY */ + useAuthToken?: boolean; + /** API_TIMEOUT_MS override in milliseconds */ + timeoutMs?: number; + /** Optional model name mappings */ + modelMappings?: { + /** Maps to ANTHROPIC_DEFAULT_HAIKU_MODEL */ + haiku?: string; + /** Maps to ANTHROPIC_DEFAULT_SONNET_MODEL */ + sonnet?: string; + /** Maps to ANTHROPIC_DEFAULT_OPUS_MODEL */ + opus?: string; + }; + /** Set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 */ + disableNonessentialTraffic?: boolean; +} + +/** Known provider templates for quick setup */ +export interface ClaudeApiProfileTemplate { + name: string; + baseUrl: string; + /** Default API key source for this template (user chooses when creating) */ + defaultApiKeySource?: ApiKeySource; + useAuthToken: boolean; + timeoutMs?: number; + modelMappings?: ClaudeApiProfile['modelMappings']; + disableNonessentialTraffic?: boolean; + description: string; + apiKeyUrl?: string; +} + +/** Predefined templates for known Claude-compatible providers */ +export const CLAUDE_API_PROFILE_TEMPLATES: ClaudeApiProfileTemplate[] = [ + { + name: 'Direct Anthropic', + baseUrl: 'https://api.anthropic.com', + defaultApiKeySource: 'credentials', + useAuthToken: false, + description: 'Standard Anthropic API with your API key', + apiKeyUrl: 'https://console.anthropic.com/settings/keys', + }, + { + name: 'OpenRouter', + baseUrl: 'https://openrouter.ai/api', + defaultApiKeySource: 'inline', + useAuthToken: true, + description: 'Access Claude and 300+ models via OpenRouter', + apiKeyUrl: 'https://openrouter.ai/keys', + }, + { + name: 'z.AI GLM', + baseUrl: 'https://api.z.ai/api/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'GLM-4.5-Air', + sonnet: 'GLM-4.7', + opus: 'GLM-4.7', + }, + disableNonessentialTraffic: true, + description: '3× usage at fraction of cost via GLM Coding Plan', + apiKeyUrl: 'https://z.ai/manage-apikey/apikey-list', + }, + { + name: 'MiniMax', + baseUrl: 'https://api.minimax.io/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'MiniMax-M2.1', + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + }, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 coding model with extended context', + apiKeyUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key', + }, + { + name: 'MiniMax (China)', + baseUrl: 'https://api.minimaxi.com/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'MiniMax-M2.1', + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + }, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 for users in China', + apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', + }, + // Future: Add AWS Bedrock, Google Vertex, etc. +]; + // ============================================================================ // Event Hooks - Custom actions triggered by system events // ============================================================================ @@ -658,6 +789,19 @@ export interface GlobalSettings { * @see EventHook for configuration details */ eventHooks?: EventHook[]; + + // Claude API Profiles Configuration + /** + * Claude-compatible API endpoint profiles + * Allows using alternative providers like z.AI GLM, AWS Bedrock, etc. + */ + claudeApiProfiles?: ClaudeApiProfile[]; + + /** + * Active profile ID (null/undefined = use direct Anthropic API) + * When set, the corresponding profile's settings will be used for Claude API calls + */ + activeClaudeApiProfileId?: string | null; } /** @@ -794,6 +938,15 @@ export interface ProjectSettings { automodeEnabled?: boolean; /** Maximum concurrent agents for this project (overrides global maxConcurrency) */ maxConcurrentAgents?: number; + + // Claude API Profile Override (per-project) + /** + * Override the active Claude API profile for this project. + * - undefined: Use global setting (activeClaudeApiProfileId) + * - null: Explicitly use Direct Anthropic API (no profile) + * - string: Use specific profile by ID + */ + activeClaudeApiProfileId?: string | null; } /** @@ -827,7 +980,7 @@ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = { }; /** Current version of the global settings schema */ -export const SETTINGS_VERSION = 4; +export const SETTINGS_VERSION = 5; /** Current version of the credentials schema */ export const CREDENTIALS_VERSION = 1; /** Current version of the project settings schema */ @@ -913,6 +1066,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { skillsSources: ['user', 'project'], enableSubagents: true, subagentsSources: ['user', 'project'], + claudeApiProfiles: [], + activeClaudeApiProfileId: null, }; /** Default credentials (empty strings - user must provide API keys) */ diff --git a/package-lock.json b/package-lock.json index 14355b8b..c851c9aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6190,6 +6190,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -6199,7 +6200,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -8410,6 +8411,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/d3-color": { @@ -11303,7 +11305,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11325,7 +11326,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11347,7 +11347,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11369,7 +11368,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11391,7 +11389,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11413,7 +11410,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11435,7 +11431,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11457,7 +11452,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11479,7 +11473,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11501,7 +11494,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11523,7 +11515,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, diff --git a/package.json b/package.json index 96c9bf1e..f7388410 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "scripts": { "postinstall": "node -e \"const fs=require('fs');if(process.platform==='darwin'){['darwin-arm64','darwin-x64'].forEach(a=>{const p='node_modules/node-pty/prebuilds/'+a+'/spawn-helper';if(fs.existsSync(p))fs.chmodSync(p,0o755)})}\" && node scripts/fix-lockfile-urls.mjs", "fix:lockfile": "node scripts/fix-lockfile-urls.mjs", - "dev": "./start-automaker.sh", - "start": "./start-automaker.sh --production", + "dev": "node start-automaker.mjs", + "start": "node start-automaker.mjs --production", "_dev:web": "npm run dev:web --workspace=apps/ui", "_dev:electron": "npm run dev:electron --workspace=apps/ui", "_dev:electron:debug": "npm run dev:electron:debug --workspace=apps/ui", diff --git a/start-automaker.mjs b/start-automaker.mjs new file mode 100644 index 00000000..97362312 --- /dev/null +++ b/start-automaker.mjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node +/** + * Cross-platform launcher for Automaker + * Works on Windows (CMD, PowerShell, Git Bash) and Unix (macOS, Linux) + */ + +import { spawn, spawnSync } from 'child_process'; +import { existsSync } from 'fs'; +import { platform } from 'os'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const isWindows = platform() === 'win32'; +const args = process.argv.slice(2); + +/** + * Detect the bash variant by checking $OSTYPE + * This is more reliable than path-based detection since bash.exe in PATH + * could be Git Bash, WSL, or something else + * @param {string} bashPath - Path to bash executable + * @returns {'WSL' | 'MSYS' | 'CYGWIN' | 'UNKNOWN'} The detected bash variant + */ +function detectBashVariant(bashPath) { + try { + const result = spawnSync(bashPath, ['-c', 'echo $OSTYPE'], { + stdio: 'pipe', + timeout: 2000, + }); + if (result.status === 0) { + const ostype = result.stdout.toString().trim(); + // WSL reports 'linux-gnu' or similar Linux identifier + if (ostype === 'linux-gnu' || ostype.startsWith('linux')) return 'WSL'; + // MSYS2/Git Bash reports 'msys' or 'mingw*' + if (ostype.startsWith('msys') || ostype.startsWith('mingw')) return 'MSYS'; + // Cygwin reports 'cygwin' + if (ostype.startsWith('cygwin')) return 'CYGWIN'; + } + } catch { + // Fall through to path-based detection + } + // Fallback to path-based detection if $OSTYPE check fails + const lower = bashPath.toLowerCase(); + if (lower.includes('cygwin')) return 'CYGWIN'; + if (lower.includes('system32')) return 'WSL'; + // Default to MSYS (Git Bash) as it's the most common + return 'MSYS'; +} + +/** + * Convert Windows path to Unix-style for the detected bash variant + * @param {string} windowsPath - Windows-style path (e.g., C:\path\to\file) + * @param {string} bashCmd - Path to bash executable (used to detect variant) + * @returns {string} Unix-style path appropriate for the bash variant + */ +function convertPathForBash(windowsPath, bashCmd) { + // Input validation + if (!windowsPath || typeof windowsPath !== 'string') { + throw new Error('convertPathForBash: invalid windowsPath'); + } + if (!bashCmd || typeof bashCmd !== 'string') { + throw new Error('convertPathForBash: invalid bashCmd'); + } + + let unixPath = windowsPath.replace(/\\/g, '/'); + if (/^[A-Za-z]:/.test(unixPath)) { + const drive = unixPath[0].toLowerCase(); + const pathPart = unixPath.slice(2); + + // Detect bash variant via $OSTYPE (more reliable than path-based) + const variant = detectBashVariant(bashCmd); + switch (variant) { + case 'CYGWIN': + // Cygwin expects /cygdrive/c/path format + return `/cygdrive/${drive}${pathPart}`; + case 'WSL': + // WSL expects /mnt/c/path format + return `/mnt/${drive}${pathPart}`; + case 'MSYS': + default: + // MSYS2/Git Bash expects /c/path format + return `/${drive}${pathPart}`; + } + } + return unixPath; +} + +/** + * Find bash executable on Windows + */ +function findBashOnWindows() { + const possiblePaths = [ + // Git Bash (most common) + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + // MSYS2 + 'C:\\msys64\\usr\\bin\\bash.exe', + 'C:\\msys32\\usr\\bin\\bash.exe', + // Cygwin + 'C:\\cygwin64\\bin\\bash.exe', + 'C:\\cygwin\\bin\\bash.exe', + // WSL bash (available in PATH on Windows 10+) + 'bash.exe', + ]; + + for (const bashPath of possiblePaths) { + if (bashPath === 'bash.exe') { + // Check if bash is in PATH + try { + const result = spawnSync('where', ['bash.exe'], { stdio: 'pipe' }); + if (result?.status === 0) { + return 'bash.exe'; + } + } catch (err) { + // where command failed, continue checking other paths + } + } else if (existsSync(bashPath)) { + return bashPath; + } + } + + return null; +} + +/** + * Run the bash script + */ +function runBashScript() { + const scriptPath = join(__dirname, 'start-automaker.sh'); + + if (!existsSync(scriptPath)) { + console.error('Error: start-automaker.sh not found'); + process.exit(1); + } + + let bashCmd; + let bashArgs; + + if (isWindows) { + bashCmd = findBashOnWindows(); + + if (!bashCmd) { + console.error('Error: Could not find bash on Windows.'); + console.error('Please install Git for Windows from https://git-scm.com/download/win'); + console.error(''); + console.error('Alternatively, you can run these commands directly:'); + console.error(' npm run dev:web - Web browser mode'); + console.error(' npm run dev:electron - Desktop app mode'); + process.exit(1); + } + + // Convert Windows path to appropriate Unix-style for the detected bash variant + const unixPath = convertPathForBash(scriptPath, bashCmd); + bashArgs = [unixPath, ...args]; + } else { + bashCmd = '/bin/bash'; + bashArgs = [scriptPath, ...args]; + } + + const child = spawn(bashCmd, bashArgs, { + stdio: 'inherit', + env: { + ...process.env, + // Ensure proper terminal handling + TERM: process.env.TERM || 'xterm-256color', + }, + // shell: false ensures signals are forwarded directly to the child process + shell: false, + }); + + child.on('error', (err) => { + if (err.code === 'ENOENT') { + console.error(`Error: Could not find bash at "${bashCmd}"`); + console.error('Please ensure Git Bash or another bash shell is installed.'); + } else { + console.error('Error launching Automaker:', err.message); + } + process.exit(1); + }); + + child.on('exit', (code, signal) => { + if (signal) { + // Process was killed by a signal - exit with 1 to indicate abnormal termination + // (Unix convention is 128 + signal number, but we use 1 for simplicity) + process.exit(1); + } + process.exit(code ?? 0); + }); + + // Forward signals to child process (guard against race conditions) + process.on('SIGINT', () => { + if (!child.killed) child.kill('SIGINT'); + }); + process.on('SIGTERM', () => { + if (!child.killed) child.kill('SIGTERM'); + }); +} + +runBashScript(); diff --git a/start-automaker.sh b/start-automaker.sh index ef7b1172..ecb499b9 100755 --- a/start-automaker.sh +++ b/start-automaker.sh @@ -37,6 +37,37 @@ DEFAULT_SERVER_PORT=3008 WEB_PORT=$DEFAULT_WEB_PORT SERVER_PORT=$DEFAULT_SERVER_PORT +# Port validation function +# Returns 0 if valid, 1 if invalid (with error message printed) +validate_port() { + local port="$1" + local port_name="${2:-port}" + + # Check if port is a number + if ! [[ "$port" =~ ^[0-9]+$ ]]; then + echo "${C_RED}Error:${RESET} $port_name must be a number, got '$port'" + return 1 + fi + + # Check if port is in valid range (1-65535) + if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then + echo "${C_RED}Error:${RESET} $port_name must be between 1-65535, got '$port'" + return 1 + fi + + # Check if port is in privileged range (warning only) + if [ "$port" -lt 1024 ]; then + echo "${C_YELLOW}Warning:${RESET} $port_name $port is in privileged range (requires root/admin)" + fi + + return 0 +} + +# Hostname configuration +# Use VITE_HOSTNAME if explicitly set, otherwise default to localhost +# Note: Don't use $HOSTNAME as it's a bash built-in containing the machine's hostname +APP_HOST="${VITE_HOSTNAME:-localhost}" + # Extract VERSION from apps/ui/package.json (the actual app version, not monorepo version) if command -v node &> /dev/null; then VERSION="v$(node -p "require('$SCRIPT_DIR/apps/ui/package.json').version" 2>/dev/null || echo "0.11.0")" @@ -504,10 +535,23 @@ check_ports() { break ;; [uU]|[uU][sS][eE]) + # Collect both ports first read -r -p "Enter web port (default $DEFAULT_WEB_PORT): " input_web - WEB_PORT=${input_web:-$DEFAULT_WEB_PORT} + input_web=${input_web:-$DEFAULT_WEB_PORT} read -r -p "Enter server port (default $DEFAULT_SERVER_PORT): " input_server - SERVER_PORT=${input_server:-$DEFAULT_SERVER_PORT} + input_server=${input_server:-$DEFAULT_SERVER_PORT} + + # Validate both before assigning either + if ! validate_port "$input_web" "Web port"; then + continue + fi + if ! validate_port "$input_server" "Server port"; then + continue + fi + + # Assign atomically after both validated + WEB_PORT=$input_web + SERVER_PORT=$input_server echo "${C_GREEN}Using ports: Web=$WEB_PORT, Server=$SERVER_PORT${RESET}" break ;; @@ -795,12 +839,25 @@ resolve_port_conflicts() { [uU]|[uU][sS][eE]) echo "" local input_pad=$(( (TERM_COLS - 40) / 2 )) + # Collect both ports first printf "%${input_pad}s" "" read -r -p "Enter web port (default $DEFAULT_WEB_PORT): " input_web - WEB_PORT=${input_web:-$DEFAULT_WEB_PORT} + input_web=${input_web:-$DEFAULT_WEB_PORT} printf "%${input_pad}s" "" read -r -p "Enter server port (default $DEFAULT_SERVER_PORT): " input_server - SERVER_PORT=${input_server:-$DEFAULT_SERVER_PORT} + input_server=${input_server:-$DEFAULT_SERVER_PORT} + + # Validate both before assigning either + if ! validate_port "$input_web" "Web port"; then + continue + fi + if ! validate_port "$input_server" "Server port"; then + continue + fi + + # Assign atomically after both validated + WEB_PORT=$input_web + SERVER_PORT=$input_server center_print "Using ports: Web=$WEB_PORT, Server=$SERVER_PORT" "$C_GREEN" break ;; @@ -850,7 +907,7 @@ launch_sequence() { case "$MODE" in web) - local url="http://localhost:$WEB_PORT" + local url="http://${APP_HOST}:$WEB_PORT" local upad=$(( (TERM_COLS - ${#url} - 10) / 2 )) echo "" printf "%${upad}s${DIM}Opening ${C_SEC}%s${RESET}\n" "" "$url" @@ -1073,10 +1130,15 @@ fi case $MODE in web) export TEST_PORT="$WEB_PORT" - export VITE_SERVER_URL="http://$HOSTNAME:$SERVER_PORT" + export VITE_SERVER_URL="http://${APP_HOST}:$SERVER_PORT" export PORT="$SERVER_PORT" export DATA_DIR="$SCRIPT_DIR/data" - export CORS_ORIGIN="http://localhost:$WEB_PORT,http://$HOSTNAME:$WEB_PORT,http://127.0.0.1:$WEB_PORT" + # Always include localhost and 127.0.0.1 for local dev, plus custom hostname if different + CORS_ORIGINS="http://localhost:$WEB_PORT,http://127.0.0.1:$WEB_PORT" + if [[ "$APP_HOST" != "localhost" && "$APP_HOST" != "127.0.0.1" ]]; then + CORS_ORIGINS="${CORS_ORIGINS},http://${APP_HOST}:$WEB_PORT" + fi + export CORS_ORIGIN="$CORS_ORIGINS" export VITE_APP_MODE="1" if [ "$PRODUCTION_MODE" = true ]; then @@ -1092,7 +1154,7 @@ case $MODE in max_retries=30 server_ready=false for ((i=0; i /dev/null 2>&1; then + if curl -s "http://localhost:$SERVER_PORT/api/health" > /dev/null 2>&1; then server_ready=true break fi @@ -1148,7 +1210,7 @@ case $MODE in center_print "✓ Server is ready!" "$C_GREEN" echo "" - center_print "The application will be available at: http://localhost:$WEB_PORT" "$C_GREEN" + center_print "The application will be available at: http://${APP_HOST}:$WEB_PORT" "$C_GREEN" echo "" # Start web app with Vite dev server (HMR enabled) From ea3930cf3dd039489d609daed978f929f92e57a6 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Mon, 19 Jan 2026 21:17:05 +0100 Subject: [PATCH 02/11] fix: convert OpenCode model format to CLI slash format (#605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: convert OpenCode model format to CLI slash format The OpenCode CLI expects models in provider/model format (e.g., opencode/big-pickle), but after commit 4b0d1399 changed model IDs from slash format to prefix format, the buildCliArgs() method was not updated to convert back to CLI format. Root cause: - Commit 4b0d1399 changed OpenCode model IDs from opencode/model to opencode-model - The old code used stripProviderPrefix() which just removed the prefix - This resulted in bare model names (e.g., "big-pickle") being passed to CLI - CLI interpreted "big-pickle" as a provider ID, causing ProviderModelNotFoundError Fix: - Updated buildCliArgs() to properly convert model formats for CLI - Bare model names (after prefix strip) now get opencode/ prepended - Models with slashes (dynamic providers) pass through unchanged Model conversion examples: - opencode-big-pickle → (stripped to) big-pickle → opencode/big-pickle - opencode-github-copilot/gpt-4o → (stripped to) github-copilot/gpt-4o → github-copilot/gpt-4o - google/gemini-2.5-pro → google/gemini-2.5-pro (unchanged) * refactor: simplify OpenCode model format conversion logic Address review feedback from Gemini Code Assist to reduce code repetition. The conditional logic for handling models with/without slashes is now unified into a simpler two-step approach: 1. Strip opencode- prefix if present 2. Prepend opencode/ if no slash exists --- apps/server/src/providers/opencode-provider.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts index 6babb978..0fd8f851 100644 --- a/apps/server/src/providers/opencode-provider.ts +++ b/apps/server/src/providers/opencode-provider.ts @@ -25,7 +25,6 @@ import type { InstallationStatus, ContentBlock, } from '@automaker/types'; -import { stripProviderPrefix } from '@automaker/types'; import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform'; import { createLogger } from '@automaker/utils'; @@ -328,10 +327,18 @@ export class OpencodeProvider extends CliProvider { args.push('--format', 'json'); // Handle model selection - // Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5' + // Convert canonical prefix format (opencode-xxx) to CLI slash format (opencode/xxx) + // OpenCode CLI expects provider/model format (e.g., 'opencode/big-model') if (options.model) { - const model = stripProviderPrefix(options.model); - args.push('--model', model); + // Strip opencode- prefix if present, then ensure slash format + const model = options.model.startsWith('opencode-') + ? options.model.slice('opencode-'.length) + : options.model; + + // If model has slash, it's already provider/model format; otherwise prepend opencode/ + const cliModel = model.includes('/') ? model : `opencode/${model}`; + + args.push('--model', cliModel); } // Note: OpenCode reads from stdin automatically when input is piped From 0d9259473eeadc4df7b60ca09784645d6316c73b Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Mon, 19 Jan 2026 22:58:47 +0100 Subject: [PATCH 03/11] fix: prevent refresh button from overlapping close button in Dev Server dialog (#610) * fix: prevent refresh button from overlapping close button in Dev Server dialog Use compact mode for DialogContent and add right padding to the header to ensure the refresh button doesn't overlap with the dialog close button. Fixes #579 * fix: restore p-0 to prevent unwanted padding from compact mode --- .../worktree-panel/components/dev-server-logs-panel.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx index 02dcdb29..0e9b5e59 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx @@ -134,9 +134,10 @@ export function DevServerLogsPanel({ {/* Compact Header */} - +
From 82e22b43629e03118a6d2e34bbce2e1421265947 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Mon, 19 Jan 2026 17:17:40 -0500 Subject: [PATCH 04/11] 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} + +
+ ); +} From 43481c2bab3295f74802af1d48761d24454c106f Mon Sep 17 00:00:00 2001 From: webdevcody Date: Mon, 19 Jan 2026 17:35:01 -0500 Subject: [PATCH 05/11] refactor: sanitize featureId for worktree paths across multiple handlers - Updated createDiffsHandler, createFileDiffHandler, createInfoHandler, createStatusHandler, and auto-mode service to sanitize featureId when constructing worktree paths. - Ensured consistent handling of featureId to prevent issues with invalid characters in branch names. - Added branchName support in UI components to enhance feature visibility and management. This change improves the robustness of worktree operations and enhances user experience by ensuring valid paths are used throughout the application. --- apps/server/src/routes/worktree/routes/diffs.ts | 5 ++++- .../src/routes/worktree/routes/file-diff.ts | 5 ++++- apps/server/src/routes/worktree/routes/info.ts | 5 ++++- apps/server/src/routes/worktree/routes/status.ts | 5 ++++- apps/server/src/services/auto-mode-service.ts | 16 ++++++++++++---- apps/ui/src/components/views/board-view.tsx | 1 + .../board-view/dialogs/agent-output-modal.tsx | 5 ++++- apps/ui/src/components/views/graph-view-page.tsx | 1 + .../src/components/views/running-agents-view.tsx | 1 + 9 files changed, 35 insertions(+), 9 deletions(-) diff --git a/apps/server/src/routes/worktree/routes/diffs.ts b/apps/server/src/routes/worktree/routes/diffs.ts index 75f43d7f..314fa8ce 100644 --- a/apps/server/src/routes/worktree/routes/diffs.ts +++ b/apps/server/src/routes/worktree/routes/diffs.ts @@ -39,7 +39,10 @@ export function createDiffsHandler() { } // Git worktrees are stored in project directory - const worktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-')) + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); try { // Check if worktree exists diff --git a/apps/server/src/routes/worktree/routes/file-diff.ts b/apps/server/src/routes/worktree/routes/file-diff.ts index 4d29eb26..f3d4ed1a 100644 --- a/apps/server/src/routes/worktree/routes/file-diff.ts +++ b/apps/server/src/routes/worktree/routes/file-diff.ts @@ -37,7 +37,10 @@ export function createFileDiffHandler() { } // Git worktrees are stored in project directory - const worktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-')) + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); try { await secureFs.access(worktreePath); diff --git a/apps/server/src/routes/worktree/routes/info.ts b/apps/server/src/routes/worktree/routes/info.ts index 3d512452..5c2eb808 100644 --- a/apps/server/src/routes/worktree/routes/info.ts +++ b/apps/server/src/routes/worktree/routes/info.ts @@ -28,7 +28,10 @@ export function createInfoHandler() { } // Check if worktree exists (git worktrees are stored in project directory) - const worktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-')) + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); try { await secureFs.access(worktreePath); const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { diff --git a/apps/server/src/routes/worktree/routes/status.ts b/apps/server/src/routes/worktree/routes/status.ts index f9d6bf88..b44c5ae4 100644 --- a/apps/server/src/routes/worktree/routes/status.ts +++ b/apps/server/src/routes/worktree/routes/status.ts @@ -28,7 +28,10 @@ export function createStatusHandler() { } // Git worktrees are stored in project directory - const worktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-')) + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); try { await secureFs.access(worktreePath); diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 606660c3..28498829 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -2060,7 +2060,9 @@ Address the follow-up instructions above. Review the previous work and make the const feature = await this.loadFeature(projectPath, featureId); // Worktrees are in project dir - const worktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); let workDir = projectPath; try { @@ -2143,7 +2145,9 @@ Address the follow-up instructions above. Review the previous work and make the } } else { // Fallback: try to find worktree at legacy location - const legacyWorktreePath = path.join(projectPath, '.worktrees', featureId); + // Sanitize featureId the same way it's sanitized when creating worktrees + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const legacyWorktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); try { await secureFs.access(legacyWorktreePath); workDir = legacyWorktreePath; @@ -2429,22 +2433,25 @@ Format your response as a structured markdown document.`; provider?: ModelProvider; title?: string; description?: string; + branchName?: string; }> > { const agents = await Promise.all( Array.from(this.runningFeatures.values()).map(async (rf) => { - // Try to fetch feature data to get title and description + // Try to fetch feature data to get title, description, and branchName let title: string | undefined; let description: string | undefined; + let branchName: string | undefined; try { const feature = await this.featureLoader.get(rf.projectPath, rf.featureId); if (feature) { title = feature.title; description = feature.description; + branchName = feature.branchName; } } catch (error) { - // Silently ignore errors - title/description are optional + // Silently ignore errors - title/description/branchName are optional } return { @@ -2456,6 +2463,7 @@ Format your response as a structured markdown document.`; provider: rf.provider, title, description, + branchName, }; }) ); diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 17d44d2b..c72fc8de 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -1415,6 +1415,7 @@ export function BoardView() { featureId={outputFeature?.id || ''} featureStatus={outputFeature?.status} onNumberKeyPress={handleOutputModalNumberKeyPress} + branchName={outputFeature?.branchName} /> {/* Archive All Verified Dialog */} diff --git a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx index ba78f1c8..cfb34f18 100644 --- a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx @@ -28,6 +28,8 @@ interface AgentOutputModalProps { onNumberKeyPress?: (key: string) => void; /** Project path - if not provided, falls back to window.__currentProject for backward compatibility */ projectPath?: string; + /** Branch name for the feature worktree - used when viewing changes */ + branchName?: string; } type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes'; @@ -40,6 +42,7 @@ export function AgentOutputModal({ featureStatus, onNumberKeyPress, projectPath: projectPathProp, + branchName, }: AgentOutputModalProps) { const isBacklogPlan = featureId.startsWith('backlog-plan:'); const [output, setOutput] = useState(''); @@ -433,7 +436,7 @@ export function AgentOutputModal({ {projectPath ? ( {/* Backlog Plan Dialog */} diff --git a/apps/ui/src/components/views/running-agents-view.tsx b/apps/ui/src/components/views/running-agents-view.tsx index b77518d0..883609db 100644 --- a/apps/ui/src/components/views/running-agents-view.tsx +++ b/apps/ui/src/components/views/running-agents-view.tsx @@ -280,6 +280,7 @@ export function RunningAgentsView() { } featureId={selectedAgent.featureId} featureStatus="running" + branchName={selectedAgent.branchName} /> )} From 02a7a54736fe172af0b845698cc3485d99bc6cd0 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Mon, 19 Jan 2026 23:36:40 +0100 Subject: [PATCH 06/11] feat: auto-discover available ports when defaults are in use (#614) * feat: auto-discover available ports when defaults are in use Instead of prompting the user to kill processes or manually enter alternative ports, the launcher now automatically finds the next available ports when the defaults (3007/3008) are already in use. This enables running the built Electron app alongside web development mode without conflicts - web dev will automatically use the next available ports (e.g., 3009/3010) when Electron is running. Changes: - Add find_next_available_port() function that searches up to 100 ports - Update resolve_port_conflicts() to auto-select ports without prompts - Update check_ports() for consistency (currently unused but kept) - Add safety check to ensure web and server ports don't conflict * fix: sanitize PIDs to single line for centered display * feat: add user choice for port conflicts with auto-select as default When ports are in use, users can now choose: - [Enter] Auto-select available ports (default, recommended) - [K] Kill processes and use default ports - [C] Choose custom ports manually - [X] Cancel Pressing Enter without typing anything will auto-select the next available ports, making it easy to quickly continue when running alongside an existing Electron instance. * fix: improve port discovery error handling and code quality Address PR review feedback: - Extract magic number 100 to PORT_SEARCH_MAX_ATTEMPTS constant - Fix find_next_available_port to return nothing on failure instead of the busy port, preventing misleading "auto-selected" messages - Update all callers to handle port discovery failure with clear error messages showing the searched range - Simplify PID formatting using xargs instead of tr|sed|sed pipeline --- start-automaker.sh | 163 ++++++++++++++++++++++++++------------------- 1 file changed, 94 insertions(+), 69 deletions(-) diff --git a/start-automaker.sh b/start-automaker.sh index ecb499b9..5d9a30a4 100755 --- a/start-automaker.sh +++ b/start-automaker.sh @@ -34,6 +34,7 @@ fi # Port configuration DEFAULT_WEB_PORT=3007 DEFAULT_SERVER_PORT=3008 +PORT_SEARCH_MAX_ATTEMPTS=100 WEB_PORT=$DEFAULT_WEB_PORT SERVER_PORT=$DEFAULT_SERVER_PORT @@ -453,6 +454,25 @@ is_port_in_use() { [ -n "$pids" ] && [ "$pids" != " " ] } +# Find the next available port starting from a given port +# Returns the port on stdout if found, nothing if all ports in range are busy +# Exit code: 0 if found, 1 if no available port in range +find_next_available_port() { + local start_port=$1 + local port=$start_port + + for ((i=0; i/dev/null || true - + # Auto-discover available ports (no user interaction required) local web_in_use=false local server_in_use=false @@ -506,72 +524,46 @@ check_ports() { if [ "$web_in_use" = true ] || [ "$server_in_use" = true ]; then echo "" + local max_port if [ "$web_in_use" = true ]; then local pids - pids=$(get_pids_on_port "$DEFAULT_WEB_PORT") - echo "${C_YELLOW}⚠${RESET} Port $DEFAULT_WEB_PORT is in use by process(es): $pids" + # Get PIDs and convert newlines to spaces for display + pids=$(get_pids_on_port "$DEFAULT_WEB_PORT" | xargs) + echo "${C_YELLOW}Port $DEFAULT_WEB_PORT in use (PID: $pids), finding alternative...${RESET}" + max_port=$((DEFAULT_WEB_PORT + PORT_SEARCH_MAX_ATTEMPTS - 1)) + if ! WEB_PORT=$(find_next_available_port "$DEFAULT_WEB_PORT"); then + echo "${C_RED}Error: No free web port in range ${DEFAULT_WEB_PORT}-${max_port}${RESET}" + exit 1 + fi fi if [ "$server_in_use" = true ]; then local pids - pids=$(get_pids_on_port "$DEFAULT_SERVER_PORT") - echo "${C_YELLOW}⚠${RESET} Port $DEFAULT_SERVER_PORT is in use by process(es): $pids" + # Get PIDs and convert newlines to spaces for display + pids=$(get_pids_on_port "$DEFAULT_SERVER_PORT" | xargs) + echo "${C_YELLOW}Port $DEFAULT_SERVER_PORT in use (PID: $pids), finding alternative...${RESET}" + max_port=$((DEFAULT_SERVER_PORT + PORT_SEARCH_MAX_ATTEMPTS - 1)) + if ! SERVER_PORT=$(find_next_available_port "$DEFAULT_SERVER_PORT"); then + echo "${C_RED}Error: No free server port in range ${DEFAULT_SERVER_PORT}-${max_port}${RESET}" + exit 1 + fi fi + + # Ensure web and server ports don't conflict with each other + if [ "$WEB_PORT" -eq "$SERVER_PORT" ]; then + local conflict_start=$((SERVER_PORT + 1)) + max_port=$((conflict_start + PORT_SEARCH_MAX_ATTEMPTS - 1)) + if ! SERVER_PORT=$(find_next_available_port "$conflict_start"); then + echo "${C_RED}Error: No free server port in range ${conflict_start}-${max_port}${RESET}" + exit 1 + fi + fi + echo "" - - while true; do - read -r -p "What would you like to do? (k)ill processes, (u)se different ports, or (c)ancel: " choice - case "$choice" in - [kK]|[kK][iI][lL][lL]) - if [ "$web_in_use" = true ]; then - kill_port "$DEFAULT_WEB_PORT" - else - echo "${C_GREEN}✓${RESET} Port $DEFAULT_WEB_PORT is available" - fi - if [ "$server_in_use" = true ]; then - kill_port "$DEFAULT_SERVER_PORT" - else - echo "${C_GREEN}✓${RESET} Port $DEFAULT_SERVER_PORT is available" - fi - break - ;; - [uU]|[uU][sS][eE]) - # Collect both ports first - read -r -p "Enter web port (default $DEFAULT_WEB_PORT): " input_web - input_web=${input_web:-$DEFAULT_WEB_PORT} - read -r -p "Enter server port (default $DEFAULT_SERVER_PORT): " input_server - input_server=${input_server:-$DEFAULT_SERVER_PORT} - - # Validate both before assigning either - if ! validate_port "$input_web" "Web port"; then - continue - fi - if ! validate_port "$input_server" "Server port"; then - continue - fi - - # Assign atomically after both validated - WEB_PORT=$input_web - SERVER_PORT=$input_server - echo "${C_GREEN}Using ports: Web=$WEB_PORT, Server=$SERVER_PORT${RESET}" - break - ;; - [cC]|[cC][aA][nN][cC][eE][lL]) - echo "${C_MUTE}Cancelled.${RESET}" - exit 0 - ;; - *) - echo "${C_RED}Invalid choice. Please enter k, u, or c.${RESET}" - ;; - esac - done - echo "" + echo "${C_GREEN}✓ Auto-selected available ports: Web=$WEB_PORT, Server=$SERVER_PORT${RESET}" else echo "${C_GREEN}✓${RESET} Port $DEFAULT_WEB_PORT is available" echo "${C_GREEN}✓${RESET} Port $DEFAULT_SERVER_PORT is available" fi - - hide_cursor - stty -echo -icanon 2>/dev/null || true } validate_terminal_size() { @@ -791,37 +783,70 @@ resolve_port_conflicts() { if is_port_in_use "$DEFAULT_WEB_PORT"; then web_in_use=true - web_pids=$(get_pids_on_port "$DEFAULT_WEB_PORT") + # Get PIDs and convert newlines to spaces for display + web_pids=$(get_pids_on_port "$DEFAULT_WEB_PORT" | xargs) fi if is_port_in_use "$DEFAULT_SERVER_PORT"; then server_in_use=true - server_pids=$(get_pids_on_port "$DEFAULT_SERVER_PORT") + # Get PIDs and convert newlines to spaces for display + server_pids=$(get_pids_on_port "$DEFAULT_SERVER_PORT" | xargs) fi if [ "$web_in_use" = true ] || [ "$server_in_use" = true ]; then echo "" if [ "$web_in_use" = true ]; then - center_print "⚠ Port $DEFAULT_WEB_PORT is in use by process(es): $web_pids" "$C_YELLOW" + center_print "Port $DEFAULT_WEB_PORT in use (PID: $web_pids)" "$C_YELLOW" fi if [ "$server_in_use" = true ]; then - center_print "⚠ Port $DEFAULT_SERVER_PORT is in use by process(es): $server_pids" "$C_YELLOW" + center_print "Port $DEFAULT_SERVER_PORT in use (PID: $server_pids)" "$C_YELLOW" fi echo "" # Show options center_print "What would you like to do?" "$C_WHITE" echo "" - center_print "[K] Kill processes and continue" "$C_GREEN" - center_print "[U] Use different ports" "$C_MUTE" - center_print "[C] Cancel" "$C_RED" + center_print "[Enter] Auto-select available ports (Recommended)" "$C_GREEN" + center_print "[K] Kill processes and use default ports" "$C_MUTE" + center_print "[C] Choose custom ports" "$C_MUTE" + center_print "[X] Cancel" "$C_RED" echo "" while true; do local choice_pad=$(( (TERM_COLS - 20) / 2 )) printf "%${choice_pad}s" "" - read -r -p "Choice: " choice + read -r -p "Choice [Enter]: " choice case "$choice" in + ""|[aA]|[aA][uU][tT][oO]) + # Auto-select: find next available ports + echo "" + local max_port=$((DEFAULT_WEB_PORT + PORT_SEARCH_MAX_ATTEMPTS - 1)) + if [ "$web_in_use" = true ]; then + if ! WEB_PORT=$(find_next_available_port "$DEFAULT_WEB_PORT"); then + center_print "No free web port in range ${DEFAULT_WEB_PORT}-${max_port}" "$C_RED" + exit 1 + fi + fi + max_port=$((DEFAULT_SERVER_PORT + PORT_SEARCH_MAX_ATTEMPTS - 1)) + if [ "$server_in_use" = true ]; then + if ! SERVER_PORT=$(find_next_available_port "$DEFAULT_SERVER_PORT"); then + center_print "No free server port in range ${DEFAULT_SERVER_PORT}-${max_port}" "$C_RED" + exit 1 + fi + fi + # Ensure web and server ports don't conflict with each other + if [ "$WEB_PORT" -eq "$SERVER_PORT" ]; then + local conflict_start=$((SERVER_PORT + 1)) + max_port=$((conflict_start + PORT_SEARCH_MAX_ATTEMPTS - 1)) + if ! SERVER_PORT=$(find_next_available_port "$conflict_start"); then + center_print "No free server port in range ${conflict_start}-${max_port}" "$C_RED" + exit 1 + fi + fi + center_print "✓ Auto-selected available ports:" "$C_GREEN" + center_print " Web: $WEB_PORT | Server: $SERVER_PORT" "$C_PRI" + break + ;; [kK]|[kK][iI][lL][lL]) echo "" if [ "$web_in_use" = true ]; then @@ -836,7 +861,7 @@ resolve_port_conflicts() { fi break ;; - [uU]|[uU][sS][eE]) + [cC]|[cC][hH][oO][oO][sS][eE]) echo "" local input_pad=$(( (TERM_COLS - 40) / 2 )) # Collect both ports first @@ -861,14 +886,14 @@ resolve_port_conflicts() { center_print "Using ports: Web=$WEB_PORT, Server=$SERVER_PORT" "$C_GREEN" break ;; - [cC]|[cC][aA][nN][cC][eE][lL]) + [xX]|[xX][cC][aA][nN][cC][eE][lL]) echo "" center_print "Cancelled." "$C_MUTE" echo "" exit 0 ;; *) - center_print "Invalid choice. Please enter K, U, or C." "$C_RED" + center_print "Invalid choice. Press Enter for auto-select, or K/C/X." "$C_RED" ;; esac done From b039b745bebe6e7903fb4bb7a192c78def4ac63f Mon Sep 17 00:00:00 2001 From: webdevcody Date: Mon, 19 Jan 2026 17:37:13 -0500 Subject: [PATCH 07/11] feat: add discard changes functionality for worktrees - Introduced a new POST /discard-changes endpoint to discard all uncommitted changes in a worktree, including resetting staged changes, discarding modifications to tracked files, and removing untracked files. - Implemented a corresponding handler in the UI to confirm and execute the discard operation, enhancing user control over worktree changes. - Added a ViewWorktreeChangesDialog component to display changes in the worktree, improving the user experience for managing worktree states. - Updated the WorktreePanel and WorktreeActionsDropdown components to integrate the new functionality, allowing users to view and discard changes directly from the UI. This update streamlines the management of worktree changes, providing users with essential tools for version control. --- apps/server/src/routes/worktree/index.ts | 9 ++ .../routes/worktree/routes/discard-changes.ts | 112 ++++++++++++++++++ .../views/board-view/dialogs/index.ts | 1 + .../dialogs/view-worktree-changes-dialog.tsx | 68 +++++++++++ .../components/worktree-actions-dropdown.tsx | 36 +++++- .../components/worktree-tab.tsx | 6 + .../worktree-panel/worktree-panel.tsx | 103 ++++++++++++++++ apps/ui/src/lib/electron.ts | 14 +++ apps/ui/src/lib/http-api-client.ts | 2 + apps/ui/src/types/electron.d.ts | 13 ++ 10 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/routes/worktree/routes/discard-changes.ts create mode 100644 apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 854e5c60..d4358b65 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -48,6 +48,7 @@ import { createDeleteInitScriptHandler, createRunInitScriptHandler, } from './routes/init-script.js'; +import { createDiscardChangesHandler } from './routes/discard-changes.js'; import type { SettingsService } from '../../services/settings-service.js'; export function createWorktreeRoutes( @@ -148,5 +149,13 @@ export function createWorktreeRoutes( createRunInitScriptHandler(events) ); + // Discard changes route + router.post( + '/discard-changes', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createDiscardChangesHandler() + ); + return router; } diff --git a/apps/server/src/routes/worktree/routes/discard-changes.ts b/apps/server/src/routes/worktree/routes/discard-changes.ts new file mode 100644 index 00000000..4f15e053 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/discard-changes.ts @@ -0,0 +1,112 @@ +/** + * POST /discard-changes endpoint - Discard all uncommitted changes in a worktree + * + * This performs a destructive operation that: + * 1. Resets staged changes (git reset HEAD) + * 2. Discards modified tracked files (git checkout .) + * 3. Removes untracked files and directories (git clean -fd) + * + * Note: Git repository validation (isGitRepo) is handled by + * the requireGitRepoOnly middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logError } from '../common.js'; + +const execAsync = promisify(exec); + +export function createDiscardChangesHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Check for uncommitted changes first + const { stdout: status } = await execAsync('git status --porcelain', { + cwd: worktreePath, + }); + + if (!status.trim()) { + res.json({ + success: true, + result: { + discarded: false, + message: 'No changes to discard', + }, + }); + return; + } + + // Count the files that will be affected + const lines = status.trim().split('\n').filter(Boolean); + const fileCount = lines.length; + + // Get branch name before discarding + const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); + const branchName = branchOutput.trim(); + + // Discard all changes: + // 1. Reset any staged changes + await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => { + // Ignore errors - might fail if there's nothing staged + }); + + // 2. Discard changes in tracked files + await execAsync('git checkout .', { cwd: worktreePath }).catch(() => { + // Ignore errors - might fail if there are no tracked changes + }); + + // 3. Remove untracked files and directories + await execAsync('git clean -fd', { cwd: worktreePath }).catch(() => { + // Ignore errors - might fail if there are no untracked files + }); + + // Verify all changes were discarded + const { stdout: finalStatus } = await execAsync('git status --porcelain', { + cwd: worktreePath, + }); + + if (finalStatus.trim()) { + // Some changes couldn't be discarded (possibly ignored files or permission issues) + const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length; + res.json({ + success: true, + result: { + discarded: true, + filesDiscarded: fileCount - remainingCount, + filesRemaining: remainingCount, + branch: branchName, + message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`, + }, + }); + } else { + res.json({ + success: true, + result: { + discarded: true, + filesDiscarded: fileCount, + filesRemaining: 0, + branch: branchName, + message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`, + }, + }); + } + } catch (error) { + logError(error, 'Discard changes failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/ui/src/components/views/board-view/dialogs/index.ts b/apps/ui/src/components/views/board-view/dialogs/index.ts index 659f4d7e..84027daf 100644 --- a/apps/ui/src/components/views/board-view/dialogs/index.ts +++ b/apps/ui/src/components/views/board-view/dialogs/index.ts @@ -8,3 +8,4 @@ export { EditFeatureDialog } from './edit-feature-dialog'; export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog'; export { PlanApprovalDialog } from './plan-approval-dialog'; export { MassEditDialog } from './mass-edit-dialog'; +export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog'; diff --git a/apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx new file mode 100644 index 00000000..1b49b23d --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx @@ -0,0 +1,68 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { FileText } from 'lucide-react'; +import { GitDiffPanel } from '@/components/ui/git-diff-panel'; + +interface WorktreeInfo { + path: string; + branch: string; + isMain: boolean; + hasChanges?: boolean; + changedFilesCount?: number; +} + +interface ViewWorktreeChangesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + worktree: WorktreeInfo | null; + projectPath: string; +} + +export function ViewWorktreeChangesDialog({ + open, + onOpenChange, + worktree, + projectPath, +}: ViewWorktreeChangesDialogProps) { + if (!worktree) return null; + + return ( + + + + + + View Changes + + + Changes in the{' '} + {worktree.branch} worktree. + {worktree.changedFilesCount !== undefined && worktree.changedFilesCount > 0 && ( + + ({worktree.changedFilesCount} file + {worktree.changedFilesCount > 1 ? 's' : ''} changed) + + )} + + + +
+
+ +
+
+
+
+ ); +} 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 97c6ecc5..f33ceba8 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 @@ -25,11 +25,13 @@ import { AlertCircle, RefreshCw, Copy, + Eye, ScrollText, Terminal, SquarePlus, SplitSquareHorizontal, Zap, + Undo2, } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; @@ -65,6 +67,8 @@ interface WorktreeActionsDropdownProps { onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void; onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void; + onViewChanges: (worktree: WorktreeInfo) => void; + onDiscardChanges: (worktree: WorktreeInfo) => void; onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; @@ -99,6 +103,8 @@ export function WorktreeActionsDropdown({ onOpenInEditor, onOpenInIntegratedTerminal, onOpenInExternalTerminal, + onViewChanges, + onDiscardChanges, onCommit, onCreatePR, onAddressPRComments, @@ -434,6 +440,13 @@ export function WorktreeActionsDropdown({ )} + + {worktree.hasChanges && ( + onViewChanges(worktree)} className="text-xs"> + + View Changes + + )} {worktree.hasChanges && ( )} + + {worktree.hasChanges && ( + + gitRepoStatus.isGitRepo && onDiscardChanges(worktree)} + disabled={!gitRepoStatus.isGitRepo} + className={cn( + 'text-xs text-destructive focus:text-destructive', + !gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed' + )} + > + + Discard Changes + {!gitRepoStatus.isGitRepo && ( + + )} + + + )} {!worktree.isMain && ( <> - onDeleteWorktree(worktree)} className="text-xs text-destructive focus:text-destructive" 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 accc5799..6c05bf8c 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 @@ -42,6 +42,8 @@ interface WorktreeTabProps { onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void; onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void; + onViewChanges: (worktree: WorktreeInfo) => void; + onDiscardChanges: (worktree: WorktreeInfo) => void; onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; @@ -90,6 +92,8 @@ export function WorktreeTab({ onOpenInEditor, onOpenInIntegratedTerminal, onOpenInExternalTerminal, + onViewChanges, + onDiscardChanges, onCommit, onCreatePR, onAddressPRComments, @@ -375,6 +379,8 @@ export function WorktreeTab({ onOpenInEditor={onOpenInEditor} onOpenInIntegratedTerminal={onOpenInIntegratedTerminal} onOpenInExternalTerminal={onOpenInExternalTerminal} + onViewChanges={onViewChanges} + onDiscardChanges={onDiscardChanges} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} 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 a79bf621..0214092c 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 @@ -22,6 +22,9 @@ import { BranchSwitchDropdown, } from './components'; import { useAppStore } from '@/store/app-store'; +import { ViewWorktreeChangesDialog } from '../dialogs'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { Undo2 } from 'lucide-react'; export function WorktreePanel({ projectPath, @@ -156,6 +159,14 @@ export function WorktreePanel({ // Track whether init script exists for the project const [hasInitScript, setHasInitScript] = useState(false); + // View changes dialog state + const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false); + const [viewChangesWorktree, setViewChangesWorktree] = useState(null); + + // Discard changes confirmation dialog state + const [discardChangesDialogOpen, setDiscardChangesDialogOpen] = useState(false); + const [discardChangesWorktree, setDiscardChangesWorktree] = useState(null); + // Log panel state management const [logPanelOpen, setLogPanelOpen] = useState(false); const [logPanelWorktree, setLogPanelWorktree] = useState(null); @@ -242,6 +253,41 @@ export function WorktreePanel({ [projectPath] ); + const handleViewChanges = useCallback((worktree: WorktreeInfo) => { + setViewChangesWorktree(worktree); + setViewChangesDialogOpen(true); + }, []); + + const handleDiscardChanges = useCallback((worktree: WorktreeInfo) => { + setDiscardChangesWorktree(worktree); + setDiscardChangesDialogOpen(true); + }, []); + + const handleConfirmDiscardChanges = useCallback(async () => { + if (!discardChangesWorktree) return; + + try { + const api = getHttpApiClient(); + const result = await api.worktree.discardChanges(discardChangesWorktree.path); + + if (result.success) { + toast.success('Changes discarded', { + description: `Discarded changes in ${discardChangesWorktree.branch}`, + }); + // Refresh worktrees to update the changes status + fetchWorktrees({ silent: true }); + } else { + toast.error('Failed to discard changes', { + description: result.error || 'Unknown error', + }); + } + } catch (error) { + toast.error('Failed to discard changes', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, [discardChangesWorktree, fetchWorktrees]); + // Handle opening the log panel for a specific worktree const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => { setLogPanelWorktree(worktree); @@ -312,6 +358,8 @@ export function WorktreePanel({ onOpenInEditor={handleOpenInEditor} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal} + onViewChanges={handleViewChanges} + onDiscardChanges={handleDiscardChanges} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -357,6 +405,36 @@ export function WorktreePanel({ )} + + {/* View Changes Dialog */} + + + {/* Discard Changes Confirmation Dialog */} + + + {/* Dev Server Logs Panel */} + ); } @@ -403,6 +481,8 @@ export function WorktreePanel({ onOpenInEditor={handleOpenInEditor} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal} + onViewChanges={handleViewChanges} + onDiscardChanges={handleDiscardChanges} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -465,6 +545,8 @@ export function WorktreePanel({ onOpenInEditor={handleOpenInEditor} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal} + onViewChanges={handleViewChanges} + onDiscardChanges={handleDiscardChanges} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -511,6 +593,27 @@ export function WorktreePanel({ )} + {/* View Changes Dialog */} + + + {/* Discard Changes Confirmation Dialog */} + + {/* Dev Server Logs Panel */} { + console.log('[Mock] Discarding changes:', { worktreePath }); + return { + success: true, + result: { + discarded: true, + filesDiscarded: 0, + filesRemaining: 0, + branch: 'main', + message: 'Mock: Changes discarded successfully', + }, + }; + }, }; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 149c5532..e6292bd7 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1851,6 +1851,8 @@ export class HttpApiClient implements ElectronAPI { this.httpDelete('/api/worktree/init-script', { projectPath }), runInitScript: (projectPath: string, worktreePath: string, branch: string) => this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }), + discardChanges: (worktreePath: string) => + this.post('/api/worktree/discard-changes', { worktreePath }), onInitScriptEvent: ( callback: (event: { type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed'; diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index ebaf5f59..e01f3588 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -1218,6 +1218,19 @@ export interface WorktreeAPI { payload: unknown; }) => void ) => () => void; + + // Discard changes for a worktree + discardChanges: (worktreePath: string) => Promise<{ + success: boolean; + result?: { + discarded: boolean; + filesDiscarded: number; + filesRemaining: number; + branch: string; + message: string; + }; + error?: string; + }>; } export interface GitAPI { From 17d42e79312697cffa0d36612e8bd02441a4698d Mon Sep 17 00:00:00 2001 From: webdevcody Date: Mon, 19 Jan 2026 17:38:21 -0500 Subject: [PATCH 08/11] feat: enhance ANSI code stripping in ClaudeUsageService - Improved the stripAnsiCodes method to handle various ANSI escape sequences, including CSI, OSC, and single-character sequences. - Added logic to manage backspaces and explicitly strip known "Synchronized Output" and "Window Title" garbage. - Updated tests to cover new functionality, ensuring robust handling of complex terminal outputs and control characters. This enhancement improves the reliability of text processing in terminal environments. --- .../src/services/claude-usage-service.ts | 64 ++++++++++++++++--- .../services/claude-usage-service.test.ts | 53 +++++++++++++++ 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index aebed98b..aa8afc1c 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -468,10 +468,41 @@ export class ClaudeUsageService { /** * Strip ANSI escape codes from text + * Handles CSI, OSC, and other common ANSI sequences */ private stripAnsiCodes(text: string): string { + // First strip ANSI sequences (colors, etc) and handle CR // eslint-disable-next-line no-control-regex - return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); + let clean = text + // CSI sequences: ESC [ ... (letter or @) + .replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '') + // OSC sequences: ESC ] ... terminated by BEL, ST, or another ESC + .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, '') + // Other ESC sequences: ESC (letter) + .replace(/\x1B[A-Za-z]/g, '') + // Carriage returns: replace with newline to avoid concatenation + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n'); + + // Handle backspaces (\x08) by applying them + // If we encounter a backspace, remove the character before it + while (clean.includes('\x08')) { + clean = clean.replace(/[^\x08]\x08/, ''); + clean = clean.replace(/^\x08+/, ''); + } + + // Explicitly strip known "Synchronized Output" and "Window Title" garbage + // even if ESC is missing (seen in some environments) + clean = clean + .replace(/\[\?2026[hl]/g, '') // CSI ? 2026 h/l + .replace(/\]0;[^\x07]*\x07/g, '') // OSC 0; Title BEL + .replace(/\]0;.*?(\[\?|$)/g, ''); // OSC 0; Title ... (unterminated or hit next sequence) + + // Strip remaining non-printable control characters (except newline \n) + // ASCII 0-8, 11-31, 127 + clean = clean.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, ''); + + return clean; } /** @@ -550,7 +581,7 @@ export class ClaudeUsageService { sectionLabel: string, type: string ): { percentage: number; resetTime: string; resetText: string } { - let percentage = 0; + let percentage: number | null = null; let resetTime = this.getDefaultResetTime(type); let resetText = ''; @@ -564,7 +595,7 @@ export class ClaudeUsageService { } if (sectionIndex === -1) { - return { percentage, resetTime, resetText }; + return { percentage: 0, resetTime, resetText }; } // Look at the lines following the section header (within a window of 5 lines) @@ -572,7 +603,8 @@ export class ClaudeUsageService { for (const line of searchWindow) { // Extract percentage - only take the first match (avoid picking up next section's data) - if (percentage === 0) { + // Use null to track "not found" since 0% is a valid percentage (100% left = 0% used) + if (percentage === null) { const percentMatch = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i); if (percentMatch) { const value = parseInt(percentMatch[1], 10); @@ -584,18 +616,31 @@ export class ClaudeUsageService { // Extract reset time - only take the first match if (!resetText && line.toLowerCase().includes('reset')) { - resetText = line; + // Only extract the part starting from "Resets" (or "Reset") to avoid garbage prefixes + const match = line.match(/(Resets?.*)$/i); + // If regex fails despite 'includes', likely a complex string issues - verify match before using line + // Only fallback to line if it's reasonably short/clean, otherwise skip it to avoid showing garbage + if (match) { + resetText = match[1]; + } } } // Parse the reset time if we found one if (resetText) { + // Clean up resetText: remove percentage info if it was matched on the same line + // e.g. "46%used Resets5:59pm" -> " Resets5:59pm" + resetText = resetText.replace(/(\d{1,3})\s*%\s*(left|used|remaining)/i, '').trim(); + + // Ensure space after "Resets" if missing (e.g. "Resets5:59pm" -> "Resets 5:59pm") + resetText = resetText.replace(/(resets?)(\d)/i, '$1 $2'); + resetTime = this.parseResetTime(resetText, type); // Strip timezone like "(Asia/Dubai)" from the display text resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim(); } - return { percentage, resetTime, resetText }; + return { percentage: percentage ?? 0, resetTime, resetText }; } /** @@ -624,7 +669,7 @@ export class ClaudeUsageService { } // Try to parse simple time-only format: "Resets 11am" or "Resets 3pm" - const simpleTimeMatch = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i); + const simpleTimeMatch = text.match(/resets\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i); if (simpleTimeMatch) { let hours = parseInt(simpleTimeMatch[1], 10); const minutes = simpleTimeMatch[2] ? parseInt(simpleTimeMatch[2], 10) : 0; @@ -649,8 +694,11 @@ export class ClaudeUsageService { } // Try to parse date format: "Resets Dec 22 at 8pm" or "Resets Jan 15, 3:30pm" + // The regex explicitly matches only valid 3-letter month abbreviations to avoid + // matching words like "Resets" when there's no space separator. + // Optional "resets\s*" prefix handles cases with or without space after "Resets" const dateMatch = text.match( - /([A-Za-z]{3,})\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i + /(?:resets\s*)?(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i ); if (dateMatch) { const monthName = dateMatch[1]; diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts index 07ad13c9..7901192c 100644 --- a/apps/server/tests/unit/services/claude-usage-service.test.ts +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -124,6 +124,59 @@ describe('claude-usage-service.ts', () => { expect(result).toBe('Plain text'); }); + + it('should strip OSC sequences (window title, etc.)', () => { + const service = new ClaudeUsageService(); + // OSC sequence to set window title: ESC ] 0 ; title BEL + const input = '\x1B]0;Claude Code\x07Regular text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Regular text'); + }); + + it('should strip DEC private mode sequences', () => { + const service = new ClaudeUsageService(); + // DEC private mode sequences like ESC[?2026h and ESC[?2026l + const input = '\x1B[?2026lClaude Code\x1B[?2026h more text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Claude Code more text'); + }); + + it('should handle complex terminal output with mixed escape sequences', () => { + const service = new ClaudeUsageService(); + // Simulate the garbled output seen in the bug: "[?2026l ]0;❇ Claude Code [?2026h" + // This contains OSC (set title) and DEC private mode sequences + const input = + '\x1B[?2026l\x1B]0;❇ Claude Code\x07\x1B[?2026hCurrent session 0%used Resets3am'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Current session 0%used Resets3am'); + }); + + it('should strip single character escape sequences', () => { + const service = new ClaudeUsageService(); + // ESC c is the reset terminal command + const input = '\x1BcReset text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Reset text'); + }); + + it('should remove control characters but preserve newlines and tabs', () => { + const service = new ClaudeUsageService(); + // BEL character (\x07) should be stripped, but the word "Bell" is regular text + const input = 'Line 1\nLine 2\tTabbed\x07 with bell'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + // BEL is stripped, newlines and tabs preserved + expect(result).toBe('Line 1\nLine 2\tTabbed with bell'); + }); }); describe('parseResetTime', () => { From 628e464b74d7dd6abebdcbc2970feebd37198a06 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Mon, 19 Jan 2026 17:40:46 -0500 Subject: [PATCH 09/11] feat: update branch handling and UI components for worktree management - Enhanced branch name determination logic in useBoardActions to ensure features created on non-main worktrees are correctly associated with their respective branches. - Improved DevServerLogsPanel styling for better responsiveness and user experience. - Added event hooks support in settings migration and sync processes to maintain consistency across application state. These changes improve the overall functionality and usability of worktree management within the application. --- .../views/board-view/hooks/use-board-actions.ts | 8 +++++--- .../worktree-panel/components/dev-server-logs-panel.tsx | 4 ++-- apps/ui/src/hooks/use-settings-migration.ts | 3 +++ apps/ui/src/hooks/use-settings-sync.ts | 2 ++ 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index fc5a85c2..de9e87ac 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -118,13 +118,14 @@ export function useBoardActions({ const workMode = featureData.workMode || 'current'; // Determine final branch name based on work mode: - // - 'current': Use selected worktree branch if available, otherwise undefined (work on main) + // - 'current': Use current worktree's branch (or undefined if on main) // - 'auto': Auto-generate branch name based on current branch // - 'custom': Use the provided branch name let finalBranchName: string | undefined; if (workMode === 'current') { - // If a worktree is selected, use its branch; otherwise work on main (undefined = no branch assignment) + // Work directly on current branch - use the current worktree's branch if not on main + // This ensures features created on a non-main worktree are associated with that worktree finalBranchName = currentWorktreeBranch || undefined; } else if (workMode === 'auto') { // Auto-generate a branch name based on primary branch (main/master) and timestamp @@ -284,7 +285,8 @@ export function useBoardActions({ let finalBranchName: string | undefined; if (workMode === 'current') { - // If a worktree is selected, use its branch; otherwise work on main (undefined = no branch assignment) + // Work directly on current branch - use the current worktree's branch if not on main + // This ensures features updated on a non-main worktree are associated with that worktree finalBranchName = currentWorktreeBranch || undefined; } else if (workMode === 'auto') { // Auto-generate a branch name based on primary branch (main/master) and timestamp diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx index 0e9b5e59..a6d7ef59 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx @@ -132,12 +132,12 @@ export function DevServerLogsPanel({ return ( !isOpen && onClose()}> {/* Compact Header */} - +
diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 05b8d183..d1daa4bd 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -730,6 +730,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false, lastProjectDir: settings.lastProjectDir ?? '', recentFolders: settings.recentFolders ?? [], + // Event hooks + eventHooks: settings.eventHooks ?? [], // Terminal font (nested in terminalState) ...(settings.terminalFontFamily && { terminalState: { @@ -808,6 +810,7 @@ function buildSettingsUpdateFromStore(): Record { lastProjectDir: state.lastProjectDir, recentFolders: state.recentFolders, terminalFontFamily: state.terminalState.fontFamily, + eventHooks: state.eventHooks, }; } diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index ef300249..8ede5600 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -682,6 +682,8 @@ export async function refreshSettingsFromServer(): Promise { worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false, lastProjectDir: serverSettings.lastProjectDir ?? '', recentFolders: serverSettings.recentFolders ?? [], + // Event hooks + eventHooks: serverSettings.eventHooks ?? [], // Terminal settings (nested in terminalState) ...((serverSettings.terminalFontFamily || serverSettings.openTerminalMode) && { terminalState: { From d266c98e48ce95a0f42170f1db18d37b62d077a0 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Mon, 19 Jan 2026 17:41:55 -0500 Subject: [PATCH 10/11] feat: add option to disable authentication for local/trusted networks - Implemented a mechanism to disable authentication when the environment variable AUTOMAKER_DISABLE_AUTH is set to 'true'. - Updated authMiddleware to bypass authentication checks for requests from trusted networks. - Modified getAuthStatus and isRequestAuthenticated functions to reflect the authentication status based on the new configuration. This enhancement allows for easier development and testing in trusted environments by simplifying access control. --- apps/server/src/lib/auth.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts index 1deef0db..60cb2d58 100644 --- a/apps/server/src/lib/auth.ts +++ b/apps/server/src/lib/auth.ts @@ -23,6 +23,13 @@ const SESSION_COOKIE_NAME = 'automaker_session'; const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days const WS_TOKEN_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for WebSocket connection tokens +/** + * Check if an environment variable is set to 'true' + */ +function isEnvTrue(envVar: string | undefined): boolean { + return envVar === 'true'; +} + // Session store - persisted to file for survival across server restarts const validSessions = new Map(); @@ -134,8 +141,8 @@ const API_KEY = ensureApiKey(); const BOX_CONTENT_WIDTH = 67; // Print API key to console for web mode users (unless suppressed for production logging) -if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') { - const autoLoginEnabled = process.env.AUTOMAKER_AUTO_LOGIN === 'true'; +if (!isEnvTrue(process.env.AUTOMAKER_HIDE_API_KEY)) { + const autoLoginEnabled = isEnvTrue(process.env.AUTOMAKER_AUTO_LOGIN); const autoLoginStatus = autoLoginEnabled ? 'enabled (auto-login active)' : 'disabled'; // Build box lines with exact padding @@ -375,6 +382,12 @@ function checkAuthentication( * 5. Session cookie (for web mode) */ export function authMiddleware(req: Request, res: Response, next: NextFunction): void { + // Allow disabling auth for local/trusted networks + if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) { + next(); + return; + } + const result = checkAuthentication( req.headers as Record, req.query as Record, @@ -420,9 +433,10 @@ export function isAuthEnabled(): boolean { * Get authentication status for health endpoint */ export function getAuthStatus(): { enabled: boolean; method: string } { + const disabled = isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH); return { - enabled: true, - method: 'api_key_or_session', + enabled: !disabled, + method: disabled ? 'disabled' : 'api_key_or_session', }; } @@ -430,6 +444,7 @@ export function getAuthStatus(): { enabled: boolean; method: string } { * Check if a request is authenticated (for status endpoint) */ export function isRequestAuthenticated(req: Request): boolean { + if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true; const result = checkAuthentication( req.headers as Record, req.query as Record, @@ -447,5 +462,6 @@ export function checkRawAuthentication( query: Record, cookies: Record ): boolean { + if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true; return checkAuthentication(headers, query, cookies).authenticated; } From 47e6ed6a1740d3da62310900987f924014650a33 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Mon, 19 Jan 2026 17:48:33 -0500 Subject: [PATCH 11/11] feat: add publish option to package.json for UI application - Introduced a new "publish" field set to null in the package.json file, allowing for future configuration of publishing settings. This change prepares the UI application for potential deployment configurations. --- apps/ui/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ui/package.json b/apps/ui/package.json index f0053d53..cd804908 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -146,6 +146,7 @@ "productName": "Automaker", "artifactName": "${productName}-${version}-${arch}.${ext}", "npmRebuild": false, + "publish": null, "afterPack": "./scripts/rebuild-server-natives.cjs", "directories": { "output": "release"